auth: publish audited password recovery
Publish the reviewed Gamertan Web Foundations v0.1.0-preview.4 snapshot with local-only administrative reset, atomic Argon2id credential replacement, mandatory rotation, all-session revocation, secret-free audit evidence, rollback coverage, and exact application-boundary guidance. Exported from reviewed private source 403e5f6ef4d0cac683aaa76ed922236571d259a9 after trusted CI run 317 and exact Go 1.26.6 verification. Material implementation assistance provided by OpenAI Codex; reviewed and verified through the maintainer workflow. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.1.0-preview.4 — 2026-08-18
|
||||||
|
|
||||||
|
- Add an explicit local-administrator password recovery operation without
|
||||||
|
adding a public recovery endpoint or network protocol.
|
||||||
|
- Atomically install a one-time Argon2id credential, restore mandatory password
|
||||||
|
rotation, revoke every session, and append a secret-free audit event.
|
||||||
|
- Prove transaction rollback when the audit event cannot commit and document
|
||||||
|
private mode-`0600` delivery as application-owned policy.
|
||||||
|
- Keep Previews 1–3 immutable; applications select Preview 4 explicitly when
|
||||||
|
adopting administrative recovery.
|
||||||
|
|
||||||
## v0.1.0-preview.3 — 2026-08-18
|
## v0.1.0-preview.3 — 2026-08-18
|
||||||
|
|
||||||
- Add cryptographically generated temporary credentials and an explicit
|
- Add cryptographically generated temporary credentials and an explicit
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Gamertan Web Foundations
|
# Gamertan Web Foundations
|
||||||
|
|
||||||
> Status: `v0.1.0-preview.2` public preview. APIs may change before a stable
|
> Status: `v0.1.0-preview.4` public preview. APIs may change before a stable
|
||||||
> release; Linux is the maintained release platform.
|
> release; Linux is the maintained release platform.
|
||||||
|
|
||||||
Small, composable Go packages for the unglamorous boundaries of a careful web
|
Small, composable Go packages for the unglamorous boundaries of a careful web
|
||||||
@@ -23,14 +23,14 @@ Pin the preview in an application module, then import only the packages that
|
|||||||
application needs:
|
application needs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web@v0.1.0-preview.2
|
go get gamertan.com/web@v0.1.0-preview.4
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
An application may also name the first package it intends to adopt:
|
An application may also name the first package it intends to adopt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.2
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.4
|
||||||
```
|
```
|
||||||
|
|
||||||
The version belongs to the `gamertan.com/web` module. Go compiles and links
|
The version belongs to the `gamertan.com/web` module. Go compiles and links
|
||||||
@@ -56,8 +56,9 @@ without turning that portability into a maintained compatibility claim.
|
|||||||
rate limits.
|
rate limits.
|
||||||
- [`abuse`](abuse): application-classified request abuse with pluggable persistence.
|
- [`abuse`](abuse): application-classified request abuse with pluggable persistence.
|
||||||
- [`auth`](auth), [`authhttp`](authhttp), and [`authsqlite`](authsqlite):
|
- [`auth`](auth), [`authhttp`](authhttp), and [`authsqlite`](authsqlite):
|
||||||
passwords, forced first-login rotation, session revocation, platform-level
|
passwords, forced first-login rotation, local administrative recovery,
|
||||||
permissions, cookies, and a no-CGO SQLite adapter.
|
session revocation, platform-level permissions, cookies, and a no-CGO SQLite
|
||||||
|
adapter.
|
||||||
- [`organizations`](organizations) and [`access`](access): organizations,
|
- [`organizations`](organizations) and [`access`](access): organizations,
|
||||||
teams, invitations, resource hierarchy, scoped roles, and audited temporary
|
teams, invitations, resource hierarchy, scoped roles, and audited temporary
|
||||||
access without turning platform operation into tenant-data access.
|
access without turning platform operation into tenant-data access.
|
||||||
|
|||||||
@@ -14,3 +14,9 @@ service-level agreement. There is no bug bounty.
|
|||||||
|
|
||||||
The preview supports only versions explicitly listed in release notes. Security
|
The preview supports only versions explicitly listed in release notes. Security
|
||||||
claims stop at the documented trust boundaries and executable tests.
|
claims stop at the documented trust boundaries and executable tests.
|
||||||
|
|
||||||
|
Password recovery is an explicitly local administrative capability. It must
|
||||||
|
not be wired directly to a public route. Applications using it are responsible
|
||||||
|
for local operator authorization and exclusive mode-`0600` credential delivery;
|
||||||
|
the library transaction requires a new password change, revokes all sessions,
|
||||||
|
and records a secret-free audit event.
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type Repository interface {
|
|||||||
CredentialByIdentifier(context.Context, string) (User, string, error)
|
CredentialByIdentifier(context.Context, string) (User, string, error)
|
||||||
CredentialByUserID(context.Context, string) (User, string, error)
|
CredentialByUserID(context.Context, string) (User, string, error)
|
||||||
ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error
|
ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error
|
||||||
|
ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) error
|
||||||
UpdateLastLogin(context.Context, string, time.Time) error
|
UpdateLastLogin(context.Context, string, time.Time) error
|
||||||
CreateSession(context.Context, Session) error
|
CreateSession(context.Context, Session) error
|
||||||
PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error)
|
PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error)
|
||||||
@@ -111,6 +112,13 @@ type CreateUser struct {
|
|||||||
RequirePasswordChange bool
|
RequirePasswordChange bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdministrativePasswordReset describes a locally authorized recovery. The
|
||||||
|
// application is responsible for delivering TemporaryPassword through a
|
||||||
|
// private, one-time channel; the value must never be logged or audited.
|
||||||
|
type AdministrativePasswordReset struct {
|
||||||
|
Identifier, TemporaryPassword string
|
||||||
|
}
|
||||||
|
|
||||||
func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) {
|
func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) {
|
||||||
username := strings.TrimSpace(input.Username)
|
username := strings.TrimSpace(input.Username)
|
||||||
email := strings.TrimSpace(input.Email)
|
email := strings.TrimSpace(input.Email)
|
||||||
@@ -178,6 +186,53 @@ func (service *Service) ChangePassword(ctx context.Context, userID, currentPassw
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetPassword replaces an active user's credential without requiring the
|
||||||
|
// current password. It is intended only for a locally authorized
|
||||||
|
// administrative recovery command. The repository atomically requires another
|
||||||
|
// password change, revokes all sessions, and appends a secret-free audit event.
|
||||||
|
func (service *Service) ResetPassword(ctx context.Context, input AdministrativePasswordReset) (User, error) {
|
||||||
|
identifier := strings.TrimSpace(input.Identifier)
|
||||||
|
user, currentHash, err := service.repository.CredentialByIdentifier(ctx, identifier)
|
||||||
|
if errors.Is(err, ErrUserNotFound) {
|
||||||
|
return User{}, ErrUserNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err)
|
||||||
|
}
|
||||||
|
if user.Status != "active" {
|
||||||
|
return User{}, ErrInactiveUser
|
||||||
|
}
|
||||||
|
if VerifyPassword(currentHash, input.TemporaryPassword) {
|
||||||
|
return User{}, ErrPasswordUnchanged
|
||||||
|
}
|
||||||
|
newHash, err := HashPasswordWithRandom(input.TemporaryPassword, service.random)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
auditID, err := randomToken(service.random, 18)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
now := service.now().UTC()
|
||||||
|
audit := AuditEvent{
|
||||||
|
ID: auditID,
|
||||||
|
Action: "auth.password.reset",
|
||||||
|
ResourceType: "user",
|
||||||
|
ResourceID: user.ID,
|
||||||
|
Summary: "A local administrator issued a one-time credential and revoked all sessions.",
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
if err = service.repository.ResetPasswordAndRevokeSessions(ctx, user.ID, currentHash, newHash, now, audit); err != nil {
|
||||||
|
if errors.Is(err, ErrInvalidCredentials) {
|
||||||
|
return User{}, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return User{}, fmt.Errorf("auth: reset password: %w", err)
|
||||||
|
}
|
||||||
|
user.PasswordChangeRequired = true
|
||||||
|
user.UpdatedAt = now
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) {
|
func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) {
|
||||||
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
|
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
|
||||||
return "", Principal{}, errors.New("auth: invalid session lifetime")
|
return "", Principal{}, errors.New("auth: invalid session lifetime")
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ func (repositoryStub) CredentialByUserID(context.Context, string) (User, string,
|
|||||||
func (repositoryStub) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
|
func (repositoryStub) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (repositoryStub) ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (repositoryStub) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
|
func (repositoryStub) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
|
||||||
func (repositoryStub) CreateSession(context.Context, Session) error { return nil }
|
func (repositoryStub) CreateSession(context.Context, Session) error { return nil }
|
||||||
func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
|
func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ func (authHTTPRepository) CredentialByUserID(context.Context, string) (auth.User
|
|||||||
func (authHTTPRepository) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
|
func (authHTTPRepository) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (authHTTPRepository) ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, auth.AuditEvent) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (authHTTPRepository) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
|
func (authHTTPRepository) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
|
||||||
func (authHTTPRepository) CreateSession(context.Context, auth.Session) error { return nil }
|
func (authHTTPRepository) CreateSession(context.Context, auth.Session) error { return nil }
|
||||||
func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) {
|
func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) {
|
||||||
|
|||||||
+46
-2
@@ -243,6 +243,38 @@ func (store *Store) ReplacePasswordAndRevokeSessions(ctx context.Context, userID
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) ResetPasswordAndRevokeSessions(ctx context.Context, userID, expectedHash, newHash string, changedAt time.Time, audit auth.AuditEvent) error {
|
||||||
|
if !opaqueID(userID) || !text(expectedHash, 1024, false) || !text(newHash, 1024, false) || changedAt.IsZero() || !validAuditEvent(audit) || audit.ActorUserID != "" || audit.ResourceType != "user" || audit.ResourceID != userID || !audit.CreatedAt.Equal(changedAt) {
|
||||||
|
return auth.ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=? AND password_hash=?`, newHash, changedAt.Unix(), userID, expectedHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed != 1 {
|
||||||
|
return auth.ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=1,updated_at=? WHERE id=?`, changedAt.Unix(), userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) UpdateLastLogin(ctx context.Context, userID string, when time.Time) error {
|
func (store *Store) UpdateLastLogin(ctx context.Context, userID string, when time.Time) error {
|
||||||
if !opaqueID(userID) || when.IsZero() {
|
if !opaqueID(userID) || when.IsZero() {
|
||||||
return errors.New("authsqlite: invalid login update")
|
return errors.New("authsqlite: invalid login update")
|
||||||
@@ -379,13 +411,25 @@ func (store *Store) GrantRole(ctx context.Context, userID, role string, when tim
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
func (store *Store) AppendAudit(ctx context.Context, event auth.AuditEvent) error {
|
func (store *Store) AppendAudit(ctx context.Context, event auth.AuditEvent) error {
|
||||||
if !opaqueID(event.ID) || event.ActorUserID != "" && !opaqueID(event.ActorUserID) || !safeName(event.Action) || !safeName(event.ResourceType) || !text(event.ResourceID, 256, false) || !text(event.RequestID, 128, true) || !text(event.Summary, 1024, true) || event.CreatedAt.IsZero() {
|
if !validAuditEvent(event) {
|
||||||
return errors.New("authsqlite: invalid audit event")
|
return errors.New("authsqlite: invalid audit event")
|
||||||
}
|
}
|
||||||
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix())
|
return appendAudit(ctx, store.db, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
type auditExecer interface {
|
||||||
|
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendAudit(ctx context.Context, execer auditExecer, event auth.AuditEvent) error {
|
||||||
|
_, err := execer.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validAuditEvent(event auth.AuditEvent) bool {
|
||||||
|
return opaqueID(event.ID) && (event.ActorUserID == "" || opaqueID(event.ActorUserID)) && safeName(event.Action) && safeName(event.ResourceType) && text(event.ResourceID, 256, false) && text(event.RequestID, 128, true) && text(event.Summary, 1024, true) && !event.CreatedAt.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
func normalize(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
|
func normalize(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
|
||||||
func safeName(value string) bool {
|
func safeName(value string) bool {
|
||||||
if value == "" || len(value) > 128 {
|
if value == "" || len(value) > 128 {
|
||||||
|
|||||||
@@ -100,6 +100,98 @@ func TestRequiredPasswordChangeRotatesCredentialAndRevokesSessions(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdministrativePasswordResetIsAtomicAndAudited(t *testing.T) {
|
||||||
|
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
now := time.Unix(4000, 0).UTC()
|
||||||
|
service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "recover.me", Email: "recover@example.test", DisplayName: "Recovery Test", Password: "original permanent credential"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token, _, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reset, err := service.ResetPassword(t.Context(), auth.AdministrativePasswordReset{Identifier: user.Email, TemporaryPassword: "one-time recovery credential"})
|
||||||
|
if err != nil || !reset.PasswordChangeRequired {
|
||||||
|
t.Fatalf("reset=%+v err=%v", reset, err)
|
||||||
|
}
|
||||||
|
if _, err = service.Session(t.Context(), token); !errors.Is(err, auth.ErrSessionNotFound) {
|
||||||
|
t.Fatalf("session survived reset: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err = service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("old credential survived reset: %v", err)
|
||||||
|
}
|
||||||
|
_, principal, err := service.Authenticate(t.Context(), user.Username, "one-time recovery credential", time.Hour)
|
||||||
|
if err != nil || !principal.User.PasswordChangeRequired {
|
||||||
|
t.Fatalf("recovery principal=%+v err=%v", principal, err)
|
||||||
|
}
|
||||||
|
var action, summary string
|
||||||
|
var events int
|
||||||
|
if err = store.db.QueryRow(`SELECT COUNT(*),action,summary FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&events, &action, &summary); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if events != 1 || action != "auth.password.reset" || strings.Contains(summary, "one-time recovery credential") || !strings.Contains(summary, "revoked all sessions") {
|
||||||
|
t.Fatalf("events=%d action=%q summary=%q", events, action, summary)
|
||||||
|
}
|
||||||
|
if _, err = service.ResetPassword(t.Context(), auth.AdministrativePasswordReset{Identifier: user.Username, TemporaryPassword: "one-time recovery credential"}); !errors.Is(err, auth.ErrPasswordUnchanged) {
|
||||||
|
t.Fatalf("same credential err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdministrativePasswordResetRollsBackWhenAuditCannotCommit(t *testing.T) {
|
||||||
|
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
now := time.Unix(5000, 0).UTC()
|
||||||
|
service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "rollback.me", Email: "rollback@example.test", DisplayName: "Rollback Test", Password: "original permanent credential"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token, _, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user, currentHash, err := store.CredentialByUserID(t.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newHash, err := auth.HashPassword("one-time recovery credential")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
audit := auth.AuditEvent{ID: "duplicate-audit-id", Action: "auth.password.reset", ResourceType: "user", ResourceID: user.ID, Summary: "A local administrator issued a one-time credential and revoked all sessions.", CreatedAt: now}
|
||||||
|
if err = store.AppendAudit(t.Context(), audit); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = store.ResetPasswordAndRevokeSessions(t.Context(), user.ID, currentHash, newHash, now, audit); err == nil {
|
||||||
|
t.Fatal("duplicate audit unexpectedly committed reset")
|
||||||
|
}
|
||||||
|
if _, err = service.Session(t.Context(), token); err != nil {
|
||||||
|
t.Fatalf("rollback revoked session: %v", err)
|
||||||
|
}
|
||||||
|
_, principal, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
|
||||||
|
if err != nil || principal.User.PasswordChangeRequired {
|
||||||
|
t.Fatalf("original credential not restored: principal=%+v err=%v", principal, err)
|
||||||
|
}
|
||||||
|
if _, _, err = service.Authenticate(t.Context(), user.Username, "one-time recovery credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("uncommitted recovery credential accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMigrationAddsPasswordRequirementWithoutChangingExistingUsers(t *testing.T) {
|
func TestMigrationAddsPasswordRequirementWithoutChangingExistingUsers(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "accounts.db")
|
path := filepath.Join(t.TempDir(), "accounts.db")
|
||||||
database, err := sql.Open("sqlite", path)
|
database, err := sql.Open("sqlite", path)
|
||||||
|
|||||||
@@ -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.3
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.4
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -67,6 +67,14 @@ 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
|
not treat a redirect alone as enforcement; apply the restriction before every
|
||||||
protected handler.
|
protected handler.
|
||||||
|
|
||||||
|
For operator-led recovery, expose `auth.ResetPassword` only through a local
|
||||||
|
administrative command—not a public HTTP endpoint. The operation installs an
|
||||||
|
application-generated one-time credential, sets `PasswordChangeRequired`,
|
||||||
|
revokes every existing session, and appends a secret-free audit event in the
|
||||||
|
same repository transaction. Deliver that credential through an exclusive
|
||||||
|
root-owned mode-`0600` file, delete it after successful rotation, and never put
|
||||||
|
it in command arguments, stdout, logs, manifests, or deployment state.
|
||||||
|
|
||||||
## Add HTML without merging responsibilities
|
## Add HTML without merging responsibilities
|
||||||
|
|
||||||
Handlers should convert request and service state into typed display data.
|
Handlers should convert request and service state into typed display data.
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
|
|||||||
and request the containing module at an exact version:
|
and request the containing module at an exact version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.2
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.4
|
||||||
```
|
```
|
||||||
|
|
||||||
Only imported packages are compiled and linked. The packages nevertheless
|
Only imported packages are compiled and linked. The packages nevertheless
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ 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,
|
to a private channel or mode-`0600` file and must never be printed into logs,
|
||||||
manifests, process arguments, or deployment state.
|
manifests, process arguments, or deployment state.
|
||||||
|
|
||||||
|
Administrative recovery is deliberately a separate capability. The storage
|
||||||
|
adapter atomically replaces the credential, restores the password-change
|
||||||
|
requirement, revokes all sessions, and appends a generic audit event. The core
|
||||||
|
library does not expose a recovery HTTP handler, deliver the credential, or
|
||||||
|
authorize the local operator. Applications must keep that command local,
|
||||||
|
generate the credential cryptographically, and write it only to a newly created
|
||||||
|
private file. A recovery must not reveal whether an account exists through a
|
||||||
|
public request surface.
|
||||||
|
|
||||||
Unsafe methods without an exact Origin or trustworthy same-origin Fetch
|
Unsafe methods without an exact Origin or trustworthy same-origin Fetch
|
||||||
Metadata fail the origin check. Authentication middleware fails closed when its
|
Metadata fail the origin check. Authentication middleware fails closed when its
|
||||||
service or `__Host-` cookie policy is invalid. Imported request records have
|
service or `__Host-` cookie policy is invalid. Imported request records have
|
||||||
|
|||||||
Reference in New Issue
Block a user