From 922112e181265e79be7fea362ce369bd8970cef2 Mon Sep 17 00:00:00 2001 From: Julien Dessaux Date: Sun, 20 Apr 2025 15:33:07 +0200 Subject: [PATCH] feat(webui): add password reset page #20 --- pkg/database/accounts.go | 21 +++++ pkg/model/account.go | 6 ++ pkg/webui/accountsIdResetPassword.go | 87 +++++++++++++++++++++ pkg/webui/html/accountsIdResetPassword.html | 57 ++++++++++++++ pkg/webui/html/base.html | 6 ++ pkg/webui/login.go | 5 +- pkg/webui/routes.go | 2 + 7 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 pkg/webui/accountsIdResetPassword.go create mode 100644 pkg/webui/html/accountsIdResetPassword.html diff --git a/pkg/database/accounts.go b/pkg/database/accounts.go index bb99cc4..66e23ad 100644 --- a/pkg/database/accounts.go +++ b/pkg/database/accounts.go @@ -215,6 +215,27 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) { return &account, nil } +func (db *DB) SaveAccount(account *model.Account) error { + _, err := db.Exec( + `UPDATE accounts + SET username = ?, + salt = ?, + password_hash = ?, + is_admin = ?, + password_reset = ? + WHERE id = ?`, + account.Username, + account.Salt, + account.PasswordHash, + account.IsAdmin, + account.PasswordReset, + account.Id) + if err != nil { + return fmt.Errorf("failed to update user id %s: %w", account.Id, err) + } + return nil +} + func (db *DB) SaveAccountSettings(account *model.Account, settings *model.Settings) error { data, err := json.Marshal(settings) if err != nil { diff --git a/pkg/model/account.go b/pkg/model/account.go index c1ea958..df7ea8a 100644 --- a/pkg/model/account.go +++ b/pkg/model/account.go @@ -27,3 +27,9 @@ func (account *Account) CheckPassword(password string) bool { hash := helpers.HashPassword(password, account.Salt) return subtle.ConstantTimeCompare(hash, account.PasswordHash) == 1 } + +func (account *Account) SetPassword(password string) { + account.Salt = helpers.GenerateSalt() + account.PasswordHash = helpers.HashPassword(password, account.Salt) + account.PasswordReset = nil +} diff --git a/pkg/webui/accountsIdResetPassword.go b/pkg/webui/accountsIdResetPassword.go new file mode 100644 index 0000000..f8e341e --- /dev/null +++ b/pkg/webui/accountsIdResetPassword.go @@ -0,0 +1,87 @@ +package webui + +import ( + "html/template" + "net/http" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" + "go.n16f.net/uuid" +) + +type AccountsIdResetPasswordPage struct { + Account *model.Account + Page *Page + PasswordInvalid bool + PasswordChanged bool + Token string +} + +var accountsIdResetPasswordTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/accountsIdResetPassword.html")) + +func processAccountsIdResetPasswordPathValues(db *database.DB, w http.ResponseWriter, r *http.Request) (*model.Account, bool) { + var accountId uuid.UUID + if err := accountId.Parse(r.PathValue("id")); err != nil { + errorResponse(w, r, http.StatusBadRequest, err) + return nil, false + } + var token uuid.UUID + if err := token.Parse(r.PathValue("token")); err != nil { + errorResponse(w, r, http.StatusBadRequest, err) + return nil, false + } + account, err := db.LoadAccountById(accountId) + if err != nil { + errorResponse(w, r, http.StatusInternalServerError, err) + return nil, false + } + if account == nil || account.PasswordReset == nil { + errorResponse(w, r, http.StatusBadRequest, err) + return nil, false + } + if !account.PasswordReset.Equal(token) { + errorResponse(w, r, http.StatusBadRequest, err) + return nil, false + } + return account, true +} + +func handleAccountsIdResetPasswordGET(db *database.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + account, valid := processAccountsIdResetPasswordPathValues(db, w, r) + if !valid { + return + } + render(w, accountsIdResetPasswordTemplates, http.StatusOK, + AccountsIdResetPasswordPage{ + Account: account, + Page: &Page{Title: "Password Reset", Section: "reset"}, + Token: r.PathValue("token"), + }) + }) +} + +func handleAccountsIdResetPasswordPOST(db *database.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + account, valid := processAccountsIdResetPasswordPathValues(db, w, r) + if !valid { + return + } + password := r.FormValue("password") + if len(password) < 8 { + errorResponse(w, r, http.StatusBadRequest, nil) + return + } + account.SetPassword(password) + if err := db.SaveAccount(account); err != nil { + errorResponse(w, r, http.StatusInternalServerError, err) + return + } + render(w, accountsIdResetPasswordTemplates, http.StatusOK, + AccountsIdResetPasswordPage{ + Account: account, + Page: &Page{Title: "Password Reset", Section: "reset"}, + PasswordChanged: true, + }) + }) +} diff --git a/pkg/webui/html/accountsIdResetPassword.html b/pkg/webui/html/accountsIdResetPassword.html new file mode 100644 index 0000000..c1f348f --- /dev/null +++ b/pkg/webui/html/accountsIdResetPassword.html @@ -0,0 +1,57 @@ +{{ define "main" }} +{{ if .PasswordChanged }} +

