From 3509cb9666822e26dc7bb410a9d2956a63044d6c Mon Sep 17 00:00:00 2001 From: Teajey <21069848+Teajey@users.noreply.github.com> Date: Sun, 7 Dec 2025 17:48:40 +0900 Subject: [PATCH] Password setting and resetting --- core/api.snap.txt | 17 ++- core/internal/db/init.sql | 1 + core/session.go | 3 +- core/user.go | 51 +++++-- http/api/login.go | 1 + http/response/session.go | 36 ++--- http/routing/account.go | 116 ++++++++++++++++ http/routing/home.go | 15 +- http/routing/login.go | 9 +- http/routing/register.go | 2 +- http/routing/users.go | 38 ++++-- http/server/server.go | 3 + http/templates/account.gotmpl | 83 ++++++++++++ http/templates/home.gotmpl | 248 ++++++++++++++++++---------------- http/templates/login.gotmpl | 5 + 15 files changed, 444 insertions(+), 184 deletions(-) create mode 100644 http/routing/account.go create mode 100644 http/templates/account.gotmpl diff --git a/core/api.snap.txt b/core/api.snap.txt index 4a7313c..e0eae5c 100644 --- a/core/api.snap.txt +++ b/core/api.snap.txt @@ -31,6 +31,8 @@ func (a *Admin) RemoveUserFromGroup(userId, groupId string) error func (u *Admin) RenameUser(userReference string, displayName string) error +func (u *Admin) SetUserPassword(userReference string, newPassword string) error + func (u *Admin) UserSetLive(userReference string, setting bool) error type ErrorInvalidCredentials error @@ -81,12 +83,13 @@ func (u *Session) SuggestWishForUser(otherUserReference string, wishName string) func (s *Session) User() User type User struct { - Id string - NormalName string - Name string - Reference string - IsAdmin bool - IsLive bool + Id string + NormalName string + Name string + Reference string + IsAdmin bool + IsLive bool + PasswordFromAdmin bool } func GetUserByReference(reference string) (*User, error) @@ -95,6 +98,8 @@ func Register(username, newPassword string) (*User, error) func (u *User) GetTodo() ([]Wish, error) +func (u *User) SetPassword(newPassword string) error + func (u *User) WishCount() (int, error) type Wish struct { diff --git a/core/internal/db/init.sql b/core/internal/db/init.sql index 4a687e2..bfe9a52 100644 --- a/core/internal/db/init.sql +++ b/core/internal/db/init.sql @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS "user" ( "password_hash" TEXT NOT NULL, "is_admin" INTEGER NOT NULL DEFAULT 0, "is_live" INTEGER NOT NULL DEFAULT 1, + "password_from_admin" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY("id" AUTOINCREMENT) ); CREATE TABLE IF NOT EXISTS "wish" ( diff --git a/core/session.go b/core/session.go index e9c9d7a..dc90682 100644 --- a/core/session.go +++ b/core/session.go @@ -23,7 +23,7 @@ func (s *Session) User() User { func SessionFromKey(key string) (*Session, error) { s := Session{} - query := "SELECT user.id, user.name, user.display_name, user.reference, user.is_admin, user.is_live, session.key, session.expiry FROM v_user as user JOIN session ON user.id = session.user_id WHERE session.key = ?" + query := "SELECT user.id, user.name, user.display_name, user.reference, user.is_admin, user.is_live, user.password_from_admin, session.key, session.expiry FROM v_user as user JOIN session ON user.id = session.user_id WHERE session.key = ?" var expiry string err := db.Connection.QueryRow(query, key).Scan( &s.user.Id, @@ -32,6 +32,7 @@ func SessionFromKey(key string) (*Session, error) { &s.user.Reference, &s.user.IsAdmin, &s.user.IsLive, + &s.user.PasswordFromAdmin, &s.Key, &expiry, ) diff --git a/core/user.go b/core/user.go index 7302a38..a37204a 100644 --- a/core/user.go +++ b/core/user.go @@ -4,19 +4,20 @@ import ( "fmt" "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" "lishwist/core/internal/db" "lishwist/core/internal/normalize" ) type User struct { - Id string - NormalName string - // TODO: rename to DisplayName - Name string - Reference string - IsAdmin bool - IsLive bool + Id string + NormalName string + Name string + Reference string + IsAdmin bool + IsLive bool + PasswordFromAdmin bool } func queryManyUsers(query string, args ...any) ([]User, error) { @@ -28,7 +29,7 @@ func queryManyUsers(query string, args ...any) ([]User, error) { users := []User{} for rows.Next() { var u User - err = rows.Scan(&u.Id, &u.NormalName, &u.Name, &u.Reference, &u.IsAdmin, &u.IsLive) + err = rows.Scan(&u.Id, &u.NormalName, &u.Name, &u.Reference, &u.IsAdmin, &u.IsLive, &u.PasswordFromAdmin) if err != nil { return nil, err } @@ -54,7 +55,7 @@ func queryOneUser(query string, args ...any) (*User, error) { func getUserByName(username string) (*User, error) { username = normalize.Name(username) - stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE name = ?" + stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM v_user WHERE name = ?" return queryOneUser(stmt, username) } @@ -91,12 +92,12 @@ func (u *User) getPassHash() ([]byte, error) { } func getUserByReference(reference string) (*User, error) { - stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE reference = ?" + stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM v_user WHERE reference = ?" return queryOneUser(stmt, reference) } func getUserById(id string) (*User, error) { - stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM v_user WHERE id = ?" + stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM v_user WHERE id = ?" return queryOneUser(stmt, id) } @@ -111,7 +112,7 @@ func hasUsers() (bool, error) { } func (*Admin) ListUsers() ([]User, error) { - stmt := "SELECT id, name, display_name, reference, is_admin, is_live FROM user" + stmt := "SELECT id, name, display_name, reference, is_admin, is_live, password_from_admin FROM user" return queryManyUsers(stmt) } @@ -172,3 +173,29 @@ func (u *Admin) RenameUser(userReference string, displayName string) error { } return err } + +func (u *Admin) SetUserPassword(userReference string, newPassword string) error { + hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost) + if err != nil { + return fmt.Errorf("Failed to hash password: %w", err) + } + query := "UPDATE user SET password_hash = ?, password_from_admin = 1 WHERE reference = ?" + _, err = db.Connection.Exec(query, hashedPasswordBytes, userReference) + if err != nil { + return err + } + return err +} + +func (u *User) SetPassword(newPassword string) error { + hashedPasswordBytes, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.MinCost) + if err != nil { + return fmt.Errorf("Failed to hash password: %w", err) + } + query := "UPDATE user SET password_hash = ?, password_from_admin = 0 WHERE id = ?" + _, err = db.Connection.Exec(query, hashedPasswordBytes, u.Id) + if err != nil { + return err + } + return err +} diff --git a/http/api/login.go b/http/api/login.go index cdf865a..21dbf93 100644 --- a/http/api/login.go +++ b/http/api/login.go @@ -7,6 +7,7 @@ import ( type LoginProps struct { GeneralError string `json:",omitempty"` SuccessfulRegistration bool `json:",omitempty"` + SuccessfulSetPassword bool `json:",omitempty"` Username templates.InputProps Password templates.InputProps } diff --git a/http/response/session.go b/http/response/session.go index d5e70fc..3ec625d 100644 --- a/http/response/session.go +++ b/http/response/session.go @@ -9,31 +9,28 @@ type Session struct { written bool } -func (s *Session) FlashGet() any { - list := s.inner.Flashes() - if len(list) < 1 { - return nil - } else { - s.written = true - return list[0] - } -} +const flashKey = "_flash" -func (s *Session) FlashPeek() any { - flash, ok := s.inner.Values["_flash"] +func (s *Session) FlashGet() any { + val, ok := s.inner.Values[flashKey] if !ok { return nil } - list := flash.([]any) - if len(list) < 1 { + delete(s.inner.Values, flashKey) + s.written = true + return val +} + +func (s *Session) FlashPeek() any { + val, ok := s.inner.Values[flashKey] + if !ok { return nil - } else { - return list[0] } + return val } func (s *Session) FlashSet(value any) { - s.inner.AddFlash(value) + s.inner.Values[flashKey] = value s.written = true } @@ -47,12 +44,17 @@ func (s *Session) SetValue(key any, value any) { s.written = true } +func (s *Session) RemoveValue(key any) { + delete(s.inner.Values, key) + s.written = true +} + func (s *Session) GetValue(key any) any { return s.inner.Values[key] } func (s *Session) ClearValues() { - s.inner.Values = nil + s.inner.Values = make(map[any]any) s.written = true } diff --git a/http/routing/account.go b/http/routing/account.go new file mode 100644 index 0000000..a469083 --- /dev/null +++ b/http/routing/account.go @@ -0,0 +1,116 @@ +package routing + +import ( + "log" + "net/http" + + lishwist "lishwist/core" + "lishwist/http/api" + "lishwist/http/response" + "lishwist/http/templates" + + "github.com/Teajey/rsvp" +) + +type AccountProps struct { + CurrentUsername string + GeneralError string `json:",omitempty"` + PasswordFromAdmin bool `json:",omitempty"` + Password templates.InputProps + ConfirmPassword templates.InputProps +} + +func (p *AccountProps) Validate() (valid bool) { + valid = true + + if p.Password.Value != p.ConfirmPassword.Value { + p.ConfirmPassword.Error = "Passwords didn't match" + valid = false + } + + if !p.Password.Validate() { + valid = false + } + + if !p.ConfirmPassword.Validate() { + valid = false + } + + return +} + +func NewAccountProps(username string, passwordFromAdmin bool, passwordVal, confirmPassVal string) *AccountProps { + return &AccountProps{ + CurrentUsername: username, + PasswordFromAdmin: passwordFromAdmin, + Password: templates.InputProps{ + Type: "password", + Name: "new_password", + Required: true, + MinLength: 5, + Value: passwordVal, + }, + ConfirmPassword: templates.InputProps{ + Type: "password", + Name: "confirm_password", + Required: true, + Value: confirmPassVal, + }, + } +} + +func Account(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response { + user := app.User() + props := NewAccountProps(user.Name, user.PasswordFromAdmin, "", "") + + flash := session.FlashGet() + + flashProps, _ := flash.(*AccountProps) + if flashProps != nil { + props.GeneralError = flashProps.GeneralError + props.ConfirmPassword.Error = flashProps.ConfirmPassword.Error + } + + return response.Data("account.gotmpl", props) +} + +func AccountPost(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response { + user := app.User() + + err := r.ParseForm() + if err != nil { + return response.Error(http.StatusBadRequest, "Failed to parse form") + } + + intent := r.Form.Get("intent") + if intent != "set_password" { + return response.Error(http.StatusBadRequest, "Invalid intent %q", intent) + } + + newPassword := r.Form.Get("new_password") + confirmPassword := r.Form.Get("confirm_password") + + props := NewAccountProps(user.Name, user.PasswordFromAdmin, newPassword, confirmPassword) + + valid := props.Validate() + props.Password.Value = "" + props.ConfirmPassword.Value = "" + if !valid { + log.Printf("Invalid account props: %#v\n", props) + session.FlashSet(&props) + return rsvp.SeeOther("/account", props) + } + + err = user.SetPassword(newPassword) + if err != nil { + props.GeneralError = "Something went wrong." + log.Printf("Set password failed: %s\n", err) + session.FlashSet(&props) + return rsvp.SeeOther("/account", props) + } + + session.RemoveValue("sessionKey") + + session.FlashSet(&api.LoginProps{SuccessfulSetPassword: true}) + return rsvp.SeeOther("/", "Set password successful!") +} diff --git a/http/routing/home.go b/http/routing/home.go index 4599c8d..bb41c11 100644 --- a/http/routing/home.go +++ b/http/routing/home.go @@ -12,12 +12,13 @@ import ( ) type HomeProps struct { - Username string - Gifts []lishwist.Wish - Todo []lishwist.Wish - Reference string - HostUrl string - Groups []lishwist.Group + Username string + Gifts []lishwist.Wish + Todo []lishwist.Wish + Reference string + HostUrl string + Groups []lishwist.Group + AccountAlert bool } func Home(app *lishwist.Session, session *response.Session, h http.Header, r *http.Request) rsvp.Response { @@ -37,7 +38,7 @@ func Home(app *lishwist.Session, session *response.Session, h http.Header, r *ht log.Printf("Failed to get groups: %s\n", err) return response.Error(http.StatusInternalServerError, "An error occurred while fetching your wishlist :(") } - p := HomeProps{Username: user.Name, Gifts: gifts, Todo: todo, Reference: user.Reference, HostUrl: env.Configuration.HostUrl, Groups: groups} + p := HomeProps{Username: user.Name, Gifts: gifts, Todo: todo, Reference: user.Reference, HostUrl: env.Configuration.HostUrl, Groups: groups, AccountAlert: user.PasswordFromAdmin} return response.Data("home.gotmpl", p) } diff --git a/http/routing/login.go b/http/routing/login.go index e38895e..a66d6e2 100644 --- a/http/routing/login.go +++ b/http/routing/login.go @@ -24,12 +24,9 @@ func Login(s *response.Session, h http.Header, r *http.Request) rsvp.Response { props.GeneralError = flashProps.GeneralError props.Username.Error = flashProps.Username.Error props.Password.Error = flashProps.Password.Error - } - flash = s.FlashGet() - successfulReg, _ := flash.(bool) - if successfulReg { - props.SuccessfulRegistration = true + props.SuccessfulRegistration = flashProps.SuccessfulRegistration + props.SuccessfulSetPassword = flashProps.SuccessfulSetPassword } return rsvp.Response{TemplateName: "login.gotmpl", Body: props} @@ -61,7 +58,7 @@ func LoginPost(session *response.Session, h http.Header, r *http.Request) rsvp.R var targ lishwist.ErrorInvalidCredentials switch { case errors.As(err, &targ): - props.GeneralError = "Username or password invalid" + props.GeneralError = "Username or password invalid. If you're having trouble accessing your account, you may want to consider asking the System Admin (Thomas) to reset your password" session.FlashSet(&props) log.Printf("Invalid credentials: %s: %#v\n", err, props) return resp diff --git a/http/routing/register.go b/http/routing/register.go index dc1b6c3..d1b1262 100644 --- a/http/routing/register.go +++ b/http/routing/register.go @@ -61,6 +61,6 @@ func RegisterPost(s *response.Session, h http.Header, r *http.Request) rsvp.Resp return rsvp.SeeOther(r.URL.Path, props) } - s.FlashSet(true) + s.FlashSet(&api.LoginProps{SuccessfulRegistration: true}) return rsvp.SeeOther("/", "Registration successful!") } diff --git a/http/routing/users.go b/http/routing/users.go index 8ffa28b..d1f6a75 100644 --- a/http/routing/users.go +++ b/http/routing/users.go @@ -53,23 +53,33 @@ func UserPost(app *lishwist.Session, session *response.Session, h http.Header, r } reference := r.PathValue("userReference") - if reference == app.User().Reference { - return response.Error(http.StatusForbidden, "You cannot delete yourself.") - } intent := r.Form.Get("intent") - switch intent { - case "delete": - err = admin.UserSetLive(reference, false) - if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to delete user: %s", err) - } - case "rename": - name := r.Form.Get("display_name") - err = admin.RenameUser(reference, name) - if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to rename user: %s", err) + if intent != "" { + switch intent { + case "delete": + if reference == app.User().Reference { + return response.Error(http.StatusForbidden, "You cannot delete yourself.") + } + err = admin.UserSetLive(reference, false) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to delete user: %s", err) + } + case "rename": + name := r.Form.Get("display_name") + err = admin.RenameUser(reference, name) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to rename user: %s", err) + } + case "set_password": + newPassword := r.Form.Get("new_password") + err = admin.SetUserPassword(reference, newPassword) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to set new password: %s", err) + } + default: + return response.Error(http.StatusBadRequest, "Invalid intent %q", intent) } } diff --git a/http/server/server.go b/http/server/server.go index 95c24f1..18e237c 100644 --- a/http/server/server.go +++ b/http/server/server.go @@ -32,6 +32,7 @@ func prefixPermanentRedirect(before, after string) response.HandlerFunc { func Create(useSecureCookies bool) *router.VisibilityRouter { gob.Register(&api.RegisterProps{}) gob.Register(&api.LoginProps{}) + gob.Register(&routing.AccountProps{}) store := session.NewInMemoryStore([]byte(env.Configuration.SessionSecret)) store.Options.MaxAge = 86_400 // 24 hours in seconds @@ -49,6 +50,7 @@ func Create(useSecureCookies bool) *router.VisibilityRouter { r.Public.HandleFunc("POST /", routing.LoginPost) r.Public.HandleFunc("POST /register", routing.RegisterPost) + r.Private.HandleFunc("GET /account", routing.ExpectAppSession(routing.Account)) r.Private.HandleFunc("GET /health", routing.ExpectAppSession(routing.Health)) r.Private.HandleFunc("GET /", routing.NotFound) r.Private.HandleFunc("GET /groups", routing.ExpectAppSession(routing.Groups)) @@ -57,6 +59,7 @@ func Create(useSecureCookies bool) *router.VisibilityRouter { r.Private.HandleFunc("GET /users", routing.ExpectAppSession(routing.Users)) r.Private.HandleFunc("GET /users/{userReference}", routing.ExpectAppSession(routing.User)) r.Private.HandleFunc("GET /{$}", routing.ExpectAppSession(routing.Home)) + r.Private.HandleFunc("POST /account", routing.ExpectAppSession(routing.AccountPost)) r.Private.HandleFunc("POST /groups/{groupReference}", routing.ExpectAppSession(routing.GroupPost)) r.Private.HandleFunc("POST /lists/{userReference}", routing.ExpectAppSession(routing.ForeignWishlistPost)) r.Private.HandleFunc("POST /logout", routing.LogoutPost) diff --git a/http/templates/account.gotmpl b/http/templates/account.gotmpl new file mode 100644 index 0000000..88c56b4 --- /dev/null +++ b/http/templates/account.gotmpl @@ -0,0 +1,83 @@ + + + + + {{template "head" .}} + + + +
+ +
+
+ {{with .GeneralError}} + + {{end}} +
+
+

Submit new password

+
You can set a new password by submitting this form.
+ {{with .PasswordFromAdmin}} + + {{end}} +
+
+ + + +
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/http/templates/home.gotmpl b/http/templates/home.gotmpl index fcbd39d..5515631 100644 --- a/http/templates/home.gotmpl +++ b/http/templates/home.gotmpl @@ -1,130 +1,138 @@ - - {{template "head" .}} - - -
- -
-
-
-
-

Your wishlist

- {{with .Gifts}} -
-
    - {{range .}} -
  • - - -
  • - {{end}} -
- -
- {{else}} -

Your list is empty. Think of some things to add!

- {{end}} -
-
- - -
-
-
-
+ + {{template "head" .}} + -
-
-

Your todo list

- {{with .Todo}} -
-
    - {{range .}} -
  • - - - - for {{.RecipientName}} - + +
    +
- -
-
-

Your groups

- {{with .Groups}} -
    - {{range .}} -
  • - {{.Name}} +
  • +
    + +
  • - {{end}}
- {{else}} -

You don't belong to any groups

- {{end}} -
-
+
+ +
- - +
+
+
+
+

Your wishlist

+ {{with .Gifts}} +
+
    + {{range .}} +
  • + + +
  • + {{end}} +
+ +
+ {{else}} +

Your list is empty. Think of some things to add!

+ {{end}} +
+
+ + +
+
+
+
+ +
+
+

Your todo list

+ {{with .Todo}} +
+
    + {{range .}} +
  • + + + + for {{.RecipientName}} + +
  • + {{end}} +
+ + +
+ {{else}} +

When you claim gifts for others, they will appear here.

+ {{end}} +
+
+ +
+
+

Your groups

+ {{with .Groups}} + + {{else}} +

You don't belong to any groups

+ {{end}} +
+
+
+
+ + + + \ No newline at end of file diff --git a/http/templates/login.gotmpl b/http/templates/login.gotmpl index 8f53bb2..6b548da 100644 --- a/http/templates/login.gotmpl +++ b/http/templates/login.gotmpl @@ -23,6 +23,11 @@

Registration successful. Now you can login.

{{end}} + {{if .SuccessfulSetPassword}} + + {{end}} {{with .GeneralError}}