chore(webui): rewrite the web session code again while preparing for csrf tokens
All checks were successful
main / main (push) Successful in 7m12s
main / publish (push) Has been skipped
main / deploy (push) Has been skipped

#60
This commit is contained in:
Julien Dessaux 2025-04-30 22:31:25 +02:00
parent 3bb5e735c6
commit 895615ad6e
Signed by: adyxax
GPG key ID: F92E51B86E07177E
20 changed files with 162 additions and 149 deletions

View file

@ -29,7 +29,7 @@ func (db *DB) CreateAccount(username string, isAdmin bool) (*model.Account, erro
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 (?, ?, ?, ?, ?);`, VALUES (?, ?, ?, jsonb(?), ?);`,
accountId, accountId,
username, username,
isAdmin, isAdmin,
@ -72,7 +72,7 @@ func (db *DB) InitAdminAccount() error {
hash := helpers.HashPassword(password.String(), salt) hash := helpers.HashPassword(password.String(), salt)
if _, err := tx.ExecContext(db.ctx, if _, err := tx.ExecContext(db.ctx,
`INSERT INTO accounts(id, username, salt, password_hash, is_admin, settings) `INSERT INTO accounts(id, username, salt, password_hash, is_admin, settings)
VALUES (:id, "admin", :salt, :hash, TRUE, :settings) VALUES (:id, "admin", :salt, :hash, TRUE, jsonb(:settings))
ON CONFLICT DO UPDATE SET password_hash = :hash ON CONFLICT DO UPDATE SET password_hash = :hash
WHERE username = "admin";`, WHERE username = "admin";`,
sql.Named("id", accountId), sql.Named("id", accountId),
@ -91,7 +91,8 @@ func (db *DB) InitAdminAccount() error {
func (db *DB) LoadAccounts() ([]model.Account, error) { func (db *DB) LoadAccounts() ([]model.Account, error) {
rows, err := db.Query( rows, err := db.Query(
`SELECT id, username, salt, password_hash, is_admin, created, last_login, settings, password_reset FROM accounts;`) `SELECT id, username, salt, password_hash, is_admin, created, last_login,
json_extract(settings, '$'), password_reset FROM accounts;`)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load accounts from database: %w", err) return nil, fmt.Errorf("failed to load accounts from database: %w", err)
} }
@ -102,6 +103,7 @@ func (db *DB) LoadAccounts() ([]model.Account, error) {
account model.Account account model.Account
created int64 created int64
lastLogin int64 lastLogin int64
settings []byte
) )
err = rows.Scan( err = rows.Scan(
&account.Id, &account.Id,
@ -111,11 +113,14 @@ func (db *DB) LoadAccounts() ([]model.Account, error) {
&account.IsAdmin, &account.IsAdmin,
&created, &created,
&lastLogin, &lastLogin,
&account.Settings, &settings,
&account.PasswordReset) &account.PasswordReset)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load account from row: %w", err) return nil, fmt.Errorf("failed to load account from row: %w", err)
} }
if err := json.Unmarshal(settings, &account.Settings); err != nil {
return nil, fmt.Errorf("failed to unmarshal account settings: %w", err)
}
account.Created = time.Unix(created, 0) account.Created = time.Unix(created, 0)
account.LastLogin = time.Unix(lastLogin, 0) account.LastLogin = time.Unix(lastLogin, 0)
accounts = append(accounts, account) accounts = append(accounts, account)
@ -161,9 +166,11 @@ func (db *DB) LoadAccountById(id *uuid.UUID) (*model.Account, error) {
var ( var (
created int64 created int64
lastLogin int64 lastLogin int64
settings []byte
) )
err := db.QueryRow( err := db.QueryRow(
`SELECT username, salt, password_hash, is_admin, created, last_login, settings, password_reset `SELECT username, salt, password_hash, is_admin, created, last_login,
json_extract(settings, '$'), password_reset
FROM accounts FROM accounts
WHERE id = ?;`, WHERE id = ?;`,
id, id,
@ -173,7 +180,7 @@ func (db *DB) LoadAccountById(id *uuid.UUID) (*model.Account, error) {
&account.IsAdmin, &account.IsAdmin,
&created, &created,
&lastLogin, &lastLogin,
&account.Settings, &settings,
&account.PasswordReset) &account.PasswordReset)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@ -181,6 +188,9 @@ func (db *DB) LoadAccountById(id *uuid.UUID) (*model.Account, error) {
} }
return nil, fmt.Errorf("failed to load account by id %s: %w", id, err) return nil, fmt.Errorf("failed to load account by id %s: %w", id, err)
} }
if err := json.Unmarshal(settings, &account.Settings); err != nil {
return nil, fmt.Errorf("failed to unmarshal account settings: %w", err)
}
account.Created = time.Unix(created, 0) account.Created = time.Unix(created, 0)
account.LastLogin = time.Unix(lastLogin, 0) account.LastLogin = time.Unix(lastLogin, 0)
return &account, nil return &account, nil
@ -193,9 +203,11 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
var ( var (
created int64 created int64
lastLogin int64 lastLogin int64
settings []byte
) )
err := db.QueryRow( err := db.QueryRow(
`SELECT id, salt, password_hash, is_admin, created, last_login, settings, password_reset `SELECT id, salt, password_hash, is_admin, created, last_login,
json_extract(settings, '$'), password_reset
FROM accounts FROM accounts
WHERE username = ?;`, WHERE username = ?;`,
username, username,
@ -205,7 +217,7 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
&account.IsAdmin, &account.IsAdmin,
&created, &created,
&lastLogin, &lastLogin,
&account.Settings, &settings,
&account.PasswordReset) &account.PasswordReset)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@ -213,6 +225,9 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) {
} }
return nil, fmt.Errorf("failed to load account by username %s: %w", username, err) return nil, fmt.Errorf("failed to load account by username %s: %w", username, err)
} }
if err := json.Unmarshal(settings, &account.Settings); err != nil {
return nil, fmt.Errorf("failed to unmarshal account settings: %w", err)
}
account.Created = time.Unix(created, 0) account.Created = time.Unix(created, 0)
account.LastLogin = time.Unix(lastLogin, 0) account.LastLogin = time.Unix(lastLogin, 0)
return &account, nil return &account, nil
@ -242,13 +257,20 @@ func (db *DB) SaveAccount(account *model.Account) error {
func (db *DB) SaveAccountSettings(account *model.Account, settings *model.Settings) error { func (db *DB) SaveAccountSettings(account *model.Account, settings *model.Settings) error {
data, err := json.Marshal(settings) data, err := json.Marshal(settings)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal settings for user account %s: %w", account.Username, err) return fmt.Errorf("failed to marshal settings for user accont %s: %w", account.Username, err)
} }
_, err = db.Exec(`UPDATE accounts SET settings = ? WHERE id = ?`, data, account.Id) _, err = db.Exec(`UPDATE accounts SET settings = ? WHERE id = ?`, data, account.Id)
if err != nil { if err != nil {
return fmt.Errorf("failed to update account settings for user account %s: %w", account.Username, err) return fmt.Errorf("failed to update account settings for user account %s: %w", account.Username, err)
} }
_, err = db.Exec(`UPDATE sessions SET settings = ? WHERE account_id = ?`, data, account.Id) _, err = db.Exec(
`UPDATE sessions
SET data = jsonb_replace(data,
'$.settings', jsonb(:data),
'$.account.settings', jsonb(:data))
WHERE data->'account'->>'id' = :id`,
sql.Named("data", data),
sql.Named("id", account.Id))
if err != nil { if err != nil {
return fmt.Errorf("failed to update account settings for user account %s: %w", account.Username, err) return fmt.Errorf("failed to update account settings for user account %s: %w", account.Username, err)
} }

View file

@ -3,6 +3,7 @@ package database
import ( import (
"database/sql" "database/sql"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"time" "time"
@ -10,31 +11,35 @@ import (
"git.adyxax.org/adyxax/tfstated/pkg/helpers" "git.adyxax.org/adyxax/tfstated/pkg/helpers"
"git.adyxax.org/adyxax/tfstated/pkg/model" "git.adyxax.org/adyxax/tfstated/pkg/model"
"git.adyxax.org/adyxax/tfstated/pkg/scrypto" "git.adyxax.org/adyxax/tfstated/pkg/scrypto"
"go.n16f.net/uuid"
) )
func (db *DB) CreateSession(account *model.Account, settingsData []byte) (string, error) { func (db *DB) CreateSession(sessionData *model.SessionData) (string, *model.Session, error) {
sessionBytes := scrypto.RandomBytes(32) sessionBytes := scrypto.RandomBytes(32)
sessionId := base64.RawURLEncoding.EncodeToString(sessionBytes[:]) sessionId := base64.RawURLEncoding.EncodeToString(sessionBytes[:])
sessionHash := helpers.HashSessionId(sessionBytes, db.sessionsSalt.Bytes()) sessionHash := helpers.HashSessionId(sessionBytes, db.sessionsSalt.Bytes())
var accountId *uuid.UUID = nil if sessionData == nil {
var settings = []byte("{}") var err error
if account != nil { sessionData, err = model.NewSessionData(nil, nil)
accountId = &account.Id if err != nil {
settings = account.Settings return "", nil, fmt.Errorf("failed to generate new session data: %w", err)
} else if settingsData != nil { }
settings = settingsData }
data, err := json.Marshal(sessionData)
if err != nil {
return "", nil, fmt.Errorf("failed to marshal session data: %w", err)
} }
if _, err := db.Exec( if _, err := db.Exec(
`INSERT INTO sessions(id, account_id, settings) `INSERT INTO sessions(id, data)
VALUES (?, ?, ?);`, VALUES (?, jsonb(?));`,
sessionHash, sessionHash,
accountId, data,
settings,
); err != nil { ); err != nil {
return "", fmt.Errorf("failed insert new session in database: %w", err) return "", nil, fmt.Errorf("failed insert new session in database: %w", err)
} }
return sessionId, nil return sessionId, &model.Session{
Id: sessionHash,
Data: sessionData,
}, nil
} }
func (db *DB) DeleteExpiredSessions() error { func (db *DB) DeleteExpiredSessions() error {
@ -66,38 +71,44 @@ func (db *DB) LoadSessionById(id string) (*model.Session, error) {
var ( var (
created int64 created int64
updated int64 updated int64
data []byte
) )
err = db.QueryRow( err = db.QueryRow(
`SELECT account_id, `SELECT created,
created,
updated, updated,
settings json_extract(data, '$')
FROM sessions FROM sessions
WHERE id = ?;`, WHERE id = ?;`,
sessionHash, sessionHash,
).Scan(&session.AccountId, ).Scan(
&created, &created,
&updated, &updated,
&session.Settings, &data)
)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("failed to load session by id %s: %w", id, err) return nil, fmt.Errorf("failed to load session by id %s: %w", id, err)
} }
if err := json.Unmarshal(data, &session.Data); err != nil {
return nil, fmt.Errorf("failed to unmarshal session data: %w", err)
}
session.Created = time.Unix(created, 0) session.Created = time.Unix(created, 0)
session.Updated = time.Unix(updated, 0) session.Updated = time.Unix(updated, 0)
return &session, nil return &session, nil
} }
func (db *DB) MigrateSession(session *model.Session, account *model.Account) (string, error) { func (db *DB) MigrateSession(session *model.Session, account *model.Account) (string, *model.Session, error) {
if err := db.DeleteSession(session); err != nil { if err := db.DeleteSession(session); err != nil {
return "", fmt.Errorf("failed to delete session: %w", err) return "", nil, fmt.Errorf("failed to delete session: %w", err)
} }
sessionId, err := db.CreateSession(account, session.Settings) sessionData, err := model.NewSessionData(account, session.Data.Settings)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create session: %w", err) return "", nil, fmt.Errorf("failed to generate new session data: %w", err)
} }
return sessionId, nil sessionId, session, err := db.CreateSession(sessionData)
if err != nil {
return "", nil, fmt.Errorf("failed to create session: %w", err)
}
return sessionId, session, nil
} }

