Author SHA1 Message Date
gamertan fb6bbd0dad 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>
2026-08-18 09:31:08 -04:00
gamertan 5905fe6fb2 auth: publish one-time bootstrap rotation
verify / verify (push) Successful in 3m14s
Publish the reviewed Web Foundations v0.1.0-preview.3 snapshot with cryptographic temporary credentials, explicit forced-rotation state, atomic password replacement and session revocation, additive SQLite migration, tests, and application-boundary documentation.

Exported from reviewed private source b8fb4ff3cd012859f2d307dfb2a1cc783a38f6db after trusted CI run 257 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>
2026-08-18 00:08:01 -04:00
12 changed files with 520 additions and 15 deletions
+23
View File
@@ -2,6 +2,29 @@
# Changelog # Changelog
## v0.1.0-preview.4 — 2026-08-18
- Add an explicit local-administrator password recovery operation without
adding a public recovery endpoint or network protocol.
- Atomically install a one-time Argon2id credential, restore mandatory password
rotation, revoke every session, and append a secret-free audit event.
- Prove transaction rollback when the audit event cannot commit and document
private mode-`0600` delivery as application-owned policy.
- Keep Previews 13 immutable; applications select Preview 4 explicitly when
adopting administrative recovery.
## v0.1.0-preview.3 — 2026-08-18
- Add cryptographically generated temporary credentials and an explicit
password-change-required account state.
- Replace credentials, clear the requirement, and revoke all existing sessions
in one repository transaction after verifying the current password.
- Migrate existing SQLite users with the new requirement disabled; applications
continue to own first-login routing, private credential delivery, and audit
policy.
- Keep Preview 1 and Preview 2 immutable; applications select Preview 3
explicitly when adopting forced bootstrap rotation.
## v0.1.0-preview.2 — 2026-08-17 ## v0.1.0-preview.2 — 2026-08-17
- Add storage-neutral organizations, teams, projects, environments, services, - Add storage-neutral organizations, teams, projects, environments, services,
+6 -5
View File
@@ -2,7 +2,7 @@
# Gamertan Web Foundations # Gamertan Web Foundations
> Status: `v0.1.0-preview.2` public preview. APIs may change before a stable > Status: `v0.1.0-preview.4` public preview. APIs may change before a stable
> release; Linux is the maintained release platform. > release; Linux is the maintained release platform.
Small, composable Go packages for the unglamorous boundaries of a careful web Small, composable Go packages for the unglamorous boundaries of a careful web
@@ -23,14 +23,14 @@ Pin the preview in an application module, then import only the packages that
application needs: application needs:
```bash ```bash
go get gamertan.com/web@v0.1.0-preview.2 go get gamertan.com/web@v0.1.0-preview.4
go mod verify go mod verify
``` ```
An application may also name the first package it intends to adopt: An application may also name the first package it intends to adopt:
```bash ```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.2 go get gamertan.com/web/requestmeta@v0.1.0-preview.4
``` ```
The version belongs to the `gamertan.com/web` module. Go compiles and links The version belongs to the `gamertan.com/web` module. Go compiles and links
@@ -56,8 +56,9 @@ without turning that portability into a maintained compatibility claim.
rate limits. rate limits.
- [`abuse`](abuse): application-classified request abuse with pluggable persistence. - [`abuse`](abuse): application-classified request abuse with pluggable persistence.
- [`auth`](auth), [`authhttp`](authhttp), and [`authsqlite`](authsqlite): - [`auth`](auth), [`authhttp`](authhttp), and [`authsqlite`](authsqlite):
passwords, sessions, platform-level permissions, cookies, and a no-CGO passwords, forced first-login rotation, local administrative recovery,
SQLite adapter. session revocation, platform-level permissions, cookies, and a no-CGO SQLite
adapter.
- [`organizations`](organizations) and [`access`](access): organizations, - [`organizations`](organizations) and [`access`](access): organizations,
teams, invitations, resource hierarchy, scoped roles, and audited temporary teams, invitations, resource hierarchy, scoped roles, and audited temporary
access without turning platform operation into tenant-data access. access without turning platform operation into tenant-data access.
+6
View File
@@ -14,3 +14,9 @@ service-level agreement. There is no bug bounty.
The preview supports only versions explicitly listed in release notes. Security The preview supports only versions explicitly listed in release notes. Security
claims stop at the documented trust boundaries and executable tests. claims stop at the documented trust boundaries and executable tests.
Password recovery is an explicitly local administrative capability. It must
not be wired directly to a public route. Applications using it are responsible
for local operator authorization and exclusive mode-`0600` credential delivery;
the library transaction requires a new password change, revokes all sessions,
and records a secret-free audit event.
+108 -2
View File
@@ -21,6 +21,7 @@ import (
var ( var (
ErrInvalidCredentials = errors.New("auth: invalid credentials") ErrInvalidCredentials = errors.New("auth: invalid credentials")
ErrInactiveUser = errors.New("auth: account is not active") ErrInactiveUser = errors.New("auth: account is not active")
ErrPasswordUnchanged = errors.New("auth: new password must differ from the current password")
ErrSessionNotFound = errors.New("auth: session not found") ErrSessionNotFound = errors.New("auth: session not found")
ErrUserNotFound = errors.New("auth: user not found") ErrUserNotFound = errors.New("auth: user not found")
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`) identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
@@ -29,6 +30,7 @@ var (
type User struct { type User struct {
ID, Username, Email, DisplayName, Status string ID, Username, Email, DisplayName, Status string
CreatedAt, UpdatedAt time.Time CreatedAt, UpdatedAt time.Time
PasswordChangeRequired bool
} }
type Principal struct { type Principal struct {
@@ -59,6 +61,9 @@ type PolicySeed struct {
type Repository interface { type Repository interface {
CreateUser(context.Context, User, string) error CreateUser(context.Context, User, string) error
CredentialByIdentifier(context.Context, string) (User, string, error) CredentialByIdentifier(context.Context, string) (User, string, error)
CredentialByUserID(context.Context, string) (User, string, error)
ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error
ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) error
UpdateLastLogin(context.Context, string, time.Time) error UpdateLastLogin(context.Context, string, time.Time) error
CreateSession(context.Context, Session) error CreateSession(context.Context, Session) error
PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error)
@@ -102,7 +107,17 @@ func New(repository Repository, options Options) (*Service, error) {
return &Service{repository: repository, random: options.Random, now: options.Now, touchInterval: options.TouchInterval}, nil return &Service{repository: repository, random: options.Random, now: options.Now, touchInterval: options.TouchInterval}, nil
} }
type CreateUser struct{ Username, Email, DisplayName, Password string } type CreateUser struct {
Username, Email, DisplayName, Password string
RequirePasswordChange bool
}
// AdministrativePasswordReset describes a locally authorized recovery. The
// application is responsible for delivering TemporaryPassword through a
// private, one-time channel; the value must never be logged or audited.
type AdministrativePasswordReset struct {
Identifier, TemporaryPassword string
}
func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) { func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) {
username := strings.TrimSpace(input.Username) username := strings.TrimSpace(input.Username)
@@ -120,13 +135,104 @@ func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User,
return User{}, err return User{}, err
} }
now := service.now().UTC() now := service.now().UTC()
user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now} user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now, PasswordChangeRequired: input.RequirePasswordChange}
if err = service.repository.CreateUser(ctx, user, hash); err != nil { if err = service.repository.CreateUser(ctx, user, hash); err != nil {
return User{}, err return User{}, err
} }
return user, nil return user, nil
} }
// GenerateTemporaryPassword returns 256 bits of URL-safe cryptographic
// entropy suitable for an application-managed one-time bootstrap credential.
func GenerateTemporaryPassword(random io.Reader) (string, error) {
if random == nil {
random = rand.Reader
}
return randomToken(random, 32)
}
// ChangePassword verifies the current credential, rejects reuse, replaces the
// Argon2id hash, clears the password-change requirement, and revokes every
// existing session through one repository operation.
func (service *Service) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
user, currentHash, err := service.repository.CredentialByUserID(ctx, strings.TrimSpace(userID))
if errors.Is(err, ErrUserNotFound) {
_ = VerifyPassword(dummyPasswordHash, currentPassword)
return ErrInvalidCredentials
}
if err != nil {
_ = VerifyPassword(dummyPasswordHash, currentPassword)
return fmt.Errorf("auth: load credentials: %w", err)
}
if !VerifyPassword(currentHash, currentPassword) {
return ErrInvalidCredentials
}
if user.Status != "active" {
return ErrInactiveUser
}
if currentPassword == newPassword {
return ErrPasswordUnchanged
}
newHash, err := HashPasswordWithRandom(newPassword, service.random)
if err != nil {
return err
}
if err = service.repository.ReplacePasswordAndRevokeSessions(ctx, user.ID, currentHash, newHash, service.now().UTC()); err != nil {
if errors.Is(err, ErrInvalidCredentials) {
return ErrInvalidCredentials
}
return fmt.Errorf("auth: replace password: %w", err)
}
return nil
}
// ResetPassword replaces an active user's credential without requiring the
// current password. It is intended only for a locally authorized
// administrative recovery command. The repository atomically requires another
// password change, revokes all sessions, and appends a secret-free audit event.
func (service *Service) ResetPassword(ctx context.Context, input AdministrativePasswordReset) (User, error) {
identifier := strings.TrimSpace(input.Identifier)
user, currentHash, err := service.repository.CredentialByIdentifier(ctx, identifier)
if errors.Is(err, ErrUserNotFound) {
return User{}, ErrUserNotFound
}
if err != nil {
return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err)
}
if user.Status != "active" {
return User{}, ErrInactiveUser
}
if VerifyPassword(currentHash, input.TemporaryPassword) {
return User{}, ErrPasswordUnchanged
}
newHash, err := HashPasswordWithRandom(input.TemporaryPassword, service.random)
if err != nil {
return User{}, err
}
auditID, err := randomToken(service.random, 18)
if err != nil {
return User{}, err
}
now := service.now().UTC()
audit := AuditEvent{
ID: auditID,
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 = service.repository.ResetPasswordAndRevokeSessions(ctx, user.ID, currentHash, newHash, now, audit); err != nil {
if errors.Is(err, ErrInvalidCredentials) {
return User{}, ErrInvalidCredentials
}
return User{}, fmt.Errorf("auth: reset password: %w", err)
}
user.PasswordChangeRequired = true
user.UpdatedAt = now
return user, nil
}
func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) { func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) {
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour { if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
return "", Principal{}, errors.New("auth: invalid session lifetime") return "", Principal{}, errors.New("auth: invalid session lifetime")
+13
View File
@@ -28,6 +28,19 @@ func TestPasswordEntropyFailsClosed(t *testing.T) {
} }
} }
func TestTemporaryPasswordUsesBoundedCryptographicEntropy(t *testing.T) {
password, err := GenerateTemporaryPassword(strings.NewReader(strings.Repeat("t", 32)))
if err != nil {
t.Fatal(err)
}
if len(password) != 43 || ValidatePassword(password) != nil || strings.ContainsAny(password, " \t\r\n") {
t.Fatalf("temporary password length=%d", len(password))
}
if _, err = GenerateTemporaryPassword(errorReader{}); err == nil {
t.Fatal("temporary password accepted entropy failure")
}
}
type errorReader struct{} type errorReader struct{}
func (errorReader) Read([]byte) (int, error) { return 0, errors.New("no entropy") } func (errorReader) Read([]byte) (int, error) { return 0, errors.New("no entropy") }
+9
View File
@@ -59,6 +59,15 @@ func (repositoryStub) CreateUser(context.Context, User, string) error { return n
func (repositoryStub) CredentialByIdentifier(context.Context, string) (User, string, error) { func (repositoryStub) CredentialByIdentifier(context.Context, string) (User, string, error) {
return User{}, "", ErrUserNotFound return User{}, "", ErrUserNotFound
} }
func (repositoryStub) CredentialByUserID(context.Context, string) (User, string, error) {
return User{}, "", ErrUserNotFound
}
func (repositoryStub) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
return nil
}
func (repositoryStub) ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) error {
return nil
}
func (repositoryStub) UpdateLastLogin(context.Context, string, time.Time) error { return nil } func (repositoryStub) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
func (repositoryStub) CreateSession(context.Context, Session) error { return nil } func (repositoryStub) CreateSession(context.Context, Session) error { return nil }
func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) { func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
+9
View File
@@ -109,6 +109,15 @@ func (authHTTPRepository) CreateUser(context.Context, auth.User, string) error {
func (authHTTPRepository) CredentialByIdentifier(context.Context, string) (auth.User, string, error) { func (authHTTPRepository) CredentialByIdentifier(context.Context, string) (auth.User, string, error) {
return auth.User{}, "", auth.ErrUserNotFound return auth.User{}, "", auth.ErrUserNotFound
} }
func (authHTTPRepository) CredentialByUserID(context.Context, string) (auth.User, string, error) {
return auth.User{}, "", auth.ErrUserNotFound
}
func (authHTTPRepository) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
return nil
}
func (authHTTPRepository) ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, auth.AuditEvent) error {
return nil
}
func (authHTTPRepository) UpdateLastLogin(context.Context, string, time.Time) error { return nil } func (authHTTPRepository) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
func (authHTTPRepository) CreateSession(context.Context, auth.Session) error { return nil } func (authHTTPRepository) CreateSession(context.Context, auth.Session) error { return nil }
func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) { func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) {
+135 -6
View File
@@ -77,7 +77,7 @@ func (store *Store) Migrate(ctx context.Context) error {
defer tx.Rollback() defer tx.Rollback()
statements := []string{ statements := []string{
`CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`, `CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`,
`CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
@@ -114,15 +114,47 @@ func (store *Store) Migrate(ctx context.Context) error {
return err return err
} }
} }
hasPasswordRequirement, err := sqliteColumnExists(ctx, tx, "gwf_users", "password_change_required")
if err != nil {
return err
}
if !hasPasswordRequirement {
if _, err = tx.ExecContext(ctx, `ALTER TABLE gwf_users ADD COLUMN password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1))`); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(1,?)`, time.Now().UTC().Unix()); err != nil { if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(1,?)`, time.Now().UTC().Unix()); err != nil {
return err return err
} }
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(2,?)`, time.Now().UTC().Unix()); err != nil { if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(2,?)`, time.Now().UTC().Unix()); err != nil {
return err return err
} }
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(3,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit() return tx.Commit()
} }
func sqliteColumnExists(ctx context.Context, tx *sql.Tx, table, column string) (bool, error) {
rows, err := tx.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var position, notNull, primaryKey int
var name, kind string
var defaultValue sql.NullString
if err = rows.Scan(&position, &name, &kind, &notNull, &defaultValue, &primaryKey); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash string) error { func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash string) error {
if !opaqueID(user.ID) || !text(user.Username, 64, false) || !text(user.Email, 320, false) || !text(user.DisplayName, 128, false) || (user.Status != "active" && user.Status != "suspended" && user.Status != "disabled") || user.CreatedAt.IsZero() || user.UpdatedAt.IsZero() || !text(passwordHash, 1024, false) { if !opaqueID(user.ID) || !text(user.Username, 64, false) || !text(user.Email, 320, false) || !text(user.DisplayName, 128, false) || (user.Status != "active" && user.Status != "suspended" && user.Status != "disabled") || user.CreatedAt.IsZero() || user.UpdatedAt.IsZero() || !text(passwordHash, 1024, false) {
return errors.New("authsqlite: invalid user") return errors.New("authsqlite: invalid user")
@@ -132,7 +164,7 @@ func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.CreatedAt.Unix(), user.UpdatedAt.Unix()) _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.PasswordChangeRequired, user.CreatedAt.Unix(), user.UpdatedAt.Unix())
if err != nil { if err != nil {
return err return err
} }
@@ -148,18 +180,101 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
} }
var user auth.User var user auth.User
var created, updated int64 var created, updated int64
var passwordChangeRequired int
var hash string var hash string
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &created, &updated, &hash) err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, "", auth.ErrUserNotFound return auth.User{}, "", auth.ErrUserNotFound
} }
if err != nil { if err != nil {
return auth.User{}, "", err return auth.User{}, "", err
} }
user.PasswordChangeRequired = passwordChangeRequired == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, hash, nil return user, hash, nil
} }
func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth.User, string, error) {
if !opaqueID(userID) {
return auth.User{}, "", auth.ErrUserNotFound
}
var user auth.User
var created, updated int64
var passwordChangeRequired int
var hash string
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.id=?`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, "", auth.ErrUserNotFound
}
if err != nil {
return auth.User{}, "", err
}
user.PasswordChangeRequired = passwordChangeRequired == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, hash, nil
}
func (store *Store) ReplacePasswordAndRevokeSessions(ctx context.Context, userID, expectedHash, newHash string, changedAt time.Time) error {
if !opaqueID(userID) || !text(expectedHash, 1024, false) || !text(newHash, 1024, false) || changedAt.IsZero() {
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=0,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
}
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 { func (store *Store) UpdateLastLogin(ctx context.Context, userID string, when time.Time) error {
if !opaqueID(userID) || when.IsZero() { if !opaqueID(userID) || when.IsZero() {
return errors.New("authsqlite: invalid login update") return errors.New("authsqlite: invalid login update")
@@ -183,10 +298,12 @@ func (store *Store) PrincipalBySession(ctx context.Context, digest [32]byte, now
var principal auth.Principal var principal auth.Principal
var session auth.Session var session auth.Session
var created, updated, sessionCreated, expires, lastSeen int64 var created, updated, sessionCreated, expires, lastSeen int64
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen) var passwordChangeRequired int
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &passwordChangeRequired, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound
} }
principal.User.PasswordChangeRequired = passwordChangeRequired == 1
if err != nil { if err != nil {
return auth.Principal{}, auth.Session{}, err return auth.Principal{}, auth.Session{}, err
} }
@@ -294,13 +411,25 @@ func (store *Store) GrantRole(ctx context.Context, userID, role string, when tim
return err return err
} }
func (store *Store) AppendAudit(ctx context.Context, event auth.AuditEvent) error { 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") 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 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 normalize(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
func safeName(value string) bool { func safeName(value string) bool {
if value == "" || len(value) > 128 { if value == "" || len(value) > 128 {
+167
View File
@@ -3,6 +3,8 @@
package authsqlite package authsqlite
import ( import (
"database/sql"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -55,6 +57,171 @@ func TestServiceRoundTripWithApplicationPolicy(t *testing.T) {
} }
} }
func TestRequiredPasswordChangeRotatesCredentialAndRevokesSessions(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(3000, 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: "bootstrap", Email: "bootstrap@example.test", DisplayName: "Bootstrap Operator", Password: "temporary bootstrap credential", RequirePasswordChange: true})
if err != nil || !user.PasswordChangeRequired {
t.Fatalf("user=%+v err=%v", user, err)
}
token, principal, err := service.Authenticate(t.Context(), user.Username, "temporary bootstrap credential", time.Hour)
if err != nil || !principal.User.PasswordChangeRequired {
t.Fatalf("principal=%+v err=%v", principal, err)
}
if err = service.ChangePassword(t.Context(), user.ID, "wrong current credential", "new permanent credential"); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("wrong current credential err=%v", err)
}
if _, err = service.Session(t.Context(), token); err != nil {
t.Fatalf("failed rotation revoked session: %v", err)
}
if err = service.ChangePassword(t.Context(), user.ID, "temporary bootstrap credential", "temporary bootstrap credential"); !errors.Is(err, auth.ErrPasswordUnchanged) {
t.Fatalf("reused credential err=%v", err)
}
if err = service.ChangePassword(t.Context(), user.ID, "temporary bootstrap credential", "new permanent credential"); err != nil {
t.Fatal(err)
}
if _, err = service.Session(t.Context(), token); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("old session survived rotation: %v", err)
}
if _, _, err = service.Authenticate(t.Context(), user.Username, "temporary bootstrap credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("temporary credential survived rotation: %v", err)
}
_, principal, err = service.Authenticate(t.Context(), user.Username, "new permanent credential", time.Hour)
if err != nil || principal.User.PasswordChangeRequired {
t.Fatalf("rotated principal=%+v err=%v", principal, err)
}
}
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)
if err != nil {
t.Fatal(err)
}
_, err = database.Exec(`CREATE TABLE gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`)
if err == nil {
_, err = database.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES('existing-user','existing','existing','existing@example.test','existing@example.test','Existing','active',1,1)`)
}
if closeErr := database.Close(); err == nil {
err = closeErr
}
if err != nil {
t.Fatal(err)
}
store, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer store.Close()
var required, migrations int
if err = store.db.QueryRow(`SELECT password_change_required FROM gwf_users WHERE id='existing-user'`).Scan(&required); err != nil || required != 0 {
t.Fatalf("required=%d err=%v", required, err)
}
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gamertan_web_migrations WHERE version=3`).Scan(&migrations); err != nil || migrations != 1 {
t.Fatalf("migrations=%d err=%v", migrations, err)
}
}
func TestSchemaIsNamespacedAndSeedsNothing(t *testing.T) { func TestSchemaIsNamespacedAndSeedsNothing(t *testing.T) {
path := filepath.Join(t.TempDir(), "accounts.db") path := filepath.Join(t.TempDir(), "accounts.db")
store, err := Open(path) store, err := Open(path)
+25 -1
View File
@@ -25,7 +25,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
module checksum: module checksum:
```bash ```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.2 go get gamertan.com/web/requestmeta@v0.1.0-preview.4
go mod verify go mod verify
``` ```
@@ -51,6 +51,30 @@ hops satisfy the resolver's trust policy. Metadata, authentication, or storage
failures that affect security decisions should stop the request rather than failures that affect security decisions should stop the request rather than
quietly changing identity or policy. quietly changing identity or policy.
## Bootstrap an account without inventing a permanent password
`auth.GenerateTemporaryPassword` returns 256 bits of URL-safe cryptographic
entropy. An application can store that value in a newly created private file
and provision an account with `RequirePasswordChange: true`. The library does
not write or print the credential because file ownership, operator identity,
and delivery are application policy.
After authentication, inspect `principal.User.PasswordChangeRequired`. Until it
is false, permit only password change and logout. `auth.ChangePassword` verifies
the current credential, rejects reuse, writes the new Argon2id hash, clears the
requirement, and revokes every existing session atomically through the storage
adapter. Clear the browser cookie and require a fresh login after success. Do
not treat a redirect alone as enforcement; apply the restriction before every
protected handler.
For operator-led recovery, expose `auth.ResetPassword` only through a local
administrative command—not a public HTTP endpoint. The operation installs an
application-generated one-time credential, sets `PasswordChangeRequired`,
revokes every existing session, and appends a secret-free audit event in the
same repository transaction. Deliver that credential through an exclusive
root-owned mode-`0600` file, delete it after successful rotation, and never put
it in command arguments, stdout, logs, manifests, or deployment state.
## Add HTML without merging responsibilities ## Add HTML without merging responsibilities
Handlers should convert request and service state into typed display data. Handlers should convert request and service state into typed display data.
+1 -1
View File
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
and request the containing module at an exact version: and request the containing module at an exact version:
```bash ```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.2 go get gamertan.com/web/requestmeta@v0.1.0-preview.4
``` ```
Only imported packages are compiled and linked. The packages nevertheless Only imported packages are compiled and linked. The packages nevertheless
+18
View File
@@ -14,6 +14,24 @@ errors, separate safe/sensitive analytics projections, organization-scoped
bindings, single-use invitation digests, and short-lived audited break-glass bindings, single-use invitation digests, and short-lived audited break-glass
grants. grants.
An application may create an account with a cryptographically generated
temporary credential and `RequirePasswordChange`. Successful rotation compares
the current credential, replaces its Argon2id hash, clears the requirement, and
revokes every session in one repository transaction. The application must
restrict such a principal to password change and logout until rotation succeeds;
the library does not infer route policy. Temporary credentials must be written
to a private channel or mode-`0600` file and must never be printed into logs,
manifests, process arguments, or deployment state.
Administrative recovery is deliberately a separate capability. The storage
adapter atomically replaces the credential, restores the password-change
requirement, revokes all sessions, and appends a generic audit event. The core
library does not expose a recovery HTTP handler, deliver the credential, or
authorize the local operator. Applications must keep that command local,
generate the credential cryptographically, and write it only to a newly created
private file. A recovery must not reveal whether an account exists through a
public request surface.
Unsafe methods without an exact Origin or trustworthy same-origin Fetch Unsafe methods without an exact Origin or trustworthy same-origin Fetch
Metadata fail the origin check. Authentication middleware fails closed when its Metadata fail the origin check. Authentication middleware fails closed when its
service or `__Host-` cookie policy is invalid. Imported request records have service or `__Host-` cookie policy is invalid. Imported request records have