chore(webui): finish implementing password reset
All checks were successful
main / main (push) Successful in 1m52s
main / deploy (push) Has been skipped
main / publish (push) Has been skipped

#20
This commit is contained in:
Julien Dessaux 2025-05-02 22:44:16 +02:00
parent 20bc9fe17a
commit 4f68621bad
Signed by: adyxax
GPG key ID: F92E51B86E07177E
9 changed files with 126 additions and 42 deletions

View file

@ -28,7 +28,8 @@ func (db *DB) CreateAccount(username string, isAdmin bool) (*model.Account, erro
if err := passwordReset.Generate(uuid.V4); err != nil { if err := passwordReset.Generate(uuid.V4); err != nil {
return nil, fmt.Errorf("failed to generate password reset uuid: %w", err) return nil, fmt.Errorf("failed to generate password reset uuid: %w", err)
} }
_, err := db.Exec(`INSERT INTO accounts(id, username, is_Admin, settings, password_reset) _, err := db.Exec(
`INSERT INTO accounts(id, username, is_Admin, settings, password_reset)
VALUES (?, ?, ?, jsonb(?), ?);`, VALUES (?, ?, ?, jsonb(?), ?);`,
accountId, accountId,
username, username,

View file

@ -54,7 +54,17 @@ func (db *DB) DeleteExpiredSessions() error {
func (db *DB) DeleteSession(session *model.Session) error { func (db *DB) DeleteSession(session *model.Session) error {
_, err := db.Exec(`DELETE FROM sessions WHERE id = ?`, session.Id) _, err := db.Exec(`DELETE FROM sessions WHERE id = ?`, session.Id)
if err != nil { if err != nil {
return fmt.Errorf("failed to delete session %s: %w", session.Id, err) return fmt.Errorf("failed to delete session: %w", err)
}
return nil
}
func (db *DB) DeleteSessions(account *model.Account) error {
_, err := db.Exec(
`DELETE FROM sessions WHERE data->'account'->>'id' = ?`,
account.Id)
if err != nil {
return fmt.Errorf("failed to delete sessions: %w", err)
} }
return nil return nil
} }

View file

@ -2,6 +2,7 @@ package model
import ( import (
"crypto/subtle" "crypto/subtle"
"fmt"
"time" "time"
"git.adyxax.org/adyxax/tfstated/pkg/helpers" "git.adyxax.org/adyxax/tfstated/pkg/helpers"
@ -27,6 +28,17 @@ func (account *Account) CheckPassword(password string) bool {
return subtle.ConstantTimeCompare(hash, account.PasswordHash) == 1 return subtle.ConstantTimeCompare(hash, account.PasswordHash) == 1
} }
func (account *Account) ResetPassword() error {
var passwordReset uuid.UUID
if err := passwordReset.Generate(uuid.V4); err != nil {
return fmt.Errorf("failed to generate password reset uuid: %w", err)
}
account.Salt = nil
account.PasswordHash = nil
account.PasswordReset = &passwordReset
return nil
}
func (account *Account) SetPassword(password string) { func (account *Account) SetPassword(password string) {
account.Salt = helpers.GenerateSalt() account.Salt = helpers.GenerateSalt()
account.PasswordHash = helpers.HashPassword(password, account.Salt) account.PasswordHash = helpers.HashPassword(password, account.Salt)

View file

@ -23,37 +23,36 @@ type AccountsIdPage struct {
var accountsIdTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/accountsId.html")) var accountsIdTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/accountsId.html"))
func handleAccountsIdGET(db *database.DB) http.Handler { func prepareAccountsIdPage(db *database.DB, w http.ResponseWriter, r *http.Request) *AccountsIdPage {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var accountId uuid.UUID var accountId uuid.UUID
if err := accountId.Parse(r.PathValue("id")); err != nil { if err := accountId.Parse(r.PathValue("id")); err != nil {
errorResponse(w, r, http.StatusBadRequest, err) errorResponse(w, r, http.StatusBadRequest, err)
return return nil
} }
account, err := db.LoadAccountById(&accountId) account, err := db.LoadAccountById(&accountId)
if err != nil { if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err) errorResponse(w, r, http.StatusInternalServerError, err)
return return nil
} }
if account == nil { if account == nil {
errorResponse(w, r, http.StatusNotFound, fmt.Errorf("The account Id could not be found.")) errorResponse(w, r, http.StatusNotFound, fmt.Errorf("The account Id could not be found."))
return return nil
} }
statePaths, err := db.LoadStatePaths() statePaths, err := db.LoadStatePaths()
if err != nil { if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err) errorResponse(w, r, http.StatusInternalServerError, err)
return return nil
} }
versions, err := db.LoadVersionsByAccount(account) versions, err := db.LoadVersionsByAccount(account)
if err != nil { if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err) errorResponse(w, r, http.StatusInternalServerError, err)
return return nil
} }
isAdmin := "" isAdmin := ""
if account.IsAdmin { if account.IsAdmin {
isAdmin = "1" isAdmin = "1"
} }
render(w, accountsIdTemplates, http.StatusOK, AccountsIdPage{ return &AccountsIdPage{
Account: account, Account: account,
IsAdmin: isAdmin, IsAdmin: isAdmin,
Page: makePage(r, &Page{ Page: makePage(r, &Page{
@ -62,6 +61,60 @@ func handleAccountsIdGET(db *database.DB) http.Handler {
}), }),
StatePaths: statePaths, StatePaths: statePaths,
Versions: versions, Versions: versions,
}) }
}
func handleAccountsIdGET(db *database.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := prepareAccountsIdPage(db, w, r)
if page != nil {
render(w, accountsIdTemplates, http.StatusOK, page)
}
})
}
func handleAccountsIdPOST(db *database.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
errorResponse(w, r, http.StatusBadRequest, err)
return
}
if !verifyCSRFToken(w, r) {
return
}
page := prepareAccountsIdPage(db, w, r)
if page == nil {
return
}
action := r.FormValue("action")
switch action {
case "delete":
errorResponse(w, r, http.StatusNotImplemented, nil)
return
case "edit":
errorResponse(w, r, http.StatusNotImplemented, nil)
return
case "reset-password":
if err := page.Account.ResetPassword(); err != nil {
errorResponse(w, r, http.StatusNotImplemented,
fmt.Errorf("failed to reset password: %w", err))
return
}
if err := db.SaveAccount(page.Account); err != nil {
errorResponse(w, r, http.StatusInternalServerError,
fmt.Errorf("failed to save account: %w", err))
return
}
if err := db.DeleteSessions(page.Account); err != nil {
errorResponse(w, r, http.StatusInternalServerError,
fmt.Errorf("failed to save account: %w", err))
return
}
default:
errorResponse(w, r, http.StatusBadRequest, nil)
return
}
render(w, accountsIdTemplates, http.StatusOK, page)
}) })
} }