View file

@ -13,15 +13,15 @@ CREATE TABLE accounts (
settings BLOB NOT NULL, settings BLOB NOT NULL,
password_reset TEXT password_reset TEXT
) STRICT; ) STRICT;
CREATE UNIQUE INDEX accounts_username on accounts(username); CREATE UNIQUE INDEX accounts_username ON accounts(username);
CREATE TABLE sessions ( CREATE TABLE sessions (
id BLOB PRIMARY KEY, id BLOB PRIMARY KEY,
account_id TEXT,
created INTEGER NOT NULL DEFAULT (unixepoch()), created INTEGER NOT NULL DEFAULT (unixepoch()),
updated INTEGER NOT NULL DEFAULT (unixepoch()), updated INTEGER NOT NULL DEFAULT (unixepoch()),
settings BLOB NOT NULL data BLOB NOT NULL
) STRICT; ) STRICT;
CREATE INDEX sessions_data_account_id ON sessions(data->'account'->>'id');
CREATE TABLE states ( CREATE TABLE states (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -30,7 +30,7 @@ CREATE TABLE states (
created INTEGER DEFAULT (unixepoch()), created INTEGER DEFAULT (unixepoch()),
updated INTEGER DEFAULT (unixepoch()) updated INTEGER DEFAULT (unixepoch())
) STRICT; ) STRICT;
CREATE UNIQUE INDEX states_path on states(path); CREATE UNIQUE INDEX states_path ON states(path);
CREATE TABLE versions ( CREATE TABLE versions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,

View file

@ -2,7 +2,6 @@ package model
import ( import (
"crypto/subtle" "crypto/subtle"
"encoding/json"
"time" "time"
"git.adyxax.org/adyxax/tfstated/pkg/helpers" "git.adyxax.org/adyxax/tfstated/pkg/helpers"
@ -12,15 +11,15 @@ import (
type AccountContextKey struct{} type AccountContextKey struct{}
type Account struct { type Account struct {
Id uuid.UUID Id uuid.UUID `json:"id"`
Username string Username string `json:"username"`
Salt []byte Salt []byte `json:"salt"`
PasswordHash []byte PasswordHash []byte `json:"password_hash"`
IsAdmin bool IsAdmin bool `json:"is_admin"`
Created time.Time Created time.Time `json:"created"`
LastLogin time.Time LastLogin time.Time `json:"last_login"`
Settings json.RawMessage Settings *Settings `json:"settings"`
PasswordReset *uuid.UUID PasswordReset *uuid.UUID `json:"password_reset"`
} }
func (account *Account) CheckPassword(password string) bool { func (account *Account) CheckPassword(password string) bool {

View file

@ -1,20 +1,40 @@
package model package model
import ( import (
"encoding/json" "fmt"
"time" "time"
"go.n16f.net/uuid" "go.n16f.net/uuid"
) )
type SessionData struct {
Account *Account `json:"account"`
CsrfToken uuid.UUID `json:"csrf_token"`
Settings *Settings `json:"settings"`
}
func NewSessionData(account *Account, previousSessionSettings *Settings) (*SessionData, error) {
data := SessionData{Account: account}
if err := data.CsrfToken.Generate(uuid.V4); err != nil {
return nil, fmt.Errorf("failed to generate csrf token uuid: %w", err)
}
if account != nil {
data.Settings = account.Settings
} else if previousSessionSettings != nil {
data.Settings = previousSessionSettings
} else {
data.Settings = &Settings{}
}
return &data, nil
}
type SessionContextKey struct{} type SessionContextKey struct{}
type Session struct { type Session struct {
Id []byte Id []byte
AccountId *uuid.UUID Created time.Time
Created time.Time Updated time.Time
Updated time.Time Data *SessionData
Settings json.RawMessage
} }
func (session *Session) IsExpired() bool { func (session *Session) IsExpired() bool {

View file

@ -1,7 +1,5 @@
package model package model
type SettingsContextKey struct{}
type Settings struct { type Settings struct {
LightMode bool `json:"light_mode"` LightMode bool `json:"light_mode"`
} }

View file

@ -11,7 +11,6 @@ import (
type AccountsPage struct { type AccountsPage struct {
Accounts []model.Account Accounts []model.Account
ActiveTab int
IsAdmin string IsAdmin string
Page *Page Page *Page
Username string Username string
@ -45,11 +44,10 @@ func handleAccountsPOST(db *database.DB) http.Handler {
accountUsername := r.FormValue("username") accountUsername := r.FormValue("username")
isAdmin := r.FormValue("isAdmin") isAdmin := r.FormValue("isAdmin")
page := AccountsPage{ page := AccountsPage{
ActiveTab: 1, Page: makePage(r, &Page{Title: "New Account", Section: "accounts"}),
Page: makePage(r, &Page{Title: "New Account", Section: "accounts"}), Accounts: accounts,
Accounts: accounts, IsAdmin: isAdmin,
IsAdmin: isAdmin, Username: accountUsername,
Username: accountUsername,
} }
if ok := validUsername.MatchString(accountUsername); !ok { if ok := validUsername.MatchString(accountUsername); !ok {
page.UsernameInvalid = true page.UsernameInvalid = true

View file

@ -1,6 +1,7 @@
package webui package webui
import ( import (
"fmt"
"html/template" "html/template"
"net/http" "net/http"
@ -35,7 +36,7 @@ func handleAccountsIdGET(db *database.DB) http.Handler {
return return
} }
if account == nil { if account == nil {
errorResponse(w, r, http.StatusNotFound, err) errorResponse(w, r, http.StatusNotFound, fmt.Errorf("The account Id could not be found."))
return return
} }
statePaths, err := db.LoadStatePaths() statePaths, err := db.LoadStatePaths()

View file

@ -19,37 +19,36 @@ type AccountsIdResetPasswordPage struct {
var accountsIdResetPasswordTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/accountsIdResetPassword.html")) 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) { func processAccountsIdResetPasswordPathValues(db *database.DB, w http.ResponseWriter, r *http.Request) *model.Account {
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) return nil
return nil, false
} }
var token uuid.UUID var token uuid.UUID
if err := token.Parse(r.PathValue("token")); err != nil { if err := token.Parse(r.PathValue("token")); err != nil {
errorResponse(w, r, http.StatusBadRequest, err) errorResponse(w, r, http.StatusBadRequest, err)
return nil, false 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 nil, false return nil
} }
if account == nil || account.PasswordReset == nil { if account == nil || account.PasswordReset == nil {
errorResponse(w, r, http.StatusBadRequest, err) errorResponse(w, r, http.StatusBadRequest, err)
return nil, false return nil
} }
if !account.PasswordReset.Equal(token) { if !account.PasswordReset.Equal(token) {
errorResponse(w, r, http.StatusBadRequest, err) errorResponse(w, r, http.StatusBadRequest, err)
return nil, false return nil
} }
return account, true return account
} }
func handleAccountsIdResetPasswordGET(db *database.DB) http.Handler { func handleAccountsIdResetPasswordGET(db *database.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
account, valid := processAccountsIdResetPasswordPathValues(db, w, r) account := processAccountsIdResetPasswordPathValues(db, w, r)
if !valid { if account == nil {
return return
} }
render(w, accountsIdResetPasswordTemplates, http.StatusOK, render(w, accountsIdResetPasswordTemplates, http.StatusOK,
@ -63,8 +62,8 @@ func handleAccountsIdResetPasswordGET(db *database.DB) http.Handler {
func handleAccountsIdResetPasswordPOST(db *database.DB) http.Handler { func handleAccountsIdResetPasswordPOST(db *database.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
account, valid := processAccountsIdResetPasswordPathValues(db, w, r) account := processAccountsIdResetPasswordPathValues(db, w, r)
if !valid { if account == nil {
return return
} }
password := r.FormValue("password") password := r.FormValue("password")

View file

@ -4,21 +4,14 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"git.adyxax.org/adyxax/tfstated/pkg/database"
"git.adyxax.org/adyxax/tfstated/pkg/model" "git.adyxax.org/adyxax/tfstated/pkg/model"
) )
func adminMiddleware(db *database.DB, requireLogin func(http.Handler) http.Handler) func(http.Handler) http.Handler { func adminMiddleware(requireLogin func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return requireLogin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return requireLogin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
account := r.Context().Value(model.AccountContextKey{}) session := r.Context().Value(model.SessionContextKey{}).(*model.Session)
if account == nil { if !session.Data.Account.IsAdmin {
// this could happen if the account was deleted in the short
// time between retrieving the session and here
http.Redirect(w, r, "/login", http.StatusFound)
return
}
if !account.(*model.Account).IsAdmin {
errorResponse(w, r, http.StatusForbidden, fmt.Errorf("Only administrators can perform this request.")) errorResponse(w, r, http.StatusForbidden, fmt.Errorf("Only administrators can perform this request."))
return return
} }

View file

@ -7,7 +7,7 @@
Use this page to inspect user accounts. Use this page to inspect user accounts.
</p> </p>
</div> </div>
{{ if .Page.IsAdmin }} {{ if .Page.Session.Data.Account.IsAdmin }}
<form action="/accounts" enctype="multipart/form-data" method="post"> <form action="/accounts" enctype="multipart/form-data" method="post">
<fieldset> <fieldset>
<legend>New User Account</legend> <legend>New User Account</legend>

View file

@ -24,7 +24,7 @@
{{ if .Account.IsAdmin }} {{ if .Account.IsAdmin }}
<p>This accounts has <strong>admin</strong> privileges on TfStated.</p> <p>This accounts has <strong>admin</strong> privileges on TfStated.</p>
{{ end }} {{ end }}
{{ if .Page.IsAdmin }} {{ if .Page.Session.Data.Account.IsAdmin }}
<h2>Operations</h2> <h2>Operations</h2>
<form action="/accounts/{{ .Account.Id }}" enctype="multipart/form-data" method="post"> <form action="/accounts/{{ .Account.Id }}" enctype="multipart/form-data" method="post">
<div class="flex-row"> <div class="flex-row">
@ -39,7 +39,7 @@
value="{{ .Username }}"> value="{{ .Username }}">
<label for="is-admin">Is Admin</label> <label for="is-admin">Is Admin</label>
<input {{ if .Account.IsAdmin }}checked{{ end }} <input {{ if .Account.IsAdmin }}checked{{ end }}
{{ if eq .Page.AccountId.String .Account.Id.String }}disabled{{ end }} {{ if eq .Page.Session.Data.Account.Id.String .Account.Id.String }}disabled{{ end }}
id="is-admin" id="is-admin"
name="is-admin" name="is-admin"
type="checkbox" type="checkbox"
@ -63,13 +63,13 @@
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Danger Zone</legend> <legend>Danger Zone</legend>
<button {{ if eq .Page.AccountId.String .Account.Id.String }}disabled{{ end }} <button {{ if eq .Page.Session.Data.Account.Id.String .Account.Id.String }}disabled{{ end }}
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.AccountId.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 }}
type="submit" type="submit"
value="reset-password"> value="reset-password">
Reset Password Reset Password

View file

@ -8,7 +8,7 @@
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet" />
<title>TFSTATED - {{ .Page.Title }}</title> <title>TFSTATED - {{ .Page.Title }}</title>
</head> </head>
<body class="{{ if .Page.LightMode }}light-theme{{ else }}black-theme{{ end }}"> <body class="{{ if .Page.Session.Data.Settings.LightMode }}light-theme{{ else }}black-theme{{ end }}">
<header> <header>
<h6>TFSTATED</h6> <h6>TFSTATED</h6>
</header> </header>

View file

@ -5,7 +5,7 @@
<legend>Account Settings</legend> <legend>Account Settings</legend>
<div style="align-items:center; display:grid; grid-template-columns:1fr 1fr;"> <div style="align-items:center; display:grid; grid-template-columns:1fr 1fr;">
<label for="dark-mode">Dark mode</label> <label for="dark-mode">Dark mode</label>
<input {{ if not .Settings.LightMode }}checked{{ end }} <input {{ if not .Page.Session.Data.Settings.LightMode }}checked{{ end }}
id="dark-mode" id="dark-mode"
name="dark-mode" name="dark-mode"
type="checkbox" type="checkbox"

View file

@ -5,26 +5,16 @@ import (
"net/http" "net/http"
"git.adyxax.org/adyxax/tfstated/pkg/model" "git.adyxax.org/adyxax/tfstated/pkg/model"
"go.n16f.net/uuid"
) )
type Page struct { type Page struct {
AccountId *uuid.UUID Section string
IsAdmin bool Session *model.Session
LightMode bool Title string
Section string
Title string
} }
func makePage(r *http.Request, page *Page) *Page { func makePage(r *http.Request, page *Page) *Page {
accountCtx := r.Context().Value(model.AccountContextKey{}) page.Session = r.Context().Value(model.SessionContextKey{}).(*model.Session)
if accountCtx != nil {
account := accountCtx.(*model.Account)
page.AccountId = &account.Id
page.IsAdmin = account.IsAdmin
}
settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings)
page.LightMode = settings.LightMode
return page return page
} }

View file

@ -26,8 +26,8 @@ func handleLoginGET() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, no-cache") w.Header().Set("Cache-Control", "no-store, no-cache")
account := r.Context().Value(model.AccountContextKey{}) session := r.Context().Value(model.SessionContextKey{}).(*model.Session)
if account != nil { if session.Data.Account != nil {
http.Redirect(w, r, "/states", http.StatusFound) http.Redirect(w, r, "/states", http.StatusFound)
return return
} }
@ -56,7 +56,7 @@ func handleLoginPOST(db *database.DB) http.Handler {
password := r.FormValue("password") password := r.FormValue("password")
if username == "" || password == "" { if username == "" || password == "" {
errorResponse(w, r, http.StatusBadRequest, nil) errorResponse(w, r, http.StatusBadRequest, fmt.Errorf("Invalid username or password"))
return return
} }
if ok := validUsername.MatchString(username); !ok { if ok := validUsername.MatchString(username); !ok {
@ -79,41 +79,31 @@ func handleLoginPOST(db *database.DB) http.Handler {
return return
} }
session := r.Context().Value(model.SessionContextKey{}).(*model.Session) session := r.Context().Value(model.SessionContextKey{}).(*model.Session)
sessionId, err := db.MigrateSession(session, account) sessionId, session, err := db.MigrateSession(session, account)
if err != nil { if err != nil {
errorResponse(w, r, http.StatusInternalServerError, errorResponse(w, r, http.StatusInternalServerError,
fmt.Errorf("failed to migrate session: %w", err)) fmt.Errorf("failed to migrate session: %w", err))
return return
} }
setSessionCookie(w, sessionId) setSessionCookie(w, sessionId)
ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session)
if err := db.DeleteExpiredSessions(); err != nil { if err := db.DeleteExpiredSessions(); err != nil {
slog.Error("failed to delete expired sessions after user login", "err", err, "accountId", account.Id) slog.Error("failed to delete expired sessions after user login", "err", err, "accountId", account.Id)
} }
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r.WithContext(ctx), "/", http.StatusFound)
}) })
} }
func loginMiddleware(db *database.DB, requireSession func(http.Handler) http.Handler) func(http.Handler) http.Handler { func loginMiddleware(requireSession func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return requireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return requireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, no-cache") w.Header().Set("Cache-Control", "no-store, no-cache")
session := r.Context().Value(model.SessionContextKey{}).(*model.Session) session := r.Context().Value(model.SessionContextKey{}).(*model.Session)
if session.AccountId == nil { if session.Data.Account == nil {
http.Redirect(w, r, "/login", http.StatusFound) http.Redirect(w, r, "/login", http.StatusFound)
return return
} }
account, err := db.LoadAccountById(session.AccountId) next.ServeHTTP(w, r)
if err != nil {
errorResponse(w, r, http.StatusInternalServerError,
fmt.Errorf("failed to load account by Id: %w", err))
return
}
if account == nil {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), model.AccountContextKey{}, account)
next.ServeHTTP(w, r.WithContext(ctx))
})) }))
} }
} }

