NextMyDay/internal/core/auth/auth.go
Claus Lohmar 5dc0a0150e feat(account): add the account page and the admin Users page
The live instance had no way to add a passkey or a user. Two core pages now
make the signed-in experience usable (SPEC-010):

  * the ACCOUNT page (GET /{GUID}/account) shows the user's factors (passkey
    count · TOTP enrolled), an "Add a passkey" button that LINKS to the
    existing D-041 ceremony screen — the WebAuthn flow is never duplicated —
    and a "Don't ask again" control writing users.passkey_opt_out;
  * the admin USERS page (GET /{GUID}/users) lists every user (name · email ·
    role · manager · identity status) and creates one: only roles below the
    creator (D-019), the manager defaulting to the creator, then users.Create
    -> identities.Mint -> the /{GUID} invite is EMAILED and shown for
    copy/paste.

Both ride the door as core dispatch modules, so the door still gates them
(session + role + CSRF). The entrypoint requires admin for the users
functions; account stays at the team floor.

Additive support: auth.Identities.TOTPEnrolled, users.Users.List,
server.Deps.Credentials (the banner's "has a passkey?" read) and the
CoreDispatchNames guard now covers account/users.
2026-10-15 07:58:18 +00:00

386 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)
// TOTPEnrolled reports whether an identity has a VERIFIED TOTP secret — its
// second factor is enrolled (SPEC-010 §4, the account page's factors). A
// missing secret and an issued-but-unverified one both read as false.
TOTPEnrolled(ctx context.Context, identityID 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
}