View file

@ -82,7 +82,8 @@ func handleAccountsIdResetPasswordPOST(db *database.DB) http.Handler {
} }
account.SetPassword(password) account.SetPassword(password)
if err := db.SaveAccount(account); err != nil { if err := db.SaveAccount(account); err != nil {
errorResponse(w, r, http.StatusInternalServerError, err) errorResponse(w, r, http.StatusInternalServerError,
fmt.Errorf("failed to save account: %w", err))
return return
} }
render(w, accountsIdResetPasswordTemplates, http.StatusOK, render(w, accountsIdResetPasswordTemplates, http.StatusOK,

View file

@ -26,9 +26,9 @@
{{ end }} {{ end }}
{{ if .Page.Session.Data.Account.IsAdmin }} {{ if .Page.Session.Data.Account.IsAdmin }}
<h2>Operations</h2> <h2>Operations</h2>
<form action="/accounts/{{ .Account.Id }}" enctype="multipart/form-data" method="post"> <div class="flex-row">
<form action="/accounts/{{ .Account.Id }}" method="post">
<input name="csrf_token" type="hidden" value="{{ .Page.Session.Data.CsrfToken }}"> <input name="csrf_token" type="hidden" value="{{ .Page.Session.Data.CsrfToken }}">
<div class="flex-row">
<fieldset> <fieldset>
<legend>Edit User Account</legend> <legend>Edit User Account</legend>
<div class="grid-2"> <div class="grid-2">
@ -59,25 +59,30 @@
</span> </span>
{{ end }} {{ end }}
<div style="align-self:stretch; display:flex; justify-content:flex-end;"> <div style="align-self:stretch; display:flex; justify-content:flex-end;">
<button type="submit" value="edit">Edit User Account</button> <button name="action" type="submit" value="edit">Edit User Account</button>
</div> </div>
</fieldset> </fieldset>
</form>
<form action="/accounts/{{ .Account.Id }}" method="post">
<input name="csrf_token" type="hidden" value="{{ .Page.Session.Data.CsrfToken }}">
<fieldset> <fieldset>
<legend>Danger Zone</legend> <legend>Danger Zone</legend>
<button {{ if eq .Page.Session.Data.Account.Id.String .Account.Id.String }}disabled{{ end }} <button {{ if eq .Page.Session.Data.Account.Id.String .Account.Id.String }}disabled{{ end }}
name="action"
type="submit" type="submit"
value="delete"> value="delete">
Delete User Account Delete User Account
</button> </button>
<!--<button type="submit" value="lock">Lock User Account</button>--> <!--<button type="submit" value="lock">Lock User Account</button>-->
<button {{ if or (ne .Account.PasswordReset nil) (eq .Page.Session.Data.Account.Id.String .Account.Id.String) }}disabled{{ end }} <button {{ if or (ne .Account.PasswordReset nil) (eq .Page.Session.Data.Account.Id.String .Account.Id.String) }}disabled{{ end }}
name="action"
type="submit" type="submit"
value="reset-password"> value="reset-password">
Reset Password Reset Password
</button> </button>
</fieldset> </fieldset>
</div> </form>
</form> </div>
{{ end }} {{ end }}
<h2>Activity</h2> <h2>Activity</h2>
{{ if gt (len .Versions) 0 }} {{ if gt (len .Versions) 0 }}

View file

@ -7,7 +7,7 @@
{{ else }} {{ else }}
<h1>User Account</h1> <h1>User Account</h1>
<h2>Password Reset</h2> <h2>Password Reset</h2>
<form action="/accounts/{{ .Account.Id }}/reset/{{ .Token }}" enctype="multipart/form-data" method="post"> <form action="/accounts/{{ .Account.Id }}/reset/{{ .Token }}" method="post">
<input name="csrf_token" type="hidden" value="{{ .Page.Session.Data.CsrfToken }}"> <input name="csrf_token" type="hidden" value="{{ .Page.Session.Data.CsrfToken }}">
<fieldset> <fieldset>
<legend>Set Password</legend> <legend>Set Password</legend>

View file

@ -15,6 +15,7 @@ func addRoutes(
requireAdmin := adminMiddleware(requireLogin) requireAdmin := adminMiddleware(requireLogin)
mux.Handle("GET /accounts", requireLogin(handleAccountsGET(db))) mux.Handle("GET /accounts", requireLogin(handleAccountsGET(db)))
mux.Handle("GET /accounts/{id}", requireLogin(handleAccountsIdGET(db))) mux.Handle("GET /accounts/{id}", requireLogin(handleAccountsIdGET(db)))
mux.Handle("POST /accounts/{id}", requireAdmin(handleAccountsIdPOST(db)))
mux.Handle("GET /accounts/{id}/reset/{token}", requireSession(handleAccountsIdResetPasswordGET(db))) mux.Handle("GET /accounts/{id}/reset/{token}", requireSession(handleAccountsIdResetPasswordGET(db)))
mux.Handle("POST /accounts/{id}/reset/{token}", requireSession(handleAccountsIdResetPasswordPOST(db))) mux.Handle("POST /accounts/{id}/reset/{token}", requireSession(handleAccountsIdResetPasswordPOST(db)))
mux.Handle("POST /accounts", requireAdmin(handleAccountsPOST(db))) mux.Handle("POST /accounts", requireAdmin(handleAccountsPOST(db)))

View file

@ -89,7 +89,8 @@ func handleStatesIdPOST(db *database.DB) http.Handler {
action := r.FormValue("action") action := r.FormValue("action")
switch action { switch action {
case "delete": case "delete":
errorResponse(w, r, http.StatusNotImplemented, err) errorResponse(w, r, http.StatusNotImplemented, nil)
return
case "edit": case "edit":
statePath := r.FormValue("path") statePath := r.FormValue("path")
parsedStatePath, err := url.Parse(statePath) parsedStatePath, err := url.Parse(statePath)
@ -128,7 +129,7 @@ func handleStatesIdPOST(db *database.DB) http.Handler {
} }
state.Lock = nil state.Lock = nil
default: default:
errorResponse(w, r, http.StatusBadRequest, err) errorResponse(w, r, http.StatusBadRequest, nil)
return return
} }
render(w, statesIdTemplate, http.StatusOK, StatesIdPage{ render(w, statesIdTemplate, http.StatusOK, StatesIdPage{