View file

@ -1,6 +1,7 @@
package webui package webui
import ( import (
"context"
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
@ -17,15 +18,16 @@ func handleLogoutGET(db *database.DB) http.Handler {
} }
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value(model.SessionContextKey{}).(*model.Session) session := r.Context().Value(model.SessionContextKey{}).(*model.Session)
sessionId, err := db.MigrateSession(session, nil) sessionId, session, err := db.MigrateSession(session, nil)
if err != nil { if err != nil {
errorResponse(w, r, http.StatusInternalServerError, errorResponse(w, r, http.StatusInternalServerError,
fmt.Errorf("failed to migrate session: %w", err)) fmt.Errorf("failed to migrate session: %w", err))
return return
} }
setSessionCookie(w, sessionId) setSessionCookie(w, sessionId)
ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session)
render(w, logoutTemplate, http.StatusOK, logoutPage{ render(w, logoutTemplate, http.StatusOK, logoutPage{
Page: makePage(r, &Page{Title: "Logout", Section: "login"}), Page: makePage(r.WithContext(ctx), &Page{Title: "Logout", Section: "login"}),
}) })
}) })
} }

View file

@ -11,8 +11,8 @@ func addRoutes(
db *database.DB, db *database.DB,
) { ) {
requireSession := sessionsMiddleware(db) requireSession := sessionsMiddleware(db)
requireLogin := loginMiddleware(db, requireSession) requireLogin := loginMiddleware(requireSession)
requireAdmin := adminMiddleware(db, 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("GET /accounts/{id}/reset/{token}", requireSession(handleAccountsIdResetPasswordGET(db))) mux.Handle("GET /accounts/{id}/reset/{token}", requireSession(handleAccountsIdResetPasswordGET(db)))

View file

@ -2,10 +2,8 @@ package webui
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"git.adyxax.org/adyxax/tfstated/pkg/database" "git.adyxax.org/adyxax/tfstated/pkg/database"
@ -40,26 +38,20 @@ func sessionsMiddleware(db *database.DB) func(http.Handler) http.Handler {
} }
} else { } else {
ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session) ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session)
var settings model.Settings
if err := json.Unmarshal(session.Settings, &settings); err != nil {
slog.Error("failed to unmarshal session settings", "err", err)
}
ctx = context.WithValue(ctx, model.SettingsContextKey{}, &settings)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
return return
} }
} }
} }
} }
sessionId, err := db.CreateSession(nil, nil) sessionId, session, err := db.CreateSession(nil)
if err != nil { if err != nil {
errorResponse(w, r, http.StatusInternalServerError, errorResponse(w, r, http.StatusInternalServerError,
fmt.Errorf("failed to create session: %w", err)) fmt.Errorf("failed to create session: %w", err))
return return
} }
setSessionCookie(w, sessionId) setSessionCookie(w, sessionId)
var settings model.Settings ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session)
ctx := context.WithValue(r.Context(), model.SettingsContextKey{}, &settings)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

