feat(auth): add WebAuthn, TOTP and the enroll/login flow (SPEC-004/006)

- webauthn: the passkey ceremony service (a fake in tests)
- totp: the TOTP authenticator; the secret is AEAD-sealed at rest (A9)
- sealer: the AEAD TOTP sealer
- identities/sessions/credentials: the GUID state machine
- flow: the enroll/login screens + the passkey nudge (pre-session
  double-submit CSRF — A15), rendered inside the shell
This commit is contained in:
Claus Lohmar 2026-10-14 10:53:53 +00:00
parent d11e14295b
commit 76a4a6950c
14 changed files with 3160 additions and 0 deletions

View file

@ -0,0 +1,578 @@
package auth_test
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// The Identities service satisfies the pure model's injected Resolver
// (SPEC-002 §3.1) — a compile-time proof.
var _ routing.Resolver = (*auth.IdentitiesService)(nil)
// fakeClock drives session/identity expiry deterministically.
type fakeClock struct{ now time.Time }
func (c *fakeClock) Now() time.Time { return c.now }
func (c *fakeClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
type fixture struct {
t *testing.T
ctx context.Context
db *store.DB
users *users.Store
ids *auth.IdentitiesService
sessions *auth.SessionsService
clock *fakeClock
}
func newFixture(t *testing.T, opts ...auth.Option) *fixture {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
clk := &fakeClock{now: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)}
allOpts := append([]auth.Option{auth.WithClock(clk.Now)}, opts...)
aud := audit.New(db.DB)
core := auth.New(db.DB, aud, allOpts...)
return &fixture{
t: t,
ctx: context.Background(),
db: db,
users: users.New(db.DB, aud),
ids: core.Identities(),
sessions: core.Sessions(),
clock: clk,
}
}
func (f *fixture) admin() int64 {
f.t.Helper()
id, err := f.users.Create(f.ctx, 0, "admin@example.com", "Admin", users.RoleAdmin)
mustNoErr(f.t, err)
return id
}
func (f *fixture) activeIdentity(userID int64) (int64, string) {
f.t.Helper()
id, guid, err := f.ids.Mint(f.ctx, userID)
mustNoErr(f.t, err)
mustNoErr(f.t, f.ids.Activate(f.ctx, id))
return id, guid
}
func (f *fixture) auditCount() int {
f.t.Helper()
var n int
if err := f.db.QueryRow(`SELECT count(*) FROM audit_log`).Scan(&n); err != nil {
f.t.Fatalf("count audit_log: %v", err)
}
return n
}
// expectAudit asserts fn produced at least one audit row (AC-7).
func (f *fixture) expectAudit(name string, fn func()) {
f.t.Helper()
before := f.auditCount()
fn()
if after := f.auditCount(); after <= before {
f.t.Errorf("AC-7: %s wrote no audit row (before=%d after=%d)", name, before, after)
}
}
func mustNoErr(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
// AC-1: two active identities for one user is impossible — service AND index.
func TestAC1_OneActiveIdentityPerUser(t *testing.T) {
f := newFixture(t)
admin := f.admin()
director, err := f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector)
mustNoErr(t, err)
f.activeIdentity(director)
// Service check: Mint refuses while a live identity exists.
if _, _, err := f.ids.Mint(f.ctx, director); !errors.Is(err, auth.ErrIdentityExists) {
t.Errorf("Mint with a live identity = %v, want ErrIdentityExists", err)
}
now := store.FormatTime(f.clock.Now())
// Index check: a hand-inserted second ACTIVE row is rejected.
if _, err := f.db.Exec(
`INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, 'manual-active', 'active', ?)`,
director, now,
); err == nil {
t.Error("a second active identity was inserted, want a UNIQUE violation (AC-1 index)")
}
// Index check via the service: a hand-inserted pending row cannot be
// activated into a second active identity.
res, err := f.db.Exec(
`INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, 'manual-pending', 'pending_enrollment', ?)`,
director, now,
)
mustNoErr(t, err)
pendingID, err := res.LastInsertId()
mustNoErr(t, err)
if err := f.ids.Activate(f.ctx, pendingID); err == nil {
t.Error("Activate produced a second active identity, want an error (AC-1)")
}
}
// AC-2: guid is unique and a revoked GUID is never reissued (tombstoned).
func TestAC2_GUIDUniqueAndTombstoned(t *testing.T) {
f := newFixture(t)
admin := f.admin()
id, guid1 := f.activeIdentity(admin)
if guid1 == "" || routing.IsReservedWorkflow(guid1) {
t.Fatalf("minted guid %q is empty or reserved", guid1)
}
mustNoErr(t, f.ids.Revoke(f.ctx, id))
guid2, err := f.ids.Rotate(f.ctx, admin)
mustNoErr(t, err)
if guid2 == guid1 {
t.Error("Rotate reissued the tombstoned GUID (AC-2)")
}
// The old GUID can never be re-inserted.
if _, err := f.db.Exec(
`INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, ?, 'pending_enrollment', ?)`,
admin, guid1, store.FormatTime(f.clock.Now()),
); err == nil {
t.Error("a tombstoned GUID was reused, want a UNIQUE violation (AC-2)")
}
// And it resolves as revoked/tombstoned, not as a live identity.
old, err := f.ids.ByGUID(f.ctx, guid1)
mustNoErr(t, err)
if old.Status != auth.StatusRevoked {
t.Errorf("old identity status = %s, want revoked", old.Status)
}
if found, enrolled := f.ids.GUID(guid1); found || enrolled {
t.Errorf("tombstoned GUID resolved found=%v enrolled=%v, want false/false", found, enrolled)
}
}
// AC-3: an empty session id is rejected (A2).
func TestAC3_EmptySessionRejected(t *testing.T) {
f := newFixture(t)
if _, err := f.sessions.Validate(f.ctx, ""); !errors.Is(err, auth.ErrEmptySessionID) {
t.Errorf("Validate(\"\") = %v, want ErrEmptySessionID (AC-3, A2)", err)
}
if err := f.sessions.Revoke(f.ctx, ""); !errors.Is(err, auth.ErrEmptySessionID) {
t.Errorf("Revoke(\"\") = %v, want ErrEmptySessionID", err)
}
}
// AC-4: expired/revoked sessions error; RevokeAll kills every session.
func TestAC4_ExpiredRevokedAndRevokeAll(t *testing.T) {
f := newFixture(t, auth.WithSessionTTL(time.Hour))
admin := f.admin()
id, _ := f.activeIdentity(admin)
s1, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test")
mustNoErr(t, err)
s2, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test")
mustNoErr(t, err)
if _, err := f.sessions.Validate(f.ctx, s1.ID); err != nil {
t.Fatalf("a fresh session failed to validate: %v", err)
}
// Revoked -> error.
mustNoErr(t, f.sessions.Revoke(f.ctx, s1.ID))
if _, err := f.sessions.Validate(f.ctx, s1.ID); !errors.Is(err, auth.ErrSessionRevoked) {
t.Errorf("Validate(revoked) = %v, want ErrSessionRevoked", err)
}
// Expired -> error.
f.clock.Advance(2 * time.Hour)
if _, err := f.sessions.Validate(f.ctx, s2.ID); !errors.Is(err, auth.ErrSessionExpired) {
t.Errorf("Validate(expired) = %v, want ErrSessionExpired", err)
}
// RevokeAll kills every live session of the identity.
a, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test")
mustNoErr(t, err)
b, err := f.sessions.Mint(f.ctx, id, "127.0.0.1", "test")
mustNoErr(t, err)
mustNoErr(t, f.sessions.RevokeAll(f.ctx, id))
for _, sid := range []string{a.ID, b.ID} {
if _, err := f.sessions.Validate(f.ctx, sid); !errors.Is(err, auth.ErrSessionRevoked) {
t.Errorf("Validate(%s) after RevokeAll = %v, want ErrSessionRevoked", sid, err)
}
}
}
// AC-5: Rotate revokes the old identity AND its sessions, and mints a new GUID.
func TestAC5_RotateRevokesOldAndMintsNew(t *testing.T) {
f := newFixture(t)
admin := f.admin()
id, guid1 := f.activeIdentity(admin)
session, err := f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua")
mustNoErr(t, err)
guid2, err := f.ids.Rotate(f.ctx, admin)
mustNoErr(t, err)
if guid2 == "" || guid2 == guid1 {
t.Fatalf("Rotate guid = %q, want a fresh non-empty GUID", guid2)
}
old, err := f.ids.ByGUID(f.ctx, guid1)
mustNoErr(t, err)
if old.Status != auth.StatusRevoked || old.RevokedAt == nil {
t.Errorf("old identity = %+v, want revoked with a timestamp", old)
}
// Every session of the old identity is dead.
if _, err := f.sessions.Validate(f.ctx, session.ID); !errors.Is(err, auth.ErrSessionRevoked) {
t.Errorf("old session after Rotate = %v, want ErrSessionRevoked", err)
}
// The new identity is pending enrollment under the same user.
fresh, err := f.ids.ByGUID(f.ctx, guid2)
mustNoErr(t, err)
if fresh.Status != auth.StatusPendingEnrollment {
t.Errorf("new identity status = %s, want pending_enrollment", fresh.Status)
}
if fresh.UserID != admin {
t.Errorf("new identity user = %d, want %d (employment survives rotation)", fresh.UserID, admin)
}
}
// AC-7: every service mutation writes an audit_log row (A11).
func TestAC7_EveryMutationAudited(t *testing.T) {
f := newFixture(t)
admin := f.admin()
var director int64
f.expectAudit("users.Create", func() {
var err error
director, err = f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector)
mustNoErr(t, err)
})
f.expectAudit("users.SetRole", func() {
mustNoErr(t, f.users.SetRole(f.ctx, director, users.RoleManager))
})
var id int64
f.expectAudit("identities.Mint", func() {
var err error
id, _, err = f.ids.Mint(f.ctx, director)
mustNoErr(t, err)
})
f.expectAudit("identities.Activate", func() {
mustNoErr(t, f.ids.Activate(f.ctx, id))
})
var sess auth.Session
f.expectAudit("sessions.Mint", func() {
var err error
sess, err = f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua")
mustNoErr(t, err)
})
f.expectAudit("sessions.Rotate", func() {
_, err := f.sessions.Rotate(f.ctx, sess.ID)
mustNoErr(t, err)
})
sess2, err := f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua")
mustNoErr(t, err)
f.expectAudit("sessions.Revoke", func() {
mustNoErr(t, f.sessions.Revoke(f.ctx, sess2.ID))
})
_, err = f.sessions.Mint(f.ctx, id, "1.2.3.4", "ua")
mustNoErr(t, err)
f.expectAudit("sessions.RevokeAll", func() {
mustNoErr(t, f.sessions.RevokeAll(f.ctx, id))
})
f.expectAudit("identities.Revoke", func() {
mustNoErr(t, f.ids.Revoke(f.ctx, id))
})
f.expectAudit("identities.Rotate", func() {
_, err := f.ids.Rotate(f.ctx, director)
mustNoErr(t, err)
})
}
// AC-8: the TOTP secret is encrypted at rest and never stored or logged in
// plaintext (A9).
func TestAC8_TOTPSecretEncryptedAndNeverPlaintext(t *testing.T) {
var logs bytes.Buffer
old := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
t.Cleanup(func() { slog.SetDefault(old) })
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{0x7f}, auth.KeySize))
mustNoErr(t, err)
f := newFixture(t, auth.WithSealer(sealer))
admin := f.admin()
id, _ := f.activeIdentity(admin)
secret := []byte("JBSWY3DPEHPK3PXP") // the shape of a base32 TOTP secret
f.expectAudit("totp.PutTOTPSecret", func() {
mustNoErr(t, f.ids.PutTOTPSecret(f.ctx, id, secret))
})
// 1) What is stored is ciphertext, not the secret.
var raw []byte
if err := f.db.QueryRow(`SELECT secret_encrypted FROM totp_secrets WHERE identity_id = ?`, id).Scan(&raw); err != nil {
t.Fatalf("read secret_encrypted: %v", err)
}
if bytes.Equal(raw, secret) || bytes.Contains(raw, secret) {
t.Error("AC-8: secret_encrypted holds the plaintext secret (A9)")
}
// 2) It still decrypts back to the secret.
got, err := f.ids.TOTPSecret(f.ctx, id)
mustNoErr(t, err)
if !bytes.Equal(got, secret) {
t.Errorf("TOTPSecret = %q, want %q", got, secret)
}
// 3) No audit row carries the secret.
rows, err := f.db.Query(
`SELECT COALESCE(action,''), COALESCE(subject,''), COALESCE(detail,''), COALESCE(correlation_id,'') FROM audit_log`,
)
mustNoErr(t, err)
defer rows.Close()
for rows.Next() {
var a, s, d, c string
mustNoErr(t, rows.Scan(&a, &s, &d, &c))
if strings.Contains(a+s+d+c, string(secret)) {
t.Error("AC-8: an audit row contains the TOTP secret (A9)")
}
}
mustNoErr(t, rows.Err())
// 4) Nothing reached the log.
if strings.Contains(logs.String(), string(secret)) {
t.Error("AC-8: the TOTP secret reached the log (A9)")
}
// Verification is audited too and never stores plaintext.
f.expectAudit("totp.MarkTOTPVerified", func() {
mustNoErr(t, f.ids.MarkTOTPVerified(f.ctx, id))
})
}
func TestTOTPWithoutSealerFailsClosed(t *testing.T) {
f := newFixture(t) // no sealer
admin := f.admin()
id, _ := f.activeIdentity(admin)
if err := f.ids.PutTOTPSecret(f.ctx, id, []byte("secret")); !errors.Is(err, auth.ErrNoSealer) {
t.Errorf("PutTOTPSecret without a sealer = %v, want ErrNoSealer (fail closed)", err)
}
}
// The Identities service satisfies routing.Resolver end to end.
func TestResolverContract(t *testing.T) {
f := newFixture(t)
admin := f.admin()
if found, enrolled := f.ids.GUID("definitely-unknown"); found || enrolled {
t.Error("unknown GUID reported found")
}
if found, _ := f.ids.GUID(""); found {
t.Error("empty GUID reported found")
}
id, guid, err := f.ids.Mint(f.ctx, admin) // pending
mustNoErr(t, err)
if found, enrolled := f.ids.GUID(guid); !found || enrolled {
t.Errorf("pending GUID: found=%v enrolled=%v, want true/false", found, enrolled)
}
mustNoErr(t, f.ids.Activate(f.ctx, id))
if found, enrolled := f.ids.GUID(guid); !found || !enrolled {
t.Errorf("active GUID: found=%v enrolled=%v, want true/true", found, enrolled)
}
}
func TestIdentityLifecycleErrors(t *testing.T) {
f := newFixture(t)
admin := f.admin()
if _, _, err := f.ids.Mint(f.ctx, 999); !errors.Is(err, auth.ErrUnknownUser) {
t.Errorf("Mint(unknown user) = %v, want ErrUnknownUser", err)
}
if _, err := f.ids.Rotate(f.ctx, 999); !errors.Is(err, auth.ErrUnknownUser) {
t.Errorf("Rotate(unknown user) = %v, want ErrUnknownUser", err)
}
id, _, err := f.ids.Mint(f.ctx, admin)
mustNoErr(t, err)
mustNoErr(t, f.ids.Activate(f.ctx, id))
if err := f.ids.Activate(f.ctx, id); !errors.Is(err, auth.ErrIdentityNotPending) {
t.Errorf("Activate twice = %v, want ErrIdentityNotPending", err)
}
// A live identity blocks a second Mint.
if _, _, err := f.ids.Mint(f.ctx, admin); !errors.Is(err, auth.ErrIdentityExists) {
t.Errorf("second Mint = %v, want ErrIdentityExists", err)
}
if _, err := f.sessions.Mint(f.ctx, id, "ip", "ua"); err != nil {
t.Fatalf("Mint session for active identity: %v", err)
}
}
func TestSessionMintRequiresActiveIdentity(t *testing.T) {
f := newFixture(t)
admin := f.admin()
pending, _, err := f.ids.Mint(f.ctx, admin) // still pending
mustNoErr(t, err)
if _, err := f.sessions.Mint(f.ctx, pending, "ip", "ua"); !errors.Is(err, auth.ErrIdentityNotActive) {
t.Errorf("Mint for pending identity = %v, want ErrIdentityNotActive", err)
}
if _, err := f.sessions.Mint(f.ctx, 999, "ip", "ua"); !errors.Is(err, auth.ErrIdentityNotFound) {
t.Errorf("Mint for unknown identity = %v, want ErrIdentityNotFound", err)
}
}
func TestSessionGCKeepsValidSessions(t *testing.T) {
f := newFixture(t, auth.WithSessionTTL(time.Hour))
admin := f.admin()
id, _ := f.activeIdentity(admin)
stale1, err := f.sessions.Mint(f.ctx, id, "ip", "ua")
mustNoErr(t, err)
stale2, err := f.sessions.Mint(f.ctx, id, "ip", "ua")
mustNoErr(t, err)
f.clock.Advance(2 * time.Hour) // both stale sessions are now expired
fresh, err := f.sessions.Mint(f.ctx, id, "ip", "ua")
mustNoErr(t, err)
n, err := f.sessions.GC(f.ctx, f.clock.Now())
mustNoErr(t, err)
if n != 2 {
t.Errorf("GC removed %d rows, want 2", n)
}
// The valid session survives — GC never takes the last valid session.
if _, err := f.sessions.Validate(f.ctx, fresh.ID); err != nil {
t.Errorf("GC removed a valid session: %v", err)
}
for _, sid := range []string{stale1.ID, stale2.ID} {
if _, err := f.sessions.Validate(f.ctx, sid); !errors.Is(err, auth.ErrSessionNotFound) {
t.Errorf("stale session %s = %v, want ErrSessionNotFound after GC", sid, err)
}
}
}
func TestEnrollmentWindow(t *testing.T) {
f := newFixture(t, auth.WithEnrollWindow(72*time.Hour))
admin := f.admin()
id, _, err := f.ids.Mint(f.ctx, admin) // pending
mustNoErr(t, err)
ok, err := f.ids.WithinEnrollmentWindow(f.ctx, id)
mustNoErr(t, err)
if !ok {
t.Error("a fresh pending identity is outside its enrollment window")
}
f.clock.Advance(73 * time.Hour)
if ok, err = f.ids.WithinEnrollmentWindow(f.ctx, id); err != nil {
t.Fatalf("WithinEnrollmentWindow: %v", err)
}
if ok {
t.Error("an identity past its window is still enrollable (A1)")
}
}
// R-4: the identity services take the ACTOR from the audit.Meta on the context
// (creator/approver) and record the AFFECTED user as the subject — never a
// hard-coded owner.
func TestR4_IdentityAuditActorFromContext(t *testing.T) {
f := newFixture(t)
admin := f.admin()
director, err := f.users.Create(f.ctx, admin, "d@example.com", "D", users.RoleDirector)
mustNoErr(t, err)
// The creator acts with an explicit actor on the context (the door would
// attach it); the affected user is the subject.
ctx := audit.WithMeta(f.ctx, audit.Meta{ActorUserID: admin, ClientIP: "203.0.113.9", CorrelationID: "corr-r4"})
id, guid, err := f.ids.Mint(ctx, director)
mustNoErr(t, err)
mustNoErr(t, f.ids.Activate(ctx, id))
mustNoErr(t, f.ids.Revoke(ctx, id))
if _, err := f.ids.Rotate(ctx, director); err != nil {
t.Fatalf("Rotate: %v", err)
}
_ = guid
rows, err := f.db.Query(`SELECT action, actor_user_id, subject, correlation_id FROM audit_log
WHERE action IN ('identity.mint','identity.activate','identity.revoke','identity.rotate')
ORDER BY id`)
if err != nil {
t.Fatalf("query audit_log: %v", err)
}
defer rows.Close()
want := map[string]bool{
"identity.mint": true,
"identity.activate": true,
"identity.revoke": true,
"identity.rotate": true,
}
seen := 0
for rows.Next() {
var action, subject string
var actor sql.NullInt64
var corr sql.NullString
if err := rows.Scan(&action, &actor, &subject, &corr); err != nil {
t.Fatalf("scan: %v", err)
}
seen++
if !actor.Valid || actor.Int64 != admin {
t.Errorf("%s actor = %+v, want the context actor %d (R-4)", action, actor, admin)
}
if subject != fmt.Sprintf("user:%d", director) {
t.Errorf("%s subject = %q, want the affected user (R-4)", action, subject)
}
if corr.String != "corr-r4" {
t.Errorf("%s correlation = %q, want corr-r4", action, corr.String)
}
delete(want, action)
}
if err := rows.Err(); err != nil {
t.Fatalf("rows: %v", err)
}
if seen != 4 || len(want) != 0 {
t.Errorf("audited %d identity events, missing %v (R-4)", seen, want)
}
}

