NextMyDay/internal/core/auth/identities.go
Claus Lohmar 76a4a6950c 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
2026-10-14 10:53:53 +00:00

285 lines
8.6 KiB
Go

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
}