Author SHA1 Message Date
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
10 changed files with 282 additions and 9 deletions
+12
View File
@@ -2,6 +2,18 @@
# Changelog # Changelog
## 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,
+2 -2
View File
@@ -56,8 +56,8 @@ 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, session revocation, platform-level
SQLite adapter. 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.
+53 -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,8 @@ 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
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 +106,10 @@ 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
}
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 +127,57 @@ 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
}
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") }
+6
View File
@@ -59,6 +59,12 @@ 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) 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) {
+6
View File
@@ -109,6 +109,12 @@ 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) 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) {
+89 -4
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,69 @@ 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) 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 +266,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
} }
+75
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,79 @@ 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 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)
+17 -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.3
go mod verify go mod verify
``` ```
@@ -51,6 +51,22 @@ 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.
## 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.
+9
View File
@@ -14,6 +14,15 @@ 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.
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