View file

@ -18,10 +18,8 @@ var settingsTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html",
func handleSettingsGET(db *database.DB) http.Handler { func handleSettingsGET(db *database.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
settings := r.Context().Value(model.SettingsContextKey{}).(*model.Settings)
render(w, settingsTemplates, http.StatusOK, SettingsPage{ render(w, settingsTemplates, http.StatusOK, SettingsPage{
Page: makePage(r, &Page{Title: "Settings", Section: "settings"}), Page: makePage(r, &Page{Title: "Settings", Section: "settings"}),
Settings: settings,
}) })
}) })
} }
@ -36,16 +34,16 @@ func handleSettingsPOST(db *database.DB) http.Handler {
settings := model.Settings{ settings := model.Settings{
LightMode: darkMode != "1", LightMode: darkMode != "1",
} }
account := r.Context().Value(model.AccountContextKey{}).(*model.Account) session := r.Context().Value(model.SessionContextKey{}).(*model.Session)
err := db.SaveAccountSettings(account, &settings) session.Data.Settings = &settings
err := db.SaveAccountSettings(session.Data.Account, &settings)
if err != nil { if err != nil {
errorResponse(w, r, http.StatusInternalServerError, err) errorResponse(w, r, http.StatusInternalServerError, err)
return return
} }
ctx := context.WithValue(r.Context(), model.SettingsContextKey{}, &settings) ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session)
page := makePage(r.WithContext(ctx), &Page{Title: "Settings", Section: "settings"})
render(w, settingsTemplates, http.StatusOK, SettingsPage{ render(w, settingsTemplates, http.StatusOK, SettingsPage{
Page: page, Page: makePage(r.WithContext(ctx), &Page{Title: "Settings", Section: "settings"}),
Settings: &settings, Settings: &settings,
}) })
}) })