feat(webui): add password reset page
All checks were successful
main / main (push) Successful in 1m49s
main / deploy (push) Has been skipped
main / publish (push) Has been skipped

#20
This commit is contained in:
Julien Dessaux 2025-04-20 15:33:07 +02:00
parent bb11b870d6
commit 922112e181
Signed by: adyxax
GPG key ID: F92E51B86E07177E
7 changed files with 181 additions and 3 deletions

View file

@ -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 {

View file

@ -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
}

View file

@ -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,
})
})
}

View file

@ -0,0 +1,57 @@
{{ define "main" }}
{{ if .PasswordChanged }}
<h2>Password Reset Successful</h2>
<p>
Your password has been set successfully. You can now try to <a href="/login">log in</a>!
</p>
{{ else }}
<h1>User Account</h1>
<h2>Password Reset</h2>
<form action="/accounts/{{ .Account.Id }}/reset/{{ .Token }}" enctype="multipart/form-data" method="post">
<fieldset>
<legend>Set Password</legend>
<p>
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 <a href="https://xkcd.com/936/">Correct-Horse-Battery-Staple</a>.
</p>
<div class="flex-row">
<label for="password">Password</label>
<input autofocus
class="flex-stretch{{ if .PasswordInvalid }} error{{ end }}"
id="password"
minlength="8"
name="password"
type="password"
required>
<button type="submit" value="edit">Set Password</button>
</div>
{{ if .PasswordInvalid }}
<span class="error">
<span class="tooltip">
Invalid password.
<span class="tooltip-text">
Passwords must be at least 8 characters long.
</span>
</span>
</span>
{{ end }}
</fieldset>
</form>
<h2>Status</h2>
<p>
The account
<strong>{{ .Account.Username }}</strong>
was created on
<strong>{{ .Account.Created }}</strong>
and
{{ if eq .Account.Created .Account.LastLogin }}
<strong>never logged in</strong>.
{{ else }}
last logged in on
<strong>{{ .Account.LastLogin }}</strong>.
{{ end }}
</p>
{{ end }}
{{ end }}

View file

@ -19,6 +19,11 @@
<i class="material-symbols-outlined">login</i>
<span>Login</span>
</a>
{{ else if eq .Page.Section "reset" }}
<a href="/login">
<i class="material-symbols-outlined">login</i>
<span>Login</span>
</a>
{{ else if eq .Page.Section "error" }}
<a href="/">
<i class="material-symbols-outlined">mountain_flag</i>
@ -50,5 +55,6 @@
</div>
<footer>
</footer>
<script type="module" src="/static/main.js"></script>
</body>
</html>

View file

@ -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 {

View file

@ -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()))