auth: publish audited password recovery
verify / verify (push) Successful in 3m7s

Publish the reviewed Gamertan Web Foundations v0.1.0-preview.4 snapshot with local-only administrative reset, atomic Argon2id credential replacement, mandatory rotation, all-session revocation, secret-free audit evidence, rollback coverage, and exact application-boundary guidance.

Exported from reviewed private source 403e5f6ef4d0cac683aaa76ed922236571d259a9 after trusted CI run 317 and exact Go 1.26.6 verification.

Material implementation assistance provided by OpenAI Codex; reviewed and verified through the maintainer workflow.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
2026-08-18 09:31:08 -04:00
parent 5905fe6fb2
commit fb6bbd0dad
11 changed files with 241 additions and 9 deletions
+46 -2
View File
@@ -243,6 +243,38 @@ func (store *Store) ReplacePasswordAndRevokeSessions(ctx context.Context, userID
return tx.Commit()
}
func (store *Store) ResetPasswordAndRevokeSessions(ctx context.Context, userID, expectedHash, newHash string, changedAt time.Time, audit auth.AuditEvent) error {
if !opaqueID(userID) || !text(expectedHash, 1024, false) || !text(newHash, 1024, false) || changedAt.IsZero() || !validAuditEvent(audit) || audit.ActorUserID != "" || audit.ResourceType != "user" || audit.ResourceID != userID || !audit.CreatedAt.Equal(changedAt) {
return auth.ErrInvalidCredentials
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=? AND password_hash=?`, newHash, changedAt.Unix(), userID, expectedHash)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return auth.ErrInvalidCredentials
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=1,updated_at=? WHERE id=?`, changedAt.Unix(), userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) UpdateLastLogin(ctx context.Context, userID string, when time.Time) error {
if !opaqueID(userID) || when.IsZero() {
return errors.New("authsqlite: invalid login update")
@@ -379,13 +411,25 @@ func (store *Store) GrantRole(ctx context.Context, userID, role string, when tim
return err
}
func (store *Store) AppendAudit(ctx context.Context, event auth.AuditEvent) error {
if !opaqueID(event.ID) || event.ActorUserID != "" && !opaqueID(event.ActorUserID) || !safeName(event.Action) || !safeName(event.ResourceType) || !text(event.ResourceID, 256, false) || !text(event.RequestID, 128, true) || !text(event.Summary, 1024, true) || event.CreatedAt.IsZero() {
if !validAuditEvent(event) {
return errors.New("authsqlite: invalid audit event")
}
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix())
return appendAudit(ctx, store.db, event)
}
type auditExecer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
func appendAudit(ctx context.Context, execer auditExecer, event auth.AuditEvent) error {
_, err := execer.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix())
return err
}
func validAuditEvent(event auth.AuditEvent) bool {
return opaqueID(event.ID) && (event.ActorUserID == "" || opaqueID(event.ActorUserID)) && safeName(event.Action) && safeName(event.ResourceType) && text(event.ResourceID, 256, false) && text(event.RequestID, 128, true) && text(event.Summary, 1024, true) && !event.CreatedAt.IsZero()
}
func normalize(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
func safeName(value string) bool {
if value == "" || len(value) > 128 {
+92
View File
@@ -100,6 +100,98 @@ func TestRequiredPasswordChangeRotatesCredentialAndRevokesSessions(t *testing.T)
}
}
func TestAdministrativePasswordResetIsAtomicAndAudited(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(4000, 0).UTC()
service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "recover.me", Email: "recover@example.test", DisplayName: "Recovery Test", Password: "original permanent credential"})
if err != nil {
t.Fatal(err)
}
token, _, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
if err != nil {
t.Fatal(err)
}
reset, err := service.ResetPassword(t.Context(), auth.AdministrativePasswordReset{Identifier: user.Email, TemporaryPassword: "one-time recovery credential"})
if err != nil || !reset.PasswordChangeRequired {
t.Fatalf("reset=%+v err=%v", reset, err)
}
if _, err = service.Session(t.Context(), token); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived reset: %v", err)
}
if _, _, err = service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("old credential survived reset: %v", err)
}
_, principal, err := service.Authenticate(t.Context(), user.Username, "one-time recovery credential", time.Hour)
if err != nil || !principal.User.PasswordChangeRequired {
t.Fatalf("recovery principal=%+v err=%v", principal, err)
}
var action, summary string
var events int
if err = store.db.QueryRow(`SELECT COUNT(*),action,summary FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&events, &action, &summary); err != nil {
t.Fatal(err)
}
if events != 1 || action != "auth.password.reset" || strings.Contains(summary, "one-time recovery credential") || !strings.Contains(summary, "revoked all sessions") {
t.Fatalf("events=%d action=%q summary=%q", events, action, summary)
}
if _, err = service.ResetPassword(t.Context(), auth.AdministrativePasswordReset{Identifier: user.Username, TemporaryPassword: "one-time recovery credential"}); !errors.Is(err, auth.ErrPasswordUnchanged) {
t.Fatalf("same credential err=%v", err)
}
}
func TestAdministrativePasswordResetRollsBackWhenAuditCannotCommit(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(5000, 0).UTC()
service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "rollback.me", Email: "rollback@example.test", DisplayName: "Rollback Test", Password: "original permanent credential"})
if err != nil {
t.Fatal(err)
}
token, _, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
if err != nil {
t.Fatal(err)
}
user, currentHash, err := store.CredentialByUserID(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
newHash, err := auth.HashPassword("one-time recovery credential")
if err != nil {
t.Fatal(err)
}
audit := auth.AuditEvent{ID: "duplicate-audit-id", Action: "auth.password.reset", ResourceType: "user", ResourceID: user.ID, Summary: "A local administrator issued a one-time credential and revoked all sessions.", CreatedAt: now}
if err = store.AppendAudit(t.Context(), audit); err != nil {
t.Fatal(err)
}
if err = store.ResetPasswordAndRevokeSessions(t.Context(), user.ID, currentHash, newHash, now, audit); err == nil {
t.Fatal("duplicate audit unexpectedly committed reset")
}
if _, err = service.Session(t.Context(), token); err != nil {
t.Fatalf("rollback revoked session: %v", err)
}
_, principal, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
if err != nil || principal.User.PasswordChangeRequired {
t.Fatalf("original credential not restored: principal=%+v err=%v", principal, err)
}
if _, _, err = service.Authenticate(t.Context(), user.Username, "one-time recovery credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("uncommitted recovery credential accepted: %v", err)
}
}
func TestMigrationAddsPasswordRequirementWithoutChangingExistingUsers(t *testing.T) {
path := filepath.Join(t.TempDir(), "accounts.db")
database, err := sql.Open("sqlite", path)