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.
118 lines
3.6 KiB
Go
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
|
|
}
|