381
internal/core/auth/auth.go Normal file
View file

@ -0,0 +1,381 @@
// Package auth owns MyDay's identities and sessions (PROJECT_PLAN.md §6).
//
// - an IDENTITY is the rotating access record (a GUID + its credentials);
// a USER is the stable person. Credentials hang off an identity, never the
// user (D-008), and a GUID is never recycled (D-008/AC-2).
// - a SESSION is the opaque credential minted after enroll/login; the GUID is
// only a username (D-001 — a bookmark is never a login).
//
// This package also provides the encryption seam for TOTP secrets (A9) — the
// key comes from config later; secrets are never stored or logged in plaintext.
//
// The two service interfaces both name Mint/Rotate/Revoke, so they are separate
// Go types (IdentitiesService, SessionsService) sharing one Core.
//
// Spec: docs/specs/spec-004-identity-sessions.md §3–§6.
package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
)
// IdentityStatus is an identity's lifecycle state (SPEC-004 §4).
type IdentityStatus string
const (
// StatusPendingEnrollment is a freshly minted identity — the invite window
// is open but the identity is a bearer until enrolled (A1).
StatusPendingEnrollment IdentityStatus = "pending_enrollment"
// StatusActive is an enrolled identity; it may authenticate.
StatusActive IdentityStatus = "active"
// StatusRevoked is a tombstoned identity — its GUID is dead forever
// (D-008; a revoked GUID behaves as "not found" to the router).
StatusRevoked IdentityStatus = "revoked"
)
// Identity is the rotating access record (SPEC-004 §4 `identities`).
type Identity struct {
ID int64
UserID int64
GUID string
Status IdentityStatus
CreatedAt time.Time
EnrolledAt *time.Time
RevokedAt *time.Time
}
// Session is one server-side session row; the id is the cookie value
// (SPEC-004 §4 `sessions`).
type Session struct {
ID string
IdentityID int64
UserID int64
CreatedAt time.Time
ExpiresAt time.Time
RevokedAt *time.Time
ClientIP string
UserAgent string
}
// Identities is the identity service contract (SPEC-004 §5). Per ruling R-1
// every method takes the request context so audit metadata (actor · client IP ·
// correlation) reaches each audit row (A11).
type Identities interface {
// Mint creates a new identity for userID with status pending_enrollment and
// returns its id and a fresh, unguessable GUID.
Mint(ctx context.Context, userID int64) (IdentityID int64, guid string, err error)
// Activate moves a pending identity to active (the enrollment completes).
Activate(ctx context.Context, id int64) error
// Rotate performs recovery (D-007): revoke the user's current identity and
// all its sessions, then mint a fresh pending identity — a NEW GUID.
Rotate(ctx context.Context, userID int64) (guid string, err error)
// Revoke revokes an identity AND every session it minted.
Revoke(ctx context.Context, id int64) error
// ByGUID resolves a GUID. A missing GUID yields ErrIdentityNotFound.
// NOTE: it is context-taking only for symmetry; a revoked (tombstoned) GUID
// is still returned so the caller can distinguish and audit it (SPEC-004 §8).
ByGUID(ctx context.Context, guid string) (Identity, error)
// ByUserID returns the user's current (non-revoked) identity — the one the
// door and the /setup wizard follow (SPEC-005 §4: "which step is next?").
// A user with no live identity yields ErrIdentityNotFound.
ByUserID(ctx context.Context, userID int64) (Identity, error)
// WithinEnrollmentWindow reports whether a pending identity is still inside
// its bounded invite window (SPEC-004 §4, A1). SPEC-006 refuses enrollment
// outside it. A non-pending identity reports false.
WithinEnrollmentWindow(ctx context.Context, id int64) (bool, error)
}
// Sessions is the session service contract (SPEC-004 §5/§6). Per ruling R-1
// every method takes the request context.
type Sessions interface {
// Mint issues a session for an ACTIVE identity.
Mint(ctx context.Context, identityID int64, ip, ua string) (Session, error)
// Validate accepts a session id only if it is non-empty (A2), exists, is
// not expired and is not revoked.
Validate(ctx context.Context, sessionID string) (Session, error)
// Rotate replaces a valid session with a fresh one (fixation defence).
Rotate(ctx context.Context, sessionID string) (Session, error)
// Revoke kills one session (logout).
Revoke(ctx context.Context, sessionID string) error
// RevokeAll kills every live session of an identity (D-007).
RevokeAll(ctx context.Context, identityID int64) error
// GC deletes expired rows (housekeeping) and returns how many it removed.
// It NEVER deletes a still-valid session (SPEC-004 §6).
GC(ctx context.Context, now time.Time) (int, error)
}
// Sentinel errors.
var (
ErrIdentityNotFound = errors.New("auth: identity not found")
ErrIdentityExists = errors.New("auth: the user already has a non-revoked identity")
ErrIdentityNotPending = errors.New("auth: identity is not pending enrollment")
ErrIdentityNotActive = errors.New("auth: identity is not active")
ErrUnknownUser = errors.New("auth: user does not exist")
ErrGUIDExhausted = errors.New("auth: could not allocate a unique GUID")
ErrEmptySessionID = errors.New("auth: empty session id") // A2
ErrSessionNotFound = errors.New("auth: session not found")
ErrSessionExpired = errors.New("auth: session expired")
ErrSessionRevoked = errors.New("auth: session revoked")
ErrNoSealer = errors.New("auth: no TOTP sealer configured")
ErrEmptySecret = errors.New("auth: empty TOTP secret")
ErrSecretNotFound = errors.New("auth: TOTP secret not found")
ErrInvalidCiphertext = errors.New("auth: invalid ciphertext")
)
// Defaults. These are code defaults to be overridden from config later
// (D-035 keeps settings in the config file).
const (
// DefaultSessionTTL is how long a minted session lives.
DefaultSessionTTL = 24 * time.Hour
// DefaultEnrollWindow bounds the un-enrolled-GUID bearer window (A1).
DefaultEnrollWindow = 72 * time.Hour
)
// Core holds the shared dependencies of the identity and session services.
type Core struct {
db *sql.DB
audit audit.Audit
sealer Sealer
now func() time.Time
sessionTTL time.Duration
enrollWindow time.Duration
}
// Option configures a Core.
type Option func(*Core)
// WithSessionTTL overrides the session lifetime (default DefaultSessionTTL).
func WithSessionTTL(d time.Duration) Option {
return func(c *Core) {
if d > 0 {
c.sessionTTL = d
}
}
}
// WithEnrollWindow overrides the enrollment window (default DefaultEnrollWindow).
func WithEnrollWindow(d time.Duration) Option {
return func(c *Core) {
if d > 0 {
c.enrollWindow = d
}
}
}
// WithSealer supplies the TOTP secret encryption seam (A9).
func WithSealer(sealer Sealer) Option {
return func(c *Core) { c.sealer = sealer }
}
// WithClock injects a clock (tests).
func WithClock(fn func() time.Time) Option {
return func(c *Core) {
if fn != nil {
c.now = fn
}
}
}
// New builds the shared Core. aud may be nil (tests) — audit is then skipped.
func New(db *sql.DB, aud audit.Audit, opts ...Option) *Core {
c := &Core{
db: db,
audit: aud,
now: time.Now,
sessionTTL: DefaultSessionTTL,
enrollWindow: DefaultEnrollWindow,
}
for _, opt := range opts {
opt(c)
}
return c
}
// Identities returns the identity service over this Core.
func (c *Core) Identities() *IdentitiesService { return &IdentitiesService{Core: c} }
// Sessions returns the session service over this Core.
func (c *Core) Sessions() *SessionsService { return &SessionsService{Core: c} }
// clock returns the current time, UTC and truncated to the storage precision,
// so comparisons line up exactly with the values written by store.FormatTime.
func (c *Core) clock() time.Time {
return c.now().UTC().Truncate(time.Second)
}
func (c *Core) record(ctx context.Context, e audit.Event) {
if c.audit != nil {
c.audit.Record(ctx, e)
}
}
// userExists reports whether the user record exists.
func (c *Core) userExists(userID int64) (bool, error) {
var one int
err := c.db.QueryRow(`SELECT 1 FROM users WHERE user_id = ?`, userID).Scan(&one)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, fmt.Errorf("auth: user lookup: %w", err)
}
return true, nil
}
// querier is the subset shared by *sql.DB and *sql.Tx.
type querier interface {
Exec(query string, args ...any) (sql.Result, error)
QueryRow(query string, args ...any) *sql.Row
}
// newGUID allocates a fresh, unguessable GUID (>=128 bits, D-001): 16 random
// bytes, base64url. It rejects reserved workflow names and any GUID already
// present (identities are never recycled, D-008/AC-2), and retries on
// collision.
func newGUID(q querier) (string, error) {
for attempt := 0; attempt < 8; attempt++ {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("auth: guid entropy: %w", err)
}
guid := base64.RawURLEncoding.EncodeToString(b[:])
if err := routing.ValidateWorkflowName(guid); err != nil {
continue // would collide with a reserved level-1 name
}
var one int
err := q.QueryRow(`SELECT 1 FROM identities WHERE guid = ?`, guid).Scan(&one)
switch {
case errors.Is(err, sql.ErrNoRows):
return guid, nil // free
case err != nil:
return "", fmt.Errorf("auth: guid lookup: %w", err)
}
}
return "", ErrGUIDExhausted
}
// newSessionID returns a 256-bit opaque session id, base64url-encoded.
func newSessionID() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("auth: session entropy: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
// rowScanner is satisfied by *sql.Row and *sql.Rows.
type rowScanner interface {
Scan(dest ...any) error
}
// scanIdentity reads one identity row.
func scanIdentity(row rowScanner) (Identity, error) {
var (
idn Identity
status string
created string
enrolled sql.NullString
revoked sql.NullString
)
err := row.Scan(&idn.ID, &idn.UserID, &idn.GUID, &status, &created, &enrolled, &revoked)
if errors.Is(err, sql.ErrNoRows) {
return Identity{}, ErrIdentityNotFound
}
if err != nil {
return Identity{}, fmt.Errorf("auth: scan identity: %w", err)
}
idn.Status = IdentityStatus(status)
if idn.CreatedAt, err = store.ParseTime(created); err != nil {
return Identity{}, fmt.Errorf("auth: parse identity created_at: %w", err)
}
if idn.EnrolledAt, err = parseNullTime(enrolled); err != nil {
return Identity{}, err
}
if idn.RevokedAt, err = parseNullTime(revoked); err != nil {
return Identity{}, err
}
return idn, nil
}
// scanSession reads one session row.
func scanSession(row rowScanner) (Session, error) {
var (
sess Session
created string
expires string
revoked sql.NullString
clientIP sql.NullString
userAgent sql.NullString
)
err := row.Scan(&sess.ID, &sess.IdentityID, &sess.UserID, &created, &expires, &revoked, &clientIP, &userAgent)
if errors.Is(err, sql.ErrNoRows) {
return Session{}, ErrSessionNotFound
}
if err != nil {
return Session{}, fmt.Errorf("auth: scan session: %w", err)
}
if sess.CreatedAt, err = store.ParseTime(created); err != nil {
return Session{}, fmt.Errorf("auth: parse session created_at: %w", err)
}
if sess.ExpiresAt, err = store.ParseTime(expires); err != nil {
return Session{}, fmt.Errorf("auth: parse session expires_at: %w", err)
}
if sess.RevokedAt, err = parseNullTime(revoked); err != nil {
return Session{}, err
}
sess.ClientIP = clientIP.String
sess.UserAgent = userAgent.String
return sess, nil
}
func parseNullTime(ns sql.NullString) (*time.Time, error) {
if !ns.Valid || ns.String == "" {
return nil, nil
}
t, err := store.ParseTime(ns.String)
if err != nil {
return nil, fmt.Errorf("auth: parse timestamp: %w", err)
}
return &t, nil
}
// identityByID loads one identity by primary key. It lives on Core so both the
// identity and session services can use it.
func (c *Core) identityByID(id int64) (Identity, error) {
row := c.db.QueryRow(`
SELECT identity_id, user_id, guid, status, created_at, enrolled_at, revoked_at
FROM identities WHERE identity_id = ?`, id)
return scanIdentity(row)
}
// nullString maps "" to NULL so the trail distinguishes "absent" from "empty".
func nullString(s string) any {
if s == "" {
return nil
}
return s
}

View file

@ -0,0 +1,151 @@
package auth
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
)
// Credentials is the WebAuthn credential store contract (SPEC-004 §4
// `webauthn_credentials`). Credentials hang off an IDENTITY, never a user
// (D-008): identity rotation destroys them.
type Credentials interface {
// PutWebAuthnCredential stores a credential for an identity (upsert by
// credential id).
PutWebAuthnCredential(ctx context.Context, identityID int64, c webauthn.Credential) error
// WebAuthnCredentials returns every credential of an identity.
WebAuthnCredentials(ctx context.Context, identityID int64) ([]webauthn.Credential, error)
// UpdateWebAuthnSignCount records an authenticator's advanced counter after
// a successful assertion (clone detection).
UpdateWebAuthnSignCount(ctx context.Context, identityID int64, credentialID []byte, signCount uint32) error
// HasWebAuthnCredential reports whether an identity has at least one
// registered passkey — the enrollment two-factor check (SPEC-006 §4 AC-2).
HasWebAuthnCredential(ctx context.Context, identityID int64) (bool, error)
}
var _ Credentials = (*IdentitiesService)(nil)
// PutWebAuthnCredential implements Credentials.
func (s *IdentitiesService) PutWebAuthnCredential(ctx context.Context, identityID int64, c webauthn.Credential) error {
if len(c.ID) == 0 {
return errors.New("auth: webauthn credential id is empty")
}
if _, err := s.identityByID(identityID); err != nil {
return err
}
if _, err := s.db.Exec(
`INSERT INTO webauthn_credentials
(credential_id, identity_id, public_key, sign_count, transports, aaguid, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(credential_id) DO UPDATE SET
public_key = excluded.public_key,
sign_count = excluded.sign_count,
transports = excluded.transports,
aaguid = excluded.aaguid`,
c.ID, identityID, c.PublicKey, int64(c.SignCount),
strings.Join(c.Transports, ","), nullBytes(c.AAGUID),
store.FormatTime(s.clock()),
); err != nil {
return fmt.Errorf("auth: store webauthn credential: %w", err)
}
// No key material in the trail — identifiers only (A9).
s.record(ctx, audit.Event{
Action: "webauthn.credential.put",
Subject: fmt.Sprintf("identity:%d", identityID),
Detail: fmt.Sprintf("credential_id=%s", fingerprint(c.ID)),
})
return nil
}
// WebAuthnCredentials implements Credentials.
func (s *IdentitiesService) WebAuthnCredentials(ctx context.Context, identityID int64) ([]webauthn.Credential, error) {
rows, err := s.db.Query(
`SELECT credential_id, public_key, sign_count, transports, aaguid
FROM webauthn_credentials WHERE identity_id = ? ORDER BY created_at`, identityID,
)
if err != nil {
return nil, fmt.Errorf("auth: load webauthn credentials: %w", err)
}
defer rows.Close()
var out []webauthn.Credential
for rows.Next() {
var (
c webauthn.Credential
signCount int64
transports string
aaguid []byte
)
if err := rows.Scan(&c.ID, &c.PublicKey, &signCount, &transports, &aaguid); err != nil {
return nil, fmt.Errorf("auth: scan webauthn credential: %w", err)
}
c.SignCount = uint32(signCount)
c.AAGUID = aaguid
if transports != "" {
c.Transports = strings.Split(transports, ",")
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("auth: iterate webauthn credentials: %w", err)
}
return out, nil
}
// UpdateWebAuthnSignCount implements Credentials. It is a no-op when the
// credential is unknown — sign-count tracking is best-effort, never a gate.
func (s *IdentitiesService) UpdateWebAuthnSignCount(ctx context.Context, identityID int64, credentialID []byte, signCount uint32) error {
if _, err := s.db.Exec(
`UPDATE webauthn_credentials SET sign_count = ?
WHERE credential_id = ? AND identity_id = ?`,
int64(signCount), credentialID, identityID,
); err != nil {
return fmt.Errorf("auth: update sign count: %w", err)
}
return nil
}
// HasWebAuthnCredential implements Credentials.
func (s *IdentitiesService) HasWebAuthnCredential(ctx context.Context, identityID int64) (bool, error) {
var one int
err := s.db.QueryRow(
`SELECT 1 FROM webauthn_credentials WHERE identity_id = ? LIMIT 1`, identityID,
).Scan(&one)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, fmt.Errorf("auth: webauthn credential lookup: %w", err)
}
return true, nil
}
// nullBytes maps an empty blob to NULL.
func nullBytes(b []byte) any {
if len(b) == 0 {
return nil
}
return b
}
// fingerprint is a short label for an opaque credential id, for the audit trail
// only — the credential id is public, but the trail keeps just a prefix.
func fingerprint(id []byte) string {
if len(id) == 0 {
return ""
}
if len(id) > 8 {
id = id[:8]
}
return fmt.Sprintf("%x", id)
}

