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:
parent
d11e14295b
commit
76a4a6950c
14 changed files with 3160 additions and 0 deletions
578
internal/core/auth/acceptance_test.go
Normal file
578
internal/core/auth/acceptance_test.go
Normal 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
381
internal/core/auth/auth.go
Normal 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
|
||||
}
|
||||
151
internal/core/auth/credentials.go
Normal file
151
internal/core/auth/credentials.go
Normal 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)
|
||||
}
|
||||
738
internal/core/auth/flow/flow.go
Normal file
738
internal/core/auth/flow/flow.go
Normal 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
|
||||
}
|
||||
129
internal/core/auth/flow/render.go
Normal file
129
internal/core/auth/flow/render.go
Normal 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&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&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'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&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>`),
|
||||
}
|
||||
}
|
||||
285
internal/core/auth/identities.go
Normal file
285
internal/core/auth/identities.go
Normal 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
|
||||
}
|
||||
73
internal/core/auth/sealer.go
Normal file
73
internal/core/auth/sealer.go
Normal 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)
|
||||
64
internal/core/auth/sealer_test.go
Normal file
64
internal/core/auth/sealer_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
197
internal/core/auth/sessions.go
Normal file
197
internal/core/auth/sessions.go
Normal 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
|
||||
}
|
||||
99
internal/core/auth/totp.go
Normal file
99
internal/core/auth/totp.go
Normal 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
|
||||
}
|
||||
123
internal/core/auth/totp/totp.go
Normal file
123
internal/core/auth/totp/totp.go
Normal 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
|
||||
}
|
||||
98
internal/core/auth/totp/totp_test.go
Normal file
98
internal/core/auth/totp/totp_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
218
internal/core/auth/webauthn/webauthn.go
Normal file
218
internal/core/auth/webauthn/webauthn.go
Normal 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}
|
||||
}
|
||||
26
internal/core/auth/webauthn/webauthn_test.go
Normal file
26
internal/core/auth/webauthn/webauthn_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue