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,8 +28,9 @@ func (db *DB) CreateAccount(username string, isAdmin bool) (*model.Account, erro
if err := passwordReset.Generate(uuid.V4); err != nil {
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)
VALUES (?, ?, ?, jsonb(?), ?);`,
_, err := db.Exec(
`INSERT INTO accounts(id, username, is_Admin, settings, password_reset)
VALUES (?, ?, ?, jsonb(?), ?);`,
accountId,
username,
isAdmin,

View file

@ -54,7 +54,17 @@ func (db *DB) DeleteExpiredSessions() error {
func (db *DB) DeleteSession(session *model.Session) error {
_, err := db.Exec(`DELETE FROM sessions WHERE id = ?`, session.Id)
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
}

View file

@ -2,6 +2,7 @@ package model
import (
"crypto/subtle"
"fmt"
"time"
"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
}
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) {
account.Salt = helpers.GenerateSalt()
account.PasswordHash = helpers.HashPassword(password, account.Salt)

View file

@ -23,45 +23,98 @@ type AccountsIdPage struct {
var accountsIdTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/accountsId.html"))
func prepareAccountsIdPage(db *database.DB, w http.ResponseWriter, r *http.Request) *AccountsIdPage {
var accountId uuid.UUID
if err := accountId.Parse(r.PathValue("id")); err != nil {
errorResponse(w, r, http.StatusBadRequest, err)
return nil
}
account, err := db.LoadAccountById(&accountId)
if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err)
return nil
}
if account == nil {
errorResponse(w, r, http.StatusNotFound, fmt.Errorf("The account Id could not be found."))
return nil
}
statePaths, err := db.LoadStatePaths()
if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err)
return nil
}
versions, err := db.LoadVersionsByAccount(account)
if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err)
return nil
}
isAdmin := ""
if account.IsAdmin {
isAdmin = "1"
}
return &AccountsIdPage{
Account: account,
IsAdmin: isAdmin,
Page: makePage(r, &Page{
Section: "accounts",
Title: account.Username,
}),
StatePaths: statePaths,
Versions: versions,
}
}
func handleAccountsIdGET(db *database.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var accountId uuid.UUID
if err := accountId.Parse(r.PathValue("id")); err != nil {
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
}
account, err := db.LoadAccountById(&accountId)
if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err)
if !verifyCSRFToken(w, r) {
return
}
if account == nil {
errorResponse(w, r, http.StatusNotFound, fmt.Errorf("The account Id could not be found."))
page := prepareAccountsIdPage(db, w, r)
if page == nil {
return
}
statePaths, err := db.LoadStatePaths()
if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err)
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
}
versions, err := db.LoadVersionsByAccount(account)
if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err)
return
}
isAdmin := ""
if account.IsAdmin {
isAdmin = "1"
}
render(w, accountsIdTemplates, http.StatusOK, AccountsIdPage{
Account: account,
IsAdmin: isAdmin,
Page: makePage(r, &Page{
Section: "accounts",
Title: account.Username,
}),
StatePaths: statePaths,
Versions: versions,
})
render(w, accountsIdTemplates, http.StatusOK, page)
})
}

View file

@ -82,7 +82,8 @@ func handleAccountsIdResetPasswordPOST(db *database.DB) http.Handler {
}
account.SetPassword(password)
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
}
render(w, accountsIdResetPasswordTemplates, http.StatusOK,

View file

@ -26,9 +26,9 @@
{{ end }}
{{ if .Page.Session.Data.Account.IsAdmin }}
<h2>Operations</h2>
<form action="/accounts/{{ .Account.Id }}" enctype="multipart/form-data" method="post">
<input name="csrf_token" type="hidden" value="{{ .Page.Session.Data.CsrfToken }}">
<div class="flex-row">
<div class="flex-row">
<form action="/accounts/{{ .Account.Id }}" method="post">
<input name="csrf_token" type="hidden" value="{{ .Page.Session.Data.CsrfToken }}">
<fieldset>
<legend>Edit User Account</legend>
<div class="grid-2">
@ -59,25 +59,30 @@
</span>
{{ 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>
</fieldset>
</form>
<form action="/accounts/{{ .Account.Id }}" method="post">
<input name="csrf_token" type="hidden" value="{{ .Page.Session.Data.CsrfToken }}">
<fieldset>
<legend>Danger Zone</legend>
<button {{ if eq .Page.Session.Data.Account.Id.String .Account.Id.String }}disabled{{ end }}
name="action"
type="submit"
value="delete">
Delete 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 }}
name="action"
type="submit"
value="reset-password">
Reset Password
</button>
</fieldset>
</div>
</form>
</form>
</div>
{{ end }}
<h2>Activity</h2>
{{ if gt (len .Versions) 0 }}

View file

@ -7,7 +7,7 @@
{{ else }}
<h1>User Account</h1>
<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 }}">
<fieldset>
<legend>Set Password</legend>

View file

@ -15,6 +15,7 @@ func addRoutes(
requireAdmin := adminMiddleware(requireLogin)
mux.Handle("GET /accounts", requireLogin(handleAccountsGET(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("POST /accounts/{id}/reset/{token}", requireSession(handleAccountsIdResetPasswordPOST(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")
switch action {
case "delete":
errorResponse(w, r, http.StatusNotImplemented, err)
errorResponse(w, r, http.StatusNotImplemented, nil)
return
case "edit":
statePath := r.FormValue("path")
parsedStatePath, err := url.Parse(statePath)
@ -128,7 +129,7 @@ func handleStatesIdPOST(db *database.DB) http.Handler {
}
state.Lock = nil
default:
errorResponse(w, r, http.StatusBadRequest, err)
errorResponse(w, r, http.StatusBadRequest, nil)
return
}
render(w, statesIdTemplate, http.StatusOK, StatesIdPage{