This commit is contained in:
@@ -2,6 +2,26 @@
|
||||
|
||||
# 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
|
||||
|
||||
- Add a documented root package and executable composition example so the
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
## Why Web Foundations?
|
||||
@@ -38,8 +38,11 @@ packages you import.
|
||||
| Bounded structured request evidence | [`requestmeta`](requestmeta) + [`requestlog`](requestlog) |
|
||||
| Browser and HTTP security primitives | [`websec`](websec) |
|
||||
| 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) |
|
||||
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
|
||||
| Organizations, teams, and invitations | [`organizations`](organizations) |
|
||||
| Organization-scoped roles and temporary access | [`access`](access) |
|
||||
| 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:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web@v0.1.0-preview.9
|
||||
go get gamertan.com/web@v0.1.0-preview.10
|
||||
go mod verify
|
||||
```
|
||||
|
||||
An application may name the first package it intends to adopt:
|
||||
|
||||
```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
|
||||
@@ -91,10 +94,13 @@ JSONL logging.
|
||||
|
||||
- [`auth`](auth) defines storage-neutral users, password credentials, opaque
|
||||
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
|
||||
request context without owning login routes or pages.
|
||||
- [`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
|
||||
operation separate from organization-data authority while supporting teams,
|
||||
invitations, scoped roles, and audited temporary access.
|
||||
|
||||
@@ -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
@@ -31,8 +31,14 @@ type User struct {
|
||||
ID, Username, Email, DisplayName, Status string
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
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 {
|
||||
User User
|
||||
Roles []string
|
||||
@@ -167,7 +173,7 @@ func (service *Service) ChangePassword(ctx context.Context, userID, currentPassw
|
||||
if !VerifyPassword(currentHash, currentPassword) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if user.Status != "active" {
|
||||
if !user.Active() {
|
||||
return ErrInactiveUser
|
||||
}
|
||||
if currentPassword == newPassword {
|
||||
@@ -199,7 +205,7 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err)
|
||||
}
|
||||
if user.Status != "active" {
|
||||
if !user.Active() {
|
||||
return User{}, ErrInactiveUser
|
||||
}
|
||||
if VerifyPassword(currentHash, input.TemporaryPassword) {
|
||||
@@ -233,24 +239,36 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP
|
||||
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) {
|
||||
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
|
||||
return "", Principal{}, errors.New("auth: invalid session lifetime")
|
||||
}
|
||||
user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier))
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
_ = VerifyPassword(dummyPasswordHash, password)
|
||||
return "", Principal{}, ErrInvalidCredentials
|
||||
}
|
||||
user, err := service.VerifyPassword(ctx, identifier, password)
|
||||
if err != nil {
|
||||
_ = VerifyPassword(dummyPasswordHash, password)
|
||||
return "", Principal{}, fmt.Errorf("auth: load credentials: %w", err)
|
||||
}
|
||||
if !VerifyPassword(hash, password) {
|
||||
return "", Principal{}, ErrInvalidCredentials
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return "", Principal{}, ErrInactiveUser
|
||||
return "", Principal{}, err
|
||||
}
|
||||
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)
|
||||
return "", Principal{}, err
|
||||
}
|
||||
if principal.User.Status != "active" {
|
||||
if !principal.User.Active() {
|
||||
_ = service.repository.DeleteSession(ctx, digest)
|
||||
return "", Principal{}, ErrInactiveUser
|
||||
}
|
||||
@@ -302,7 +320,7 @@ func (service *Service) Session(ctx context.Context, token string) (Principal, e
|
||||
if err != nil {
|
||||
return Principal{}, fmt.Errorf("auth: load session: %w", err)
|
||||
}
|
||||
if principal.User.Status != "active" {
|
||||
if !principal.User.Active() {
|
||||
_ = service.repository.DeleteSession(ctx, digest)
|
||||
return Principal{}, ErrInactiveUser
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
repositoryStub
|
||||
deleted bool
|
||||
@@ -68,6 +93,22 @@ type activeSessionRepository struct {
|
||||
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) {
|
||||
return repository.principal, Session{}, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(®istration.User.ID, ®istration.User.Username, ®istration.User.Email, ®istration.User.DisplayName, ®istration.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(®isteredUserID)
|
||||
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)
|
||||
@@ -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
@@ -29,7 +29,7 @@ func (store *Store) CreatePasskeyUser(ctx context.Context, user auth.User, enrol
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
@@ -53,14 +53,14 @@ func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (au
|
||||
if !text(identifier, 320, false) {
|
||||
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) {
|
||||
if !boundedCredentialID(credentialID) {
|
||||
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) {
|
||||
return auth.User{}, authwebauthn.ErrCredentialNotFound
|
||||
}
|
||||
@@ -291,7 +291,7 @@ func (store *Store) ConsumeEnrollmentToken(ctx context.Context, digest [32]byte,
|
||||
if err != nil {
|
||||
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 {
|
||||
return auth.User{}, err
|
||||
}
|
||||
@@ -310,7 +310,7 @@ func (store *Store) RecoverUser(ctx context.Context, identifier string, enrollme
|
||||
return auth.User{}, err
|
||||
}
|
||||
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 {
|
||||
return auth.User{}, err
|
||||
}
|
||||
@@ -342,21 +342,22 @@ type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
func scanPasskeyUser(row rowScanner) (auth.User, error) {
|
||||
var user auth.User
|
||||
var passwordChangeRequired int
|
||||
var passwordChangeRequired, registrationPending int
|
||||
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, ®istrationPending, &created, &updated); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return auth.User{}, auth.ErrUserNotFound
|
||||
}
|
||||
return auth.User{}, err
|
||||
}
|
||||
user.PasswordChangeRequired = passwordChangeRequired == 1
|
||||
user.RegistrationPending = registrationPending == 1
|
||||
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||
return user, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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
@@ -24,6 +24,17 @@ import (
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -56,7 +67,7 @@ func Open(path string) (*Store, error) {
|
||||
store := &Store{db: db}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err = db.PingContext(ctx); err == nil {
|
||||
if err = db.PingContext(ctx); err == nil && options.Migrate {
|
||||
err = store.Migrate(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -66,6 +77,34 @@ func Open(path string) (*Store, error) {
|
||||
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) 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()
|
||||
statements := []string{
|
||||
`CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), 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_roles (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 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 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_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)`,
|
||||
@@ -132,6 +176,7 @@ func (store *Store) Migrate(ctx context.Context) error {
|
||||
for _, migration := range []struct {
|
||||
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", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 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 {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -212,7 +266,7 @@ func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,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 {
|
||||
return err
|
||||
}
|
||||
@@ -228,9 +282,9 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
|
||||
}
|
||||
var user auth.User
|
||||
var created, updated int64
|
||||
var passwordChangeRequired int
|
||||
var passwordChangeRequired, registrationPending int
|
||||
var hash string
|
||||
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.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, ®istrationPending, &created, &updated, &hash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return auth.User{}, "", auth.ErrUserNotFound
|
||||
}
|
||||
@@ -238,6 +292,7 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
|
||||
return auth.User{}, "", err
|
||||
}
|
||||
user.PasswordChangeRequired = passwordChangeRequired == 1
|
||||
user.RegistrationPending = registrationPending == 1
|
||||
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||
return user, hash, nil
|
||||
}
|
||||
@@ -248,9 +303,9 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth
|
||||
}
|
||||
var user auth.User
|
||||
var created, updated int64
|
||||
var passwordChangeRequired int
|
||||
var passwordChangeRequired, registrationPending int
|
||||
var hash string
|
||||
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.id=?`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
|
||||
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, ®istrationPending, &created, &updated, &hash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return auth.User{}, "", auth.ErrUserNotFound
|
||||
}
|
||||
@@ -258,6 +313,7 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth
|
||||
return auth.User{}, "", err
|
||||
}
|
||||
user.PasswordChangeRequired = passwordChangeRequired == 1
|
||||
user.RegistrationPending = registrationPending == 1
|
||||
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||
return user, hash, nil
|
||||
}
|
||||
@@ -346,12 +402,13 @@ func (store *Store) PrincipalBySession(ctx context.Context, digest [32]byte, now
|
||||
var principal auth.Principal
|
||||
var session auth.Session
|
||||
var created, updated, sessionCreated, expires, lastSeen int64
|
||||
var passwordChangeRequired int
|
||||
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &passwordChangeRequired, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
|
||||
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.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, ®istrationPending, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound
|
||||
}
|
||||
principal.User.PasswordChangeRequired = passwordChangeRequired == 1
|
||||
principal.User.RegistrationPending = registrationPending == 1
|
||||
if err != nil {
|
||||
return auth.Principal{}, auth.Session{}, err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,24 @@ import (
|
||||
"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) {
|
||||
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||
if err != nil {
|
||||
|
||||
+49
-16
@@ -190,7 +190,7 @@ func (service *Service) BeginEnrollment(ctx context.Context, enrollmentToken, la
|
||||
if err != nil {
|
||||
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) {
|
||||
@@ -198,7 +198,24 @@ func (service *Service) BeginRegistration(ctx context.Context, userID, label str
|
||||
if err != nil {
|
||||
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
|
||||
@@ -216,15 +233,15 @@ func (service *Service) BeginPasswordMigration(ctx context.Context, userID, labe
|
||||
if !exists {
|
||||
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)
|
||||
if err != nil {
|
||||
return BeginResult{}, err
|
||||
}
|
||||
adapter, err := service.user(ctx, user)
|
||||
adapter, err := service.user(ctx, user, allowPending)
|
||||
if err != nil {
|
||||
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) {
|
||||
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
|
||||
@@ -255,18 +284,18 @@ func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyTok
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
return Credential{}, ErrOperationBinding
|
||||
}
|
||||
@@ -277,7 +306,7 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
adapter, err := service.user(ctx, user)
|
||||
adapter, err := service.user(ctx, user, allowPending)
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
@@ -312,6 +341,10 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
|
||||
}
|
||||
if retirePassword {
|
||||
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 {
|
||||
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) {
|
||||
return nil, ErrCredentialNotFound
|
||||
}
|
||||
loaded, lookupErr = service.user(ctx, account)
|
||||
loaded, lookupErr = service.user(ctx, account, false)
|
||||
return loaded, lookupErr
|
||||
}, session, parsed)
|
||||
if err != nil || loaded == nil || user == nil {
|
||||
@@ -385,7 +418,7 @@ func (service *Service) BeginApproval(ctx context.Context, userID string, bindin
|
||||
if err != nil {
|
||||
return BeginResult{}, err
|
||||
}
|
||||
adapter, err := service.user(ctx, account)
|
||||
adapter, err := service.user(ctx, account, false)
|
||||
if err != nil {
|
||||
return BeginResult{}, err
|
||||
}
|
||||
@@ -415,7 +448,7 @@ func (service *Service) FinishApproval(ctx context.Context, ceremonyToken string
|
||||
if err != nil {
|
||||
return Approval{}, err
|
||||
}
|
||||
adapter, err := service.user(ctx, account)
|
||||
adapter, err := service.user(ctx, account, false)
|
||||
if err != nil {
|
||||
return Approval{}, err
|
||||
}
|
||||
@@ -552,8 +585,8 @@ func (service *Service) takeCeremony(ctx context.Context, token, kind string) (C
|
||||
return ceremony, nil
|
||||
}
|
||||
|
||||
func (service *Service) user(ctx context.Context, account auth.User) (*passkeyUser, error) {
|
||||
if account.Status != "active" {
|
||||
func (service *Service) user(ctx context.Context, account auth.User, allowPending bool) (*passkeyUser, error) {
|
||||
if account.Status != "active" || account.RegistrationPending && !allowPending {
|
||||
return nil, auth.ErrInactiveUser
|
||||
}
|
||||
records, err := service.repository.CredentialsByUserID(ctx, account.ID)
|
||||
|
||||
+11
-4
@@ -1,9 +1,10 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package authwebauthn provides storage-neutral, passkey-only WebAuthn
|
||||
// ceremonies. It owns relying-party policy, bounded single-use ceremony state,
|
||||
// credential lifecycle, and recovery tokens while delegating protocol parsing
|
||||
// and signature verification to a pinned WebAuthn implementation.
|
||||
// Package authwebauthn provides storage-neutral WebAuthn ceremonies for
|
||||
// passkey login, enrollment, and operation-bound step-up. It owns relying-party
|
||||
// policy, bounded single-use ceremony state, credential lifecycle, and recovery
|
||||
// tokens while delegating protocol parsing and signature verification to a
|
||||
// pinned WebAuthn implementation.
|
||||
package authwebauthn
|
||||
|
||||
import (
|
||||
@@ -92,6 +93,12 @@ type Approval struct {
|
||||
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
|
||||
// enrollment tokens and ceremonies atomically and must perform recovery and
|
||||
// credential removal invariants in transactions.
|
||||
|
||||
@@ -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.
|
||||
@@ -25,7 +25,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
|
||||
module checksum:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.9
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.10
|
||||
go mod verify
|
||||
```
|
||||
|
||||
|
||||
+7
-1
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
|
||||
and request the containing module at an exact version:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.9
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.10
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
Authenticated sessions currently belong to three deliberate packages:
|
||||
|
||||
+190
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ SECURITY.md
|
||||
THIRD_PARTY_NOTICES.md
|
||||
abuse/abuse.go
|
||||
abuse/abuse_test.go
|
||||
account/account.go
|
||||
access/access.go
|
||||
access/access_test.go
|
||||
analytics/analytics.go
|
||||
@@ -27,6 +28,8 @@ auth/auth.go
|
||||
auth/context.go
|
||||
auth/password.go
|
||||
auth/password_test.go
|
||||
authrecovery/recovery.go
|
||||
authrecovery/recovery_test.go
|
||||
auth/service_test.go
|
||||
authhttp/authhttp.go
|
||||
authhttp/authhttp_test.go
|
||||
@@ -34,18 +37,26 @@ authhttp/passkey.go
|
||||
authhttp/passkey_test.go
|
||||
authsqlite/store.go
|
||||
authsqlite/store_test.go
|
||||
authsqlite/account.go
|
||||
authsqlite/account_test.go
|
||||
authsqlite/access.go
|
||||
authsqlite/organizations.go
|
||||
authsqlite/passkey.go
|
||||
authsqlite/passkey_test.go
|
||||
authsqlite/recovery.go
|
||||
authwebauthn/fuzz_test.go
|
||||
authwebauthn/service.go
|
||||
authwebauthn/service_test.go
|
||||
authwebauthn/types.go
|
||||
media/media.go
|
||||
media/media_test.go
|
||||
medialocal/store.go
|
||||
medialocal/store_test.go
|
||||
internal/webauthnvendored/
|
||||
docs/ADOPTION.md
|
||||
docs/ARCHITECTURE.md
|
||||
docs/DEPENDENCIES.md
|
||||
docs/DOGFOOD.md
|
||||
docs/GETTING_STARTED.md
|
||||
docs/MODULES.md
|
||||
docs/ORGANIZATIONS.md
|
||||
|
||||
Reference in New Issue
Block a user