Add atomic account registration and local media
verify / verify (push) Successful in 3m35s

This commit is contained in:
2026-09-03 11:51:53 -04:00
parent 337b56ec1b
commit 92ef63ba00
23 changed files with 1816 additions and 62 deletions
+20
View File
@@ -2,6 +2,26 @@
# Changelog # Changelog
## v0.1.0-preview.10 — 2026-09-03
- Add atomic public-account registration with required canonical email,
password authentication, printable recovery codes, a personal organization,
direct owner access, and an optional initial passkey. Pending registrations
cannot authenticate, and abandoned drafts expire without reserving identity
fields indefinitely.
- Add password verification without session issuance plus operation-bound
WebAuthn completion hooks, allowing applications to require fresh passkeys
for sensitive actions without imposing passkeys on ordinary customer use.
- Add digest-only recovery-code persistence and short-lived, single-use
recovery grants that consume a code and revoke existing sessions atomically.
- Add bounded raster/PDF media preparation and a hardened content-addressed
local filesystem adapter with atomic writes, private modes, and symlink
rejection.
- Add explicit SQLite open-without-migration and schema-requirement APIs while
preserving the historical migrating `Open` behavior for existing adopters.
- Record application dogfood findings and the independent future commerce
module boundary.
## v0.1.0-preview.9 — 2026-09-03 ## v0.1.0-preview.9 — 2026-09-03
- Add a documented root package and executable composition example so the - Add a documented root package and executable composition example so the
+11 -5
View File
@@ -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.9`. APIs may change before a stable > **Public preview:** `v0.1.0-preview.10`. 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?
@@ -38,8 +38,11 @@ packages you import.
| Bounded structured request evidence | [`requestmeta`](requestmeta) + [`requestlog`](requestlog) | | Bounded structured request evidence | [`requestmeta`](requestmeta) + [`requestlog`](requestlog) |
| Browser and HTTP security primitives | [`websec`](websec) | | Browser and HTTP security primitives | [`websec`](websec) |
| Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) | | Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) |
| Passkey-only authentication | [`authwebauthn`](authwebauthn) | | Atomic password-plus-passkey registration | [`account`](account) |
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
| Printable single-use recovery codes | [`authrecovery`](authrecovery) |
| Private SQLite persistence | [`authsqlite`](authsqlite) | | Private SQLite persistence | [`authsqlite`](authsqlite) |
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
| Organizations, teams, and invitations | [`organizations`](organizations) | | Organizations, teams, and invitations | [`organizations`](organizations) |
| Organization-scoped roles and temporary access | [`access`](access) | | Organization-scoped roles and temporary access | [`access`](access) |
| Application-classified request abuse | [`abuse`](abuse) | | Application-classified request abuse | [`abuse`](abuse) |
@@ -53,14 +56,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.9 go get gamertan.com/web@v0.1.0-preview.10
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.9 go get gamertan.com/web/requestmeta@v0.1.0-preview.10
``` ```
The version belongs to the `gamertan.com/web` module. See the The version belongs to the `gamertan.com/web` module. See the
@@ -91,10 +94,13 @@ JSONL logging.
- [`auth`](auth) defines storage-neutral users, password credentials, opaque - [`auth`](auth) defines storage-neutral users, password credentials, opaque
sessions, platform permissions, and audit events. sessions, platform permissions, and audit events.
- [`account`](account) composes the first password, printable recovery codes,
personal organization, and owner access as one registration transaction,
optionally including an initial passkey.
- [`authhttp`](authhttp) connects those sessions to secure browser cookies and - [`authhttp`](authhttp) connects those sessions to secure browser cookies and
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,
passkey-only enrollment, fresh-operation approval, and bounded recovery. enrollment, operation-bound fresh approval, and bounded recovery.
- [`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.
+338
View File
@@ -0,0 +1,338 @@
// SPDX-License-Identifier: MPL-2.0
// Package account orchestrates atomic account registration. Email is the
// canonical sign-in identifier; username remains the stable public/profile
// identity. Applications may finish with password-only base access or include
// an initial passkey when their onboarding policy requires one.
package account
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/mail"
"regexp"
"strings"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
"gamertan.com/web/authwebauthn"
"gamertan.com/web/organizations"
)
var (
ErrRegistrationNotFound = errors.New("account: registration not found")
ErrPasskeysUnavailable = errors.New("account: passkeys are unavailable")
usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
)
type Registration struct {
Digest [32]byte
User auth.User
CreatedAt, ExpiresAt time.Time
}
type RegistrationCompletion struct {
Credential *authwebauthn.Credential
RecoveryDigests [][32]byte
Organization organizations.Organization
Membership organizations.Membership
OwnerBinding access.Binding
AuthAudit auth.AuditEvent
OrganizationAudit organizations.AuditEvent
AccessAudit access.AuditEvent
CompletedAt time.Time
}
type Repository interface {
CreateRegistration(context.Context, Registration, string, auth.AuditEvent) error
Registration(context.Context, [32]byte, time.Time) (Registration, error)
CompleteRegistration(context.Context, [32]byte, RegistrationCompletion) error
}
type Passkeys interface {
BeginAccountRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
FinishAccountRegistration(context.Context, string, []byte, []byte, authwebauthn.RegistrationCommit) (authwebauthn.Credential, error)
}
type Sessions interface {
IssueSession(context.Context, string, time.Duration) (string, auth.Principal, error)
}
type Options struct {
Random io.Reader
Now func() time.Time
RegistrationTTL time.Duration
SessionLifetime time.Duration
RecoveryCodes int
OwnerRole string
}
type Service struct {
repository Repository
passkeys Passkeys
sessions Sessions
random io.Reader
now func() time.Time
draftTTL time.Duration
sessionTTL time.Duration
codeCount int
ownerRole string
}
func New(repository Repository, passkeys Passkeys, sessions Sessions, options Options) (*Service, error) {
if repository == nil || sessions == nil {
return nil, errors.New("account: repository and sessions are required")
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
if options.RegistrationTTL == 0 {
options.RegistrationTTL = 15 * time.Minute
}
if options.SessionLifetime == 0 {
options.SessionLifetime = 12 * time.Hour
}
if options.RecoveryCodes == 0 {
options.RecoveryCodes = authrecovery.DefaultCodeCount
}
if options.OwnerRole == "" {
options.OwnerRole = "owner"
}
if options.RegistrationTTL < 5*time.Minute || options.RegistrationTTL > time.Hour || options.SessionLifetime < 5*time.Minute || options.SessionLifetime > 30*24*time.Hour || options.RecoveryCodes < 5 || options.RecoveryCodes > 20 || !roleName(options.OwnerRole) {
return nil, errors.New("account: invalid registration policy")
}
return &Service{repository: repository, passkeys: passkeys, sessions: sessions, random: options.Random, now: options.Now, draftTTL: options.RegistrationTTL, sessionTTL: options.SessionLifetime, codeCount: options.RecoveryCodes, ownerRole: options.OwnerRole}, nil
}
type StartInput struct {
Email, Username, DisplayName, Password string
}
type StartResult struct {
RegistrationToken string
User auth.User
ExpiresAt time.Time
}
// Start validates and stores a bounded pending registration. The returned
// secret is displayed only to the same browser flow and binds every following
// ceremony to this draft.
func (service *Service) Start(ctx context.Context, input StartInput) (StartResult, error) {
email, err := canonicalEmail(input.Email)
if err != nil {
return StartResult{}, err
}
username := strings.TrimSpace(input.Username)
displayName := strings.TrimSpace(input.DisplayName)
if !usernamePattern.MatchString(username) || displayName == "" || len(displayName) > 128 || strings.ContainsAny(displayName, "\x00\r\n") {
return StartResult{}, errors.New("account: invalid profile")
}
passwordHash, err := auth.HashPasswordWithRandom(input.Password, service.random)
if err != nil {
return StartResult{}, err
}
userID, err := service.token(18)
if err != nil {
return StartResult{}, err
}
rawToken, err := service.token(32)
if err != nil {
return StartResult{}, err
}
now := service.now().UTC()
user := auth.User{ID: userID, Username: username, Email: email, DisplayName: displayName, Status: "active", RegistrationPending: true, CreatedAt: now, UpdatedAt: now}
registration := Registration{Digest: sha256.Sum256([]byte(rawToken)), User: user, CreatedAt: now, ExpiresAt: now.Add(service.draftTTL)}
audit, err := service.authAudit(user.ID, "auth.account.registration.start", "A public account registration was started.")
if err != nil {
return StartResult{}, err
}
if err = service.repository.CreateRegistration(ctx, registration, passwordHash, audit); err != nil {
return StartResult{}, err
}
return StartResult{RegistrationToken: rawToken, User: user, ExpiresAt: registration.ExpiresAt}, nil
}
func (service *Service) BeginPasskey(ctx context.Context, registrationToken, label string) (authwebauthn.BeginResult, error) {
if service.passkeys == nil {
return authwebauthn.BeginResult{}, ErrPasskeysUnavailable
}
registration, err := service.registration(ctx, registrationToken)
if err != nil {
return authwebauthn.BeginResult{}, err
}
return service.passkeys.BeginAccountRegistration(ctx, registration.User.ID, label, []byte(registrationToken))
}
type FinishResult struct {
User auth.User
Organization organizations.Organization
RecoveryCodes []string
SessionToken string
Principal auth.Principal
PasskeyCredential authwebauthn.Credential
}
// FinishPassword activates a base account without requiring WebAuthn. The
// application can require an operation-bound passkey assertion later for
// sensitive permissions.
func (service *Service) FinishPassword(ctx context.Context, registrationToken string) (FinishResult, error) {
registration, err := service.registration(ctx, registrationToken)
if err != nil {
return FinishResult{}, err
}
codes, recoveryDigests, err := authrecovery.GenerateCodeSet(service.random, service.codeCount)
if err != nil {
return FinishResult{}, err
}
completion, err := service.completion(registration, recoveryDigests)
if err != nil {
return FinishResult{}, err
}
completion.AuthAudit, err = service.authAudit(registration.User.ID, "auth.account.registration.complete", "The password-authenticated account registration was completed.")
if err != nil {
return FinishResult{}, err
}
if err = service.repository.CompleteRegistration(ctx, registration.Digest, completion); err != nil {
return FinishResult{}, err
}
return service.finishSession(ctx, registration, completion, codes, authwebauthn.Credential{})
}
// FinishWithPasskey completes the same atomic account transaction while also
// storing a verified initial passkey.
func (service *Service) FinishWithPasskey(ctx context.Context, registrationToken, ceremonyToken string, response []byte) (FinishResult, error) {
if service.passkeys == nil {
return FinishResult{}, ErrPasskeysUnavailable
}
registration, err := service.registration(ctx, registrationToken)
if err != nil {
return FinishResult{}, err
}
codes, recoveryDigests, err := authrecovery.GenerateCodeSet(service.random, service.codeCount)
if err != nil {
return FinishResult{}, err
}
completion, err := service.completion(registration, recoveryDigests)
if err != nil {
return FinishResult{}, err
}
credential, err := service.passkeys.FinishAccountRegistration(ctx, ceremonyToken, []byte(registrationToken), response, func(commitCtx context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
completion.Credential = &verified
completion.AuthAudit = passkeyAudit
return service.repository.CompleteRegistration(commitCtx, registration.Digest, completion)
})
if err != nil {
return FinishResult{}, err
}
return service.finishSession(ctx, registration, completion, codes, credential)
}
func (service *Service) finishSession(ctx context.Context, registration Registration, completion RegistrationCompletion, codes []string, credential authwebauthn.Credential) (FinishResult, error) {
user := registration.User
user.RegistrationPending = false
user.UpdatedAt = completion.CompletedAt
result := FinishResult{User: user, Organization: completion.Organization, RecoveryCodes: codes, PasskeyCredential: credential}
sessionToken, principal, err := service.sessions.IssueSession(ctx, user.ID, service.sessionTTL)
if err != nil {
// Registration is already durable. Preserve the one-time recovery codes
// in the returned result so an application can display them while asking
// the user to sign in again.
return result, fmt.Errorf("account: registration completed but session issuance failed: %w", err)
}
result.SessionToken, result.Principal = sessionToken, principal
return result, nil
}
func (service *Service) completion(registration Registration, recoveryDigests [][32]byte) (RegistrationCompletion, error) {
organizationID, err := service.token(18)
if err != nil {
return RegistrationCompletion{}, err
}
bindingID, err := service.token(18)
if err != nil {
return RegistrationCompletion{}, err
}
slugBytes := make([]byte, 6)
if _, err = io.ReadFull(service.random, slugBytes); err != nil {
return RegistrationCompletion{}, fmt.Errorf("account: secure randomness unavailable: %w", err)
}
now := service.now().UTC()
organization := organizations.Organization{ID: organizationID, Slug: "personal-" + hex.EncodeToString(slugBytes), Name: registration.User.DisplayName + " — Personal", Status: "active", Personal: true, Revision: 1, CreatedAt: now, UpdatedAt: now}
membership := organizations.Membership{OrganizationID: organizationID, UserID: registration.User.ID, Status: "active", JoinedAt: now}
binding := access.Binding{ID: bindingID, SubjectKind: access.User, SubjectID: registration.User.ID, Role: service.ownerRole, Scope: access.Scope{OrganizationID: organizationID}, GrantedBy: registration.User.ID, GrantedAt: now}
organizationAuditID, err := service.token(18)
if err != nil {
return RegistrationCompletion{}, err
}
accessAuditID, err := service.token(18)
if err != nil {
return RegistrationCompletion{}, err
}
return RegistrationCompletion{
RecoveryDigests: recoveryDigests,
Organization: organization,
Membership: membership,
OwnerBinding: binding,
OrganizationAudit: organizations.AuditEvent{ID: organizationAuditID, OrganizationID: organizationID, ActorUserID: registration.User.ID, Action: "organization.personal.create", ResourceType: "organization", ResourceID: organizationID, Summary: "Personal organization created during account registration.", CreatedAt: now},
AccessAudit: access.AuditEvent{ID: accessAuditID, OrganizationID: organizationID, ActorUserID: registration.User.ID, Action: "access.owner.grant", ResourceType: "user", ResourceID: registration.User.ID, Summary: "Initial personal-organization owner access granted.", CreatedAt: now},
CompletedAt: now,
}, nil
}
func (service *Service) registration(ctx context.Context, raw string) (Registration, error) {
if len(raw) < 32 || len(raw) > 128 {
return Registration{}, ErrRegistrationNotFound
}
if _, err := base64.RawURLEncoding.DecodeString(raw); err != nil {
return Registration{}, ErrRegistrationNotFound
}
return service.repository.Registration(ctx, sha256.Sum256([]byte(raw)), service.now().UTC())
}
func (service *Service) authAudit(userID, action, summary string) (auth.AuditEvent, error) {
id, err := service.token(18)
if err != nil {
return auth.AuditEvent{}, err
}
return auth.AuditEvent{ID: id, ActorUserID: userID, Action: action, ResourceType: "user", ResourceID: userID, Summary: summary, CreatedAt: service.now().UTC()}, nil
}
func (service *Service) token(size int) (string, error) {
value := make([]byte, size)
if _, err := io.ReadFull(service.random, value); err != nil {
return "", fmt.Errorf("account: secure randomness unavailable: %w", err)
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func canonicalEmail(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
parsed, err := mail.ParseAddress(value)
if err != nil || parsed.Address != value || len(value) > 320 || strings.ContainsAny(value, "\x00\r\n") {
return "", errors.New("account: a valid email address is required")
}
return value, nil
}
func roleName(value string) bool {
if len(value) < 2 || len(value) > 128 || value[0] < 'a' || value[0] > 'z' {
return false
}
for _, character := range value[1:] {
if character < 'a' || character > 'z' && (character < '0' || character > '9') && character != '.' && character != '_' && character != '-' {
return false
}
}
return true
}
+35 -17
View File
@@ -31,8 +31,14 @@ type User struct {
ID, Username, Email, DisplayName, Status string ID, Username, Email, DisplayName, Status string
CreatedAt, UpdatedAt time.Time CreatedAt, UpdatedAt time.Time
PasswordChangeRequired bool PasswordChangeRequired bool
// RegistrationPending keeps a partially completed public registration
// ineligible for authentication until its credentials, personal scope, and
// recovery material have been committed atomically.
RegistrationPending bool
} }
func (user User) Active() bool { return user.Status == "active" && !user.RegistrationPending }
type Principal struct { type Principal struct {
User User User User
Roles []string Roles []string
@@ -167,7 +173,7 @@ func (service *Service) ChangePassword(ctx context.Context, userID, currentPassw
if !VerifyPassword(currentHash, currentPassword) { if !VerifyPassword(currentHash, currentPassword) {
return ErrInvalidCredentials return ErrInvalidCredentials
} }
if user.Status != "active" { if !user.Active() {
return ErrInactiveUser return ErrInactiveUser
} }
if currentPassword == newPassword { if currentPassword == newPassword {
@@ -199,7 +205,7 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP
if err != nil { if err != nil {
return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err) return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err)
} }
if user.Status != "active" { if !user.Active() {
return User{}, ErrInactiveUser return User{}, ErrInactiveUser
} }
if VerifyPassword(currentHash, input.TemporaryPassword) { if VerifyPassword(currentHash, input.TemporaryPassword) {
@@ -233,24 +239,36 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP
return user, nil return user, nil
} }
// VerifyPassword verifies the password credential for an active account
// without creating a session. Applications use it as the first step of a
// bounded multi-factor ceremony and must not treat success as an authenticated
// browser session on its own.
func (service *Service) VerifyPassword(ctx context.Context, identifier, password string) (User, error) {
user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier))
if errors.Is(err, ErrUserNotFound) {
_ = VerifyPassword(dummyPasswordHash, password)
return User{}, ErrInvalidCredentials
}
if err != nil {
_ = VerifyPassword(dummyPasswordHash, password)
return User{}, fmt.Errorf("auth: load credentials: %w", err)
}
if !VerifyPassword(hash, password) {
return User{}, ErrInvalidCredentials
}
if !user.Active() {
return User{}, ErrInactiveUser
}
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")
} }
user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier)) user, err := service.VerifyPassword(ctx, identifier, password)
if errors.Is(err, ErrUserNotFound) {
_ = VerifyPassword(dummyPasswordHash, password)
return "", Principal{}, ErrInvalidCredentials
}
if err != nil { if err != nil {
_ = VerifyPassword(dummyPasswordHash, password) return "", Principal{}, err
return "", Principal{}, fmt.Errorf("auth: load credentials: %w", err)
}
if !VerifyPassword(hash, password) {
return "", Principal{}, ErrInvalidCredentials
}
if user.Status != "active" {
return "", Principal{}, ErrInactiveUser
} }
return service.IssueSession(ctx, user.ID, lifetime) return service.IssueSession(ctx, user.ID, lifetime)
} }
@@ -282,7 +300,7 @@ func (service *Service) IssueSession(ctx context.Context, userID string, lifetim
_ = service.repository.DeleteSession(ctx, digest) _ = service.repository.DeleteSession(ctx, digest)
return "", Principal{}, err return "", Principal{}, err
} }
if principal.User.Status != "active" { if !principal.User.Active() {
_ = service.repository.DeleteSession(ctx, digest) _ = service.repository.DeleteSession(ctx, digest)
return "", Principal{}, ErrInactiveUser return "", Principal{}, ErrInactiveUser
} }
@@ -302,7 +320,7 @@ func (service *Service) Session(ctx context.Context, token string) (Principal, e
if err != nil { if err != nil {
return Principal{}, fmt.Errorf("auth: load session: %w", err) return Principal{}, fmt.Errorf("auth: load session: %w", err)
} }
if principal.User.Status != "active" { if !principal.User.Active() {
_ = service.repository.DeleteSession(ctx, digest) _ = service.repository.DeleteSession(ctx, digest)
return Principal{}, ErrInactiveUser return Principal{}, ErrInactiveUser
} }
+41
View File
@@ -57,6 +57,31 @@ func TestIssueSessionRejectsInactiveRepositoryPrincipal(t *testing.T) {
} }
} }
func TestVerifyPasswordDoesNotIssueSession(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatal(err)
}
repository := &credentialRepository{
user: User{ID: "valid-user-id", Username: "person", Email: "person@example.test", Status: "active"},
hash: hash,
}
service, err := New(repository, Options{})
if err != nil {
t.Fatal(err)
}
user, err := service.VerifyPassword(t.Context(), "person@example.test", "correct horse battery staple")
if err != nil || user.ID != repository.user.ID {
t.Fatalf("user=%+v err=%v", user, err)
}
if repository.sessionCreated {
t.Fatal("password verification issued a session")
}
if _, err = service.VerifyPassword(t.Context(), "person@example.test", "wrong password"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("wrong password err=%v", err)
}
}
type recordingRepository struct { type recordingRepository struct {
repositoryStub repositoryStub
deleted bool deleted bool
@@ -68,6 +93,22 @@ type activeSessionRepository struct {
deleted bool deleted bool
} }
type credentialRepository struct {
repositoryStub
user User
hash string
sessionCreated bool
}
func (repository *credentialRepository) CredentialByIdentifier(context.Context, string) (User, string, error) {
return repository.user, repository.hash, nil
}
func (repository *credentialRepository) CreateSession(context.Context, Session) error {
repository.sessionCreated = true
return nil
}
func (repository *activeSessionRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) { func (repository *activeSessionRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
return repository.principal, Session{}, nil return repository.principal, Session{}, nil
} }
+182
View File
@@ -0,0 +1,182 @@
// SPDX-License-Identifier: MPL-2.0
// Package authrecovery provides printable one-time recovery codes and bounded
// recovery grants for password-plus-passkey accounts.
package authrecovery
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"errors"
"fmt"
"io"
"strings"
"time"
"gamertan.com/web/auth"
)
const DefaultCodeCount = 10
var (
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
)
type Grant struct {
Digest [32]byte
UserID string
CreatedAt time.Time
ExpiresAt time.Time
}
type Repository interface {
ReplaceRecoveryCodes(context.Context, string, [][32]byte, time.Time, auth.AuditEvent) error
ConsumeRecoveryCodeAndCreateGrant(context.Context, string, [32]byte, Grant, auth.AuditEvent) error
TakeRecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error)
}
type PasswordVerifier interface {
VerifyPassword(context.Context, string, string) (auth.User, error)
}
type Options struct {
Random io.Reader
Now func() time.Time
CodeCount int
GrantLifetime time.Duration
}
type Service struct {
repository Repository
passwords PasswordVerifier
random io.Reader
now func() time.Time
count int
grantTTL time.Duration
}
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
if repository == nil || passwords == nil {
return nil, errors.New("authrecovery: repository and password verifier are required")
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
if options.CodeCount == 0 {
options.CodeCount = DefaultCodeCount
}
if options.GrantLifetime == 0 {
options.GrantLifetime = 10 * time.Minute
}
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute {
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}, nil
}
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
// once; only domain-separated digests are persisted.
func (service *Service) ReplaceCodes(ctx context.Context, userID, actorUserID string) ([]string, error) {
codes, digests, err := GenerateCodeSet(service.random, service.count)
if err != nil {
return nil, err
}
now := service.now().UTC()
auditID, err := token(service.random, 18)
if err != nil {
return nil, err
}
audit := auth.AuditEvent{ID: auditID, ActorUserID: actorUserID, Action: "auth.recovery-codes.replace", ResourceType: "user", ResourceID: userID, Summary: "The account recovery-code set was replaced.", CreatedAt: now}
if err = service.repository.ReplaceRecoveryCodes(ctx, userID, digests, now, audit); err != nil {
return nil, err
}
return codes, nil
}
// Begin verifies the password, atomically consumes one code, revokes sessions,
// and returns a short-lived grant. Applications bind the grant to the passkey
// replacement ceremony and do not issue a normal session from it.
func (service *Service) Begin(ctx context.Context, identifier, password, code string) (auth.User, string, error) {
user, err := service.passwords.VerifyPassword(ctx, identifier, password)
if err != nil {
return auth.User{}, "", err
}
digest, err := DigestCode(code)
if err != nil {
return auth.User{}, "", auth.ErrInvalidCredentials
}
rawGrant, err := token(service.random, 32)
if err != nil {
return auth.User{}, "", err
}
now := service.now().UTC()
grant := Grant{Digest: sha256.Sum256([]byte(rawGrant)), UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(service.grantTTL)}
auditID, err := token(service.random, 18)
if err != nil {
return auth.User{}, "", err
}
audit := auth.AuditEvent{ID: auditID, ActorUserID: user.ID, Action: "auth.recovery.begin", ResourceType: "user", ResourceID: user.ID, Summary: "A recovery code was consumed and existing sessions were revoked.", CreatedAt: now}
if err = service.repository.ConsumeRecoveryCodeAndCreateGrant(ctx, user.ID, digest, grant, audit); err != nil {
if errors.Is(err, ErrCodeNotFound) {
return auth.User{}, "", auth.ErrInvalidCredentials
}
return auth.User{}, "", err
}
return user, rawGrant, nil
}
func (service *Service) TakeGrant(ctx context.Context, raw string) (auth.User, error) {
if len(raw) < 32 || len(raw) > 128 {
return auth.User{}, ErrGrantNotFound
}
return service.repository.TakeRecoveryGrant(ctx, sha256.Sum256([]byte(raw)), service.now().UTC())
}
func GenerateCodeSet(random io.Reader, count int) ([]string, [][32]byte, error) {
if random == nil || count < 1 || count > 20 {
return nil, nil, errors.New("authrecovery: invalid code-set request")
}
codes := make([]string, 0, count)
digests := make([][32]byte, 0, count)
seen := make(map[[32]byte]struct{}, count)
for len(codes) < count {
value := make([]byte, 16)
if _, err := io.ReadFull(random, value); err != nil {
return nil, nil, fmt.Errorf("authrecovery: secure randomness unavailable: %w", err)
}
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(value)
code := strings.Join([]string{encoded[0:5], encoded[5:10], encoded[10:15], encoded[15:20], encoded[20:26]}, "-")
digest, _ := DigestCode(code)
if _, duplicate := seen[digest]; duplicate {
continue
}
seen[digest] = struct{}{}
codes = append(codes, code)
digests = append(digests, digest)
}
return codes, digests, nil
}
func DigestCode(code string) ([32]byte, error) {
normalized := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(code), "-", ""), " ", ""))
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalized)
if err != nil || len(decoded) != 16 {
return [32]byte{}, ErrCodeNotFound
}
return sha256.Sum256(append([]byte("gamertan-web-recovery-code-v1\x00"), decoded...)), nil
}
func token(random io.Reader, size int) (string, error) {
value := make([]byte, size)
if _, err := io.ReadFull(random, value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MPL-2.0
package authrecovery_test
import (
"errors"
"path/filepath"
"strings"
"testing"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
"gamertan.com/web/authsqlite"
)
func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) {
now := time.Date(2026, 9, 3, 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)
}
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.person", Email: "recover@example.test", DisplayName: "Recover Person", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
codes, err := recovery.ReplaceCodes(t.Context(), user.ID, user.ID)
if err != nil || len(codes) != authrecovery.DefaultCodeCount {
t.Fatalf("codes=%d err=%v", len(codes), err)
}
session, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
loaded, grant, err := recovery.Begin(t.Context(), strings.ToUpper(user.Email), "correct horse battery staple", strings.ToLower(codes[0]))
if err != nil || loaded.ID != user.ID || grant == "" {
t.Fatalf("loaded=%+v grant=%q err=%v", loaded, grant, err)
}
if _, err = authService.Session(t.Context(), session); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived recovery: %v", err)
}
if _, _, err = recovery.Begin(t.Context(), user.Email, "correct horse battery staple", codes[0]); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("code replay err=%v", err)
}
loaded, err = recovery.TakeGrant(t.Context(), grant)
if err != nil || loaded.ID != user.ID {
t.Fatalf("grant user=%+v err=%v", loaded, err)
}
if _, err = recovery.TakeGrant(t.Context(), grant); !errors.Is(err, authrecovery.ErrGrantNotFound) {
t.Fatalf("grant replay err=%v", err)
}
}
type counterReader struct{ value byte }
func (reader *counterReader) Read(target []byte) (int, error) {
for index := range target {
reader.value++
target[index] = reader.value
}
return len(target), nil
}
+160
View File
@@ -0,0 +1,160 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"errors"
"time"
"gamertan.com/web/access"
"gamertan.com/web/account"
"gamertan.com/web/auth"
)
func (store *Store) CreateRegistration(ctx context.Context, registration account.Registration, passwordHash string, audit auth.AuditEvent) error {
user := registration.User
if zeroDigest(registration.Digest) || !validPendingUser(user) || !registration.CreatedAt.Equal(user.CreatedAt) || !registration.ExpiresAt.After(registration.CreatedAt) || registration.ExpiresAt.Sub(registration.CreatedAt) > time.Hour || !text(passwordHash, 1024, false) || !validAuditEvent(audit) || audit.ActorUserID != user.ID || audit.ResourceID != user.ID {
return errors.New("authsqlite: invalid account registration")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// A bounded abandoned registration must not reserve its email or username
// forever. Deleting the pending user cascades every private draft artifact.
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_users WHERE registration_pending=1 AND id IN (SELECT user_id FROM gwf_account_registrations WHERE expires_at<=?)`, registration.CreatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,0,1,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, user.ID, passwordHash, user.CreatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_registrations(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, registration.Digest[:], user.ID, registration.CreatedAt.Unix(), registration.ExpiresAt.Unix()); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) Registration(ctx context.Context, digest [32]byte, now time.Time) (account.Registration, error) {
if zeroDigest(digest) || now.IsZero() {
return account.Registration{}, account.ErrRegistrationNotFound
}
var registration account.Registration
var passwordChangeRequired, pending int
var created, updated, draftCreated, expires int64
err := store.db.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,r.created_at,r.expires_at FROM gwf_account_registrations r JOIN gwf_users u ON u.id=r.user_id WHERE r.token_hash=? AND r.expires_at>? AND u.registration_pending=1`, digest[:], now.Unix()).Scan(&registration.User.ID, &registration.User.Username, &registration.User.Email, &registration.User.DisplayName, &registration.User.Status, &passwordChangeRequired, &pending, &created, &updated, &draftCreated, &expires)
if errors.Is(err, sql.ErrNoRows) {
return account.Registration{}, account.ErrRegistrationNotFound
}
if err != nil {
return account.Registration{}, err
}
registration.Digest = digest
registration.User.PasswordChangeRequired = passwordChangeRequired == 1
registration.User.RegistrationPending = pending == 1
registration.User.CreatedAt = time.Unix(created, 0).UTC()
registration.User.UpdatedAt = time.Unix(updated, 0).UTC()
registration.CreatedAt = time.Unix(draftCreated, 0).UTC()
registration.ExpiresAt = time.Unix(expires, 0).UTC()
return registration, nil
}
func (store *Store) CompleteRegistration(ctx context.Context, digest [32]byte, completion account.RegistrationCompletion) error {
userID := completion.Membership.UserID
validOptionalCredential := completion.Credential == nil || validCredential(*completion.Credential, true) && completion.Credential.UserID == userID
if zeroDigest(digest) || !validOptionalCredential || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || !validOrganization(completion.Organization) || !completion.Organization.Personal || completion.Membership.OrganizationID != completion.Organization.ID || !opaqueID(userID) || completion.Membership.Status != "active" || completion.Membership.JoinedAt.IsZero() || !validOwnerBinding(completion.OwnerBinding, completion.Organization.ID, userID) || !validAuditEvent(completion.AuthAudit) || completion.AuthAudit.ActorUserID != userID || !validOrganizationAudit(completion.OrganizationAudit, completion.Organization.ID) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.OrganizationID != completion.Organization.ID || completion.CompletedAt.IsZero() {
return errors.New("authsqlite: invalid account registration completion")
}
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
for _, recoveryDigest := range completion.RecoveryDigests {
if zeroDigest(recoveryDigest) {
return errors.New("authsqlite: invalid recovery code digest")
}
if _, exists := seen[recoveryDigest]; exists {
return errors.New("authsqlite: duplicate recovery code digest")
}
seen[recoveryDigest] = struct{}{}
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var registeredUserID string
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_account_registrations WHERE token_hash=? AND expires_at>? RETURNING user_id`, digest[:], completion.CompletedAt.Unix()).Scan(&registeredUserID)
if errors.Is(err, sql.ErrNoRows) {
return account.ErrRegistrationNotFound
}
if err != nil {
return err
}
if registeredUserID != userID {
return account.ErrRegistrationNotFound
}
var pending int
if err = tx.QueryRowContext(ctx, `SELECT registration_pending FROM gwf_users WHERE id=? AND status='active'`, userID).Scan(&pending); err != nil || pending != 1 {
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
return account.ErrRegistrationNotFound
}
if completion.Credential != nil {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, completion.Credential.ID, userID, completion.Credential.Label, []byte(completion.Credential.Data), completion.Credential.CreatedAt.Unix()); err != nil {
return err
}
}
for _, recoveryDigest := range completion.RecoveryDigests {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, recoveryDigest[:], completion.CompletedAt.Unix()); err != nil {
return err
}
}
organization := completion.Organization
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,1,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, userID, organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, completion.Membership.OrganizationID, userID, completion.Membership.Status, completion.Membership.JoinedAt.Unix()); err != nil {
return err
}
binding := completion.OwnerBinding
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, binding.ID, organization.ID, userID, binding.Role, userID, binding.GrantedAt.Unix(), binding.Role)
if err != nil {
return err
}
if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 {
if rowsErr != nil {
return rowsErr
}
return errors.New("authsqlite: account owner role has not been seeded")
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET registration_pending=0,updated_at=? WHERE id=? AND registration_pending=1`, completion.CompletedAt.Unix(), userID); err != nil {
return err
}
if err = appendAudit(ctx, tx, completion.AuthAudit); err != nil {
return err
}
if err = appendOrganizationAudit(ctx, tx, completion.OrganizationAudit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil {
return err
}
return tx.Commit()
}
func validPendingUser(user auth.User) bool {
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && user.RegistrationPending && !user.PasswordChangeRequired && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
}
func validOwnerBinding(binding access.Binding, organizationID, userID string) bool {
return opaqueID(binding.ID) && binding.SubjectKind == access.User && binding.SubjectID == userID && safeName(binding.Role) && binding.Scope.OrganizationID == organizationID && binding.Scope.ProjectID == "" && binding.Scope.EnvironmentID == "" && binding.Scope.ServiceID == "" && binding.GrantedBy == userID && !binding.GrantedAt.IsZero()
}
var _ account.Repository = (*Store)(nil)
+155
View File
@@ -0,0 +1,155 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/account"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
func TestAccountRegistrationCommitsEveryRequiredArtifact(t *testing.T) {
store, authService, accountService, passkeys := accountFixture(t, true)
started, err := accountService.Start(t.Context(), account.StartInput{Email: "PERSON@example.test", Username: "person.one", DisplayName: "Person One", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
if started.User.Email != "person@example.test" || !started.User.RegistrationPending {
t.Fatalf("pending user=%+v", started.User)
}
if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); !errors.Is(err, auth.ErrInactiveUser) {
t.Fatalf("pending password verification err=%v", err)
}
if _, err = accountService.BeginPasskey(t.Context(), started.RegistrationToken, "Primary passkey"); err != nil {
t.Fatal(err)
}
finished, err := accountService.FinishWithPasskey(t.Context(), started.RegistrationToken, "ceremony-token", []byte(`{"id":"fixture"}`))
if err != nil {
t.Fatal(err)
}
if finished.User.RegistrationPending || finished.User.ID != started.User.ID || len(finished.RecoveryCodes) != 10 || finished.SessionToken == "" || !finished.Organization.Personal {
t.Fatalf("finish=%+v code-count=%d", finished, len(finished.RecoveryCodes))
}
if passkeys.userID != started.User.ID || passkeys.binding != started.RegistrationToken {
t.Fatalf("passkey binding user=%q binding=%q", passkeys.userID, passkeys.binding)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 10)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organizations WHERE personal_owner_user_id=?`, started.User.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND role_name='owner'`, started.User.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_account_registrations WHERE user_id=?`, started.User.ID, 0)
if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); err != nil {
t.Fatalf("completed password verification: %v", err)
}
}
func TestPasswordAccountCanFinishWithoutPasskey(t *testing.T) {
store, authService, accountService, _ := accountFixture(t, true)
started, err := accountService.Start(t.Context(), account.StartInput{Email: "reader@example.test", Username: "reader.one", DisplayName: "Reader One", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
finished, err := accountService.FinishPassword(t.Context(), started.RegistrationToken)
if err != nil {
t.Fatal(err)
}
if finished.SessionToken == "" || len(finished.RecoveryCodes) != 10 || len(finished.PasskeyCredential.ID) != 0 {
t.Fatalf("password finish=%+v code-count=%d", finished, len(finished.RecoveryCodes))
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 10)
if _, err = authService.VerifyPassword(t.Context(), "reader@example.test", "correct horse battery staple"); err != nil {
t.Fatalf("password account not active: %v", err)
}
}
func TestAccountRegistrationRollsBackWhenOwnerPolicyIsMissing(t *testing.T) {
store, authService, accountService, _ := accountFixture(t, false)
started, err := accountService.Start(t.Context(), account.StartInput{Email: "rollback@example.test", Username: "rollback.one", DisplayName: "Rollback One", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
if _, err = accountService.BeginPasskey(t.Context(), started.RegistrationToken, "Primary passkey"); err != nil {
t.Fatal(err)
}
if _, err = accountService.FinishWithPasskey(t.Context(), started.RegistrationToken, "ceremony-token", []byte(`{"id":"fixture"}`)); err == nil {
t.Fatal("completion unexpectedly succeeded without seeded owner role")
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organizations WHERE personal_owner_user_id=?`, started.User.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_account_registrations WHERE user_id=?`, started.User.ID, 1)
if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); !errors.Is(err, auth.ErrInactiveUser) {
t.Fatalf("rolled-back account became usable: %v", err)
}
}
func accountFixture(t *testing.T, seedOwner bool) (*Store, *auth.Service, *account.Service, *accountPasskeys) {
t.Helper()
store, err := Open(filepath.Join(t.TempDir(), "identity.sqlite"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
if seedOwner {
err = store.SeedAccessPolicy(t.Context(), access.Policy{
Roles: map[string]string{"owner": "Personal organization owner"},
Permissions: map[string]string{"account.view": "View the account"},
Grants: map[string][]string{"owner": {"account.view"}},
})
if err != nil {
t.Fatal(err)
}
}
authService, err := auth.New(store, auth.Options{})
if err != nil {
t.Fatal(err)
}
passkeys := &accountPasskeys{now: time.Now().UTC()}
accountService, err := account.New(store, passkeys, authService, account.Options{})
if err != nil {
t.Fatal(err)
}
return store, authService, accountService, passkeys
}
type accountPasskeys struct {
userID, binding string
now time.Time
}
func (passkeys *accountPasskeys) BeginAccountRegistration(_ context.Context, userID, _ string, binding []byte) (authwebauthn.BeginResult, error) {
passkeys.userID = userID
passkeys.binding = string(binding)
return authwebauthn.BeginResult{CeremonyToken: "ceremony-token", PublicKey: []byte(`{}`), ExpiresAt: passkeys.now.Add(5 * time.Minute)}, nil
}
func (passkeys *accountPasskeys) FinishAccountRegistration(ctx context.Context, ceremonyToken string, binding, _ []byte, commit authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) {
if ceremonyToken != "ceremony-token" || string(binding) != passkeys.binding {
return authwebauthn.Credential{}, authwebauthn.ErrOperationBinding
}
credential := authwebauthn.Credential{ID: []byte("fixture-credential-id"), UserID: passkeys.userID, Label: "Primary passkey", Data: []byte(`{"id":"fixture-credential-id"}`), CreatedAt: passkeys.now}
audit := auth.AuditEvent{ID: "passkey-audit-id", ActorUserID: passkeys.userID, Action: "auth.account.passkey", ResourceType: "passkey", ResourceID: "fixture-credential-id", Summary: "The initial account passkey was enrolled.", CreatedAt: passkeys.now}
if err := commit(ctx, credential, audit); err != nil {
return authwebauthn.Credential{}, err
}
return credential, nil
}
func assertCount(t *testing.T, store *Store, query, id string, want int) {
t.Helper()
var got int
if err := store.db.QueryRow(query, id).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("count for %q = %d, want %d", query, got, want)
}
}
+10 -9
View File
@@ -29,7 +29,7 @@ func (store *Store) CreatePasskeyUser(ctx context.Context, user auth.User, enrol
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
if _, 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, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil { if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, user.RegistrationPending, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
return err return err
} }
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], enrollment.UserID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil { if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], enrollment.UserID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil {
@@ -45,7 +45,7 @@ func (store *Store) UserByID(ctx context.Context, userID string) (auth.User, err
if !opaqueID(userID) { if !opaqueID(userID) {
return auth.User{}, auth.ErrUserNotFound return auth.User{}, auth.ErrUserNotFound
} }
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID)) return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
} }
func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (auth.User, error) { func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (auth.User, error) {
@@ -53,14 +53,14 @@ func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (au
if !text(identifier, 320, false) { if !text(identifier, 320, false) {
return auth.User{}, auth.ErrUserNotFound return auth.User{}, auth.ErrUserNotFound
} }
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier))) return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
} }
func (store *Store) UserByCredentialID(ctx context.Context, credentialID []byte) (auth.User, error) { func (store *Store) UserByCredentialID(ctx context.Context, credentialID []byte) (auth.User, error) {
if !boundedCredentialID(credentialID) { if !boundedCredentialID(credentialID) {
return auth.User{}, authwebauthn.ErrCredentialNotFound return auth.User{}, authwebauthn.ErrCredentialNotFound
} }
user, err := scanPasskeyUser(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 FROM gwf_users u JOIN gwf_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID)) user, err := scanPasskeyUser(store.db.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_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID))
if errors.Is(err, auth.ErrUserNotFound) { if errors.Is(err, auth.ErrUserNotFound) {
return auth.User{}, authwebauthn.ErrCredentialNotFound return auth.User{}, authwebauthn.ErrCredentialNotFound
} }
@@ -291,7 +291,7 @@ func (store *Store) ConsumeEnrollmentToken(ctx context.Context, digest [32]byte,
if err != nil { if err != nil {
return auth.User{}, err return auth.User{}, err
} }
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID)) user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
if err != nil { if err != nil {
return auth.User{}, err return auth.User{}, err
} }
@@ -310,7 +310,7 @@ func (store *Store) RecoverUser(ctx context.Context, identifier string, enrollme
return auth.User{}, err return auth.User{}, err
} }
defer tx.Rollback() defer tx.Rollback()
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier))) user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
if err != nil { if err != nil {
return auth.User{}, err return auth.User{}, err
} }
@@ -342,21 +342,22 @@ type rowScanner interface{ Scan(...any) error }
func scanPasskeyUser(row rowScanner) (auth.User, error) { func scanPasskeyUser(row rowScanner) (auth.User, error) {
var user auth.User var user auth.User
var passwordChangeRequired int var passwordChangeRequired, registrationPending int
var created, updated int64 var created, updated int64
if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated); err != nil { if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &registrationPending, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, auth.ErrUserNotFound return auth.User{}, auth.ErrUserNotFound
} }
return auth.User{}, err return auth.User{}, err
} }
user.PasswordChangeRequired = passwordChangeRequired == 1 user.PasswordChangeRequired = passwordChangeRequired == 1
user.RegistrationPending = registrationPending == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, nil return user, nil
} }
func validPasskeyUser(user auth.User) bool { func validPasskeyUser(user auth.User) bool {
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero() return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.RegistrationPending && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
} }
func validEnrollment(token authwebauthn.EnrollmentToken) bool { func validEnrollment(token authwebauthn.EnrollmentToken) bool {
+99
View File
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"errors"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
)
func (store *Store) ReplaceRecoveryCodes(ctx context.Context, userID string, digests [][32]byte, createdAt time.Time, audit auth.AuditEvent) error {
if !opaqueID(userID) || len(digests) < 5 || len(digests) > 20 || createdAt.IsZero() || !validAuditEvent(audit) || audit.ResourceID != userID {
return errors.New("authsqlite: invalid recovery-code set")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); err != nil {
return err
}
for _, digest := range digests {
if zeroDigest(digest) {
return errors.New("authsqlite: invalid recovery-code digest")
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at) VALUES(?,?,?)`, userID, digest[:], createdAt.Unix()); err != nil {
return err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) ConsumeRecoveryCodeAndCreateGrant(ctx context.Context, userID string, codeDigest [32]byte, grant authrecovery.Grant, audit auth.AuditEvent) error {
if !opaqueID(userID) || zeroDigest(codeDigest) || grant.UserID != userID || zeroDigest(grant.Digest) || grant.CreatedAt.IsZero() || !grant.ExpiresAt.After(grant.CreatedAt) || grant.ExpiresAt.Sub(grant.CreatedAt) > 30*time.Minute || !validAuditEvent(audit) || audit.ResourceID != userID {
return errors.New("authsqlite: invalid recovery attempt")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE gwf_recovery_codes SET used_at=? WHERE user_id=? AND code_hash=? AND used_at IS NULL`, grant.CreatedAt.Unix(), userID, codeDigest[:])
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil || changed != 1 {
return authrecovery.ErrCodeNotFound
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_grants WHERE user_id=?`, userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_grants(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, grant.Digest[:], userID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) TakeRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
if zeroDigest(digest) || now.IsZero() {
return auth.User{}, authrecovery.ErrGrantNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.User{}, err
}
defer tx.Rollback()
var userID string
if err = tx.QueryRowContext(ctx, `SELECT user_id FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>?`, digest[:], now.Unix()).Scan(&userID); errors.Is(err, sql.ErrNoRows) {
return auth.User{}, authrecovery.ErrGrantNotFound
} else if err != nil {
return auth.User{}, err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=?`, digest[:]); err != nil {
return auth.User{}, err
}
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
if err != nil {
return auth.User{}, err
}
if err = tx.Commit(); err != nil {
return auth.User{}, err
}
return user, nil
}
+66 -9
View File
@@ -24,6 +24,17 @@ import (
type Store struct{ db *sql.DB } type Store struct{ db *sql.DB }
func Open(path string) (*Store, error) { func Open(path string) (*Store, error) {
return OpenWithOptions(path, OpenOptions{Migrate: true})
}
type OpenOptions struct {
// Migrate preserves the historical Open behavior when true. Applications
// with operator-controlled releases set it false and call Migrate only from
// their explicit migration command.
Migrate bool
}
func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
absolute, err := filepath.Abs(path) absolute, err := filepath.Abs(path)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -56,7 +67,7 @@ func Open(path string) (*Store, error) {
store := &Store{db: db} store := &Store{db: db}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
if err = db.PingContext(ctx); err == nil { if err = db.PingContext(ctx); err == nil && options.Migrate {
err = store.Migrate(ctx) err = store.Migrate(ctx)
} }
if err != nil { if err != nil {
@@ -66,6 +77,34 @@ func Open(path string) (*Store, error) {
return store, nil return store, nil
} }
const SchemaVersion = 8
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
var exists int
if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='gamertan_web_migrations'`).Scan(&exists); err != nil || exists == 0 {
return 0, err
}
var version sql.NullInt64
if err := store.db.QueryRowContext(ctx, `SELECT MAX(version) FROM gamertan_web_migrations`).Scan(&version); err != nil {
return 0, err
}
if !version.Valid {
return 0, nil
}
return int(version.Int64), nil
}
func (store *Store) RequireCurrentSchema(ctx context.Context) error {
version, err := store.CurrentSchema(ctx)
if err != nil {
return err
}
if version != SchemaVersion {
return fmt.Errorf("authsqlite: schema version %d; run migration for version %d", version, SchemaVersion)
}
return nil
}
func (store *Store) Close() error { return store.db.Close() } func (store *Store) Close() error { return store.db.Close() }
func (store *Store) Ping(ctx context.Context) error { return store.db.PingContext(ctx) } func (store *Store) Ping(ctx context.Context) error { return store.db.PingContext(ctx) }
@@ -77,7 +116,7 @@ func (store *Store) Migrate(ctx context.Context) error {
defer tx.Rollback() defer tx.Rollback()
statements := []string{ statements := []string{
`CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), 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_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)), registration_pending INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`,
`CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
@@ -94,6 +133,11 @@ func (store *Store) Migrate(ctx context.Context) error {
`CREATE INDEX IF NOT EXISTS gwf_passkey_enrollment_expiry ON gwf_passkey_enrollment_tokens(expires_at)`, `CREATE INDEX IF NOT EXISTS gwf_passkey_enrollment_expiry ON gwf_passkey_enrollment_tokens(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_passkey_ceremonies (token_hash BLOB PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('registration','login','approval')), user_id TEXT REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, session_json BLOB NOT NULL, binding_hash BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_passkey_ceremonies (token_hash BLOB PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('registration','login','approval')), user_id TEXT REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, session_json BLOB NOT NULL, binding_hash BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_passkey_ceremonies_expiry ON gwf_passkey_ceremonies(expires_at)`, `CREATE INDEX IF NOT EXISTS gwf_passkey_ceremonies_expiry ON gwf_passkey_ceremonies(expires_at)`,
`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 INDEX IF NOT EXISTS gwf_recovery_grants_expiry ON gwf_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 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)`,
`CREATE TABLE IF NOT EXISTS gwf_organization_memberships (organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK(status IN ('active','suspended')), joined_at INTEGER NOT NULL, PRIMARY KEY(organization_id,user_id))`, `CREATE TABLE IF NOT EXISTS gwf_organization_memberships (organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK(status IN ('active','suspended')), joined_at INTEGER NOT NULL, PRIMARY KEY(organization_id,user_id))`,
`CREATE INDEX IF NOT EXISTS gwf_organization_memberships_user ON gwf_organization_memberships(user_id,organization_id)`, `CREATE INDEX IF NOT EXISTS gwf_organization_memberships_user ON gwf_organization_memberships(user_id,organization_id)`,
@@ -132,6 +176,7 @@ func (store *Store) Migrate(ctx context.Context) error {
for _, migration := range []struct { for _, migration := range []struct {
table, column, definition string table, column, definition string
}{ }{
{"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`},
{"gwf_organizations", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`}, {"gwf_organizations", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`},
{"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`}, {"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
{"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 0`}, {"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 0`},
@@ -180,6 +225,15 @@ func (store *Store) Migrate(ctx context.Context) error {
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(5,?)`, time.Now().UTC().Unix()); err != nil { if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(5,?)`, 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(6,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(7,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
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 tx.Commit() return tx.Commit()
} }
@@ -212,7 +266,7 @@ func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,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()) _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.PasswordChangeRequired, user.RegistrationPending, user.CreatedAt.Unix(), user.UpdatedAt.Unix())
if err != nil { if err != nil {
return err return err
} }
@@ -228,9 +282,9 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
} }
var user auth.User var user auth.User
var created, updated int64 var created, updated int64
var passwordChangeRequired int var passwordChangeRequired, registrationPending int
var hash string var hash string
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.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) err := store.db.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,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, &registrationPending, &created, &updated, &hash)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, "", auth.ErrUserNotFound return auth.User{}, "", auth.ErrUserNotFound
} }
@@ -238,6 +292,7 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
return auth.User{}, "", err return auth.User{}, "", err
} }
user.PasswordChangeRequired = passwordChangeRequired == 1 user.PasswordChangeRequired = passwordChangeRequired == 1
user.RegistrationPending = registrationPending == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, hash, nil return user, hash, nil
} }
@@ -248,9 +303,9 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth
} }
var user auth.User var user auth.User
var created, updated int64 var created, updated int64
var passwordChangeRequired int var passwordChangeRequired, registrationPending int
var hash string var hash string
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.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) err := store.db.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,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, &registrationPending, &created, &updated, &hash)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, "", auth.ErrUserNotFound return auth.User{}, "", auth.ErrUserNotFound
} }
@@ -258,6 +313,7 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth
return auth.User{}, "", err return auth.User{}, "", err
} }
user.PasswordChangeRequired = passwordChangeRequired == 1 user.PasswordChangeRequired = passwordChangeRequired == 1
user.RegistrationPending = registrationPending == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, hash, nil return user, hash, nil
} }
@@ -346,12 +402,13 @@ func (store *Store) PrincipalBySession(ctx context.Context, digest [32]byte, now
var principal auth.Principal var principal auth.Principal
var session auth.Session var session auth.Session
var created, updated, sessionCreated, expires, lastSeen int64 var created, updated, sessionCreated, expires, lastSeen int64
var passwordChangeRequired int var passwordChangeRequired, registrationPending 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) err := store.db.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,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, &registrationPending, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound
} }
principal.User.PasswordChangeRequired = passwordChangeRequired == 1 principal.User.PasswordChangeRequired = passwordChangeRequired == 1
principal.User.RegistrationPending = registrationPending == 1
if err != nil { if err != nil {
return auth.Principal{}, auth.Session{}, err return auth.Principal{}, auth.Session{}, err
} }
+18
View File
@@ -17,6 +17,24 @@ import (
"gamertan.com/web/organizations" "gamertan.com/web/organizations"
) )
func TestOpenCanRequireExplicitMigration(t *testing.T) {
path := filepath.Join(t.TempDir(), "explicit.db")
store, err := OpenWithOptions(path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err = store.RequireCurrentSchema(t.Context()); err == nil {
t.Fatal("unmigrated database reported current")
}
if err = store.Migrate(t.Context()); err != nil {
t.Fatal(err)
}
if err = store.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal(err)
}
}
func TestServiceRoundTripWithApplicationPolicy(t *testing.T) { func TestServiceRoundTripWithApplicationPolicy(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db")) store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil { if err != nil {
+49 -16
View File
@@ -190,7 +190,7 @@ func (service *Service) BeginEnrollment(ctx context.Context, enrollmentToken, la
if err != nil { if err != nil {
return BeginResult{}, err return BeginResult{}, err
} }
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}) return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}, false)
} }
func (service *Service) BeginRegistration(ctx context.Context, userID, label string) (BeginResult, error) { func (service *Service) BeginRegistration(ctx context.Context, userID, label string) (BeginResult, error) {
@@ -198,7 +198,24 @@ func (service *Service) BeginRegistration(ctx context.Context, userID, label str
if err != nil { if err != nil {
return BeginResult{}, err return BeginResult{}, err
} }
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}) return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}, false)
}
// BeginAccountRegistration starts the initial passkey ceremony for a pending
// account. Binding must identify the surrounding single-use registration
// draft; only its digest is retained in ceremony state.
func (service *Service) BeginAccountRegistration(ctx context.Context, userID, label string, binding []byte) (BeginResult, error) {
if len(binding) < 16 || len(binding) > 4096 {
return BeginResult{}, ErrOperationBinding
}
user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID))
if err != nil {
return BeginResult{}, err
}
if !user.RegistrationPending || user.Status != "active" {
return BeginResult{}, auth.ErrInactiveUser
}
return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), true)
} }
// BeginPasswordMigration starts registration for an already authenticated // BeginPasswordMigration starts registration for an already authenticated
@@ -216,15 +233,15 @@ func (service *Service) BeginPasswordMigration(ctx context.Context, userID, labe
if !exists { if !exists {
return BeginResult{}, ErrPasswordNotAvailable return BeginResult{}, ErrPasswordNotAvailable
} }
return service.beginRegistration(ctx, user, label, CeremonyRegistration, passwordMigrationBinding(user.ID)) return service.beginRegistration(ctx, user, label, CeremonyRegistration, passwordMigrationBinding(user.ID), false)
} }
func (service *Service) beginRegistration(ctx context.Context, user auth.User, label, kind string, binding [32]byte) (BeginResult, error) { func (service *Service) beginRegistration(ctx context.Context, user auth.User, label, kind string, binding [32]byte, allowPending bool) (BeginResult, error) {
label, err := credentialLabel(label) label, err := credentialLabel(label)
if err != nil { if err != nil {
return BeginResult{}, err return BeginResult{}, err
} }
adapter, err := service.user(ctx, user) adapter, err := service.user(ctx, user, allowPending)
if err != nil { if err != nil {
return BeginResult{}, err return BeginResult{}, err
} }
@@ -245,7 +262,19 @@ func (service *Service) beginRegistration(ctx context.Context, user auth.User, l
} }
func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) { func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, [32]byte{}, response, false) return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, [32]byte{}, response, false, false, nil)
}
// FinishAccountRegistration verifies an initial credential and delegates its
// persistence to commit so user activation, personal organization creation,
// owner binding, recovery-code storage, and the passkey can share one
// transaction. A failed commit consumes the WebAuthn ceremony and leaves the
// bounded account draft eligible for a fresh ceremony.
func (service *Service) FinishAccountRegistration(ctx context.Context, ceremonyToken string, binding, response []byte, commit RegistrationCommit) (Credential, error) {
if len(binding) < 16 || len(binding) > 4096 || commit == nil {
return Credential{}, ErrOperationBinding
}
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, BindingDigest(binding), response, false, true, commit)
} }
// FinishPasswordMigration verifies the new passkey and persists it together // FinishPasswordMigration verifies the new passkey and persists it together
@@ -255,18 +284,18 @@ func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyTok
if err != nil { if err != nil {
return Credential{}, err return Credential{}, err
} }
return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true) return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true, false, nil)
} }
func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind string, expectedBinding [32]byte, response []byte, retirePassword bool) (Credential, error) { func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind string, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, kind) ceremony, err := service.takeCeremony(ctx, ceremonyToken, kind)
if err != nil { if err != nil {
return Credential{}, err return Credential{}, err
} }
return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword) return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword, allowPending, commit)
} }
func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony Ceremony, expectedBinding [32]byte, response []byte, retirePassword bool) (Credential, error) { func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony Ceremony, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
if ceremony.BindingDigest != expectedBinding { if ceremony.BindingDigest != expectedBinding {
return Credential{}, ErrOperationBinding return Credential{}, ErrOperationBinding
} }
@@ -277,7 +306,7 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
if err != nil { if err != nil {
return Credential{}, err return Credential{}, err
} }
adapter, err := service.user(ctx, user) adapter, err := service.user(ctx, user, allowPending)
if err != nil { if err != nil {
return Credential{}, err return Credential{}, err
} }
@@ -312,6 +341,10 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
} }
if retirePassword { if retirePassword {
err = service.repository.SaveCredentialAndRetirePassword(ctx, record, audit) err = service.repository.SaveCredentialAndRetirePassword(ctx, record, audit)
} else if commit != nil {
audit.Action = "auth.account.passkey"
audit.Summary = "The initial account passkey was enrolled."
err = commit(ctx, record, audit)
} else { } else {
err = service.repository.SaveCredential(ctx, record, audit) err = service.repository.SaveCredential(ctx, record, audit)
} }
@@ -358,7 +391,7 @@ func (service *Service) FinishLogin(ctx context.Context, ceremonyToken string, r
if lookupErr != nil || account.ID != string(userHandle) { if lookupErr != nil || account.ID != string(userHandle) {
return nil, ErrCredentialNotFound return nil, ErrCredentialNotFound
} }
loaded, lookupErr = service.user(ctx, account) loaded, lookupErr = service.user(ctx, account, false)
return loaded, lookupErr return loaded, lookupErr
}, session, parsed) }, session, parsed)
if err != nil || loaded == nil || user == nil { if err != nil || loaded == nil || user == nil {
@@ -385,7 +418,7 @@ func (service *Service) BeginApproval(ctx context.Context, userID string, bindin
if err != nil { if err != nil {
return BeginResult{}, err return BeginResult{}, err
} }
adapter, err := service.user(ctx, account) adapter, err := service.user(ctx, account, false)
if err != nil { if err != nil {
return BeginResult{}, err return BeginResult{}, err
} }
@@ -415,7 +448,7 @@ func (service *Service) FinishApproval(ctx context.Context, ceremonyToken string
if err != nil { if err != nil {
return Approval{}, err return Approval{}, err
} }
adapter, err := service.user(ctx, account) adapter, err := service.user(ctx, account, false)
if err != nil { if err != nil {
return Approval{}, err return Approval{}, err
} }
@@ -552,8 +585,8 @@ func (service *Service) takeCeremony(ctx context.Context, token, kind string) (C
return ceremony, nil return ceremony, nil
} }
func (service *Service) user(ctx context.Context, account auth.User) (*passkeyUser, error) { func (service *Service) user(ctx context.Context, account auth.User, allowPending bool) (*passkeyUser, error) {
if account.Status != "active" { if account.Status != "active" || account.RegistrationPending && !allowPending {
return nil, auth.ErrInactiveUser return nil, auth.ErrInactiveUser
} }
records, err := service.repository.CredentialsByUserID(ctx, account.ID) records, err := service.repository.CredentialsByUserID(ctx, account.ID)
+11 -4
View File
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: MPL-2.0 // SPDX-License-Identifier: MPL-2.0
// Package authwebauthn provides storage-neutral, passkey-only WebAuthn // Package authwebauthn provides storage-neutral WebAuthn ceremonies for
// ceremonies. It owns relying-party policy, bounded single-use ceremony state, // passkey login, enrollment, and operation-bound step-up. It owns relying-party
// credential lifecycle, and recovery tokens while delegating protocol parsing // policy, bounded single-use ceremony state, credential lifecycle, and recovery
// and signature verification to a pinned WebAuthn implementation. // tokens while delegating protocol parsing and signature verification to a
// pinned WebAuthn implementation.
package authwebauthn package authwebauthn
import ( import (
@@ -92,6 +93,12 @@ type Approval struct {
ApprovedAt time.Time ApprovedAt time.Time
} }
// RegistrationCommit lets a higher-level account workflow commit a verified
// initial credential together with the rest of the account state. The
// callback receives only public-key credential material and a secret-free
// audit event.
type RegistrationCommit func(context.Context, Credential, auth.AuditEvent) error
// Repository persists passkey-specific state. Implementations must consume // Repository persists passkey-specific state. Implementations must consume
// enrollment tokens and ceremonies atomically and must perform recovery and // enrollment tokens and ceremonies atomically and must perform recovery and
// credential removal invariants in transactions. // credential removal invariants in transactions.
+36
View File
@@ -0,0 +1,36 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Web Foundations dogfood notes
This living note records concrete pressure discovered while Gamertan services
adopt Web Foundations. It is implementation evidence, not a promise that every
application concern belongs in the shared module.
## Gamertan accounts and commerce
- The account email remains required and unique. Gamertan uses normalized
email as the canonical login identifier and keeps username as a stable public
identity. Until a mail package exists, the application must not describe an
address as verified merely because it was entered during registration.
- Password authentication is sufficient for an ordinary customer base
session. Privileged application actions use an exact operation binding with
`authwebauthn.BeginApproval` and `FinishApproval`; that is safer than a broad
long-lived "elevated" session. A user without a passkey can use ordinary
features but must enroll one before performing protected work.
- `auth.Service.VerifyPassword` remains available for flows that truly require
password plus passkey before session issuance.
- Public registration exposed a cross-package transaction boundary. The
`account` package now keeps an unusable bounded registration draft and makes
recovery-code digests, personal organization, membership, owner binding,
activation, audits, and an optional initial passkey one repository commit.
A failed WebAuthn ceremony can be restarted, or an ordinary password account
can finish without it, without persisting a partly privileged account.
- Media belongs behind a storage-neutral interface with a hardened local
adapter. Content workflow, references, and authorization remain application
policy.
- Historical `authsqlite.Open` still migrates for compatibility. Applications
with reviewed deployment gates use `OpenWithOptions` with migration disabled,
require the current schema at startup, and invoke `Migrate` only from an
explicit operator command.
- Commerce remains a separately versioned nested module so payment-provider
policy and catalog evolution do not enlarge the authentication core.
+1 -1
View File
@@ -25,7 +25,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
module checksum: module checksum:
```bash ```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.9 go get gamertan.com/web/requestmeta@v0.1.0-preview.10
go mod verify go mod verify
``` ```
+7 -1
View File
@@ -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.9 go get gamertan.com/web/requestmeta@v0.1.0-preview.10
``` ```
Only imported packages are compiled and linked. The packages nevertheless Only imported packages are compiled and linked. The packages nevertheless
@@ -52,6 +52,12 @@ Do not split merely to make an architecture diagram look modular. Package
interfaces provide source-level modularity today; modules are introduced only interfaces provide source-level modularity today; modules are introduced only
for an independent dependency and release lifecycle. for an independent dependency and release lifecycle.
The `media` package and `medialocal` adapter deliberately remain in the root
module: they use only the standard library, and applications can adopt the core
interface without importing the local adapter. Commerce is different. Its
provider SDK and independently evolving catalog/payment contract justify a
future nested `gamertan.com/web/commerce` module after application dogfood.
## Session boundaries ## Session boundaries
Authenticated sessions currently belong to three deliberate packages: Authenticated sessions currently belong to three deliberate packages:
+190
View File
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: MPL-2.0
// Package media defines bounded media preparation and storage-neutral blob
// interfaces. Applications retain authorization, references, lifecycle, and
// presentation policy.
package media
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
"image/png"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
"time"
"unicode/utf8"
)
const (
KindImage = "image"
KindAttachment = "attachment"
)
var (
ErrInvalidMedia = errors.New("media: invalid media")
ErrTooLarge = errors.New("media: upload exceeds its size limit")
ErrNotFound = errors.New("media: object not found")
)
type Limits struct {
MaxBytes int64
MaxWidth int
MaxHeight int
MaxPixels int64
}
func (limits Limits) withDefaults() Limits {
if limits.MaxBytes == 0 {
limits.MaxBytes = 10 << 20
}
if limits.MaxWidth == 0 {
limits.MaxWidth = 8192
}
if limits.MaxHeight == 0 {
limits.MaxHeight = 8192
}
if limits.MaxPixels == 0 {
limits.MaxPixels = 40_000_000
}
return limits
}
func (limits Limits) validate() error {
if limits.MaxBytes < 1024 || limits.MaxBytes > 100<<20 || limits.MaxWidth < 1 || limits.MaxWidth > 32768 || limits.MaxHeight < 1 || limits.MaxHeight > 32768 || limits.MaxPixels < 1 || limits.MaxPixels > 250_000_000 {
return errors.New("media: invalid limits")
}
return nil
}
// Prepared is a sanitized, bounded object ready for durable storage. Raster
// images are decoded and re-encoded so source metadata and unparsed trailing
// bytes are not retained. PDFs are attachments and are never inline media.
type Prepared struct {
Digest [32]byte
Data []byte
MediaType string
Kind string
OriginalName string
Width int
Height int
}
func (prepared Prepared) Key() string { return hex.EncodeToString(prepared.Digest[:]) }
type Object struct {
Key string
Size int64
MediaType string
CreatedAt time.Time
}
type Store interface {
Put(context.Context, Prepared) (Object, error)
Open(context.Context, string) (io.ReadCloser, Object, error)
Delete(context.Context, string) error
}
// Prepare reads at most the configured bound and accepts JPEG, PNG, GIF, or a
// PDF attachment. Animated images are deliberately flattened to the decoded
// first frame. The returned byte slice is owned by the caller.
func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, error) {
if reader == nil {
return Prepared{}, ErrInvalidMedia
}
limits = limits.withDefaults()
if err := limits.validate(); err != nil {
return Prepared{}, err
}
name, err := boundedName(originalName)
if err != nil {
return Prepared{}, err
}
data, err := io.ReadAll(io.LimitReader(reader, limits.MaxBytes+1))
if err != nil {
return Prepared{}, fmt.Errorf("media: read upload: %w", err)
}
if int64(len(data)) > limits.MaxBytes {
return Prepared{}, ErrTooLarge
}
if len(data) == 0 {
return Prepared{}, ErrInvalidMedia
}
detected := http.DetectContentType(data)
if detected == "application/pdf" && bytes.HasPrefix(data, []byte("%PDF-")) {
result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name}
result.Digest = sha256.Sum256(result.Data)
return result, nil
}
imageValue, format, err := image.Decode(bytes.NewReader(data))
if err != nil || format != "jpeg" && format != "png" && format != "gif" {
return Prepared{}, ErrInvalidMedia
}
bounds := imageValue.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width < 1 || height < 1 || width > limits.MaxWidth || height > limits.MaxHeight || int64(width) > limits.MaxPixels/int64(height) {
return Prepared{}, ErrTooLarge
}
var output bytes.Buffer
mediaType := "image/png"
if format == "jpeg" {
mediaType = "image/jpeg"
err = jpeg.Encode(&output, imageValue, &jpeg.Options{Quality: 90})
} else {
err = png.Encode(&output, imageValue)
}
if err != nil {
return Prepared{}, fmt.Errorf("media: sanitize image: %w", err)
}
if int64(output.Len()) > limits.MaxBytes {
return Prepared{}, ErrTooLarge
}
result := Prepared{Data: output.Bytes(), MediaType: mediaType, Kind: KindImage, OriginalName: name, Width: width, Height: height}
result.Digest = sha256.Sum256(result.Data)
return result, nil
}
func Extension(mediaType string) string {
switch mediaType {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "application/pdf":
return ".pdf"
default:
values, _ := mime.ExtensionsByType(mediaType)
if len(values) > 0 {
return values[0]
}
return ""
}
}
func ValidKey(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value)
}
func boundedName(value string) (string, error) {
value = strings.TrimSpace(filepath.Base(value))
if value == "." || value == "" || !utf8.ValidString(value) || len(value) > 240 || strings.ContainsAny(value, "\x00\r\n") {
return "", ErrInvalidMedia
}
return value, nil
}
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MPL-2.0
package media
import (
"bytes"
"errors"
"image"
"image/color"
"image/jpeg"
"strings"
"testing"
)
func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) {
var source bytes.Buffer
value := image.NewRGBA(image.Rect(0, 0, 3, 2))
value.Set(1, 1, color.RGBA{R: 220, G: 20, B: 50, A: 255})
if err := jpeg.Encode(&source, value, &jpeg.Options{Quality: 95}); err != nil {
t.Fatal(err)
}
source.WriteString("secret trailing metadata")
prepared, err := Prepare(bytes.NewReader(source.Bytes()), " portrait.jpg ", Limits{})
if err != nil {
t.Fatal(err)
}
if prepared.Kind != KindImage || prepared.MediaType != "image/jpeg" || prepared.Width != 3 || prepared.Height != 2 || prepared.OriginalName != "portrait.jpg" {
t.Fatalf("prepared=%+v", prepared)
}
if bytes.Contains(prepared.Data, []byte("secret trailing metadata")) || prepared.Key() == strings.Repeat("0", 64) {
t.Fatal("image source data was not sanitized")
}
}
func TestPreparePDFIsAttachment(t *testing.T) {
prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{})
if err != nil {
t.Fatal(err)
}
if prepared.Kind != KindAttachment || prepared.MediaType != "application/pdf" {
t.Fatalf("prepared=%+v", prepared)
}
}
func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) {
t.Fatalf("svg err=%v", err)
}
if _, err := Prepare(strings.NewReader(strings.Repeat("x", 1025)), "large.png", Limits{MaxBytes: 1024, MaxWidth: 10, MaxHeight: 10, MaxPixels: 100}); !errors.Is(err, ErrTooLarge) {
t.Fatalf("large err=%v", err)
}
}
+187
View File
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: MPL-2.0
// Package medialocal stores prepared media in a private content-addressed
// filesystem tree.
package medialocal
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
"gamertan.com/web/media"
)
type Store struct {
root string
now func() time.Time
}
type Options struct {
Now func() time.Time
}
func Open(root string, options Options) (*Store, error) {
absolute, err := filepath.Abs(root)
if err != nil || filepath.Clean(absolute) != absolute {
return nil, errors.New("medialocal: root must be a clean absolute path")
}
if err = secureDirectory(absolute, true); err != nil {
return nil, err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err != nil {
return nil, fmt.Errorf("medialocal: resolve root: %w", err)
}
if options.Now == nil {
options.Now = time.Now
}
return &Store{root: resolved, now: options.Now}, nil
}
func (store *Store) Put(ctx context.Context, prepared media.Prepared) (media.Object, error) {
if err := ctx.Err(); err != nil {
return media.Object{}, err
}
if len(prepared.Data) == 0 || !media.ValidKey(prepared.Key()) || sha256.Sum256(prepared.Data) != prepared.Digest {
return media.Object{}, media.ErrInvalidMedia
}
shard, target := store.objectPath(prepared.Key())
if err := secureDirectory(shard, true); err != nil {
return media.Object{}, err
}
if object, ok, err := inspect(target, prepared.MediaType); err != nil {
return media.Object{}, err
} else if ok {
if object.Size != int64(len(prepared.Data)) {
return media.Object{}, errors.New("medialocal: existing digest has an unexpected size")
}
return object, nil
}
temporary, err := os.CreateTemp(shard, ".upload-*")
if err != nil {
return media.Object{}, fmt.Errorf("medialocal: create temporary object: %w", err)
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
if err = temporary.Chmod(0o640); err == nil {
_, err = temporary.Write(prepared.Data)
}
if err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return media.Object{}, fmt.Errorf("medialocal: write object: %w", err)
}
if err = os.Link(temporaryName, target); err != nil {
if errors.Is(err, os.ErrExist) {
object, ok, inspectErr := inspect(target, prepared.MediaType)
if inspectErr != nil {
return media.Object{}, inspectErr
}
if ok && object.Size == int64(len(prepared.Data)) {
return object, nil
}
}
return media.Object{}, fmt.Errorf("medialocal: commit object: %w", err)
}
return media.Object{Key: prepared.Key(), Size: int64(len(prepared.Data)), MediaType: prepared.MediaType, CreatedAt: store.now().UTC()}, nil
}
func (store *Store) Open(ctx context.Context, key string) (io.ReadCloser, media.Object, error) {
if err := ctx.Err(); err != nil {
return nil, media.Object{}, err
}
if !media.ValidKey(key) {
return nil, media.Object{}, media.ErrNotFound
}
shard, target := store.objectPath(key)
if err := secureDirectory(shard, false); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, media.Object{}, media.ErrNotFound
}
return nil, media.Object{}, err
}
before, err := os.Lstat(target)
if errors.Is(err, os.ErrNotExist) {
return nil, media.Object{}, media.ErrNotFound
}
if err != nil || !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 {
return nil, media.Object{}, errors.New("medialocal: object is not a regular file")
}
file, err := os.Open(target)
if err != nil {
return nil, media.Object{}, err
}
after, err := file.Stat()
if err != nil || !os.SameFile(before, after) {
file.Close()
return nil, media.Object{}, errors.New("medialocal: object changed while opening")
}
return file, media.Object{Key: key, Size: after.Size(), CreatedAt: after.ModTime().UTC()}, nil
}
func (store *Store) Delete(ctx context.Context, key string) error {
if err := ctx.Err(); err != nil {
return err
}
if !media.ValidKey(key) {
return media.ErrNotFound
}
_, target := store.objectPath(key)
info, err := os.Lstat(target)
if errors.Is(err, os.ErrNotExist) {
return media.ErrNotFound
}
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("medialocal: refusing to delete a non-regular object")
}
if err = os.Remove(target); errors.Is(err, os.ErrNotExist) {
return media.ErrNotFound
}
return err
}
func (store *Store) objectPath(key string) (string, string) {
shard := filepath.Join(store.root, key[:2])
return shard, filepath.Join(shard, key)
}
func secureDirectory(path string, create bool) error {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) && create {
if err = os.MkdirAll(path, 0o750); err != nil {
return fmt.Errorf("medialocal: create directory: %w", err)
}
info, err = os.Lstat(path)
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.New("medialocal: storage directory must not be a symlink")
}
return nil
}
func inspect(path, mediaType string) (media.Object, bool, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return media.Object{}, false, nil
}
if err != nil {
return media.Object{}, false, err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return media.Object{}, false, errors.New("medialocal: existing object is not a regular file")
}
return media.Object{Key: filepath.Base(path), Size: info.Size(), MediaType: mediaType, CreatedAt: info.ModTime().UTC()}, true, nil
}
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MPL-2.0
package medialocal
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"testing"
"time"
"gamertan.com/web/media"
)
func TestStoreRoundTripAndIdempotentPut(t *testing.T) {
now := time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC)
store, err := Open(filepath.Join(t.TempDir(), "media"), Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
prepared, err := media.Prepare(bytes.NewReader([]byte("%PDF-1.7\nfixture")), "fixture.pdf", media.Limits{})
if err != nil {
t.Fatal(err)
}
first, err := store.Put(t.Context(), prepared)
if err != nil {
t.Fatal(err)
}
second, err := store.Put(t.Context(), prepared)
if err != nil || second.Key != first.Key || second.Size != first.Size {
t.Fatalf("second=%+v err=%v", second, err)
}
reader, object, err := store.Open(t.Context(), first.Key)
if err != nil {
t.Fatal(err)
}
data, readErr := io.ReadAll(reader)
closeErr := reader.Close()
if readErr != nil || closeErr != nil || !bytes.Equal(data, prepared.Data) || object.Size != int64(len(data)) {
t.Fatalf("round trip object=%+v read=%v close=%v", object, readErr, closeErr)
}
if err = store.Delete(t.Context(), first.Key); err != nil {
t.Fatal(err)
}
if _, _, err = store.Open(t.Context(), first.Key); !errors.Is(err, media.ErrNotFound) {
t.Fatalf("missing err=%v", err)
}
}
func TestOpenRejectsSymlinkRoot(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "target")
if err := os.Mkdir(target, 0o750); err != nil {
t.Fatal(err)
}
link := filepath.Join(base, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if _, err := Open(link, Options{}); err == nil {
t.Fatal("symlink root accepted")
}
}
+11
View File
@@ -17,6 +17,7 @@ SECURITY.md
THIRD_PARTY_NOTICES.md THIRD_PARTY_NOTICES.md
abuse/abuse.go abuse/abuse.go
abuse/abuse_test.go abuse/abuse_test.go
account/account.go
access/access.go access/access.go
access/access_test.go access/access_test.go
analytics/analytics.go analytics/analytics.go
@@ -27,6 +28,8 @@ auth/auth.go
auth/context.go auth/context.go
auth/password.go auth/password.go
auth/password_test.go auth/password_test.go
authrecovery/recovery.go
authrecovery/recovery_test.go
auth/service_test.go auth/service_test.go
authhttp/authhttp.go authhttp/authhttp.go
authhttp/authhttp_test.go authhttp/authhttp_test.go
@@ -34,18 +37,26 @@ authhttp/passkey.go
authhttp/passkey_test.go authhttp/passkey_test.go
authsqlite/store.go authsqlite/store.go
authsqlite/store_test.go authsqlite/store_test.go
authsqlite/account.go
authsqlite/account_test.go
authsqlite/access.go authsqlite/access.go
authsqlite/organizations.go authsqlite/organizations.go
authsqlite/passkey.go authsqlite/passkey.go
authsqlite/passkey_test.go authsqlite/passkey_test.go
authsqlite/recovery.go
authwebauthn/fuzz_test.go authwebauthn/fuzz_test.go
authwebauthn/service.go authwebauthn/service.go
authwebauthn/service_test.go authwebauthn/service_test.go
authwebauthn/types.go authwebauthn/types.go
media/media.go
media/media_test.go
medialocal/store.go
medialocal/store_test.go
internal/webauthnvendored/ internal/webauthnvendored/
docs/ADOPTION.md docs/ADOPTION.md
docs/ARCHITECTURE.md docs/ARCHITECTURE.md
docs/DEPENDENCIES.md docs/DEPENDENCIES.md
docs/DOGFOOD.md
docs/GETTING_STARTED.md docs/GETTING_STARTED.md
docs/MODULES.md docs/MODULES.md
docs/ORGANIZATIONS.md docs/ORGANIZATIONS.md