diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c7dd89..2a354de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ # 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 - Add storage-neutral organizations, teams, projects, environments, services, diff --git a/README.md b/README.md index ac0c130..54e060a 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,8 @@ without turning that portability into a maintained compatibility claim. rate limits. - [`abuse`](abuse): application-classified request abuse with pluggable persistence. - [`auth`](auth), [`authhttp`](authhttp), and [`authsqlite`](authsqlite): - passwords, sessions, platform-level permissions, cookies, and a no-CGO - SQLite adapter. + passwords, forced first-login rotation, session revocation, platform-level + permissions, cookies, and a no-CGO SQLite adapter. - [`organizations`](organizations) and [`access`](access): organizations, teams, invitations, resource hierarchy, scoped roles, and audited temporary access without turning platform operation into tenant-data access. diff --git a/auth/auth.go b/auth/auth.go index 49f1f2e..12f231a 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -21,6 +21,7 @@ import ( var ( ErrInvalidCredentials = errors.New("auth: invalid credentials") 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") ErrUserNotFound = errors.New("auth: user not found") identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`) @@ -29,6 +30,7 @@ var ( type User struct { ID, Username, Email, DisplayName, Status string CreatedAt, UpdatedAt time.Time + PasswordChangeRequired bool } type Principal struct { @@ -59,6 +61,8 @@ type PolicySeed struct { type Repository interface { CreateUser(context.Context, 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 CreateSession(context.Context, 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 } -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) { username := strings.TrimSpace(input.Username) @@ -120,13 +127,57 @@ func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, return User{}, err } 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 { return User{}, err } 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) { if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour { return "", Principal{}, errors.New("auth: invalid session lifetime") diff --git a/auth/password_test.go b/auth/password_test.go index ff9b7d4..62ecf53 100644 --- a/auth/password_test.go +++ b/auth/password_test.go @@ -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{} func (errorReader) Read([]byte) (int, error) { return 0, errors.New("no entropy") } diff --git a/auth/service_test.go b/auth/service_test.go index 35822d1..ebd8e35 100644 --- a/auth/service_test.go +++ b/auth/service_test.go @@ -59,6 +59,12 @@ func (repositoryStub) CreateUser(context.Context, User, string) error { return n func (repositoryStub) CredentialByIdentifier(context.Context, string) (User, string, error) { 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) CreateSession(context.Context, Session) error { return nil } func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) { diff --git a/authhttp/authhttp_test.go b/authhttp/authhttp_test.go index cbc1050..16f64f2 100644 --- a/authhttp/authhttp_test.go +++ b/authhttp/authhttp_test.go @@ -109,6 +109,12 @@ func (authHTTPRepository) CreateUser(context.Context, auth.User, string) error { func (authHTTPRepository) CredentialByIdentifier(context.Context, string) (auth.User, string, error) { 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) CreateSession(context.Context, auth.Session) error { return nil } func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) { diff --git a/authsqlite/store.go b/authsqlite/store.go index 0b62829..9d0726a 100644 --- a/authsqlite/store.go +++ b/authsqlite/store.go @@ -77,7 +77,7 @@ func (store *Store) Migrate(ctx context.Context) error { defer tx.Rollback() statements := []string{ `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_roles (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 } } + 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 { 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 { 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() } +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, ¬Null, &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 { 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") @@ -132,7 +164,7 @@ func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash return err } 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 { return err } @@ -148,18 +180,69 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin } 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.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) { 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) 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 { if !opaqueID(userID) || when.IsZero() { 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 session auth.Session 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) { return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound } + principal.User.PasswordChangeRequired = passwordChangeRequired == 1 if err != nil { return auth.Principal{}, auth.Session{}, err } diff --git a/authsqlite/store_test.go b/authsqlite/store_test.go index 9e25c73..a0556ab 100644 --- a/authsqlite/store_test.go +++ b/authsqlite/store_test.go @@ -3,6 +3,8 @@ package authsqlite import ( + "database/sql" + "errors" "os" "path/filepath" "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) { path := filepath.Join(t.TempDir(), "accounts.db") store, err := Open(path) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index c963ee1..2cb5e42 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -25,7 +25,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```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 ``` @@ -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 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 Handlers should convert request and service state into typed display data. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 7368620..0323358 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -14,6 +14,15 @@ errors, separate safe/sensitive analytics projections, organization-scoped bindings, single-use invitation digests, and short-lived audited break-glass 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 Metadata fail the origin check. Authentication middleware fails closed when its service or `__Host-` cookie policy is invalid. Imported request records have