Password Reset Successful

+

+ Your password has been set successfully. You can now try to log in! +

+{{ else }} +

User Account

+

Password Reset

+
+
+ Set Password +

+ You have been invited to set a password for this account. Please choose a + strong password or passphrase that you haven't used before. Think about a + combination of words that would be memorable yet complex + like Correct-Horse-Battery-Staple. +

+
+ + + +
+ {{ if .PasswordInvalid }} + + + Invalid password. + + Passwords must be at least 8 characters long. + + + + {{ end }} +
+
+

Status

+

+ The account + {{ .Account.Username }} + was created on + {{ .Account.Created }} + and + {{ if eq .Account.Created .Account.LastLogin }} + never logged in. + {{ else }} + last logged in on + {{ .Account.LastLogin }}. + {{ end }} +

+{{ end }} +{{ end }} diff --git a/pkg/webui/html/base.html b/pkg/webui/html/base.html index 81e39a6..2a5b5a9 100644 --- a/pkg/webui/html/base.html +++ b/pkg/webui/html/base.html @@ -19,6 +19,11 @@ login Login + {{ else if eq .Page.Section "reset" }} + + login + Login + {{ else if eq .Page.Section "error" }} mountain_flag @@ -50,5 +55,6 @@
+ diff --git a/pkg/webui/login.go b/pkg/webui/login.go index f6762e7..467b305 100644 --- a/pkg/webui/login.go +++ b/pkg/webui/login.go @@ -3,7 +3,6 @@ package webui import ( "context" "encoding/json" - "fmt" "html/template" "log/slog" "net/http" @@ -55,8 +54,8 @@ func handleLoginPOST(db *database.DB) http.Handler { username := r.FormValue("username") password := r.FormValue("password") - if username == "" || password == "" { // the webui cannot issue this - errorResponse(w, r, http.StatusBadRequest, fmt.Errorf("Forbidden")) + if username == "" || password == "" { + errorResponse(w, r, http.StatusBadRequest, nil) return } if ok := validUsername.MatchString(username); !ok { diff --git a/pkg/webui/routes.go b/pkg/webui/routes.go index ee52fa6..a3a8a1a 100644 --- a/pkg/webui/routes.go +++ b/pkg/webui/routes.go @@ -15,6 +15,8 @@ func addRoutes( requireAdmin := adminMiddleware(db, requireLogin) mux.Handle("GET /accounts", requireLogin(handleAccountsGET(db))) mux.Handle("GET /accounts/{id}", requireLogin(handleAccountsIdGET(db))) + mux.Handle("GET /accounts/{id}/reset/{token}", handleAccountsIdResetPasswordGET(db)) + mux.Handle("POST /accounts/{id}/reset/{token}", handleAccountsIdResetPasswordPOST(db)) mux.Handle("POST /accounts", requireAdmin(handleAccountsPOST(db))) mux.Handle("GET /healthz", handleHealthz()) mux.Handle("GET /login", requireSession(handleLoginGET()))