Add session-bound personal profile editing
verify / verify (push) Successful in 4m35s

This commit is contained in:
2026-09-05 02:37:38 -04:00
parent 7c68a3499a
commit a16283efd7
12 changed files with 499 additions and 8 deletions
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"errors"
"math"
"time"
"gamertan.com/web/auth"
"modernc.org/sqlite"
)
var _ auth.OwnProfileRepository = (*Store)(nil)
const ownProfileQuery = `SELECT u.id,u.username,u.email,u.display_name,u.profile_revision
FROM gwf_users u JOIN gwf_auth_sessions s ON s.user_id=u.id
WHERE s.token_hash=? AND s.expires_at>? AND u.status='active'
AND u.registration_pending=0 AND u.password_change_required=0`
func scanOwnProfile(row interface{ Scan(...any) error }) (auth.OwnProfile, error) {
var profile auth.OwnProfile
err := row.Scan(&profile.UserID, &profile.Username, &profile.Email, &profile.DisplayName, &profile.Revision)
if errors.Is(err, sql.ErrNoRows) {
return auth.OwnProfile{}, auth.ErrProfileAccess
}
return profile, err
}
func (store *Store) OwnProfile(ctx context.Context, session [32]byte, now time.Time) (auth.OwnProfile, error) {
if zeroDigest(session) || now.IsZero() {
return auth.OwnProfile{}, auth.ErrProfileAccess
}
return scanOwnProfile(store.db.QueryRowContext(ctx, ownProfileQuery, session[:], now.Unix()))
}
func (store *Store) UpdateOwnProfile(ctx context.Context, change auth.ProfileEdit, audit auth.AuditEvent) (auth.OwnProfile, error) {
value, err := auth.NormalizeProfileValue(change.Field, change.Value)
if err != nil || !opaqueID(change.UserID) || zeroDigest(change.SessionDigest) || change.ExpectedRevision < 1 || change.ExpectedRevision == math.MaxInt64 ||
!validAuditEvent(audit) || audit.ActorUserID != change.UserID || audit.ResourceType != "user" || audit.ResourceID != change.UserID || audit.Action != "auth.profile."+change.Field ||
change.ExpectedPasswordHash != "" && (change.Field != "username" || len(change.ExpectedPasswordHash) > 1024) {
return auth.OwnProfile{}, auth.ErrProfileInput
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.OwnProfile{}, err
}
defer tx.Rollback()
// This first statement takes the writer lock and tests the real current
// session/account/revision together. No read-before-write lock upgrade race.
set := `display_name=?`
args := []any{value}
if change.Field == "username" {
set = `username=?,username_normalized=?`
args = append(args, normalize(value))
}
args = append(args, audit.CreatedAt.Unix(), change.UserID, change.ExpectedRevision, change.SessionDigest[:], audit.CreatedAt.Unix(), change.ExpectedPasswordHash, change.ExpectedPasswordHash)
result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET `+set+`,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?)
WHERE id=? AND profile_revision=? AND status='active' AND registration_pending=0 AND password_change_required=0
AND EXISTS (SELECT 1 FROM gwf_auth_sessions WHERE user_id=gwf_users.id AND token_hash=? AND expires_at>?)
AND (?='' OR EXISTS (SELECT 1 FROM gwf_password_credentials WHERE user_id=gwf_users.id AND password_hash=?))`, args...)
if err != nil {
var constraint *sqlite.Error
if errors.As(err, &constraint) && constraint.Code() == 2067 {
return auth.OwnProfile{}, auth.ErrUsernameUnavailable
}
return auth.OwnProfile{}, err
}
changed, err := result.RowsAffected()
if err != nil {
return auth.OwnProfile{}, err
}
if changed != 1 {
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
if err != nil {
return auth.OwnProfile{}, err
}
if profile.UserID != change.UserID {
return auth.OwnProfile{}, auth.ErrProfileAccess
}
return auth.OwnProfile{}, auth.ErrProfileConflict
}
if change.Field == "username" {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=? AND token_hash<>?`, change.UserID, change.SessionDigest[:]); err != nil {
return auth.OwnProfile{}, err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return auth.OwnProfile{}, err
}
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
if err != nil {
return auth.OwnProfile{}, err
}
if err = tx.Commit(); err != nil {
return auth.OwnProfile{}, err
}
return profile, nil
}