feat(api): add passkey enrollment persistence layer
Phase 6 WebAuthn bind, enrollment-only slice (spec section 14). Adds the data layer an already-authenticated principal needs to bind and manage passkeys: - migration 0007: webauthn_credentials (one bound passkey per row, public attestation material only) and webauthn_challenges (server-stashed ceremony state between begin and finish, single-use via consumed_at). Both rows are bound to a known user_id; there is no usernameless login lookup, since the assertion/login path is a deferred slice. - PasskeyCredential type and five Repo methods (create/consume challenge, create/list/delete credential) with the PG semantics the handlers rely on: supersede-prior-live on begin, expiry-before-consume single-use on finish, credential_id UNIQUE -> ErrConflict, owner-scoped delete -> ErrNotFound. - ErrPasskeyChallengeInvalid sentinel for a missing/expired/consumed ceremony.
This commit is contained in:
4 files changed
+263
No files matched your search
@@ -36,6 +36,12 @@ var (
|
||||
// can answer 429 (back off / request a new code) rather than inviting another
|
||||
// guess against a code that will never accept one.
|
||||
ErrOTPLocked = errors.New("email code locked: too many attempts")
|
||||
// ErrPasskeyChallengeInvalid means a passkey enrollment ceremony cannot be
|
||||
// finished: there is no live (unconsumed, unexpired) challenge for the caller and
|
||||
// purpose (Phase 6 WebAuthn bind). Like ErrOTPInvalid it is a client error — the
|
||||
// finish endpoint exists; the ceremony state is gone (never begun, already
|
||||
// consumed, or expired) — so handlers map it to 400, not 404.
|
||||
ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid or expired")
|
||||
)
|
||||
|
||||
// apiError is a handler-level error carrying an HTTP status and a stable,
|
||||
|
||||
@@ -721,3 +721,149 @@ func (p *PGRepo) SetSetting(ctx context.Context, key string, value []byte) error
|
||||
key, string(value))
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- player passkey enrollment (spec §14 WebAuthn / Phase 6 bind, migration 0007) ----
|
||||
|
||||
// CreatePasskeyChallenge supersedes any prior live challenge for (user, purpose) and
|
||||
// inserts the fresh one, in one transaction (mirrors CreateEmailOTP). The supersede
|
||||
// DELETE means a re-begin invalidates the earlier ceremony, so only the most recent
|
||||
// challenge can ever finish — at most one outstanding challenge per (user, purpose).
|
||||
// The opaque SessionData is held server-side so the client cannot forge the challenge
|
||||
// it must answer at finish.
|
||||
func (p *PGRepo) CreatePasskeyChallenge(ctx context.Context, id, userID, purpose string, sessionData []byte, expiresAt time.Time) error {
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM webauthn_challenges WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL`,
|
||||
userID, purpose); err != nil {
|
||||
return fmt.Errorf("supersede prior passkey challenge: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO webauthn_challenges (id, user_id, purpose, session_data, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
id, userID, purpose, sessionData, expiresAt); err != nil {
|
||||
return fmt.Errorf("insert passkey challenge: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ConsumePasskeyChallengeByUser redeems the newest live (unconsumed, unexpired at now)
|
||||
// challenge for (user, purpose) in one transaction (mirrors VerifyEmailOTP). The row is
|
||||
// taken FOR UPDATE so a concurrent finish cannot double-spend it; expiry is checked
|
||||
// before consuming so a stale challenge is never accepted. On success consumed_at is
|
||||
// stamped (single-use) and the stashed SessionData is returned. No live row →
|
||||
// ErrPasskeyChallengeInvalid.
|
||||
func (p *PGRepo) ConsumePasskeyChallengeByUser(ctx context.Context, userID, purpose string, now time.Time) ([]byte, error) {
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
||||
|
||||
var (
|
||||
id string
|
||||
sessionData []byte
|
||||
expiresAt time.Time
|
||||
)
|
||||
switch err := tx.QueryRowContext(ctx,
|
||||
`SELECT id, session_data, expires_at FROM webauthn_challenges
|
||||
WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1 FOR UPDATE`,
|
||||
userID, purpose).Scan(&id, &sessionData, &expiresAt); {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, ErrPasskeyChallengeInvalid
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !expiresAt.After(now) {
|
||||
return nil, ErrPasskeyChallengeInvalid
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE webauthn_challenges SET consumed_at = $2 WHERE id = $1`, id, now); err != nil {
|
||||
return nil, fmt.Errorf("consume passkey challenge: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sessionData, nil
|
||||
}
|
||||
|
||||
// CreatePasskeyCredential stores a freshly verified passkey (enrollment). Only public
|
||||
// attestation material is written; a credential_id already bound to ANY account is left
|
||||
// untouched (ON CONFLICT DO NOTHING) and reported as ErrConflict via a zero RowsAffected,
|
||||
// so an authenticator is never silently rebound. Empty aaguid/name land as SQL NULL.
|
||||
func (p *PGRepo) CreatePasskeyCredential(ctx context.Context, c PasskeyCredential) error {
|
||||
res, err := p.db.ExecContext(ctx,
|
||||
`INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, sign_count, aaguid, name, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), NULLIF($7, ''), $8)
|
||||
ON CONFLICT (credential_id) DO NOTHING`,
|
||||
c.ID, c.UserID, c.CredentialID, c.PublicKey, int64(c.SignCount), c.AAGUID, c.Name, c.CreatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PasskeyCredentialsForUser lists the passkeys a user has bound, newest first, for the
|
||||
// credential-management view. Nullable aaguid/name collapse to "" via COALESCE; the
|
||||
// nullable last_used_at maps to a *time.Time (nil until an assertion is verified).
|
||||
func (p *PGRepo) PasskeyCredentialsForUser(ctx context.Context, userID string) ([]PasskeyCredential, error) {
|
||||
const q = `SELECT id, user_id, credential_id, public_key, sign_count,
|
||||
COALESCE(aaguid, ''), COALESCE(name, ''), created_at, last_used_at
|
||||
FROM webauthn_credentials WHERE user_id = $1 ORDER BY created_at DESC`
|
||||
rows, err := p.db.QueryContext(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PasskeyCredential
|
||||
for rows.Next() {
|
||||
var (
|
||||
c PasskeyCredential
|
||||
signCount int64
|
||||
lastUsed sql.NullTime
|
||||
)
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.CredentialID, &c.PublicKey, &signCount,
|
||||
&c.AAGUID, &c.Name, &c.CreatedAt, &lastUsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SignCount = uint32(signCount)
|
||||
if lastUsed.Valid {
|
||||
t := lastUsed.Time
|
||||
c.LastUsedAt = &t
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeletePasskeyCredential removes the passkey row id, scoped to userID so a caller can
|
||||
// only unbind their OWN credential. No matching (user, id) row → ErrNotFound via a zero
|
||||
// RowsAffected, so a stale or cross-user id cannot silently no-op as success.
|
||||
func (p *PGRepo) DeletePasskeyCredential(ctx context.Context, userID, id string) error {
|
||||
res, err := p.db.ExecContext(ctx,
|
||||
`DELETE FROM webauthn_credentials WHERE id = $1 AND user_id = $2`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -89,6 +89,26 @@ type StaffUser struct {
|
||||
EmailVerified bool
|
||||
}
|
||||
|
||||
// PasskeyCredential is one bound passkey (Phase 6 WebAuthn enrollment). It carries
|
||||
// only public, non-secret attestation material: a WebAuthn public key is meant to
|
||||
// be public (unlike a session token), so it is safe at rest. CredentialID is the
|
||||
// authenticator's globally-unique handle (base64url) and PublicKey the COSE key
|
||||
// (base64); SignCount is the uint32 signature counter captured at registration.
|
||||
// LastUsedAt is nil until an assertion is verified — the login/step-up path that
|
||||
// would stamp it is out of scope for this enrollment-only slice (deferred), so it
|
||||
// stays nil through the flow this type backs.
|
||||
type PasskeyCredential struct {
|
||||
ID string
|
||||
UserID string
|
||||
CredentialID string
|
||||
PublicKey string
|
||||
SignCount uint32
|
||||
AAGUID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
LastUsedAt *time.Time
|
||||
}
|
||||
|
||||
// SessionedUser is the projection resolved from a live session cookie: the
|
||||
// identity SessionAuth needs to build a Principal. It omits the password hash —
|
||||
// the session has already authenticated the caller — but carries the pending
|
||||
@@ -196,6 +216,39 @@ type Repo interface {
|
||||
// proven email is returned. now is the API clock so expiry is testable.
|
||||
VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) (email string, err error)
|
||||
|
||||
// ---- player passkey enrollment (spec §14 WebAuthn / Phase 6 bind) ----
|
||||
|
||||
// CreatePasskeyChallenge persists the server-side state of a credential-creation
|
||||
// ceremony for (userID, purpose): the opaque go-webauthn SessionData blob and its
|
||||
// expiry. Only the server holds it, so the client cannot forge the challenge it
|
||||
// must answer at finish. It supersedes any prior live (unconsumed) challenge for
|
||||
// the same (userID, purpose) so a re-begin invalidates the earlier ceremony —
|
||||
// at most one outstanding challenge per (user, purpose). expiresAt is the API
|
||||
// clock + TTL so expiry is driven by one authoritative clock.
|
||||
CreatePasskeyChallenge(ctx context.Context, id, userID, purpose string, sessionData []byte, expiresAt time.Time) error
|
||||
// ConsumePasskeyChallengeByUser redeems the newest live (unconsumed, unexpired at
|
||||
// now) challenge for (userID, purpose), atomically: it stamps consumed_at and
|
||||
// returns the stashed SessionData so finish can validate the attestation against
|
||||
// it. No live challenge → ErrPasskeyChallengeInvalid. Single-use: a second finish
|
||||
// for the same ceremony finds nothing live and fails. now is the API clock so
|
||||
// expiry is testable. Bound to user_id — enrollment always has a principal, so
|
||||
// there is no usernameless consume-by-hash variant (login is a deferred slice).
|
||||
ConsumePasskeyChallengeByUser(ctx context.Context, userID, purpose string, now time.Time) (sessionData []byte, err error)
|
||||
// CreatePasskeyCredential stores a freshly verified passkey for a user (Phase 6
|
||||
// enrollment). It writes only public attestation material (credential_id,
|
||||
// public_key, sign_count, aaguid) plus the caller's nickname. A credential_id
|
||||
// already bound to ANY account → ErrConflict (the UNIQUE guard); the handler maps
|
||||
// that to 409 rather than silently rebinding an authenticator.
|
||||
CreatePasskeyCredential(ctx context.Context, c PasskeyCredential) error
|
||||
// PasskeyCredentialsForUser lists the passkeys a user has bound, newest first, for
|
||||
// the credential-management view. It returns only display fields (never a secret —
|
||||
// a passkey carries none); LastUsedAt is nil where no assertion has been verified.
|
||||
PasskeyCredentialsForUser(ctx context.Context, userID string) ([]PasskeyCredential, error)
|
||||
// DeletePasskeyCredential removes the passkey row id, scoped to userID so a caller
|
||||
// can only unbind their OWN credential. No matching (user, id) row → ErrNotFound,
|
||||
// so a stale or cross-user id cannot silently no-op as success.
|
||||
DeletePasskeyCredential(ctx context.Context, userID, id string) error
|
||||
|
||||
// ---- player game-login: username-collision reclaim (spec §B3) ----
|
||||
|
||||
// ReclaimUsername records a Mojang-priority username reclaim, atomically (spec
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Phase 6 passkey/WebAuthn bind (spec §14 WebAuthn, §B2 onboarding "link + email-OTP
|
||||
-- + passkey"). This is the ENROLLMENT data layer only: an already-authenticated
|
||||
-- principal binds a passkey to their account (the credential-creation ceremony),
|
||||
-- and manages the credentials they have bound. Email-OTP remains the fallback
|
||||
-- factor (migration 0004), so a player with no passkey is never locked out.
|
||||
--
|
||||
-- Scope boundary (deliberate): this slice covers ENROLLMENT only. Assertion
|
||||
-- verification for LOGIN — proving a passkey to mint/elevate a session from an
|
||||
-- unauthenticated state — is out of scope here and deferred. The spec keeps the two
|
||||
-- passkey surfaces distinct (§14: admin.* rides "Tunnel+Access,WebAuthn/posture",
|
||||
-- panel.* is "app 登录"; §B2 lists passkey among the player onboarding factors), so
|
||||
-- where the panel.* passkey relying-party boundary ultimately lands (felis-api vs.
|
||||
-- the Access edge) is a later decision, not settled by this migration. Accordingly
|
||||
-- this migration models only the authenticated enrollment ceremony: every challenge
|
||||
-- is bound to a known user_id, and there is no usernameless (pre-session) login
|
||||
-- lookup column. Adding a login path later would also need the felis_session
|
||||
-- honoring model in session.go extended.
|
||||
|
||||
-- webauthn_credentials stores one bound passkey per row. The public key and the
|
||||
-- signature counter are attestation outputs captured at registration; only public,
|
||||
-- non-secret material is held (a WebAuthn public key is meant to be public, unlike
|
||||
-- the RCON password or a session token). credential_id is the authenticator's
|
||||
-- globally-unique handle, base64url-encoded; UNIQUE guards the (astronomically
|
||||
-- unlikely) cross-account collision and is the key a future login path would match.
|
||||
CREATE TABLE webauthn_credentials (
|
||||
id text PRIMARY KEY, -- opaque row id (crypto-random hex)
|
||||
user_id text NOT NULL REFERENCES users(id),
|
||||
credential_id text NOT NULL UNIQUE, -- base64url(raw credential id)
|
||||
public_key text NOT NULL, -- base64(COSE public key bytes)
|
||||
sign_count bigint NOT NULL DEFAULT 0, -- uint32 widened (overflows int4)
|
||||
aaguid text, -- authenticator model id, for display only
|
||||
name text, -- caller-supplied nickname ("My phone")
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_used_at timestamptz -- NULL until an assertion is verified (deferred login path)
|
||||
);
|
||||
|
||||
-- The credential-management views list a user's bound passkeys, so index that lookup.
|
||||
CREATE INDEX webauthn_credentials_user_id_idx ON webauthn_credentials (user_id);
|
||||
|
||||
-- webauthn_challenges holds the server-side ceremony state between begin and finish.
|
||||
-- The full go-webauthn SessionData blob (challenge, allowed credentials, expiry) is
|
||||
-- stashed here and reloaded at finish, so the client never echoes — and so cannot
|
||||
-- forge — the challenge it must answer (the same principle as email_otps.code_hash).
|
||||
-- Every row is bound to a known user_id (NOT NULL): enrollment always rides on an
|
||||
-- authenticated principal, so there is no usernameless consume-by-hash path. consumed_at
|
||||
-- enforces single-use; a fresh begin supersedes the prior live row for (user, purpose).
|
||||
CREATE TABLE webauthn_challenges (
|
||||
id text PRIMARY KEY, -- opaque row id (crypto-random hex)
|
||||
user_id text NOT NULL REFERENCES users(id),
|
||||
purpose text NOT NULL, -- 'passkey_register'
|
||||
session_data bytea NOT NULL, -- opaque go-webauthn SessionData
|
||||
expires_at timestamptz NOT NULL, -- short TTL set by the API clock
|
||||
consumed_at timestamptz, -- non-NULL once redeemed (single-use)
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- The finish path consumes the newest live row for a (user, purpose), so index it.
|
||||
CREATE INDEX webauthn_challenges_user_purpose_idx ON webauthn_challenges (user_id, purpose);
|
||||
Reference in new issue
Block a user