Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb6bbd0dad | ||
|
|
5905fe6fb2 |
@@ -2,6 +2,29 @@
|
||||
|
||||
# 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 1–3 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
|
||||
|
||||
- Add storage-neutral organizations, teams, projects, environments, services,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# 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.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web@v0.1.0-preview.2
|
||||
go get gamertan.com/web@v0.1.0-preview.4
|
||||
go mod verify
|
||||
```
|
||||
|
||||
An application may also name the first package it intends to adopt:
|
||||
|
||||
```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
|
||||
@@ -56,8 +56,9 @@ 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, local administrative recovery,
|
||||
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.
|
||||
|
||||
@@ -14,3 +14,9 @@ service-level agreement. There is no bug bounty.
|
||||
|
||||
The preview supports only versions explicitly listed in release notes. Security
|
||||
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
@@ -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,9 @@ 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
|
||||
ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) 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 +107,17 @@ 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
username := strings.TrimSpace(input.Username)
|
||||
@@ -120,13 +135,104 @@ 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
|
||||
return "", Principal{}, errors.New("auth: invalid session lifetime")
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -59,6 +59,15 @@ 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) 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) CreateSession(context.Context, Session) error { return nil }
|
||||
func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
|
||||
|
||||
@@ -109,6 +109,15 @@ 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) 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) CreateSession(context.Context, auth.Session) error { return nil }
|
||||
func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) {
|
||||
|
||||
+135
-6
@@ -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,101 @@ 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) ResetPasswordAndRevokeSessions(ctx context.Context, userID, expectedHash, newHash string, changedAt time.Time, audit auth.AuditEvent) error {
|
||||
if !opaqueID(userID) || !text(expectedHash, 1024, false) || !text(newHash, 1024, false) || changedAt.IsZero() || !validAuditEvent(audit) || audit.ActorUserID != "" || audit.ResourceType != "user" || audit.ResourceID != userID || !audit.CreatedAt.Equal(changedAt) {
|
||||
return auth.ErrInvalidCredentials
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=? AND password_hash=?`, newHash, changedAt.Unix(), userID, expectedHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed != 1 {
|
||||
return auth.ErrInvalidCredentials
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=1,updated_at=? WHERE id=?`, changedAt.Unix(), userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = appendAudit(ctx, tx, audit); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (store *Store) UpdateLastLogin(ctx context.Context, userID string, when time.Time) error {
|
||||
if !opaqueID(userID) || when.IsZero() {
|
||||
return errors.New("authsqlite: invalid login update")
|
||||
@@ -183,10 +298,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
|
||||
}
|
||||
@@ -294,13 +411,25 @@ func (store *Store) GrantRole(ctx context.Context, userID, role string, when tim
|
||||
return err
|
||||
}
|
||||
func (store *Store) AppendAudit(ctx context.Context, event auth.AuditEvent) error {
|
||||
if !opaqueID(event.ID) || event.ActorUserID != "" && !opaqueID(event.ActorUserID) || !safeName(event.Action) || !safeName(event.ResourceType) || !text(event.ResourceID, 256, false) || !text(event.RequestID, 128, true) || !text(event.Summary, 1024, true) || event.CreatedAt.IsZero() {
|
||||
if !validAuditEvent(event) {
|
||||
return errors.New("authsqlite: invalid audit event")
|
||||
}
|
||||
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix())
|
||||
return appendAudit(ctx, store.db, event)
|
||||
}
|
||||
|
||||
type auditExecer interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
func appendAudit(ctx context.Context, execer auditExecer, event auth.AuditEvent) error {
|
||||
_, err := execer.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func validAuditEvent(event auth.AuditEvent) bool {
|
||||
return opaqueID(event.ID) && (event.ActorUserID == "" || opaqueID(event.ActorUserID)) && safeName(event.Action) && safeName(event.ResourceType) && text(event.ResourceID, 256, false) && text(event.RequestID, 128, true) && text(event.Summary, 1024, true) && !event.CreatedAt.IsZero()
|
||||
}
|
||||
|
||||
func normalize(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
|
||||
func safeName(value string) bool {
|
||||
if value == "" || len(value) > 128 {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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) {
|
||||
path := filepath.Join(t.TempDir(), "accounts.db")
|
||||
store, err := Open(path)
|
||||
|
||||
+25
-1
@@ -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.4
|
||||
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
|
||||
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
|
||||
|
||||
Handlers should convert request and service state into typed display data.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
|
||||
and request the containing module at an exact version:
|
||||
|
||||
```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
|
||||
|
||||
@@ -14,6 +14,24 @@ 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.
|
||||
|
||||
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
|
||||
Metadata fail the origin check. Authentication middleware fails closed when its
|
||||
service or `__Host-` cookie policy is invalid. Imported request records have
|
||||
|
||||
Reference in New Issue
Block a user