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