NextMyDay/internal/core/auth/totp.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

118 lines
3.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"
)
// 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
}
// TOTPEnrolled reports whether an identity has a VERIFIED TOTP secret — the
// second factor is enrolled (SPEC-010 §4). A missing secret and an issued-but-
// unverified one both read as not-enrolled. It reads a boolean, never the
// secret (A9).
func (s *IdentitiesService) TOTPEnrolled(ctx context.Context, identityID int64) (bool, error) {
var one int
err := s.db.QueryRow(
`SELECT 1 FROM totp_secrets WHERE identity_id = ? AND verified_at IS NOT NULL LIMIT 1`,
identityID,
).Scan(&one)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, fmt.Errorf("auth: totp enrolled lookup: %w", err)
}
return true, nil
}