View file

@ -0,0 +1,738 @@
// Package flow implements SPEC-006: the GUID-driven enroll/login state machine.
//
// GET /{GUID} renders the enrollment screen (pending, in window) or the login
// screen (active, no session); the POST actions run the WebAuthn and TOTP
// ceremonies (`do=auth&action=…`) and mint, rotate or revoke the session.
//
// Two factors only — Passkey (primary) + TOTP (fallback) (D-005). Enrollment
// completes ONLY when BOTH a passkey credential AND a verified TOTP code are
// present (AC-2); outside the bounded window it is refused (A1/AC-4). The
// /setup wizard is the one exception (D-041/TOTPOnlyEnroll): it enrolls TOTP
// only, because a loopback passkey is unusable at the public domain — a nudged
// TOTP login later adds one (§8).
//
// The package holds no HTTP routing and no storage of its own: it drives the
// core auth services and renders the two screens. The door verifies the
// pre-session CSRF token before invoking an action (A15).
//
// Spec: docs/specs/spec-006-enroll-login.md.
package flow
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// CeremonyCookieName carries the signed WebAuthn session data between begin and
// finish (no server-side table needed).
const CeremonyCookieName = "myday_webauthn"
// Deps assembles the flow's dependencies.
type Deps struct {
// Identities resolves GUIDs, activates identities and checks the window.
Identities auth.Identities
// Credentials stores the WebAuthn credentials.
Credentials auth.Credentials
// Sessions mints/rotates/revokes the credential.
Sessions auth.Sessions
// Users loads the display name for the WebAuthn user entity.
Users users.Users
// WebAuthn is the ceremony seam (fake in tests).
WebAuthn webauthn.Service
// TOTP issues and verifies the second factor.
TOTP totp.Service
// CSRF issues/verifies the pre-session double-submit token and lends its
// signer for the ceremony cookie.
CSRF *httpx.PreSessionCSRF
// Audit receives the enroll/login/logout events (optional).
Audit audit.Audit
// SuccessPath, when set, overrides where a successful enroll/login lands
// (default "/{guid}/home"). SPEC-005's wizard injects "/setup" so the
// bootstrap stays on the wizard until it is finished.
SuccessPath func(guid string) string
// TOTPOnlyEnroll activates a PENDING identity on a verified TOTP code
// ALONE (D-041). The /setup wizard sets it: a passkey registered over
// plain-HTTP loopback binds RP ID 127.0.0.1 and is unusable at the public
// domain (O-13). Default false keeps SPEC-006 §4's mandatory two factors.
TOTPOnlyEnroll bool
// SuppressNudge, when non-nil and true, disables the passkey nudge
// (SPEC-006 §8). The door wires it to setup mode, where a passkey is
// useless. nil = the nudge runs (subject to the other conditions).
SuppressNudge func() bool
// Secure marks cookies Secure (https).
Secure bool
// Logger receives structured logs. Defaults to slog.Default().
Logger *slog.Logger
// Now is the clock (tests).
Now func() time.Time
}
// Actions is the SPEC-006 flow engine.
type Actions struct {
d Deps
}
// New validates and builds the flow.
func New(d Deps) (*Actions, error) {
switch {
case d.Identities == nil:
return nil, errors.New("flow: Identities is required")
case d.Credentials == nil:
return nil, errors.New("flow: Credentials is required")
case d.Sessions == nil:
return nil, errors.New("flow: Sessions is required")
case d.WebAuthn == nil:
return nil, errors.New("flow: WebAuthn is required")
case d.TOTP == nil:
return nil, errors.New("flow: TOTP is required")
case d.CSRF == nil:
return nil, errors.New("flow: CSRF is required")
}
if d.Logger == nil {
d.Logger = slog.Default()
}
if d.Now == nil {
d.Now = time.Now
}
return &Actions{d: d}, nil
}
// ── screens ─────────────────────────────────────────────────────────────────
// EnrollScreen renders the enrollment screen for a pending GUID (SPEC-006 §4).
func (a *Actions) EnrollScreen(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, ok, err := a.pendingInWindow(ctx, guid)
if err != nil {
return err
}
if !ok {
// Unknown, revoked, already active or past the window. An already-active
// identity is sent to the app; everything else is a generic 404 (A1).
if idn.Status == auth.StatusActive {
http.Redirect(w, r, "/"+guid+"/home", http.StatusFound)
return nil
}
writeError(w, http.StatusNotFound, "not found")
return nil
}
token, err := a.d.CSRF.Ensure(w, r)
if err != nil {
return err
}
return renderHTML(w, http.StatusOK, enrollPage(guid, token))
}
// LoginScreen renders the login screen for an active GUID with no session
// (SPEC-006 §5). It answers 200 — a rendered page is a successful response;
// only a FAILED credential attempt is 401/400 (F-1, SPEC-005 §7).
func (a *Actions) LoginScreen(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolve(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
if idn.Status != auth.StatusActive {
writeError(w, http.StatusNotFound, "not found")
return nil
}
token, err := a.d.CSRF.Ensure(w, r)
if err != nil {
return err
}
return renderHTML(w, http.StatusOK, loginPage(guid, token))
}
// ── enrollment actions ──────────────────────────────────────────────────────
// EnrollBegin starts the WebAuthn registration ceremony (SPEC-006 §4).
func (a *Actions) EnrollBegin(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, ok, err := a.pendingInWindow(ctx, guid)
if err != nil {
return err
}
if !ok {
writeError(w, http.StatusNotFound, "not found")
return nil
}
creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID)
if err != nil {
return err
}
begin, err := a.d.WebAuthn.BeginRegistration(ctx, a.webauthnUser(ctx, idn), creds)
if err != nil {
// A ceremony error is a client-side problem, not a server fault.
a.d.Logger.Warn("flow: begin registration failed", "error", err)
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
if err := a.putCeremony(w, begin); err != nil {
return err
}
return writeJSON(w, http.StatusOK, begin.Options)
}
// EnrollFinish completes the WebAuthn registration and stores the credential.
// It does NOT enroll the identity — TOTP must still follow (AC-2).
func (a *Actions) EnrollFinish(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, ok, err := a.pendingInWindow(ctx, guid)
if err != nil {
return err
}
if !ok {
writeError(w, http.StatusNotFound, "not found")
return nil
}
begin, err := a.takeCeremony(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
cred, err := a.d.WebAuthn.FinishRegistration(ctx, a.webauthnUser(ctx, idn), begin, r)
if err != nil {
a.d.Logger.Warn("flow: finish registration failed", "error", err)
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
if err := a.d.Credentials.PutWebAuthnCredential(ctx, idn.ID, cred); err != nil {
return err
}
a.audit(ctx, "enroll.passkey", idn, "passkey registered")
return writeJSON(w, http.StatusOK, []byte(`{"status":"credential-stored"}`))
}
// TOTPBegin issues (and encrypts) the TOTP secret and returns the otpauth URL.
func (a *Actions) TOTPBegin(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, ok, err := a.pendingInWindow(ctx, guid)
if err != nil {
return err
}
if !ok {
writeError(w, http.StatusNotFound, "not found")
return nil
}
issued, err := a.d.TOTP.Begin(ctx, idn.ID, idn.GUID)
if err != nil {
return err
}
a.audit(ctx, "enroll.totp.begin", idn, "secret issued")
payload, err := json.Marshal(map[string]string{"otpauth_url": issued.URL})
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, payload)
}
// TOTPVerify verifies a code. For a PENDING identity it completes enrollment —
// but ONLY when a passkey credential already exists (two-factor, AC-2), unless
// TOTPOnlyEnroll is set (the /setup wizard, D-041). For an ACTIVE identity it
// is the login fallback (SPEC-006 §5) and may trigger the passkey nudge (§8).
func (a *Actions) TOTPVerify(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolve(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
code := r.FormValue("code")
switch idn.Status {
case auth.StatusActive:
// Login fallback (Passkey is primary; this is device-loss recovery).
if err := a.d.TOTP.Verify(ctx, idn.ID, code); err != nil {
a.failAuth(w)
return nil
}
a.audit(ctx, "login.totp", idn, "totp")
// The passkey nudge (SPEC-006 §8): a TOTP login with no passkey, on a
// non-loopback origin, is nudged to add one. A TOTP login WITH a
// passkey is never nudged; nor is a passkey login (login.finish).
target := ""
if a.shouldNudge(ctx, r, idn) {
target = nudgePath(idn.GUID)
}
return a.establishSessionAt(w, r, idn, target)
case auth.StatusPendingEnrollment:
inWindow, err := a.d.Identities.WithinEnrollmentWindow(ctx, idn.ID)
if err != nil {
return err
}
if !inWindow {
writeError(w, http.StatusNotFound, "not found")
return nil
}
// Both factors are mandatory for a normal enrollment (AC-2). The
// /setup wizard is the exception: it enrolls TOTP only (D-041), since
// a loopback-enrolled passkey is unusable at the public domain.
detail := "passkey+totp"
if a.d.TOTPOnlyEnroll {
detail = "totp"
} else {
hasPasskey, err := a.d.Credentials.HasWebAuthnCredential(ctx, idn.ID)
if err != nil {
return err
}
if !hasPasskey {
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
}
if err := a.d.TOTP.Verify(ctx, idn.ID, code); err != nil {
a.failAuth(w)
return nil
}
if err := a.d.Identities.Activate(ctx, idn.ID); err != nil {
return err
}
a.audit(ctx, "enroll.complete", idn, detail)
return a.establishSession(w, r, idn)
default: // revoked — tombstoned
writeError(w, http.StatusNotFound, "not found")
return nil
}
}
// ── login actions ───────────────────────────────────────────────────────────
// LoginBegin starts the WebAuthn assertion ceremony (SPEC-006 §5).
func (a *Actions) LoginBegin(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolveActive(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID)
if err != nil {
return err
}
if len(creds) == 0 {
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
begin, err := a.d.WebAuthn.BeginLogin(ctx, a.webauthnUser(ctx, idn), creds)
if err != nil {
a.d.Logger.Warn("flow: begin login failed", "error", err)
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
if err := a.putCeremony(w, begin); err != nil {
return err
}
return writeJSON(w, http.StatusOK, begin.Options)
}
// LoginFinish verifies the assertion and mints (or rotates) the session.
func (a *Actions) LoginFinish(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolveActive(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
begin, err := a.takeCeremony(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID)
if err != nil {
return err
}
cred, err := a.d.WebAuthn.FinishLogin(ctx, a.webauthnUser(ctx, idn), begin, r, creds)
if err != nil {
a.d.Logger.Warn("flow: finish login failed", "error", err)
a.failAuth(w)
return nil
}
if err := a.d.Credentials.UpdateWebAuthnSignCount(ctx, idn.ID, cred.ID, cred.SignCount); err != nil {
return err
}
a.audit(ctx, "login.passkey", idn, "passkey")
return a.establishSession(w, r, idn)
}
// Logout revokes the current session and clears the cookie (SPEC-006 §5).
func (a *Actions) Logout(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
if c, err := r.Cookie(httpx.SessionCookieName); err == nil && c.Value != "" {
if err := a.d.Sessions.Revoke(ctx, c.Value); err != nil {
a.d.Logger.Warn("flow: logout revoke failed", "error", err)
}
}
httpx.ClearSessionCookie(w, httpx.SessionCookieName, a.d.Secure)
http.Redirect(w, r, "/home", http.StatusSeeOther)
return nil
}
// ── internals ───────────────────────────────────────────────────────────────
// resolve loads the identity for a GUID. A missing or tombstoned GUID reports
// auth.ErrIdentityNotFound (mapped to 404); any other error is a real fault and
// is propagated (500).
func (a *Actions) resolve(ctx context.Context, guid string) (auth.Identity, error) {
if guid == "" {
return auth.Identity{}, auth.ErrIdentityNotFound
}
idn, err := a.d.Identities.ByGUID(ctx, guid)
if err != nil {
return auth.Identity{}, err
}
if idn.Status == auth.StatusRevoked {
return auth.Identity{}, auth.ErrIdentityNotFound
}
return idn, nil
}
// resolveActive resolves a GUID that must map to an ACTIVE identity.
func (a *Actions) resolveActive(ctx context.Context, guid string) (auth.Identity, error) {
idn, err := a.resolve(ctx, guid)
if err != nil {
return auth.Identity{}, err
}
if idn.Status != auth.StatusActive {
return auth.Identity{}, auth.ErrIdentityNotFound
}
return idn, nil
}
// pendingInWindow resolves a GUID that must map to a pending identity still
// inside its bounded enrollment window (A1). The returned identity is also
// present when ok=false so the caller can distinguish "already active".
func (a *Actions) pendingInWindow(ctx context.Context, guid string) (auth.Identity, bool, error) {
idn, err := a.resolve(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
return auth.Identity{}, false, nil
}
return auth.Identity{}, false, err
}
if idn.Status != auth.StatusPendingEnrollment {
return idn, false, nil
}
inWindow, err := a.d.Identities.WithinEnrollmentWindow(ctx, idn.ID)
if err != nil {
return auth.Identity{}, false, err
}
return idn, inWindow, nil
}
// webauthnUser builds the ceremony user for an identity.
func (a *Actions) webauthnUser(ctx context.Context, idn auth.Identity) webauthn.User {
display := idn.GUID
if a.d.Users != nil {
if u, err := a.d.Users.Get(ctx, idn.UserID); err == nil && u.DisplayName != "" {
display = u.DisplayName
}
}
return webauthn.User{
ID: handleFor(idn.UserID),
Name: idn.GUID,
DisplayName: display,
}
}
// handleFor derives the WebAuthn user handle from the stable user id.
func handleFor(userID int64) []byte {
return []byte(fmt.Sprintf("myday-user-%d", userID))
}
// establishSession mints a session on success, rotating a live one when the
// request already carries a valid session for the same identity (fixation
// defence, SPEC-006 §5), then redirects to the app.
func (a *Actions) establishSession(w http.ResponseWriter, r *http.Request, idn auth.Identity) error {
return a.establishSessionAt(w, r, idn, "")
}
// establishSessionAt is establishSession with an optional explicit landing
// path (the passkey nudge overrides the default success target, SPEC-006 §8).
func (a *Actions) establishSessionAt(w http.ResponseWriter, r *http.Request, idn auth.Identity, target string) error {
sess, err := a.issueSession(r, idn)
if err != nil {
return err
}
httpx.SetSessionCookie(w, httpx.SessionCookieName, sess.ID, a.d.Secure)
if target == "" {
target = a.successTarget(idn.GUID)
}
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
// successTarget is where a successful flow lands: the configured SuccessPath
// (the wizard's "/setup") or the user's dashboard.
func (a *Actions) successTarget(guid string) string {
if a.d.SuccessPath != nil {
if p := a.d.SuccessPath(guid); p != "" {
return p
}
}
return "/" + guid + "/home"
}
// nudgePath is where a TOTP login lands when the passkey nudge applies
// (SPEC-006 §8): the session-gated passkey screen carried by the `auth` module.
func nudgePath(guid string) string { return "/" + guid + "/auth/passkey" }
// shouldNudge reports whether the passkey nudge applies to a TOTP login
// (SPEC-006 §8): the identity has NO passkey, the user has not opted out, the
// origin is NOT loopback, and the nudge is not suppressed (setup mode). Any
// lookup error fails safe — no nudge.
func (a *Actions) shouldNudge(ctx context.Context, r *http.Request, idn auth.Identity) bool {
if a.d.SuppressNudge != nil && a.d.SuppressNudge() {
return false
}
if httpx.IsLoopbackHost(r.Host) {
return false
}
if a.d.Users == nil {
return false
}
optOut, err := a.d.Users.PasskeyOptOut(ctx, idn.UserID)
if err != nil {
a.d.Logger.Warn("flow: passkey opt-out lookup failed", "error", err)
return false
}
if optOut {
return false
}
hasPasskey, err := a.d.Credentials.HasWebAuthnCredential(ctx, idn.ID)
if err != nil {
a.d.Logger.Warn("flow: credential lookup failed", "error", err)
return false
}
return !hasPasskey
}
// ── the passkey nudge (SPEC-006 §8, D-041) ──────────────────────────────────
// PasskeyPrompt renders the passkey nudge screen. It is a session-gated ROUTE
// on the `auth` module (GET /{GUID}/auth/passkey) — the redirect target of a
// nudged TOTP login. It BEGINS the WebAuthn registration ceremony and embeds
// the options for the browser, so the single `passkey.add` action completes it.
//
// The door has already validated the session, so the forms carry the
// SESSION-bound CSRF token (SPEC-003 §6) — A15 does not apply.
func (a *Actions) PasskeyPrompt(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolveActive(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
creds, err := a.d.Credentials.WebAuthnCredentials(ctx, idn.ID)
if err != nil {
return err
}
begin, err := a.d.WebAuthn.BeginRegistration(ctx, a.webauthnUser(ctx, idn), creds)
if err != nil {
a.d.Logger.Warn("flow: begin passkey registration failed", "error", err)
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
if err := a.putCeremony(w, begin); err != nil {
return err
}
return renderHTML(w, http.StatusOK, passkeyPage(guid, httpx.TokenFromRequest(r), string(begin.Options)))
}
// PasskeyAdd completes the WebAuthn registration begun by PasskeyPrompt and
// stores the credential (audited, AC-11). It is session-gated by the door.
func (a *Actions) PasskeyAdd(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolveActive(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
begin, err := a.takeCeremony(w, r)
if err != nil {
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
cred, err := a.d.WebAuthn.FinishRegistration(ctx, a.webauthnUser(ctx, idn), begin, r)
if err != nil {
a.d.Logger.Warn("flow: finish passkey registration failed", "error", err)
writeError(w, http.StatusBadRequest, "bad request")
return nil
}
if err := a.d.Credentials.PutWebAuthnCredential(ctx, idn.ID, cred); err != nil {
return err
}
a.audit(ctx, "passkey.add", idn, "passkey registered from the login nudge")
http.Redirect(w, r, a.successTarget(idn.GUID), http.StatusSeeOther)
return nil
}
// PasskeyLater skips the nudge this time. Nothing is persisted (SPEC-006 §8) —
// the user is nudged again at the next TOTP login.
func (a *Actions) PasskeyLater(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolveActive(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
http.Redirect(w, r, a.successTarget(idn.GUID), http.StatusSeeOther)
return nil
}
// PasskeyNever records the persistent opt-out (users.passkey_opt_out, D-041).
// The write is audited by the users service (A11); later TOTP logins never
// nudge again. The marker survives identity rotation.
func (a *Actions) PasskeyNever(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
idn, err := a.resolveActive(ctx, guid)
if err != nil {
if errors.Is(err, auth.ErrIdentityNotFound) {
writeError(w, http.StatusNotFound, "not found")
return nil
}
return err
}
if a.d.Users == nil {
return errors.New("flow: Users is required for the passkey opt-out")
}
if err := a.d.Users.SetPasskeyOptOut(ctx, idn.UserID, true); err != nil {
return err
}
http.Redirect(w, r, a.successTarget(idn.GUID), http.StatusSeeOther)
return nil
}
// issueSession rotates a matching live session, else mints a fresh one.
func (a *Actions) issueSession(r *http.Request, idn auth.Identity) (auth.Session, error) {
ctx := r.Context()
var ip string
if m, ok := audit.MetaFrom(ctx); ok {
ip = m.ClientIP
}
if c, err := r.Cookie(httpx.SessionCookieName); err == nil && c.Value != "" {
if old, err := a.d.Sessions.Validate(ctx, c.Value); err == nil && old.IdentityID == idn.ID {
return a.d.Sessions.Rotate(ctx, c.Value)
}
}
return a.d.Sessions.Mint(ctx, idn.ID, ip, r.UserAgent())
}
// putCeremony signs the WebAuthn state and stores it in a short-lived cookie.
func (a *Actions) putCeremony(w http.ResponseWriter, b *webauthn.Begin) error {
raw, err := json.Marshal(b)
if err != nil {
return fmt.Errorf("flow: marshal ceremony: %w", err)
}
http.SetCookie(w, &http.Cookie{
Name: CeremonyCookieName,
Value: a.d.CSRF.Signer().Sign(string(raw)),
Path: "/",
HttpOnly: true,
Secure: a.d.Secure,
SameSite: http.SameSiteLaxMode,
MaxAge: 300,
})
return nil
}
// takeCeremony reads and clears the ceremony cookie.
func (a *Actions) takeCeremony(w http.ResponseWriter, r *http.Request) (*webauthn.Begin, error) {
c, err := r.Cookie(CeremonyCookieName)
if err != nil || c.Value == "" {
return nil, errors.New("flow: missing ceremony state")
}
raw, ok := a.d.CSRF.Signer().Unsign(c.Value)
if !ok {
return nil, errors.New("flow: invalid ceremony state")
}
var b webauthn.Begin
if err := json.Unmarshal([]byte(raw), &b); err != nil {
return nil, fmt.Errorf("flow: decode ceremony: %w", err)
}
// Single use.
http.SetCookie(w, &http.Cookie{
Name: CeremonyCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: a.d.Secure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
return &b, nil
}
// audit records a flow event. The actor is the affected user (they proved it);
// the identity is the subject.
func (a *Actions) audit(ctx context.Context, action string, idn auth.Identity, detail string) {
if a.d.Audit == nil {
return
}
a.d.Audit.Record(ctx, audit.Event{
ActorUserID: idn.UserID,
Action: action,
Subject: fmt.Sprintf("identity:%d", idn.ID),
Detail: detail,
})
}
// failAuth answers with a terse, generic 401 — never revealing which factor
// failed, whether the identity exists, or why (A3).
func (a *Actions) failAuth(w http.ResponseWriter) {
writeError(w, http.StatusUnauthorized, "unauthorized")
}
// writeError writes a terse plain-text error.
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
_, _ = w.Write([]byte(msg + "\n"))
}
// writeJSON writes a JSON body. Every ceremony response is `no-store` (F-2):
// the TOTP response embeds the otpauth URL, which contains the secret.
func writeJSON(w http.ResponseWriter, status int, body []byte) error {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, err := w.Write(body)
return err
}

View file

@ -0,0 +1,129 @@
package flow
import (
"html"
"net/http"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
)
// renderHTML writes a server-rendered page inside the core shell (SPEC-009 §4).
// The door renders no dynamic HTML itself (SPEC-003 §4); these are the flow's
// hand-off targets.
//
// F-2: the enrollment screen carries the TOTP ceremony and the login screen a
// session — neither may be cached, so every flow response is `no-store`.
func renderHTML(w http.ResponseWriter, status int, page web.Page) error {
w.Header().Set("Cache-Control", "no-store")
return web.Render(w, status, page)
}
// enrollPage is the enrollment screen (SPEC-006 §4). The passkey ceremony is
// driven by the same-origin WebAuthn client (SPEC-009 §5) from the data-*
// contract on <main>; the TOTP forms work without JavaScript. Every form
// carries the signed pre-session CSRF token (A15), which the client also reads
// from the shell's <meta name="csrf-token">.
func enrollPage(guid, token string) web.Page {
g := html.EscapeString(guid)
c := html.EscapeString(token)
action := func(name string) string {
return `"/` + g + `?do=auth&amp;action=` + name + `"`
}
return web.Page{
Title: "Enrollment",
GUID: guid,
Guest: true,
CSRF: token,
MainAttrs: web.Attrs(`data-webauthn="enroll"` +
` data-begin=` + action("enroll.begin") +
` data-finish=` + action("enroll.finish")),
Content: web.Markup(`<h1>Enrollment</h1>
<p>Two factors are required: a passkey <strong>and</strong> an authenticator app.</p>
<section aria-label="passkey">
<h2>1. Passkey</h2>
<p id="passkey-status" data-webauthn-status>Register a passkey for this device.</p>
<button type="button" data-webauthn-start>Register a passkey</button>
<noscript><p>Registering a passkey needs JavaScript in the browser. You can still set up your authenticator app below, and add a passkey later at sign-in.</p></noscript>
</section>
<section aria-label="totp">
<h2>2. Authenticator app</h2>
<form method="post" action=` + action("totp.begin") + `>
<input type="hidden" name="csrf" value="` + c + `">
<button type="submit" class="secondary">Set up authenticator</button></form>
<form method="post" action=` + action("totp.verify") + `>
<input type="hidden" name="csrf" value="` + c + `">
<label>Code <input name="code" inputmode="numeric" autocomplete="one-time-code"></label>
<button type="submit">Verify code</button></form>
</section>`),
}
}
// passkeyPage is the post-login passkey nudge (SPEC-006 §8, D-041). The
// WebAuthn registration is begun server-side and the options are embedded for
// the browser; the single `passkey.add` action completes it. The two skip forms
// work without JavaScript. Every form carries the SESSION-bound CSRF token (a
// session exists here — A15 does not apply).
func passkeyPage(guid, token, options string) web.Page {
g := html.EscapeString(guid)
c := html.EscapeString(token)
action := func(name string) string {
return `"/` + g + `?do=auth&amp;action=` + name + `"`
}
return web.Page{
Title: "Add a passkey",
GUID: guid,
CSRF: token,
Flash: "Skip the one-time code next time by adding a passkey for this device.",
MainAttrs: web.Attrs(`data-webauthn="passkey"` +
` data-finish=` + action("passkey.add") +
` data-options="` + html.EscapeString(options) + `"`),
Content: web.Markup(`<h1>Add a passkey</h1>
<section aria-label="passkey">
<p id="passkey-status" data-webauthn-status>Use this device's screen lock or security key.</p>
<button type="button" data-webauthn-start>Add a passkey</button>
<noscript><p>Adding a passkey needs JavaScript in the browser.</p></noscript>
</section>
<section aria-label="skip">
<h2>Not now?</h2>
<form method="post" action=` + action("passkey.later") + `>
<input type="hidden" name="csrf" value="` + c + `">
<button type="submit" class="secondary">Not now</button></form>
<form method="post" action=` + action("passkey.never") + `>
<input type="hidden" name="csrf" value="` + c + `">
<button type="submit" class="secondary">Don&#39;t ask again</button></form>
</section>`),
}
}
// loginPage is the login screen (SPEC-006 §5): Passkey primary, TOTP fallback.
func loginPage(guid, token string) web.Page {
g := html.EscapeString(guid)
c := html.EscapeString(token)
action := func(name string) string {
return `"/` + g + `?do=auth&amp;action=` + name + `"`
}
return web.Page{
Title: "Sign in",
GUID: guid,
Guest: true,
CSRF: token,
MainAttrs: web.Attrs(`data-webauthn="login"` +
` data-begin=` + action("login.begin") +
` data-finish=` + action("login.finish")),
Content: web.Markup(`<h1>Sign in</h1>
<p>Use a passkey for this device, or your authenticator code.</p>
<section aria-label="passkey">
<h2>Passkey</h2>
<p id="passkey-status" data-webauthn-status>Sign in with a passkey.</p>
<button type="button" data-webauthn-start>Use a passkey</button>
<noscript><p>Passkey sign-in needs JavaScript in the browser. Use your authenticator code below.</p></noscript>
</section>
<section aria-label="totp">
<h2>Authenticator code</h2>
<form method="post" action=` + action("totp.verify") + `>
<input type="hidden" name="csrf" value="` + c + `">
<label>Code <input name="code" inputmode="numeric" autocomplete="one-time-code"></label>
<button type="submit">Use an authenticator code</button></form>
</section>`),
}
}

View file

@ -0,0 +1,285 @@
package auth
import (
"context"
"database/sql"
"errors"
"fmt"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
)
// IdentitiesService implements Identities (and the routing.Resolver contract).
type IdentitiesService struct {
*Core
}
var (
_ Identities = (*IdentitiesService)(nil)
)
// Mint implements Identities.Mint (SPEC-004 §5).
//
// A user has at most one non-revoked identity at a time, so Mint refuses if one
// still exists — recovery goes through Rotate, which retires the old one first
// (D-007).
func (s *IdentitiesService) Mint(ctx context.Context, userID int64) (int64, string, error) {
ok, err := s.userExists(userID)
if err != nil {
return 0, "", err
}
if !ok {
return 0, "", fmt.Errorf("%w: user:%d", ErrUnknownUser, userID)
}
var live int
if err := s.db.QueryRow(
`SELECT count(*) FROM identities WHERE user_id = ? AND status <> 'revoked'`, userID,
).Scan(&live); err != nil {
return 0, "", fmt.Errorf("auth: count live identities: %w", err)
}
if live > 0 {
return 0, "", fmt.Errorf("%w: user:%d", ErrIdentityExists, userID)
}
guid, err := newGUID(s.db)
if err != nil {
return 0, "", err
}
res, err := s.db.Exec(
`INSERT INTO identities (user_id, guid, status, created_at)
VALUES (?, ?, ?, ?)`,
userID, guid, string(StatusPendingEnrollment), store.FormatTime(s.clock()),
)
if err != nil {
return 0, "", fmt.Errorf("auth: insert identity: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, "", fmt.Errorf("auth: identity id: %w", err)
}
// The ACTOR is the creator/approver and comes from the audit.Meta on ctx
// (ruling R-4); the affected user is the SUBJECT. The event leaves
// ActorUserID unset so audit.Merge fills it from the context.
s.record(ctx, audit.Event{
Action: "identity.mint",
Subject: fmt.Sprintf("user:%d", userID),
Detail: fmt.Sprintf("identity:%d status=pending_enrollment", id),
})
return id, guid, nil
}
// Activate implements Identities.Activate: pending_enrollment -> active.
func (s *IdentitiesService) Activate(ctx context.Context, id int64) error {
idn, err := s.identityByID(id)
if err != nil {
return err
}
if idn.Status != StatusPendingEnrollment {
return fmt.Errorf("%w: identity:%d is %s", ErrIdentityNotPending, id, idn.Status)
}
now := store.FormatTime(s.clock())
if _, err := s.db.Exec(
`UPDATE identities SET status = ?, enrolled_at = ? WHERE identity_id = ?`,
string(StatusActive), now, id,
); err != nil {
// The partial unique index rejects a second active identity (AC-1).
return fmt.Errorf("auth: activate identity: %w", err)
}
// The actor is the context actor (R-4); the affected user is the subject.
s.record(ctx, audit.Event{
Action: "identity.activate",
Subject: fmt.Sprintf("user:%d", idn.UserID),
Detail: fmt.Sprintf("identity:%d status=active", id),
})
return nil
}
// Rotate implements Identities.Rotate (D-007, AC-5): revoke the current
// identity AND all its sessions, then mint a fresh pending identity with a NEW
// GUID. The old GUID is tombstoned forever.
func (s *IdentitiesService) Rotate(ctx context.Context, userID int64) (string, error) {
ok, err := s.userExists(userID)
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("%w: user:%d", ErrUnknownUser, userID)
}
now := store.FormatTime(s.clock())
tx, err := s.db.Begin()
if err != nil {
return "", fmt.Errorf("auth: begin rotate: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Retire the current identity, if any.
var oldID int64
err = tx.QueryRow(
`SELECT identity_id FROM identities
WHERE user_id = ? AND status <> 'revoked'
ORDER BY identity_id DESC LIMIT 1`, userID,
).Scan(&oldID)
switch {
case errors.Is(err, sql.ErrNoRows):
// nothing to revoke
case err != nil:
return "", fmt.Errorf("auth: find current identity: %w", err)
default:
if _, err := tx.Exec(
`UPDATE identities SET status = ?, revoked_at = ? WHERE identity_id = ?`,
string(StatusRevoked), now, oldID,
); err != nil {
return "", fmt.Errorf("auth: revoke old identity: %w", err)
}
if _, err := tx.Exec(
`UPDATE sessions SET revoked_at = ? WHERE identity_id = ? AND revoked_at IS NULL`,
now, oldID,
); err != nil {
return "", fmt.Errorf("auth: revoke old sessions: %w", err)
}
}
// Mint the replacement.
guid, err := newGUID(tx)
if err != nil {
return "", err
}
if _, err := tx.Exec(
`INSERT INTO identities (user_id, guid, status, created_at)
VALUES (?, ?, ?, ?)`,
userID, guid, string(StatusPendingEnrollment), now,
); err != nil {
return "", fmt.Errorf("auth: insert rotated identity: %w", err)
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("auth: commit rotate: %w", err)
}
s.record(ctx, audit.Event{
Action: "identity.rotate",
Subject: fmt.Sprintf("user:%d", userID),
Detail: "old identity revoked; new GUID minted",
})
return guid, nil
}
// Revoke implements Identities.Revoke: revoke the identity and every session
// it minted.
func (s *IdentitiesService) Revoke(ctx context.Context, id int64) error {
idn, err := s.identityByID(id)
if err != nil {
return err
}
if idn.Status == StatusRevoked {
return nil // idempotent
}
now := store.FormatTime(s.clock())
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("auth: begin revoke: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(
`UPDATE identities SET status = ?, revoked_at = ? WHERE identity_id = ?`,
string(StatusRevoked), now, id,
); err != nil {
return fmt.Errorf("auth: revoke identity: %w", err)
}
if _, err := tx.Exec(
`UPDATE sessions SET revoked_at = ? WHERE identity_id = ? AND revoked_at IS NULL`,
now, id,
); err != nil {
return fmt.Errorf("auth: revoke sessions: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("auth: commit revoke: %w", err)
}
s.record(ctx, audit.Event{
Action: "identity.revoke",
Subject: fmt.Sprintf("user:%d", idn.UserID),
Detail: fmt.Sprintf("identity:%d status=revoked", id),
})
return nil
}
// ByGUID implements Identities.ByGUID. Unlike a revoked GUID — which is
// tombstoned and looks like "not found" to the door — a revoked identity is
// still returned here so callers can distinguish and audit it.
//
// It takes a context for symmetry with the rest of the service contract
// (ruling R-1); the lookup itself is read-only.
func (s *IdentitiesService) ByGUID(ctx context.Context, guid string) (Identity, error) {
return s.byGUID(guid)
}
// byGUID is the context-free read behind ByGUID and the routing.Resolver
// contract (which cannot take a context — it is part of the pure model,
// SPEC-002 §3.1).
func (s *IdentitiesService) byGUID(guid string) (Identity, error) {
if guid == "" {
return Identity{}, ErrIdentityNotFound
}
row := s.db.QueryRow(`
SELECT identity_id, user_id, guid, status, created_at, enrolled_at, revoked_at
FROM identities WHERE guid = ?`, guid)
return scanIdentity(row)
}
// ByUserID returns the user's current (non-revoked) identity — the newest row
// that is not a tombstone. It is the wizard's lookup (SPEC-005 §4): the step is
// derived from the admin's identity status. Revoked rows stay as tombstones
// (D-008) and are never selected.
func (s *IdentitiesService) ByUserID(ctx context.Context, userID int64) (Identity, error) {
row := s.db.QueryRow(`
SELECT identity_id, user_id, guid, status, created_at, enrolled_at, revoked_at
FROM identities
WHERE user_id = ? AND status <> 'revoked'
ORDER BY identity_id DESC LIMIT 1`, userID)
return scanIdentity(row)
}
// GUID implements the routing.Resolver contract (SPEC-002 §3.1): it answers
// found/enrolled for the pure model. A revoked (tombstoned) GUID is reported as
// not found so the door returns 404 (SPEC-006 §3).
//
// It deliberately takes NO context: it is an implementation of the pure
// model's injected interface and never writes audit.
func (s *IdentitiesService) GUID(guid string) (found, enrolled bool) {
idn, err := s.byGUID(guid)
if err != nil {
return false, false
}
switch idn.Status {
case StatusActive:
return true, true
case StatusPendingEnrollment:
return true, false
default: // revoked
return false, false
}
}
// WithinEnrollmentWindow reports whether a pending identity is still inside its
// bounded invite window (SPEC-004 §4, A1). A non-pending identity is false.
func (s *IdentitiesService) WithinEnrollmentWindow(ctx context.Context, id int64) (bool, error) {
idn, err := s.identityByID(id)
if err != nil {
return false, err
}
if idn.Status != StatusPendingEnrollment {
return false, nil
}
return idn.CreatedAt.Add(s.enrollWindow).After(s.clock()), nil
}

View file

@ -0,0 +1,73 @@
package auth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
)
// Sealer is the encryption seam for TOTP secrets at rest (A9). The key comes
// from config later (D-035); this package only defines the mechanism.
//
// A Sealer must return ciphertext that never equals — and never contains — the
// plaintext.
type Sealer interface {
Seal(plaintext []byte) ([]byte, error)
Open(ciphertext []byte) ([]byte, error)
}
// KeySize is the AES-256 key length required by AEADSealer.
const KeySize = 32
// AEADSealer encrypts with AES-256-GCM. The stored blob is
//
// nonce || ciphertext || tag
//
// A fresh random nonce is generated on every Seal, so sealing the same secret
// twice yields different ciphertext.
type AEADSealer struct {
aead cipher.AEAD
}
// NewAEADSealer builds a Sealer from a 32-byte key.
func NewAEADSealer(key []byte) (*AEADSealer, error) {
if len(key) != KeySize {
return nil, fmt.Errorf("auth: TOTP encryption key must be %d bytes, got %d", KeySize, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("auth: new cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("auth: new GCM: %w", err)
}
return &AEADSealer{aead: gcm}, nil
}
// Seal encrypts plaintext.
func (s *AEADSealer) Seal(plaintext []byte) ([]byte, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("auth: seal nonce: %w", err)
}
// Seal appends to nonce, producing nonce || ciphertext||tag.
return s.aead.Seal(nonce, nonce, plaintext, nil), nil
}
// Open decrypts a blob produced by Seal.
func (s *AEADSealer) Open(ciphertext []byte) ([]byte, error) {
ns := s.aead.NonceSize()
if len(ciphertext) < ns {
return nil, ErrInvalidCiphertext
}
nonce, ct := ciphertext[:ns], ciphertext[ns:]
plaintext, err := s.aead.Open(nil, nonce, ct, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidCiphertext, err)
}
return plaintext, nil
}
var _ Sealer = (*AEADSealer)(nil)

View file

@ -0,0 +1,64 @@
package auth_test
import (
"bytes"
"errors"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
)
func TestAEADSealerRoundTrip(t *testing.T) {
key := bytes.Repeat([]byte{0x42}, auth.KeySize)
sealer, err := auth.NewAEADSealer(key)
if err != nil {
t.Fatalf("NewAEADSealer: %v", err)
}
plaintext := []byte("JBSWY3DPEHPK3PXP")
sealed, err := sealer.Seal(plaintext)
if err != nil {
t.Fatalf("Seal: %v", err)
}
if bytes.Contains(sealed, plaintext) {
t.Fatal("ciphertext contains the plaintext")
}
opened, err := sealer.Open(sealed)
if err != nil {
t.Fatalf("Open: %v", err)
}
if !bytes.Equal(opened, plaintext) {
t.Errorf("Open = %q, want %q", opened, plaintext)
}
// A fresh nonce means sealing twice yields different ciphertext.
sealed2, err := sealer.Seal(plaintext)
if err != nil {
t.Fatalf("Seal (2): %v", err)
}
if bytes.Equal(sealed, sealed2) {
t.Error("two seals of the same plaintext are identical (nonce not fresh)")
}
}
func TestAEADSealerRejectsWrongKeySize(t *testing.T) {
if _, err := auth.NewAEADSealer(make([]byte, 16)); err == nil {
t.Error("NewAEADSealer(16 bytes) = nil error, want error")
}
}
func TestAEADSealerRejectsTamperedCiphertext(t *testing.T) {
sealer, err := auth.NewAEADSealer(bytes.Repeat([]byte{1}, auth.KeySize))
if err != nil {
t.Fatalf("NewAEADSealer: %v", err)
}
sealed, err := sealer.Seal([]byte("secret"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
sealed[len(sealed)-1] ^= 0xff // flip a tag bit
if _, err := sealer.Open(sealed); !errors.Is(err, auth.ErrInvalidCiphertext) {
t.Errorf("Open(tampered) = %v, want ErrInvalidCiphertext", err)
}
}

View file

@ -0,0 +1,197 @@
package auth
import (
"context"
"errors"
"fmt"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
)
// SessionsService implements Sessions.
type SessionsService struct {
*Core
}
var _ Sessions = (*SessionsService)(nil)
// Mint implements Sessions.Mint (SPEC-004 §6): issue a session for an ACTIVE
// identity. A pending or revoked identity gets no session.
func (s *SessionsService) Mint(ctx context.Context, identityID int64, ip, ua string) (Session, error) {
idn, err := s.identityByID(identityID)
if err != nil {
return Session{}, err
}
if idn.Status != StatusActive {
return Session{}, fmt.Errorf("%w: identity:%d is %s", ErrIdentityNotActive, identityID, idn.Status)
}
sid, err := newSessionID()
if err != nil {
return Session{}, err
}
now := s.clock()
sess := Session{
ID: sid,
IdentityID: identityID,
UserID: idn.UserID,
CreatedAt: now,
ExpiresAt: now.Add(s.sessionTTL),
ClientIP: ip,
UserAgent: ua,
}
if _, err := s.db.Exec(
`INSERT INTO sessions
(session_id, identity_id, user_id, created_at, expires_at, client_ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
sess.ID, sess.IdentityID, sess.UserID,
store.FormatTime(sess.CreatedAt), store.FormatTime(sess.ExpiresAt),
nullString(ip), nullString(ua),
); err != nil {
return Session{}, fmt.Errorf("auth: insert session: %w", err)
}
s.record(ctx, audit.Event{
ActorUserID: idn.UserID,
Action: "session.mint",
Subject: fmt.Sprintf("identity:%d", identityID),
Detail: "expires_at=" + store.FormatTime(sess.ExpiresAt),
ClientIP: ip,
})
return sess, nil
}
// Validate implements Sessions.Validate (A2). An empty id is rejected outright;
// a missing, revoked or expired session is an error.
func (s *SessionsService) Validate(ctx context.Context, sessionID string) (Session, error) {
if sessionID == "" {
return Session{}, ErrEmptySessionID
}
row := s.db.QueryRow(`
SELECT session_id, identity_id, user_id, created_at, expires_at, revoked_at, client_ip, user_agent
FROM sessions WHERE session_id = ?`, sessionID)
sess, err := scanSession(row)
if err != nil {
return Session{}, err
}
if sess.RevokedAt != nil {
return Session{}, fmt.Errorf("%w: %s", ErrSessionRevoked, sessionID)
}
if !sess.ExpiresAt.After(s.clock()) {
return Session{}, fmt.Errorf("%w: %s", ErrSessionExpired, sessionID)
}
return sess, nil
}
// Rotate implements Sessions.Rotate (fixation defence, SPEC-006 §5): the old
// session is revoked and a fresh one issued for the same identity.
func (s *SessionsService) Rotate(ctx context.Context, sessionID string) (Session, error) {
old, err := s.Validate(ctx, sessionID)
if err != nil {
return Session{}, err
}
if _, err := s.db.Exec(
`UPDATE sessions SET revoked_at = ? WHERE session_id = ? AND revoked_at IS NULL`,
store.FormatTime(s.clock()), sessionID,
); err != nil {
return Session{}, fmt.Errorf("auth: revoke on rotate: %w", err)
}
sess, err := s.Mint(ctx, old.IdentityID, old.ClientIP, old.UserAgent)
if err != nil {
return Session{}, err
}
s.record(ctx, audit.Event{
ActorUserID: old.UserID,
Action: "session.rotate",
Subject: fmt.Sprintf("identity:%d", old.IdentityID),
Detail: "session rotated on login",
ClientIP: old.ClientIP,
})
return sess, nil
}
// Revoke implements Sessions.Revoke (logout). It is idempotent for a session
// that is already revoked.
func (s *SessionsService) Revoke(ctx context.Context, sessionID string) error {
if sessionID == "" {
return ErrEmptySessionID
}
sess, err := s.lookupSession(sessionID)
if err != nil {
return err
}
if sess.RevokedAt != nil {
return nil
}
if _, err := s.db.Exec(
`UPDATE sessions SET revoked_at = ? WHERE session_id = ?`,
store.FormatTime(s.clock()), sessionID,
); err != nil {
return fmt.Errorf("auth: revoke session: %w", err)
}
s.record(ctx, audit.Event{
ActorUserID: sess.UserID,
Action: "session.revoke",
Subject: fmt.Sprintf("identity:%d", sess.IdentityID),
Detail: "logout",
ClientIP: sess.ClientIP,
})
return nil
}
// RevokeAll implements Sessions.RevokeAll (D-007): kill every live session of
// an identity.
func (s *SessionsService) RevokeAll(ctx context.Context, identityID int64) error {
res, err := s.db.Exec(
`UPDATE sessions SET revoked_at = ? WHERE identity_id = ? AND revoked_at IS NULL`,
store.FormatTime(s.clock()), identityID,
)
if err != nil {
return fmt.Errorf("auth: revoke all: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return nil
}
s.record(ctx, audit.Event{
Action: "session.revoke_all",
Subject: fmt.Sprintf("identity:%d", identityID),
Detail: fmt.Sprintf("revoked=%d", n),
})
return nil
}
// GC implements Sessions.GC (SPEC-004 §6): delete expired rows only. It never
// removes a still-valid session — "never the principal's last valid session".
func (s *SessionsService) GC(ctx context.Context, now time.Time) (int, error) {
res, err := s.db.Exec(
`DELETE FROM sessions WHERE expires_at <= ?`,
store.FormatTime(now),
)
if err != nil {
return 0, fmt.Errorf("auth: gc: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("auth: gc rows affected: %w", err)
}
return int(n), nil
}
// lookupSession loads a session by id without validating it (used by Revoke,
// which must tolerate a revoked row).
func (s *SessionsService) lookupSession(sessionID string) (Session, error) {
row := s.db.QueryRow(`
SELECT session_id, identity_id, user_id, created_at, expires_at, revoked_at, client_ip, user_agent
FROM sessions WHERE session_id = ?`, sessionID)
sess, err := scanSession(row)
if errors.Is(err, ErrSessionNotFound) {
return Session{}, fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID)
}
return sess, err
}

View file

@ -0,0 +1,99 @@
package auth
import (
"context"
"database/sql"
"errors"
"fmt"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
)
// The TOTP secret store (SPEC-004 §4 `totp_secrets`, A9).
//
// The plaintext secret exists only transiently in memory while being sealed or
// opened; the database only ever holds ciphertext, and no secret material ever
// reaches an audit row, a log line, or an error string.
// PutTOTPSecret seals secret and stores the ciphertext for an identity,
// replacing any previous secret and clearing its verified mark.
func (s *IdentitiesService) PutTOTPSecret(ctx context.Context, identityID int64, secret []byte) error {
if s.sealer == nil {
// Fail closed: never fall back to storing plaintext (A9).
return ErrNoSealer
}
if len(secret) == 0 {
return ErrEmptySecret
}
if _, err := s.identityByID(identityID); err != nil {
return err
}
encrypted, err := s.sealer.Seal(secret)
if err != nil {
return fmt.Errorf("auth: seal totp secret: %w", err)
}
if _, err := s.db.Exec(
`INSERT INTO totp_secrets (identity_id, secret_encrypted, verified_at)
VALUES (?, ?, NULL)
ON CONFLICT(identity_id) DO UPDATE SET
secret_encrypted = excluded.secret_encrypted,
verified_at = NULL`,
identityID, encrypted,
); err != nil {
return fmt.Errorf("auth: store totp secret: %w", err)
}
// NOTE: the secret is deliberately absent from this event (A9).
s.record(ctx, audit.Event{
Action: "totp.secret.put",
Subject: fmt.Sprintf("identity:%d", identityID),
Detail: "secret stored encrypted",
})
return nil
}
// TOTPSecret returns the decrypted secret for an identity.
func (s *IdentitiesService) TOTPSecret(ctx context.Context, identityID int64) ([]byte, error) {
if s.sealer == nil {
return nil, ErrNoSealer
}
var encrypted []byte
err := s.db.QueryRow(
`SELECT secret_encrypted FROM totp_secrets WHERE identity_id = ?`, identityID,
).Scan(&encrypted)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: identity:%d", ErrSecretNotFound, identityID)
}
if err != nil {
return nil, fmt.Errorf("auth: load totp secret: %w", err)
}
plaintext, err := s.sealer.Open(encrypted)
if err != nil {
return nil, fmt.Errorf("auth: open totp secret: %w", err)
}
return plaintext, nil
}
// MarkTOTPVerified records that a TOTP code has been verified for an identity.
func (s *IdentitiesService) MarkTOTPVerified(ctx context.Context, identityID int64) error {
res, err := s.db.Exec(
`UPDATE totp_secrets SET verified_at = ? WHERE identity_id = ?`,
store.FormatTime(s.clock()), identityID,
)
if err != nil {
return fmt.Errorf("auth: mark totp verified: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("%w: identity:%d", ErrSecretNotFound, identityID)
}
s.record(ctx, audit.Event{
Action: "totp.verified",
Subject: fmt.Sprintf("identity:%d", identityID),
Detail: "verified_at set",
})
return nil
}

View file

@ -0,0 +1,123 @@
// Package totp owns MyDay's time-based one-time-password factor (D-005): it
// issues an identity's secret and verifies a code.
//
// The secret is never stored in plaintext: Begin hands the secret to the Store,
// which seals it through the existing AEAD sealer (A9). The plaintext lives
// only transiently in memory and in the one otpauth URL the user scans.
//
// Spec: docs/specs/spec-006-enroll-login.md §4 (A9).
package totp
import (
"context"
"errors"
"fmt"
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
// ErrInvalidCode is returned when a code does not match the stored secret.
var ErrInvalidCode = errors.New("totp: invalid code")
// Store persists an identity's TOTP secret. It is implemented by the auth
// service, which encrypts at rest through the AEAD sealer (A9) — so this
// package never touches the ciphertext or the key.
type Store interface {
PutTOTPSecret(ctx context.Context, identityID int64, secret []byte) error
TOTPSecret(ctx context.Context, identityID int64) ([]byte, error)
MarkTOTPVerified(ctx context.Context, identityID int64) error
}
// Issued is the outcome of Begin. URL embeds the secret — it is the one display
// of it (the QR code); it must never be logged.
type Issued struct {
URL string
}
// Service is the TOTP seam.
type Service interface {
// Begin issues and stores (encrypted) a fresh secret for identityID.
Begin(ctx context.Context, identityID int64, account string) (Issued, error)
// Verify checks code against the stored secret; on success it records the
// verification.
Verify(ctx context.Context, identityID int64, code string) error
}
// Generator is the real Service, backed by github.com/pquerna/otp.
type Generator struct {
issuer string
store Store
now func() time.Time
opts totp.ValidateOpts
}
var _ Service = (*Generator)(nil)
// New builds the TOTP service. issuer labels the entry in the authenticator
// app; store is the (encrypting) auth service.
func New(issuer string, store Store) *Generator {
if issuer == "" {
issuer = "MyDay"
}
return &Generator{
issuer: issuer,
store: store,
now: time.Now,
opts: totp.ValidateOpts{
Period: 30,
Skew: 1,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
},
}
}
// SetClock injects a clock (tests).
func (g *Generator) SetClock(fn func() time.Time) {
if fn != nil {
g.now = fn
}
}
// Begin issues a fresh secret, stores it encrypted (A9) and returns the otpauth
// URL for the authenticator app.
func (g *Generator) Begin(ctx context.Context, identityID int64, account string) (Issued, error) {
if g.store == nil {
return Issued{}, errors.New("totp: no store configured")
}
key, err := totp.Generate(totp.GenerateOpts{Issuer: g.issuer, AccountName: account})
if err != nil {
return Issued{}, fmt.Errorf("totp: generate: %w", err)
}
// The Store seals this through the AEAD sealer before it reaches the DB.
if err := g.store.PutTOTPSecret(ctx, identityID, []byte(key.Secret())); err != nil {
return Issued{}, err
}
return Issued{URL: key.URL()}, nil
}
// Verify validates code against the stored secret, then records the
// verification. The code and the secret never reach an error string or a log.
func (g *Generator) Verify(ctx context.Context, identityID int64, code string) error {
if code == "" {
return ErrInvalidCode
}
secret, err := g.store.TOTPSecret(ctx, identityID)
if err != nil {
return err
}
ok, err := totp.ValidateCustom(code, string(secret), g.now().UTC(), g.opts)
if err != nil {
// A malformed code is an invalid code, not a server fault.
return ErrInvalidCode
}
if !ok {
return ErrInvalidCode
}
if err := g.store.MarkTOTPVerified(ctx, identityID); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,98 @@
package totp_test
import (
"context"
"errors"
"strings"
"testing"
"time"
pqotp "github.com/pquerna/otp/totp"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/totp"
)
// mapStore is an in-memory Store. Encryption at rest (A9) is the real store's
// job (proven in the SPEC-006 flow tests); here we pin the TOTP mechanics.
type mapStore struct {
secrets map[int64][]byte
verified map[int64]bool
}
func newMapStore() *mapStore {
return &mapStore{secrets: map[int64][]byte{}, verified: map[int64]bool{}}
}
func (m *mapStore) PutTOTPSecret(_ context.Context, id int64, secret []byte) error {
m.secrets[id] = append([]byte(nil), secret...)
delete(m.verified, id)
return nil
}
func (m *mapStore) TOTPSecret(_ context.Context, id int64) ([]byte, error) {
s, ok := m.secrets[id]
if !ok {
return nil, errors.New("no secret")
}
return s, nil
}
func (m *mapStore) MarkTOTPVerified(_ context.Context, id int64) error {
m.verified[id] = true
return nil
}
func TestBeginIssuesOtpauthURLAndStoresSecret(t *testing.T) {
st := newMapStore()
svc := totp.New("MyDay", st)
issued, err := svc.Begin(context.Background(), 7, "guid-abc")
if err != nil {
t.Fatalf("Begin: %v", err)
}
if !strings.HasPrefix(issued.URL, "otpauth://totp/") {
t.Errorf("URL = %q, want an otpauth:// URL", issued.URL)
}
secret, ok := st.secrets[7]
if !ok || len(secret) == 0 {
t.Fatal("Begin did not store a secret")
}
if !strings.Contains(issued.URL, string(secret)) {
t.Error("the otpauth URL does not carry the stored secret")
}
}
func TestVerifyAcceptsAValidCodeAndMarksVerified(t *testing.T) {
st := newMapStore()
svc := totp.New("MyDay", st)
if _, err := svc.Begin(context.Background(), 1, "guid"); err != nil {
t.Fatalf("Begin: %v", err)
}
code, err := pqotp.GenerateCode(string(st.secrets[1]), time.Now())
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
if err := svc.Verify(context.Background(), 1, code); err != nil {
t.Fatalf("Verify(valid) = %v, want nil", err)
}
if !st.verified[1] {
t.Error("a valid code did not mark the secret verified")
}
}
func TestVerifyRejectsBadOrEmptyCode(t *testing.T) {
st := newMapStore()
svc := totp.New("MyDay", st)
if _, err := svc.Begin(context.Background(), 1, "guid"); err != nil {
t.Fatalf("Begin: %v", err)
}
if err := svc.Verify(context.Background(), 1, ""); !errors.Is(err, totp.ErrInvalidCode) {
t.Errorf("Verify(\"\") = %v, want ErrInvalidCode", err)
}
if err := svc.Verify(context.Background(), 1, "000000"); !errors.Is(err, totp.ErrInvalidCode) {
t.Errorf("Verify(bad) = %v, want ErrInvalidCode", err)
}
if st.verified[1] {
t.Error("a bad code marked the secret verified")
}
}

View file

@ -0,0 +1,218 @@
// Package webauthn wraps github.com/go-webauthn/webauthn behind a small
// interface so the enroll/login flows (SPEC-006) can be exercised without a
// browser. The ceremony is browser-bound, so tests inject a fake Service.
//
// The package persists nothing: it turns a begin/response pair into a
// Credential the caller stores (SPEC-004 §4 `webauthn_credentials`), keeping
// the storage decision in the auth service.
//
// Spec: docs/specs/spec-006-enroll-login.md §4/§5.
package webauthn
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/go-webauthn/webauthn/protocol"
lib "github.com/go-webauthn/webauthn/webauthn"
)
// Credential is the persisted WebAuthn credential (SPEC-004 §4). It is the
// subset of the library's Credential the core stores.
type Credential struct {
// ID is the credential id (primary key).
ID []byte
// PublicKey is the COSE-encoded credential public key.
PublicKey []byte
// SignCount is the authenticator's signature counter (clone detection).
SignCount uint32
// Transports are the authenticator transport hints, comma-joined at rest.
Transports []string
// AAGUID identifies the authenticator model (may be empty).
AAGUID []byte
}
// User is the Relying Party's user record for a ceremony. ID is the WebAuthn
// user handle (<= 64 bytes) — it must be stable for the life of the identity.
type User struct {
ID []byte
Name string
DisplayName string
}
// Begin pairs the browser options (JSON) with the server-side session data the
// finish step must carry. The flow serializes it into a signed cookie between
// the two calls.
type Begin struct {
// Options is the JSON for navigator.credentials.create/get.
Options json.RawMessage
// SessionData is the opaque server state (challenge, expiry, …).
SessionData []byte
}
// Service is WebAuthn behind an interface (browser-bound, so tests inject a
// fake). Finish* parse the response from r's body.
type Service interface {
BeginRegistration(ctx context.Context, u User, existing []Credential) (*Begin, error)
FinishRegistration(ctx context.Context, u User, b *Begin, r *http.Request) (Credential, error)
BeginLogin(ctx context.Context, u User, creds []Credential) (*Begin, error)
FinishLogin(ctx context.Context, u User, b *Begin, r *http.Request, creds []Credential) (Credential, error)
}
// Config is the Relying Party configuration (derived from server.base_url).
type Config struct {
// RPID is the relying party id — the origin's host without scheme/port.
RPID string
// RPDisplayName is a human-readable name.
RPDisplayName string
// RPOrigins are the permitted origins, fully qualified.
RPOrigins []string
}
// impl is the real Service backed by go-webauthn.
type impl struct {
wa *lib.WebAuthn
}
// New builds the real WebAuthn service. It fails closed when the configuration
// is invalid (no origin, malformed RPID).
func New(cfg Config) (Service, error) {
if cfg.RPDisplayName == "" {
cfg.RPDisplayName = "MyDay"
}
wa, err := lib.New(&lib.Config{
RPID: cfg.RPID,
RPDisplayName: cfg.RPDisplayName,
RPOrigins: cfg.RPOrigins,
})
if err != nil {
return nil, fmt.Errorf("webauthn: config: %w", err)
}
return &impl{wa: wa}, nil
}
var _ Service = (*impl)(nil)
// BeginRegistration implements Service.
func (w *impl) BeginRegistration(_ context.Context, u User, existing []Credential) (*Begin, error) {
creation, session, err := w.wa.BeginRegistration(buildUser(u, existing))
if err != nil {
return nil, fmt.Errorf("webauthn: begin registration: %w", err)
}
return newBegin(creation, session)
}
// FinishRegistration implements Service.
func (w *impl) FinishRegistration(_ context.Context, u User, b *Begin, r *http.Request) (Credential, error) {
session, err := decodeSession(b)
if err != nil {
return Credential{}, err
}
cred, err := w.wa.FinishRegistration(buildUser(u, nil), session, r)
if err != nil {
return Credential{}, fmt.Errorf("webauthn: finish registration: %w", err)
}
return fromLib(cred), nil
}
// BeginLogin implements Service.
func (w *impl) BeginLogin(_ context.Context, u User, creds []Credential) (*Begin, error) {
assertion, session, err := w.wa.BeginLogin(buildUser(u, creds))
if err != nil {
return nil, fmt.Errorf("webauthn: begin login: %w", err)
}
return newBegin(assertion, session)
}
// FinishLogin implements Service.
func (w *impl) FinishLogin(_ context.Context, u User, b *Begin, r *http.Request, creds []Credential) (Credential, error) {
session, err := decodeSession(b)
if err != nil {
return Credential{}, err
}
cred, err := w.wa.FinishLogin(buildUser(u, creds), session, r)
if err != nil {
return Credential{}, fmt.Errorf("webauthn: finish login: %w", err)
}
return fromLib(cred), nil
}
// newBegin marshals the options and the session data into a Begin.
func newBegin(options any, session *lib.SessionData) (*Begin, error) {
opts, err := json.Marshal(options)
if err != nil {
return nil, fmt.Errorf("webauthn: marshal options: %w", err)
}
sd, err := json.Marshal(session)
if err != nil {
return nil, fmt.Errorf("webauthn: marshal session: %w", err)
}
return &Begin{Options: opts, SessionData: sd}, nil
}
// decodeSession restores the library session data from a Begin.
func decodeSession(b *Begin) (lib.SessionData, error) {
if b == nil || len(b.SessionData) == 0 {
return lib.SessionData{}, fmt.Errorf("webauthn: missing ceremony state")
}
var session lib.SessionData
if err := json.Unmarshal(b.SessionData, &session); err != nil {
return lib.SessionData{}, fmt.Errorf("webauthn: decode session: %w", err)
}
return session, nil
}
// fromLib maps a library credential to the persisted subset.
func fromLib(c *lib.Credential) Credential {
if c == nil {
return Credential{}
}
transports := make([]string, 0, len(c.Transport))
for _, t := range c.Transport {
transports = append(transports, string(t))
}
return Credential{
ID: c.ID,
PublicKey: c.PublicKey,
SignCount: c.Authenticator.SignCount,
Transports: transports,
AAGUID: c.Authenticator.AAGUID,
}
}
// libUser adapts our User to the library's User interface.
type libUser struct {
id []byte
name string
displayName string
creds []lib.Credential
}
func (u libUser) WebAuthnID() []byte { return u.id }
func (u libUser) WebAuthnName() string { return u.name }
func (u libUser) WebAuthnDisplayName() string { return u.displayName }
func (u libUser) WebAuthnCredentials() []lib.Credential { return u.creds }
// buildUser adapts a User plus its stored credentials for the library.
func buildUser(u User, creds []Credential) libUser {
libCreds := make([]lib.Credential, 0, len(creds))
for _, c := range creds {
transports := make([]protocol.AuthenticatorTransport, 0, len(c.Transports))
for _, t := range c.Transports {
transports = append(transports, protocol.AuthenticatorTransport(t))
}
libCreds = append(libCreds, lib.Credential{
ID: c.ID,
PublicKey: c.PublicKey,
Transport: transports,
Authenticator: lib.Authenticator{
AAGUID: c.AAGUID,
SignCount: c.SignCount,
},
})
}
return libUser{id: u.ID, name: u.Name, displayName: u.DisplayName, creds: libCreds}
}

View file

@ -0,0 +1,26 @@
package webauthn_test
import (
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn"
)
func TestNewValidatesConfig(t *testing.T) {
svc, err := webauthn.New(webauthn.Config{
RPID: "myday.example.com",
RPDisplayName: "MyDay",
RPOrigins: []string{"https://myday.example.com"},
})
if err != nil {
t.Fatalf("New(valid) = %v, want nil", err)
}
if svc == nil {
t.Fatal("New returned a nil service")
}
// No origin -> fail closed.
if _, err := webauthn.New(webauthn.Config{RPID: "myday.example.com"}); err == nil {
t.Error("New(no origins) = nil, want an error")
}
}