This commit is contained in:
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.1.0-preview.18 — 2026-09-04
|
||||||
|
|
||||||
|
- Add owner-assisted account recovery for a documented human-review path when
|
||||||
|
normal password, passkey, and recovery-code authentication is unavailable.
|
||||||
|
Issuance requires an active direct organization owner and returns a bounded,
|
||||||
|
single-use, 15-minute secret while persisting and auditing only its digest.
|
||||||
|
- Invalidate the recovered member's existing password, passkeys, recovery
|
||||||
|
codes, sessions, ceremonies, and older recovery grants when the reviewed
|
||||||
|
enrollment is issued. Completion atomically installs one replacement
|
||||||
|
password, passkey, and recovery-code set without issuing a normal session.
|
||||||
|
- Keep identity and organization-visible recovery audits in the same SQLite
|
||||||
|
transactions as their credential changes, and document the application
|
||||||
|
boundary for fresh passkey authorization, secret-fragment delivery, and
|
||||||
|
human evidence review.
|
||||||
|
|
||||||
## v0.1.0-preview.17 — 2026-09-04
|
## v0.1.0-preview.17 — 2026-09-04
|
||||||
|
|
||||||
- Add optimistic organization-membership suspension, reactivation, and
|
- Add optimistic organization-membership suspension, reactivation, and
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and
|
|||||||
deployment. Adopt one boundary at a time; Go compiles and links only the
|
deployment. Adopt one boundary at a time; Go compiles and links only the
|
||||||
packages you import.
|
packages you import.
|
||||||
|
|
||||||
> **Public preview:** `v0.1.0-preview.17`. APIs may change before a stable
|
> **Public preview:** `v0.1.0-preview.18`. APIs may change before a stable
|
||||||
> release. Linux is the maintained release platform.
|
> release. Linux is the maintained release platform.
|
||||||
|
|
||||||
## Why Web Foundations?
|
## Why Web Foundations?
|
||||||
@@ -41,7 +41,7 @@ packages you import.
|
|||||||
| Atomic password-plus-passkey registration | [`account`](account) |
|
| Atomic password-plus-passkey registration | [`account`](account) |
|
||||||
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
|
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
|
||||||
| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) |
|
| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) |
|
||||||
| Printable single-use recovery codes | [`authrecovery`](authrecovery) |
|
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
|
||||||
| Private SQLite persistence | [`authsqlite`](authsqlite) |
|
| Private SQLite persistence | [`authsqlite`](authsqlite) |
|
||||||
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
|
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
|
||||||
| Organizations, teams, and invitations | [`organizations`](organizations) |
|
| Organizations, teams, and invitations | [`organizations`](organizations) |
|
||||||
@@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy.
|
|||||||
Pin the preview in an application module:
|
Pin the preview in an application module:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web@v0.1.0-preview.17
|
go get gamertan.com/web@v0.1.0-preview.18
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
An application may name the first package it intends to adopt:
|
An application may name the first package it intends to adopt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.17
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.18
|
||||||
```
|
```
|
||||||
|
|
||||||
The version belongs to the `gamertan.com/web` module. See the
|
The version belongs to the `gamertan.com/web` module. See the
|
||||||
@@ -102,6 +102,9 @@ JSONL logging.
|
|||||||
request context without owning login routes or pages.
|
request context without owning login routes or pages.
|
||||||
- [`authwebauthn`](authwebauthn) provides discoverable passkey login,
|
- [`authwebauthn`](authwebauthn) provides discoverable passkey login,
|
||||||
enrollment, operation-bound fresh approval, and bounded recovery.
|
enrollment, operation-bound fresh approval, and bounded recovery.
|
||||||
|
- [`authrecovery`](authrecovery) supports printable self-service recovery and
|
||||||
|
a separate owner-assisted flow that atomically replaces compromised account
|
||||||
|
credentials while writing both identity and organization-visible audits.
|
||||||
- [`organizations`](organizations) and [`access`](access) keep platform
|
- [`organizations`](organizations) and [`access`](access) keep platform
|
||||||
operation separate from organization-data authority while supporting teams,
|
operation separate from organization-data authority while supporting teams,
|
||||||
invitations, scoped roles, and audited temporary access.
|
invitations, scoped roles, and audited temporary access.
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authrecovery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/authrecovery"
|
||||||
|
"gamertan.com/web/authsqlite"
|
||||||
|
"gamertan.com/web/authwebauthn"
|
||||||
|
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
|
||||||
|
"gamertan.com/web/organizations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOwnerAssistedRecoveryInvalidatesAndAtomicallyReplacesAccountCredentials(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
store, err := authsqlite.Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
random := &counterReader{}
|
||||||
|
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "home.owner", Email: "owner@example.test", DisplayName: "Home Owner", Password: "owner password for assisted recovery"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.member", Email: "member@example.test", DisplayName: "Recover Member", Password: "old member password before recovery"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
organizationsService, err := organizations.New(store, organizations.Options{Random: random, Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
home, err := organizationsService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "assisted-home", Name: "Assisted Home", OwnerUserID: owner.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
invitation, _, err := organizationsService.Invite(t.Context(), home.ID, target.Email, owner.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = organizationsService.AcceptInvitation(t.Context(), invitation, target.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
policy := access.Policy{
|
||||||
|
Roles: map[string]string{"owner": "Organization owner", "viewer": "Organization viewer"},
|
||||||
|
Permissions: map[string]string{"account.recover": "Recover an organization member"},
|
||||||
|
Grants: map[string][]string{"owner": {"account.recover"}, "viewer": {}},
|
||||||
|
}
|
||||||
|
accessService, err := access.New(store, policy, access.Options{Random: random, Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = accessService.Seed(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: target.ID, Role: "viewer", Scope: access.Scope{OrganizationID: home.ID}, GrantedBy: owner.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingID := bytes.Repeat([]byte{7}, 32)
|
||||||
|
existingJSON, err := json.Marshal(wa.Credential{ID: existingID, PublicKey: []byte{1, 2, 3}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: existingID, UserID: target.ID, Label: "Old passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "old-passkey-audit-id", ActorUserID: target.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Old passkey fixture", CreatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
passkeys := &passkeyRecoveryStub{now: now, credentialID: bytes.Repeat([]byte{8}, 32)}
|
||||||
|
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys, OwnerRole: "owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldCodes, err := recovery.ReplaceCodes(t.Context(), target.ID, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldSession, _, err := authService.IssueSession(t.Context(), target.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err = recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: target.ID, TargetUserID: owner.ID, RequestID: "request-denied-123", Reason: "Target asked for recovery after identity review"}); !errors.Is(err, authrecovery.ErrAssistedDenied) {
|
||||||
|
t.Fatalf("non-owner assisted recovery err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); err != nil {
|
||||||
|
t.Fatalf("denied recovery changed password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, grant, err := recovery.IssueAssistedRecovery(t.Context(), authrecovery.AssistedIssue{OrganizationID: home.ID, ActorUserID: owner.ID, TargetUserID: target.ID, RequestID: "request-assisted-123", Reason: "Member verified ownership through the documented support review"})
|
||||||
|
if err != nil || loaded.ID != target.ID || grant == "" {
|
||||||
|
t.Fatalf("loaded=%+v grant_present=%v err=%v", loaded, grant != "", err)
|
||||||
|
}
|
||||||
|
if _, err = authService.Session(t.Context(), oldSession); !errors.Is(err, auth.ErrSessionNotFound) {
|
||||||
|
t.Fatalf("old session survived assisted recovery issue: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = authService.VerifyPassword(t.Context(), target.Email, "old member password before recovery"); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("old password survived assisted recovery issue: %v", err)
|
||||||
|
}
|
||||||
|
credentials, err := store.CredentialsByUserID(t.Context(), target.ID)
|
||||||
|
if err != nil || len(credentials) != 0 {
|
||||||
|
t.Fatalf("old passkeys survived issue: credentials=%+v err=%v", credentials, err)
|
||||||
|
}
|
||||||
|
if _, _, err = recovery.Begin(t.Context(), target.Email, "old member password before recovery", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("old recovery path survived issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
begin, err := recovery.BeginAssistedPasskey(t.Context(), grant, "Recovered passkey")
|
||||||
|
if err != nil || begin.CeremonyToken == "" || passkeys.userID != target.ID || passkeys.beginBinding != grant {
|
||||||
|
t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err)
|
||||||
|
}
|
||||||
|
result, err := recovery.FinishAssistedRecovery(t.Context(), grant, begin.CeremonyToken, "new member password after recovery", []byte(`{"fixture":true}`))
|
||||||
|
if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount {
|
||||||
|
t.Fatalf("result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
if _, err = authService.VerifyPassword(t.Context(), target.Email, "new member password after recovery"); err != nil {
|
||||||
|
t.Fatalf("replacement password unavailable: %v", err)
|
||||||
|
}
|
||||||
|
credentials, err = store.CredentialsByUserID(t.Context(), target.ID)
|
||||||
|
if err != nil || len(credentials) != 1 || !bytes.Equal(credentials[0].ID, passkeys.credentialID) {
|
||||||
|
t.Fatalf("replacement credentials=%+v err=%v", credentials, err)
|
||||||
|
}
|
||||||
|
if _, err = recovery.BeginAssistedPasskey(t.Context(), grant, "Replay"); !errors.Is(err, authrecovery.ErrAssistedNotFound) {
|
||||||
|
t.Fatalf("assisted grant replay err=%v", err)
|
||||||
|
}
|
||||||
|
if _, nextGrant, beginErr := recovery.Begin(t.Context(), target.Email, "new member password after recovery", result.RecoveryCodes[0]); beginErr != nil || nextGrant == "" {
|
||||||
|
t.Fatalf("replacement recovery material unavailable: grant_present=%v err=%v", nextGrant != "", beginErr)
|
||||||
|
}
|
||||||
|
audits, err := accessService.Audit(t.Context(), home.ID, 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seenIssue, seenComplete := false, false
|
||||||
|
for _, audit := range audits {
|
||||||
|
seenIssue = seenIssue || audit.Action == "access.account-recovery.issue" && audit.ActorUserID == owner.ID && audit.ResourceID == target.ID && audit.RequestID == "request-assisted-123"
|
||||||
|
seenComplete = seenComplete || audit.Action == "access.account-recovery.complete" && audit.ActorUserID == target.ID && audit.ResourceID == target.ID
|
||||||
|
}
|
||||||
|
if !seenIssue || !seenComplete {
|
||||||
|
t.Fatalf("organization recovery audits issue=%v complete=%v events=%+v", seenIssue, seenComplete, audits)
|
||||||
|
}
|
||||||
|
}
|
||||||
+198
-14
@@ -16,6 +16,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
"gamertan.com/web/auth"
|
"gamertan.com/web/auth"
|
||||||
"gamertan.com/web/authwebauthn"
|
"gamertan.com/web/authwebauthn"
|
||||||
)
|
)
|
||||||
@@ -25,6 +26,8 @@ const DefaultCodeCount = 10
|
|||||||
var (
|
var (
|
||||||
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
|
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
|
||||||
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
|
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
|
||||||
|
ErrAssistedNotFound = errors.New("authrecovery: assisted recovery grant not found")
|
||||||
|
ErrAssistedDenied = errors.New("authrecovery: assisted recovery is not authorized")
|
||||||
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
|
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +52,47 @@ type PasskeyRepository interface {
|
|||||||
CompletePasskeyRecovery(context.Context, PasskeyCompletion) error
|
CompletePasskeyRecovery(context.Context, PasskeyCompletion) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AssistedGrant is the digest-only authority created by an organization
|
||||||
|
// owner after a human recovery review. The plaintext token is returned once
|
||||||
|
// to the caller and never persisted or audited.
|
||||||
|
type AssistedGrant struct {
|
||||||
|
Digest [32]byte
|
||||||
|
OrganizationID, UserID string
|
||||||
|
IssuedByUserID string
|
||||||
|
CreatedAt, ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssistedIssue binds an owner-reviewed recovery to one organization member.
|
||||||
|
// Reason is deliberately bounded and must not contain credential material.
|
||||||
|
type AssistedIssue struct {
|
||||||
|
OrganizationID, ActorUserID, TargetUserID, RequestID, Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssistedRepository provides the two transactional boundaries for delegated
|
||||||
|
// recovery. Issuance invalidates all existing account authenticators and
|
||||||
|
// sessions while recording both identity and organization-visible audits.
|
||||||
|
// Completion consumes the grant exactly once and installs the replacement
|
||||||
|
// password, passkey, and recovery-code set atomically.
|
||||||
|
type AssistedRepository interface {
|
||||||
|
Repository
|
||||||
|
IssueAssistedRecovery(context.Context, AssistedGrant, string, auth.AuditEvent, access.AuditEvent) (auth.User, error)
|
||||||
|
AssistedRecoveryGrant(context.Context, [32]byte, time.Time) (AssistedGrant, auth.User, error)
|
||||||
|
CompleteAssistedRecovery(context.Context, AssistedCompletion) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssistedCompletion contains only the password hash, public passkey
|
||||||
|
// credential, digest-only recovery codes, and secret-free audit material.
|
||||||
|
type AssistedCompletion struct {
|
||||||
|
GrantDigest [32]byte
|
||||||
|
Credential authwebauthn.Credential
|
||||||
|
PasswordHash string
|
||||||
|
RecoveryDigests [][32]byte
|
||||||
|
PasskeyAudit auth.AuditEvent
|
||||||
|
RecoveryAudit auth.AuditEvent
|
||||||
|
AccessAudit access.AuditEvent
|
||||||
|
CompletedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// Passkeys performs recovery-bound WebAuthn registration ceremonies.
|
// Passkeys performs recovery-bound WebAuthn registration ceremonies.
|
||||||
type Passkeys interface {
|
type Passkeys interface {
|
||||||
BeginRecoveryRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
|
BeginRecoveryRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
|
||||||
@@ -78,21 +122,25 @@ type PasswordVerifier interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Random io.Reader
|
Random io.Reader
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
CodeCount int
|
CodeCount int
|
||||||
GrantLifetime time.Duration
|
GrantLifetime time.Duration
|
||||||
Passkeys Passkeys
|
AssistedGrantLifetime time.Duration
|
||||||
|
OwnerRole string
|
||||||
|
Passkeys Passkeys
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repository Repository
|
repository Repository
|
||||||
passwords PasswordVerifier
|
passwords PasswordVerifier
|
||||||
random io.Reader
|
random io.Reader
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
count int
|
count int
|
||||||
grantTTL time.Duration
|
grantTTL time.Duration
|
||||||
passkeys Passkeys
|
assistedTTL time.Duration
|
||||||
|
ownerRole string
|
||||||
|
passkeys Passkeys
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
|
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
|
||||||
@@ -111,10 +159,120 @@ func New(repository Repository, passwords PasswordVerifier, options Options) (*S
|
|||||||
if options.GrantLifetime == 0 {
|
if options.GrantLifetime == 0 {
|
||||||
options.GrantLifetime = 10 * time.Minute
|
options.GrantLifetime = 10 * time.Minute
|
||||||
}
|
}
|
||||||
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute {
|
if options.AssistedGrantLifetime == 0 {
|
||||||
|
options.AssistedGrantLifetime = 15 * time.Minute
|
||||||
|
}
|
||||||
|
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute || options.AssistedGrantLifetime < 5*time.Minute || options.AssistedGrantLifetime > 30*time.Minute || options.OwnerRole != "" && !safeRole(options.OwnerRole) {
|
||||||
return nil, errors.New("authrecovery: invalid recovery policy")
|
return nil, errors.New("authrecovery: invalid recovery policy")
|
||||||
}
|
}
|
||||||
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, passkeys: options.Passkeys}, nil
|
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, assistedTTL: options.AssistedGrantLifetime, ownerRole: options.OwnerRole, passkeys: options.Passkeys}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueAssistedRecovery creates one owner-authorized, single-use recovery
|
||||||
|
// token. The repository immediately invalidates the target's previous
|
||||||
|
// password, passkeys, recovery codes, sessions, and pending ceremonies so the
|
||||||
|
// reviewed recovery cannot race an older authenticator.
|
||||||
|
func (service *Service) IssueAssistedRecovery(ctx context.Context, input AssistedIssue) (auth.User, string, error) {
|
||||||
|
repository, ok := service.repository.(AssistedRepository)
|
||||||
|
input.OrganizationID = strings.TrimSpace(input.OrganizationID)
|
||||||
|
input.ActorUserID = strings.TrimSpace(input.ActorUserID)
|
||||||
|
input.TargetUserID = strings.TrimSpace(input.TargetUserID)
|
||||||
|
input.RequestID = strings.TrimSpace(input.RequestID)
|
||||||
|
input.Reason = strings.TrimSpace(input.Reason)
|
||||||
|
if !ok || service.passkeys == nil || service.ownerRole == "" {
|
||||||
|
return auth.User{}, "", ErrPasskeyUnavailable
|
||||||
|
}
|
||||||
|
if !opaqueID(input.OrganizationID) || !opaqueID(input.ActorUserID) || !opaqueID(input.TargetUserID) || input.RequestID != "" && !opaqueID(input.RequestID) || len(input.Reason) < 8 || len(input.Reason) > 240 || strings.ContainsAny(input.Reason, "\x00\r\n") {
|
||||||
|
return auth.User{}, "", errors.New("authrecovery: invalid assisted recovery request")
|
||||||
|
}
|
||||||
|
raw, err := token(service.random, 32)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, "", err
|
||||||
|
}
|
||||||
|
now := service.now().UTC()
|
||||||
|
grant := AssistedGrant{Digest: sha256.Sum256([]byte(raw)), OrganizationID: input.OrganizationID, UserID: input.TargetUserID, IssuedByUserID: input.ActorUserID, CreatedAt: now, ExpiresAt: now.Add(service.assistedTTL)}
|
||||||
|
authAuditID, err := token(service.random, 18)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, "", err
|
||||||
|
}
|
||||||
|
accessAuditID, err := token(service.random, 18)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, "", err
|
||||||
|
}
|
||||||
|
summary := "Owner-assisted account recovery issued after human review. Reason: " + input.Reason
|
||||||
|
authAudit := auth.AuditEvent{ID: authAuditID, ActorUserID: input.ActorUserID, Action: "auth.assisted-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
|
||||||
|
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.account-recovery.issue", ResourceType: "user", ResourceID: input.TargetUserID, RequestID: input.RequestID, Summary: summary, CreatedAt: now}
|
||||||
|
user, err := repository.IssueAssistedRecovery(ctx, grant, service.ownerRole, authAudit, accessAudit)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, "", err
|
||||||
|
}
|
||||||
|
return user, raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginAssistedPasskey starts a replacement ceremony without issuing a normal
|
||||||
|
// session. The grant remains reusable for ceremony restart until completion or
|
||||||
|
// expiry; only completion consumes it.
|
||||||
|
func (service *Service) BeginAssistedPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) {
|
||||||
|
repository, ok := service.repository.(AssistedRepository)
|
||||||
|
if !ok || service.passkeys == nil {
|
||||||
|
return authwebauthn.BeginResult{}, ErrPasskeyUnavailable
|
||||||
|
}
|
||||||
|
digest, err := grantDigest(rawGrant)
|
||||||
|
if err != nil {
|
||||||
|
return authwebauthn.BeginResult{}, ErrAssistedNotFound
|
||||||
|
}
|
||||||
|
_, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return authwebauthn.BeginResult{}, err
|
||||||
|
}
|
||||||
|
return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishAssistedRecovery consumes a reviewed grant only inside the transaction
|
||||||
|
// that installs every replacement credential and both audit trails. No normal
|
||||||
|
// session is issued; the recovered user signs in with the new credentials.
|
||||||
|
func (service *Service) FinishAssistedRecovery(ctx context.Context, rawGrant, ceremonyToken, password string, response []byte) (PasskeyFinishResult, error) {
|
||||||
|
repository, ok := service.repository.(AssistedRepository)
|
||||||
|
if !ok || service.passkeys == nil {
|
||||||
|
return PasskeyFinishResult{}, ErrPasskeyUnavailable
|
||||||
|
}
|
||||||
|
digest, err := grantDigest(rawGrant)
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, ErrAssistedNotFound
|
||||||
|
}
|
||||||
|
grant, user, err := repository.AssistedRecoveryGrant(ctx, digest, service.now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
passwordHash, err := auth.HashPasswordWithRandom(password, service.random)
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
codes, digests, err := GenerateCodeSet(service.random, service.count)
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
credential, err := service.passkeys.FinishRecoveryRegistration(ctx, ceremonyToken, []byte(rawGrant), response, func(commitContext context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
|
||||||
|
if verified.UserID != user.ID {
|
||||||
|
return errors.New("authrecovery: assisted recovery identity mismatch")
|
||||||
|
}
|
||||||
|
completedAt := service.now().UTC()
|
||||||
|
recoveryAuditID, auditErr := token(service.random, 18)
|
||||||
|
if auditErr != nil {
|
||||||
|
return auditErr
|
||||||
|
}
|
||||||
|
accessAuditID, auditErr := token(service.random, 18)
|
||||||
|
if auditErr != nil {
|
||||||
|
return auditErr
|
||||||
|
}
|
||||||
|
recoveryAudit := auth.AuditEvent{ID: recoveryAuditID, ActorUserID: user.ID, Action: "auth.assisted-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Owner-assisted recovery replaced the password, passkeys, recovery codes, and sessions.", CreatedAt: completedAt}
|
||||||
|
accessAudit := access.AuditEvent{ID: accessAuditID, OrganizationID: grant.OrganizationID, ActorUserID: user.ID, Action: "access.account-recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "The organization member completed owner-assisted account recovery.", CreatedAt: completedAt}
|
||||||
|
return repository.CompleteAssistedRecovery(commitContext, AssistedCompletion{GrantDigest: digest, Credential: verified, PasswordHash: passwordHash, RecoveryDigests: digests, PasskeyAudit: passkeyAudit, RecoveryAudit: recoveryAudit, AccessAudit: accessAudit, CompletedAt: completedAt})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
|
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
|
||||||
@@ -290,3 +448,29 @@ func token(random io.Reader, size int) (string, error) {
|
|||||||
}
|
}
|
||||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func opaqueID(value string) bool {
|
||||||
|
if len(value) < 8 || len(value) > 128 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if character == '-' || character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeRole(value string) bool {
|
||||||
|
if len(value) < 1 || len(value) > 96 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, character := range value {
|
||||||
|
if character == '-' || character == '_' || character == '.' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package authsqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gamertan.com/web/access"
|
||||||
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/authrecovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (store *Store) IssueAssistedRecovery(ctx context.Context, grant authrecovery.AssistedGrant, ownerRole string, authAudit auth.AuditEvent, accessAudit access.AuditEvent) (auth.User, error) {
|
||||||
|
if !validAssistedGrant(grant) || !safeName(ownerRole) || !validAuditEvent(authAudit) || authAudit.ActorUserID != grant.IssuedByUserID || authAudit.Action != "auth.assisted-recovery.issue" || authAudit.ResourceType != "user" || authAudit.ResourceID != grant.UserID || !authAudit.CreatedAt.Equal(grant.CreatedAt) || !validAccessAudit(accessAudit) || accessAudit.OrganizationID != grant.OrganizationID || accessAudit.ActorUserID != grant.IssuedByUserID || accessAudit.Action != "access.account-recovery.issue" || accessAudit.ResourceType != "user" || accessAudit.ResourceID != grant.UserID || !accessAudit.CreatedAt.Equal(grant.CreatedAt) {
|
||||||
|
return auth.User{}, errors.New("authsqlite: invalid assisted recovery issue")
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Take the SQLite write lock before checking owner authority so a role or
|
||||||
|
// membership mutation cannot race the reviewed recovery decision.
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
|
||||||
|
WHERE organization_id=? AND user_id=? AND status='active'
|
||||||
|
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
|
||||||
|
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)
|
||||||
|
AND EXISTS (SELECT 1 FROM gwf_access_bindings b WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id=? AND b.role_name=? AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL AND b.revoked_at IS NULL)`, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, grant.OrganizationID, grant.IssuedByUserID, ownerRole)
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return auth.User{}, authrecovery.ErrAssistedDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at
|
||||||
|
FROM gwf_users u JOIN gwf_organization_memberships m ON m.user_id=u.id
|
||||||
|
WHERE u.id=? AND u.status='active' AND u.registration_pending=0 AND m.organization_id=? AND m.status='active'`, grant.UserID, grant.OrganizationID))
|
||||||
|
if errors.Is(err, auth.ErrUserNotFound) {
|
||||||
|
return auth.User{}, authrecovery.ErrAssistedDenied
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, statement := range []string{
|
||||||
|
`DELETE FROM gwf_auth_sessions WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_recovery_grants WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_password_credentials WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_passkey_credentials WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_recovery_codes WHERE user_id=?`,
|
||||||
|
} {
|
||||||
|
if _, err = tx.ExecContext(ctx, statement, grant.UserID); err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_assisted_recovery_grants(token_hash,user_id,organization_id,issued_by_user_id,created_at,expires_at) VALUES(?,?,?,?,?,?)`, grant.Digest[:], grant.UserID, grant.OrganizationID, grant.IssuedByUserID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, authAudit); err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
if err = appendAccessAudit(ctx, tx, accessAudit); err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) AssistedRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (authrecovery.AssistedGrant, auth.User, error) {
|
||||||
|
if zeroDigest(digest) || now.IsZero() {
|
||||||
|
return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound
|
||||||
|
}
|
||||||
|
var grant authrecovery.AssistedGrant
|
||||||
|
var user auth.User
|
||||||
|
var created, expires, userCreated, userUpdated int64
|
||||||
|
var passwordChangeRequired, registrationPending int
|
||||||
|
err := store.db.QueryRowContext(ctx, `SELECT g.user_id,g.organization_id,g.issued_by_user_id,g.created_at,g.expires_at,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at
|
||||||
|
FROM gwf_assisted_recovery_grants g
|
||||||
|
JOIN gwf_users u ON u.id=g.user_id AND u.status='active' AND u.registration_pending=0
|
||||||
|
JOIN gwf_organizations o ON o.id=g.organization_id AND o.status='active'
|
||||||
|
JOIN gwf_organization_memberships m ON m.organization_id=g.organization_id AND m.user_id=g.user_id AND m.status='active'
|
||||||
|
WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()).Scan(&grant.UserID, &grant.OrganizationID, &grant.IssuedByUserID, &created, &expires, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, ®istrationPending, &userCreated, &userUpdated)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return authrecovery.AssistedGrant{}, auth.User{}, authrecovery.ErrAssistedNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return authrecovery.AssistedGrant{}, auth.User{}, err
|
||||||
|
}
|
||||||
|
grant.Digest, grant.CreatedAt, grant.ExpiresAt = digest, time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
|
||||||
|
user.ID, user.PasswordChangeRequired, user.RegistrationPending = grant.UserID, passwordChangeRequired == 1, registrationPending == 1
|
||||||
|
user.CreatedAt, user.UpdatedAt = time.Unix(userCreated, 0).UTC(), time.Unix(userUpdated, 0).UTC()
|
||||||
|
return grant, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CompleteAssistedRecovery(ctx context.Context, completion authrecovery.AssistedCompletion) error {
|
||||||
|
credential := completion.Credential
|
||||||
|
credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID)
|
||||||
|
if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || !text(completion.PasswordHash, 1024, false) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || !validAuditEvent(completion.RecoveryAudit) || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.assisted-recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID || !completion.RecoveryAudit.CreatedAt.Equal(completion.CompletedAt) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.ActorUserID != credential.UserID || completion.AccessAudit.Action != "access.account-recovery.complete" || completion.AccessAudit.ResourceType != "user" || completion.AccessAudit.ResourceID != credential.UserID || !completion.AccessAudit.CreatedAt.Equal(completion.CompletedAt) {
|
||||||
|
return errors.New("authsqlite: invalid assisted recovery completion")
|
||||||
|
}
|
||||||
|
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
|
||||||
|
for _, digest := range completion.RecoveryDigests {
|
||||||
|
if zeroDigest(digest) {
|
||||||
|
return errors.New("authsqlite: invalid assisted recovery-code digest")
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[digest]; duplicate {
|
||||||
|
return errors.New("authsqlite: duplicate assisted recovery-code digest")
|
||||||
|
}
|
||||||
|
seen[digest] = struct{}{}
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var userID, organizationID string
|
||||||
|
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_assisted_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id,organization_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID, &organizationID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return authrecovery.ErrAssistedNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if userID != credential.UserID || organizationID != completion.AccessAudit.OrganizationID {
|
||||||
|
return errors.New("authsqlite: assisted recovery identity mismatch")
|
||||||
|
}
|
||||||
|
var active int
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users u
|
||||||
|
JOIN gwf_organization_memberships m ON m.user_id=u.id AND m.organization_id=? AND m.status='active'
|
||||||
|
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
|
||||||
|
WHERE u.id=? AND u.status='active' AND u.registration_pending=0`, organizationID, userID).Scan(&active); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if active != 1 {
|
||||||
|
return auth.ErrInactiveUser
|
||||||
|
}
|
||||||
|
for _, statement := range []string{
|
||||||
|
`DELETE FROM gwf_auth_sessions WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_recovery_grants WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_assisted_recovery_grants WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_password_credentials WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_passkey_credentials WHERE user_id=?`,
|
||||||
|
`DELETE FROM gwf_recovery_codes WHERE user_id=?`,
|
||||||
|
} {
|
||||||
|
if _, err = tx.ExecContext(ctx, statement, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, userID, completion.PasswordHash, completion.CompletedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, userID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, digest := range completion.RecoveryDigests {
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, digest[:], completion.CompletedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, completion.CompletedAt.Unix(), userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, completion.PasskeyAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, completion.RecoveryAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAssistedGrant(grant authrecovery.AssistedGrant) bool {
|
||||||
|
return !zeroDigest(grant.Digest) && opaqueID(grant.OrganizationID) && opaqueID(grant.UserID) && opaqueID(grant.IssuedByUserID) && !grant.CreatedAt.IsZero() && grant.ExpiresAt.After(grant.CreatedAt) && grant.ExpiresAt.Sub(grant.CreatedAt) >= 5*time.Minute && grant.ExpiresAt.Sub(grant.CreatedAt) <= 30*time.Minute
|
||||||
|
}
|
||||||
+6
-1
@@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
|
|||||||
return store, nil
|
return store, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const SchemaVersion = 8
|
const SchemaVersion = 9
|
||||||
|
|
||||||
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
|
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
|
||||||
var exists int
|
var exists int
|
||||||
@@ -136,6 +136,8 @@ func (store *Store) Migrate(ctx context.Context) error {
|
|||||||
`CREATE TABLE IF NOT EXISTS gwf_recovery_codes (user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, code_hash BLOB NOT NULL, created_at INTEGER NOT NULL, used_at INTEGER, PRIMARY KEY(user_id,code_hash))`,
|
`CREATE TABLE IF NOT EXISTS gwf_recovery_codes (user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, code_hash BLOB NOT NULL, created_at INTEGER NOT NULL, used_at INTEGER, PRIMARY KEY(user_id,code_hash))`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
`CREATE TABLE IF NOT EXISTS gwf_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
||||||
`CREATE INDEX IF NOT EXISTS gwf_recovery_grants_expiry ON gwf_recovery_grants(expires_at)`,
|
`CREATE INDEX IF NOT EXISTS gwf_recovery_grants_expiry ON gwf_recovery_grants(expires_at)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS gwf_assisted_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, issued_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS gwf_assisted_recovery_grants_expiry ON gwf_assisted_recovery_grants(expires_at)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_account_registrations (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
`CREATE TABLE IF NOT EXISTS gwf_account_registrations (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
|
||||||
`CREATE INDEX IF NOT EXISTS gwf_account_registrations_expiry ON gwf_account_registrations(expires_at)`,
|
`CREATE INDEX IF NOT EXISTS gwf_account_registrations_expiry ON gwf_account_registrations(expires_at)`,
|
||||||
`CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
|
`CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
|
||||||
@@ -234,6 +236,9 @@ func (store *Store) Migrate(ctx context.Context) error {
|
|||||||
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(8,?)`, time.Now().UTC().Unix()); err != nil {
|
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(8,?)`, time.Now().UTC().Unix()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(9,?)`, time.Now().UTC().Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,3 +65,11 @@ application concern belongs in the shared module.
|
|||||||
optimistic extension serializes on the active administrator membership,
|
optimistic extension serializes on the active administrator membership,
|
||||||
rechecks the exact state bound into the passkey assertion, applies team and
|
rechecks the exact state bound into the passkey assertion, applies team and
|
||||||
direct-binding consequences, and writes the audit in one transaction.
|
direct-binding consequences, and writes the audit in one transaction.
|
||||||
|
- Human-assisted recovery cannot safely be expressed as a root command behind
|
||||||
|
an HTTP button. Preview 18 adds a distinct owner-assisted protocol: the
|
||||||
|
application performs the human review and fresh operation-bound passkey
|
||||||
|
ceremony, while the SQLite transaction rechecks an active direct owner,
|
||||||
|
invalidates every old account authenticator, stores only the grant digest,
|
||||||
|
and writes identity plus organization audits. Grant completion installs the
|
||||||
|
replacement password, passkey, and recovery-code set atomically and never
|
||||||
|
issues a session.
|
||||||
|
|||||||
@@ -26,7 +26,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.17
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.18
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+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.17
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.18
|
||||||
```
|
```
|
||||||
|
|
||||||
Only imported packages are compiled and linked. The packages nevertheless
|
Only imported packages are compiled and linked. The packages nevertheless
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ grant organization-data access. If an operator must inspect tenant data during
|
|||||||
an incident, use a reasoned break-glass grant. It expires within one hour and
|
an incident, use a reasoned break-glass grant. It expires within one hour and
|
||||||
creates an append-only audit event in the same transaction.
|
creates an append-only audit event in the same transaction.
|
||||||
|
|
||||||
|
An application that offers owner-assisted account recovery must not infer that
|
||||||
|
authority from a broad administration page. Use the dedicated
|
||||||
|
`authrecovery.IssueAssistedRecovery` boundary after an operation-bound passkey
|
||||||
|
assertion. The SQLite adapter requires a current active direct owner binding
|
||||||
|
and active target membership in the same transaction that invalidates the old
|
||||||
|
credentials and records the organization-visible recovery audit. Team,
|
||||||
|
break-glass, platform, and merely descriptive roles do not satisfy this owner
|
||||||
|
check.
|
||||||
|
|
||||||
The SQLite adapter namespaces all tables, enforces active organization and team
|
The SQLite adapter namespaces all tables, enforces active organization and team
|
||||||
membership plus resource ancestry before accepting or evaluating a binding,
|
membership plus resource ancestry before accepting or evaluating a binding,
|
||||||
and keeps invitations and sessions as digests. Applications remain responsible
|
and keeps invitations and sessions as digests. Applications remain responsible
|
||||||
|
|||||||
@@ -85,5 +85,22 @@ ceremony until expiry. A binding mismatch consumes the mismatched ceremony.
|
|||||||
Applications must use generic failure responses and the same credential-attempt
|
Applications must use generic failure responses and the same credential-attempt
|
||||||
rate limiting as login.
|
rate limiting as login.
|
||||||
|
|
||||||
|
Owner-assisted recovery is a third, deliberately separate path. Configure
|
||||||
|
`authrecovery.Options.OwnerRole`, authorize an active direct organization owner,
|
||||||
|
and bind that owner's fresh passkey assertion to the exact organization,
|
||||||
|
target user, request identifier, and bounded human-review reason before calling
|
||||||
|
`IssueAssistedRecovery`. The SQLite transaction rechecks the active direct
|
||||||
|
owner and target membership, invalidates the target's password, passkeys,
|
||||||
|
recovery codes, sessions, and pending ceremonies, then stores only a digest of
|
||||||
|
the 15-minute grant with identity and organization-visible audits.
|
||||||
|
|
||||||
|
Deliver the returned grant exactly once in a URL fragment. A public recovery
|
||||||
|
page can pass it to `BeginAssistedPasskey` and `FinishAssistedRecovery` while
|
||||||
|
keeping it out of request URLs, referrers, and access logs. Completion consumes
|
||||||
|
the grant atomically with one replacement password, passkey, recovery-code set,
|
||||||
|
and both audit trails. It issues no session. Losing the fragment after issuance
|
||||||
|
requires another reviewed owner or root-local recovery; old authenticators
|
||||||
|
must not become valid again as a fallback.
|
||||||
|
|
||||||
Before enabling production mutations, applications should require at least two
|
Before enabling production mutations, applications should require at least two
|
||||||
independent passkeys and complete a local recovery drill.
|
independent passkeys and complete a local recovery drill.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ auth/password.go
|
|||||||
auth/password_test.go
|
auth/password_test.go
|
||||||
authrecovery/recovery.go
|
authrecovery/recovery.go
|
||||||
authrecovery/recovery_test.go
|
authrecovery/recovery_test.go
|
||||||
|
authrecovery/assisted_test.go
|
||||||
auth/service_test.go
|
auth/service_test.go
|
||||||
authhttp/authhttp.go
|
authhttp/authhttp.go
|
||||||
authhttp/authhttp_test.go
|
authhttp/authhttp_test.go
|
||||||
@@ -40,6 +41,7 @@ authsqlite/store_test.go
|
|||||||
authsqlite/account.go
|
authsqlite/account.go
|
||||||
authsqlite/account_test.go
|
authsqlite/account_test.go
|
||||||
authsqlite/access.go
|
authsqlite/access.go
|
||||||
|
authsqlite/assisted_recovery.go
|
||||||
authsqlite/bootstrap.go
|
authsqlite/bootstrap.go
|
||||||
authsqlite/bootstrap_test.go
|
authsqlite/bootstrap_test.go
|
||||||
authsqlite/organizations.go
|
authsqlite/organizations.go
|
||||||
|
|||||||
Reference in New Issue
Block a user