- 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
285 lines
8.6 KiB
Go
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
|
|
}
|