- 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
381 lines
12 KiB
Go
381 lines
12 KiB
Go
// 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
|
||
}
|