feat(account): migrate a live account's owned servers to a new account (§B3 inherit)
Old account runs /felis migrate in-game to open a migration, proves control via a fresh web step-up (passkey forced when enrolled, else email-OTP), names the target and mints a one-time code. The target redeems it while authenticated AS that target: in one transaction the source's owned servers re-point to the target and the source is retired (sessions revoked, disabled, soft-deleted), which also spends the code so it cannot be replayed. Only server ownership moves; the mc_uuid link and web credentials stay with the source, so migrate is not a credential-theft primitive. - 0015 migration: account_migrations state machine (initiated -> confirmed -> code_issued -> redeemed), one live migration per source - Repo/PGRepo: Start/ForSource/Confirm/IssueCode/Redeem - 8 routes (1 internal /felis side, 7 web) with openapi parity - passkey step-up runs the same clone-signal (sign-count) check as the login door - code bound to the named target at issue and at redeem Quota is grandfathered at redeem: no per-target quota re-check when servers move.
This commit is contained in:
8 files changed
+1692
No files matched your search
@@ -805,6 +805,56 @@ paths:
|
|||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
|
||||||
|
/api/v1/internal/account/migrate/start:
|
||||||
|
post:
|
||||||
|
tags: [account-internal]
|
||||||
|
operationId: migrateStart
|
||||||
|
summary: Put the account linked to a verified in-game UUID into migrate mode (spec §B3 inherit, in-game side).
|
||||||
|
description: >
|
||||||
|
Internal-only. The in-game /felis migrate command calls this for the running
|
||||||
|
player's verified UUID: it resolves the linked account and opens a fresh
|
||||||
|
migration in the initiated state, superseding any earlier unfinished attempt
|
||||||
|
by the same source. The web side then drives a fresh step-up confirmation.
|
||||||
|
The transfer itself moves server ownership only — never the mc_uuid link nor
|
||||||
|
web credentials — so this endpoint starts a flow, it does not move anything.
|
||||||
|
x-felis-face: [internal]
|
||||||
|
x-felis-tier: service
|
||||||
|
security: [{ serviceToken: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [mc_uuid]
|
||||||
|
properties:
|
||||||
|
mc_uuid: { type: string, format: uuid }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Migration opened in the initiated state.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [started, state]
|
||||||
|
properties:
|
||||||
|
started: { type: boolean, const: true }
|
||||||
|
state: { type: string, const: initiated }
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'404':
|
||||||
|
description: The UUID is not linked to any account (not_linked).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'409':
|
||||||
|
description: The source account has already been retired by a completed migration (account_retired).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
|
||||||
/api/v1/internal/player/reclaim:
|
/api/v1/internal/player/reclaim:
|
||||||
post:
|
post:
|
||||||
tags: [account-internal]
|
tags: [account-internal]
|
||||||
@@ -3264,6 +3314,324 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
|
||||||
|
/api/v1/account/migrate:
|
||||||
|
get:
|
||||||
|
tags: [account]
|
||||||
|
operationId: migrateStatus
|
||||||
|
summary: Report the caller's active account-migration and where it is in the flow (spec §B3 inherit, web side).
|
||||||
|
description: >
|
||||||
|
Read-only. Returns the live migration whose source is the authenticated
|
||||||
|
principal, if any, so the web onboarding can resume the flow: whether a
|
||||||
|
confirmation step-up is still needed, which factor confirmed it, the named
|
||||||
|
target, and the one-time code's expiry once issued. active:false when the
|
||||||
|
caller has no live migration.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The caller's live migration, or active:false.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [active]
|
||||||
|
properties:
|
||||||
|
active: { type: boolean }
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
enum: [initiated, confirmed, code_issued]
|
||||||
|
description: Present only when active; a redeemed migration is terminal and not reported here.
|
||||||
|
target_user_id: { type: string }
|
||||||
|
confirm_factor:
|
||||||
|
type: string
|
||||||
|
enum: [passkey, email_otp]
|
||||||
|
code_expires_at: { type: string, format: date-time }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
|
||||||
|
/api/v1/account/migrate/confirm/otp/start:
|
||||||
|
post:
|
||||||
|
tags: [account]
|
||||||
|
operationId: migrateConfirmOtpStart
|
||||||
|
summary: Send a fresh email one-time code to confirm control of the migrating source account (spec §B3 step-up).
|
||||||
|
description: >
|
||||||
|
Opens the email-OTP confirmation factor for the caller's initiated migration.
|
||||||
|
This is a FRESH step-up bound to the migrate purpose, never mere session
|
||||||
|
possession. If the account has ANY passkey enrolled, email-OTP is refused with
|
||||||
|
409 passkey_required — the stronger factor is forced. The code is delivered out
|
||||||
|
of band and never returned; requires a verified email on the account.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Confirmation code minted and dispatched.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [sent, expires_at]
|
||||||
|
properties:
|
||||||
|
sent: { type: boolean, const: true }
|
||||||
|
expires_at: { type: string, format: date-time }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'404':
|
||||||
|
description: The caller has no initiated migration to confirm (no_migration).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'409':
|
||||||
|
description: >
|
||||||
|
A passkey is enrolled so email-OTP is forbidden (passkey_required); the
|
||||||
|
migration is already confirmed (already_confirmed); or the account has no
|
||||||
|
email step-up factor (no_step_up_factor).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'429':
|
||||||
|
description: Resend requested before the cooldown elapsed.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
|
||||||
|
/api/v1/account/migrate/confirm/otp/verify:
|
||||||
|
post:
|
||||||
|
tags: [account]
|
||||||
|
operationId: migrateConfirmOtpVerify
|
||||||
|
summary: Redeem the email one-time code and confirm the migration (spec §B3 step-up).
|
||||||
|
description: >
|
||||||
|
Consumes the fresh migrate-purpose email code for the caller's initiated
|
||||||
|
migration and advances it to confirmed with confirm_factor email_otp. Too many
|
||||||
|
wrong attempts lock the code (429 otp_locked); an unknown, expired, consumed, or
|
||||||
|
mismatched code is a 400 invalid_code.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [code]
|
||||||
|
properties:
|
||||||
|
code: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Migration confirmed.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [confirmed]
|
||||||
|
properties:
|
||||||
|
confirmed: { type: boolean, const: true }
|
||||||
|
'400':
|
||||||
|
description: Invalid or expired code (invalid_code).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'404':
|
||||||
|
description: The caller has no initiated migration to confirm (no_migration).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'409':
|
||||||
|
description: The migration is already confirmed (already_confirmed).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'429':
|
||||||
|
description: The code is locked after too many wrong attempts (otp_locked).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
|
||||||
|
/api/v1/account/migrate/confirm/passkey/begin:
|
||||||
|
post:
|
||||||
|
tags: [account]
|
||||||
|
operationId: migrateConfirmPasskeyBegin
|
||||||
|
summary: Begin a fresh passkey assertion to confirm control of the migrating source account (spec §B3 step-up).
|
||||||
|
description: >
|
||||||
|
Returns WebAuthn assertion request options for the caller's own enrolled
|
||||||
|
passkeys, bound to a fresh migrate-purpose challenge. This is the forced factor
|
||||||
|
whenever a passkey exists. The finish call proves the assertion and, exactly as
|
||||||
|
the login door does, runs the clone-signal (sign-count) check before confirming.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { type: object, description: Opaque WebAuthn PublicKeyCredentialRequestOptions. }
|
||||||
|
'400':
|
||||||
|
description: The caller has no enrolled passkey (no_passkey).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'404':
|
||||||
|
description: The caller has no initiated migration to confirm (no_migration).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
|
||||||
|
/api/v1/account/migrate/confirm/passkey/finish:
|
||||||
|
post:
|
||||||
|
tags: [account]
|
||||||
|
operationId: migrateConfirmPasskeyFinish
|
||||||
|
summary: Finish the passkey assertion and confirm the migration (spec §B3 step-up).
|
||||||
|
description: >
|
||||||
|
Verifies the WebAuthn assertion against the fresh migrate-purpose challenge and,
|
||||||
|
like the login door, applies the authenticator sign-count clone check: a cloned
|
||||||
|
authenticator is rejected fail-closed (400 passkey_login_invalid) and audited. On
|
||||||
|
success the migration advances to confirmed with confirm_factor passkey.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [assertion]
|
||||||
|
properties:
|
||||||
|
assertion:
|
||||||
|
type: object
|
||||||
|
description: The navigator.credentials.get() PublicKeyCredential assertion.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Migration confirmed.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [confirmed]
|
||||||
|
properties:
|
||||||
|
confirmed: { type: boolean, const: true }
|
||||||
|
'400':
|
||||||
|
description: Assertion invalid, challenge stale, or a cloned authenticator was detected (passkey_login_invalid).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'404':
|
||||||
|
description: The caller has no initiated migration to confirm (no_migration).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
|
||||||
|
/api/v1/account/migrate/issue-code:
|
||||||
|
post:
|
||||||
|
tags: [account]
|
||||||
|
operationId: migrateIssueCode
|
||||||
|
summary: Name the target account and mint the one-time migration code (spec §B3 inherit).
|
||||||
|
description: >
|
||||||
|
For a confirmed migration, binds the named target account and mints a single
|
||||||
|
one-time code (only its hash is stored) that the target must redeem while logged
|
||||||
|
in AS that target — an intercepted code is useless to anyone else. The target
|
||||||
|
must exist and be neither disabled nor soft-deleted, and cannot be the source.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [target_user_id]
|
||||||
|
properties:
|
||||||
|
target_user_id: { type: string }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Code minted, bound to the named target.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [code, expires_at]
|
||||||
|
properties:
|
||||||
|
code: { type: string }
|
||||||
|
expires_at: { type: string, format: date-time }
|
||||||
|
'400':
|
||||||
|
description: >
|
||||||
|
The target is the source itself (invalid_target), does not exist
|
||||||
|
(target_not_found), or is disabled/retired (target_unavailable).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'404':
|
||||||
|
description: The caller has no migration to issue against (no_migration).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'409':
|
||||||
|
description: The migration has not been confirmed by a step-up yet (not_confirmed).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
|
||||||
|
/api/v1/account/migrate/redeem:
|
||||||
|
post:
|
||||||
|
tags: [account]
|
||||||
|
operationId: migrateRedeem
|
||||||
|
summary: Redeem a migration code as the named target and inherit the source's owned servers (spec §B3 inherit).
|
||||||
|
description: >
|
||||||
|
The authenticated caller — who must be the target named at issue time — spends
|
||||||
|
the one-time code. In a single atomic step the source's owned servers are
|
||||||
|
re-pointed to the caller and the source account is retired (disabled and
|
||||||
|
soft-deleted), which also spends the code so it cannot be replayed. The caller
|
||||||
|
keeps its own in-game identity and credentials; only server ownership moves.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: app
|
||||||
|
security: [{ accessJWT: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [code]
|
||||||
|
properties:
|
||||||
|
code: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Migration redeemed; owned servers moved to the caller.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [migrated, servers_moved, servers]
|
||||||
|
properties:
|
||||||
|
migrated: { type: boolean, const: true }
|
||||||
|
servers_moved: { type: integer, format: int32 }
|
||||||
|
servers:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
'400':
|
||||||
|
description: Unknown, expired, or already-spent code, or the caller is not the named target (invalid_code).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
|
||||||
/api/v1/me/submissions:
|
/api/v1/me/submissions:
|
||||||
post:
|
post:
|
||||||
tags: [submissions]
|
tags: [submissions]
|
||||||
|
|||||||
@@ -243,6 +243,11 @@ func (a *API) internalAPIRoutes() []apiRoute {
|
|||||||
// and keyed by the verified UUID (not the scanned code), so it consumes nothing
|
// and keyed by the verified UUID (not the scanned code), so it consumes nothing
|
||||||
// and is safe to poll repeatedly.
|
// and is safe to poll repeatedly.
|
||||||
{Method: "GET", Pattern: "/api/v1/internal/account/link/status/{mc_uuid}", h: a.handleLinkStatus},
|
{Method: "GET", Pattern: "/api/v1/internal/account/link/status/{mc_uuid}", h: a.handleLinkStatus},
|
||||||
|
// Account migration (spec §B3 inherit), in-game side: /felis migrate puts the
|
||||||
|
// account linked to the running player's verified UUID into migrate mode. Internal
|
||||||
|
// only — the initiator is proven by online-mode auth, and the sensitive proof
|
||||||
|
// (step-up) still happens web-side before anything transfers.
|
||||||
|
{Method: "POST", Pattern: "/api/v1/internal/account/migrate/start", h: a.handleMigrateStart},
|
||||||
// Username-collision reclaim (spec §B3): velocity records a Mojang-priority
|
// Username-collision reclaim (spec §B3): velocity records a Mojang-priority
|
||||||
// reclaim (bar the squatter UUID + stash its data for 30 days) and gates the
|
// reclaim (bar the squatter UUID + stash its data for 30 days) and gates the
|
||||||
// limbo login by checking whether a connecting UUID was barred. Internal-only —
|
// limbo login by checking whether a connecting UUID was barred. Internal-only —
|
||||||
@@ -365,6 +370,19 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
{Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish},
|
{Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish},
|
||||||
{Method: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList},
|
{Method: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList},
|
||||||
{Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", SetupAllowed: true, h: a.handlePasskeyDelete},
|
{Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", SetupAllowed: true, h: a.handlePasskeyDelete},
|
||||||
|
// Account migration (spec §B3 inherit), web side. App-tier, principal-scoped: the
|
||||||
|
// SOURCE drives status → step-up confirm (passkey forced when enrolled, else
|
||||||
|
// email-OTP) → issue-code+name-target; the TARGET drives redeem as itself. Not
|
||||||
|
// SetupAllowed — migrating is a normal post-onboarding operation, never part of
|
||||||
|
// lockdown enrollment. The step-up is a FRESH proof, so a stolen session alone
|
||||||
|
// cannot advance a migration.
|
||||||
|
{Method: "GET", Pattern: "/api/v1/account/migrate", h: a.handleMigrateStatus},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/account/migrate/confirm/otp/start", h: a.handleMigrateConfirmOTPStart},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/account/migrate/confirm/otp/verify", h: a.handleMigrateConfirmOTPVerify},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/account/migrate/confirm/passkey/begin", h: a.handleMigrateConfirmPasskeyBegin},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/account/migrate/confirm/passkey/finish", h: a.handleMigrateConfirmPasskeyFinish},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/account/migrate/issue-code", h: a.handleMigrateIssueCode},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/account/migrate/redeem", h: a.handleMigrateRedeem},
|
||||||
// Modpack submission (user-directed lane over §16), user side: a user files an upload for review
|
// Modpack submission (user-directed lane over §16), user side: a user files an upload for review
|
||||||
// and lists their own. App-tier — the submitter and the "my uploads" scope are
|
// and lists their own. App-tier — the submitter and the "my uploads" scope are
|
||||||
// both taken from the principal, never the body, so an ordinary authenticated
|
// both taken from the principal, never the body, so an ordinary authenticated
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ type fakeRepo struct {
|
|||||||
// pingErr, when non-nil, is returned by Ping to simulate DB liveness check
|
// pingErr, when non-nil, is returned by Ping to simulate DB liveness check
|
||||||
// failures in /readyz tests.
|
// failures in /readyz tests.
|
||||||
pingErr error
|
pingErr error
|
||||||
|
// account migration (spec §B3 inherit, scenario A) keyed by row id, mirroring
|
||||||
|
// account_migrations. Server ownership for the transfer is read/written on
|
||||||
|
// byName[*].OwnerID (the servers projection), and the source's retirement is
|
||||||
|
// applied to seededUsers, so the fake exercises the same all-or-nothing shape the
|
||||||
|
// PG transaction enforces.
|
||||||
|
migrations map[string]*fakeMigration
|
||||||
}
|
}
|
||||||
|
|
||||||
// fakePasskeyChallenge mirrors a webauthn_challenges row: its owner and purpose, the
|
// fakePasskeyChallenge mirrors a webauthn_challenges row: its owner and purpose, the
|
||||||
@@ -213,6 +219,7 @@ func newFakeRepo() *fakeRepo {
|
|||||||
|
|
||||||
discoverableChallenges: map[string]*fakeDiscoverableChallenge{},
|
discoverableChallenges: map[string]*fakeDiscoverableChallenge{},
|
||||||
fakeQuotas: map[string]*QuotaView{},
|
fakeQuotas: map[string]*QuotaView{},
|
||||||
|
migrations: map[string]*fakeMigration{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,6 +928,134 @@ func (f *fakeRepo) SetUserDisabled(_ context.Context, userID string, disabled bo
|
|||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- account migration fakes (spec §B3 inherit, scenario A) ----
|
||||||
|
|
||||||
|
// fakeMigration mirrors an account_migrations row through its state machine. Zero
|
||||||
|
// times mean the corresponding NULL column (not yet confirmed / no code issued).
|
||||||
|
type fakeMigration struct {
|
||||||
|
id string
|
||||||
|
sourceUserID string
|
||||||
|
targetUserID string
|
||||||
|
state string
|
||||||
|
confirmFactor string
|
||||||
|
confirmedAt time.Time
|
||||||
|
codeHash string
|
||||||
|
codeExpiresAt time.Time
|
||||||
|
redeemedAt time.Time
|
||||||
|
createdAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *fakeMigration) view() *MigrationView {
|
||||||
|
v := &MigrationView{
|
||||||
|
ID: m.id, SourceUserID: m.sourceUserID, TargetUserID: m.targetUserID,
|
||||||
|
State: m.state, ConfirmFactor: m.confirmFactor, CreatedAt: m.createdAt,
|
||||||
|
}
|
||||||
|
if !m.confirmedAt.IsZero() {
|
||||||
|
t := m.confirmedAt
|
||||||
|
v.ConfirmedAt = &t
|
||||||
|
}
|
||||||
|
if !m.codeExpiresAt.IsZero() {
|
||||||
|
t := m.codeExpiresAt
|
||||||
|
v.CodeExpiresAt = &t
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// userLive mirrors the PG "id = $1 AND deleted_at IS NULL" guard: a seeded, not-yet-
|
||||||
|
// retired user is live; an unknown or soft-deleted one is not.
|
||||||
|
func (f *fakeRepo) userLive(userID string) bool {
|
||||||
|
for _, su := range f.seededUsers {
|
||||||
|
if su.view.ID == userID {
|
||||||
|
return su.detail.DeletedAt == nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) StartMigration(_ context.Context, id, sourceUserID string, now time.Time) error {
|
||||||
|
if !f.userLive(sourceUserID) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
for k, m := range f.migrations { // supersede any prior non-redeemed row for the source
|
||||||
|
if m.sourceUserID == sourceUserID && m.state != "redeemed" {
|
||||||
|
delete(f.migrations, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.migrations[id] = &fakeMigration{
|
||||||
|
id: id, sourceUserID: sourceUserID, state: "initiated", createdAt: now,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) MigrationForSource(_ context.Context, sourceUserID string) (*MigrationView, error) {
|
||||||
|
for _, m := range f.migrations {
|
||||||
|
if m.sourceUserID == sourceUserID && m.state != "redeemed" {
|
||||||
|
return m.view(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) ConfirmMigration(_ context.Context, sourceUserID, factor string, now time.Time) error {
|
||||||
|
for _, m := range f.migrations {
|
||||||
|
if m.sourceUserID == sourceUserID && m.state == "initiated" {
|
||||||
|
m.state = "confirmed"
|
||||||
|
m.confirmFactor = factor
|
||||||
|
m.confirmedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) IssueMigrationCode(_ context.Context, sourceUserID, targetUserID, codeHash string, expiresAt time.Time) error {
|
||||||
|
for _, m := range f.migrations {
|
||||||
|
if m.sourceUserID == sourceUserID && m.state == "confirmed" {
|
||||||
|
m.state = "code_issued"
|
||||||
|
m.targetUserID = targetUserID
|
||||||
|
m.codeHash = codeHash
|
||||||
|
m.codeExpiresAt = expiresAt
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRepo) RedeemMigration(_ context.Context, targetUserID, codeHash string, now time.Time) (string, []string, error) {
|
||||||
|
var mig *fakeMigration
|
||||||
|
for _, m := range f.migrations {
|
||||||
|
if m.codeHash == codeHash && m.targetUserID == targetUserID &&
|
||||||
|
m.state == "code_issued" && m.codeExpiresAt.After(now) {
|
||||||
|
mig = m
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mig == nil {
|
||||||
|
return "", nil, ErrLinkCodeInvalid
|
||||||
|
}
|
||||||
|
// Re-point every server the source owns to the target (byName holds pointers).
|
||||||
|
var moved []string
|
||||||
|
for name, rec := range f.byName {
|
||||||
|
if rec.OwnerID == mig.sourceUserID {
|
||||||
|
rec.OwnerID = targetUserID
|
||||||
|
moved = append(moved, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(moved) // deterministic for assertions
|
||||||
|
// Retire the source: disable + soft-delete.
|
||||||
|
for i, su := range f.seededUsers {
|
||||||
|
if su.view.ID == mig.sourceUserID {
|
||||||
|
f.seededUsers[i].view.Disabled = true
|
||||||
|
f.seededUsers[i].detail.Disabled = true
|
||||||
|
t := now
|
||||||
|
f.seededUsers[i].detail.DeletedAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mig.state = "redeemed"
|
||||||
|
mig.redeemedAt = now
|
||||||
|
return mig.sourceUserID, moved, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---- quota admin fakes ----
|
// ---- quota admin fakes ----
|
||||||
|
|
||||||
func (f *fakeRepo) GetQuotas(_ context.Context, userID string) (*QuotaView, error) {
|
func (f *fakeRepo) GetQuotas(_ context.Context, userID string) (*QuotaView, error) {
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account migration (spec §B3 inherit, scenario A). A LIVE old account hands its
|
||||||
|
// owned servers to a new account and is retired. The flow, and which side of the
|
||||||
|
// house each step lives on:
|
||||||
|
//
|
||||||
|
// 1. in-game /felis migrate → handleMigrateStart (internal face): the
|
||||||
|
// source, known by its verified mc_uuid, enters
|
||||||
|
// migrate mode (state 'initiated').
|
||||||
|
// 2. web step-up confirm → handleMigrateConfirm{OTP,Passkey}*: the
|
||||||
|
// source proves control with a FRESH factor —
|
||||||
|
// passkey if any is enrolled (forced), else an
|
||||||
|
// email-OTP — advancing to 'confirmed'. Mere
|
||||||
|
// session possession is never enough; a stolen
|
||||||
|
// session cannot read the mailbox nor present the
|
||||||
|
// authenticator.
|
||||||
|
// 3. web issue code + name target → handleMigrateIssueCode: the source names the
|
||||||
|
// target account by id and mints a one-time code
|
||||||
|
// ('code_issued').
|
||||||
|
// 4. web target redeems code → handleMigrateRedeem: the target, logged in as
|
||||||
|
// itself, submits the code; ownership of the
|
||||||
|
// source's servers moves to the target and the
|
||||||
|
// source is retired ('redeemed').
|
||||||
|
//
|
||||||
|
// The code is bound to the named target at issue AND the redeemer must authenticate AS
|
||||||
|
// that target, so an intercepted code is useless to anyone else. Only server ownership
|
||||||
|
// moves — the mc_uuid link and web credentials (email, passkeys) stay with their
|
||||||
|
// accounts; moving credentials would make migrate a credential-theft primitive.
|
||||||
|
//
|
||||||
|
// CODE-ONLY (Java/Velocity, not represented here): the /felis migrate command that calls
|
||||||
|
// handleMigrateStart, and the web forms that drive steps 2–4.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// otpPurposeMigrate scopes an email-OTP to the migration step-up, so a
|
||||||
|
// migrate-confirm code never collides with an onboarding or login code for the
|
||||||
|
// same user (see otpPurposeOnboard).
|
||||||
|
otpPurposeMigrate = "migrate_confirm"
|
||||||
|
// passkeyPurposeMigrate scopes a passkey assertion challenge to the migration
|
||||||
|
// step-up, keeping it apart from the login assertion challenge (passkeyPurposeLogin).
|
||||||
|
passkeyPurposeMigrate = "passkey_migrate"
|
||||||
|
// migrateCodeTTL bounds the one-time code the source hands to the target. Short
|
||||||
|
// enough that a leaked code is useless soon, long enough to switch accounts and type.
|
||||||
|
migrateCodeTTL = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// newMigrationID returns an opaque random row id (128 bits, hex) for an
|
||||||
|
// account_migrations row, mirroring the other one-time-handle mints.
|
||||||
|
func newMigrationID() (string, error) {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateStartRequest is the in-game /felis migrate callback body (internal face): the
|
||||||
|
// verified UUID of the player who ran the command. Its linked account becomes the
|
||||||
|
// migration source.
|
||||||
|
type migrateStartRequest struct {
|
||||||
|
MCUUID string `json:"mc_uuid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMigrateStart puts the account linked to a verified in-game UUID into migrate
|
||||||
|
// mode (spec §B3, internal face). It is the server side of /felis migrate: velocity has
|
||||||
|
// already established the UUID via online-mode auth, so the initiator is trustworthy;
|
||||||
|
// the sensitive proof (step-up) still happens on the web before anything transfers. An
|
||||||
|
// unlinked UUID has no account to migrate (404); a retired/already-migrated account
|
||||||
|
// cannot re-initiate (409).
|
||||||
|
func (a *API) handleMigrateStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req migrateStartRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mcUUID := strings.TrimSpace(req.MCUUID)
|
||||||
|
if mcUUID == "" {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "mc_uuid is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sourceUserID, err := a.Repo.UserByMCUUID(r.Context(), mcUUID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeError(w, r, newError(http.StatusNotFound, "not_linked",
|
||||||
|
"this in-game identity is not linked to a Felis account"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := newMigrationID()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Repo.StartMigration(r.Context(), id, sourceUserID, a.now()); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "account_retired",
|
||||||
|
"the linked account can no longer start a migration"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Internal-face event: attribute to the in-game initiator, Source 'internal'.
|
||||||
|
_ = a.Repo.Audit(r.Context(), AuditEntry{
|
||||||
|
Actor: "mc:" + mcUUID,
|
||||||
|
Source: "internal",
|
||||||
|
Action: "account.migrate.start",
|
||||||
|
RequestID: requestIDFromContext(r.Context()),
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"started": true, "state": "initiated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMigrateStatus reports the caller's live migration for the web flow to drive its
|
||||||
|
// next step (spec §B3, external app face). No migration in flight → {active:false}.
|
||||||
|
func (a *API) handleMigrateStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
m, err := a.Repo.MigrationForSource(r.Context(), p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"active": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{"active": true, "state": m.State}
|
||||||
|
if m.TargetUserID != "" {
|
||||||
|
resp["target_user_id"] = m.TargetUserID
|
||||||
|
}
|
||||||
|
if m.ConfirmFactor != "" {
|
||||||
|
resp["confirm_factor"] = m.ConfirmFactor
|
||||||
|
}
|
||||||
|
if m.CodeExpiresAt != nil {
|
||||||
|
resp["code_expires_at"] = m.CodeExpiresAt.UTC()
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireInitiatedMigration loads the caller's live migration and requires it be in
|
||||||
|
// 'initiated' — the only state from which step-up may run. It writes the right error and
|
||||||
|
// returns ok=false when the caller should stop, so the confirm handlers stay flat.
|
||||||
|
func (a *API) requireInitiatedMigration(w http.ResponseWriter, r *http.Request, userID string) (*MigrationView, bool) {
|
||||||
|
m, err := a.Repo.MigrationForSource(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeError(w, r, newError(http.StatusNotFound, "no_migration",
|
||||||
|
"no migration is in progress; start one in-game with /felis migrate"))
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if m.State != "initiated" {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "already_confirmed",
|
||||||
|
"this migration has already been confirmed"))
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// userHasPasskey reports whether the account has any passkey enrolled — the predicate
|
||||||
|
// that forces the passkey factor for the step-up.
|
||||||
|
func (a *API) userHasPasskey(ctx context.Context, userID string) (bool, error) {
|
||||||
|
creds, err := a.Repo.PasskeyCredentialsForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return len(creds) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMigrateConfirmOTPStart mints and delivers a fresh email-OTP for the migration
|
||||||
|
// step-up (spec §B3, external app face). It is refused when the account has a passkey
|
||||||
|
// enrolled — a strong factor must not be downgradable to email for an identity transfer.
|
||||||
|
func (a *API) handleMigrateConfirmOTPStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasPk, err := a.userHasPasskey(r.Context(), p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hasPk {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "passkey_required",
|
||||||
|
"this account has a passkey; confirm the migration with your passkey"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Email == "" {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "no_step_up_factor",
|
||||||
|
"no verified email or passkey on this account to confirm the migration"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Per-recipient cooldown, namespaced apart from the other OTP doors so they never
|
||||||
|
// perturb each other's throttle.
|
||||||
|
emailKey := "migrate:confirm:" + strings.ToLower(p.Email)
|
||||||
|
lim := a.otpLimiter()
|
||||||
|
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
|
||||||
|
"a code was sent recently; wait a moment before requesting another"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
lim.release(emailKey, emailAt)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
code, err := newEmailOTP()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := newOTPID()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt := a.now().Add(otpTTL)
|
||||||
|
if err := a.Repo.CreateEmailOTP(r.Context(), id, p.UserID, p.Email, otpCodeHash(code), otpPurposeMigrate, expiresAt); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.deliverOTP(r.Context(), p.Email, code); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
a.audit(r, auditActor(p), "account.migrate.confirm_otp_sent", "")
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateConfirmOTPVerifyRequest is the OTP step-up verify body: the code from the email.
|
||||||
|
type migrateConfirmOTPVerifyRequest struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMigrateConfirmOTPVerify redeems the migration step-up code and, on a match,
|
||||||
|
// advances the migration to 'confirmed' (spec §B3, external app face). The code lifecycle
|
||||||
|
// is the login-door one (no identity side-effect): the address is already proven.
|
||||||
|
func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
var req migrateConfirmOTPVerifyRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := strings.TrimSpace(req.Code)
|
||||||
|
if code == "" {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), p.UserID, otpPurposeMigrate, otpCodeHash(code), a.now()); {
|
||||||
|
case errors.Is(err, ErrOTPLocked):
|
||||||
|
writeError(w, r, newError(http.StatusTooManyRequests, "otp_locked",
|
||||||
|
"too many incorrect attempts; request a new code"))
|
||||||
|
return
|
||||||
|
case errors.Is(err, ErrOTPInvalid):
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired"))
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "email_otp", a.now()); err != nil {
|
||||||
|
if errors.Is(err, ErrConflict) {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "already_confirmed",
|
||||||
|
"this migration has already been confirmed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.audit(r, auditActor(p), "account.migrate.confirmed", "")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"confirmed": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// migratePasskeyUser builds the PasskeyUser the assertion ceremony needs for the
|
||||||
|
// already-logged-in source (contrast the login door, which resolves it from a typed
|
||||||
|
// email). The credential set must be identical between begin and finish.
|
||||||
|
func migratePasskeyUser(p *Principal, creds []PasskeyCredential) PasskeyUser {
|
||||||
|
name := p.Email
|
||||||
|
if name == "" {
|
||||||
|
name = p.UserID
|
||||||
|
}
|
||||||
|
return PasskeyUser{ID: p.UserID, Name: name, DisplayName: name, Credentials: creds}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMigrateConfirmPasskeyBegin starts a fresh passkey assertion bound to the
|
||||||
|
// migration step-up (spec §B3, external app face). Unlike the login door it needs no
|
||||||
|
// email — the caller is already authenticated — so it scopes the challenge to the
|
||||||
|
// session principal and purpose passkeyPurposeMigrate.
|
||||||
|
func (a *API) handleMigrateConfirmPasskeyBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
if a.Passkey == nil {
|
||||||
|
writeError(w, r, errPasskeyUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(creds) == 0 {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "no_passkey",
|
||||||
|
"no passkey enrolled; confirm the migration with an email code"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options, sessionData, err := a.Passkey.BeginLogin(migratePasskeyUser(p, creds))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed",
|
||||||
|
"could not start passkey confirmation"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := newPasskeyID()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt := a.now().Add(passkeyChallengeTTL)
|
||||||
|
if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, p.UserID, passkeyPurposeMigrate, sessionData, expiresAt); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateConfirmPasskeyFinishRequest is the assertion the browser produced, captured
|
||||||
|
// as raw bytes so the exact response reaches the verifier without re-encoding.
|
||||||
|
type migrateConfirmPasskeyFinishRequest struct {
|
||||||
|
Assertion json.RawMessage `json:"assertion"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMigrateConfirmPasskeyFinish verifies the migration step-up assertion and, on
|
||||||
|
// success, advances the migration to 'confirmed' (spec §B3, external app face). It
|
||||||
|
// consumes the stashed migrate challenge atomically (a missing/expired one → 400).
|
||||||
|
func (a *API) handleMigrateConfirmPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
if a.Passkey == nil {
|
||||||
|
writeError(w, r, errPasskeyUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req migrateConfirmPasskeyFinishRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Assertion) == 0 {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "assertion is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, passkeyPurposeMigrate, a.now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrPasskeyChallengeInvalid) {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||||
|
"passkey confirmation could not be completed; begin again"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
va, err := a.Passkey.FinishLogin(migratePasskeyUser(p, creds), sessionData, bytes.NewReader(req.Assertion))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||||
|
"passkey confirmation could not be completed; begin again"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Same clone policy as the login door (applyAssertionCounter): a rolled-back counter
|
||||||
|
// fails closed with the opaque envelope and advances nothing, so the migrate step-up is
|
||||||
|
// never a weaker sibling that would accept an authenticator login refuses. A clean
|
||||||
|
// assertion advances the stored sign-count, keeping the clone signal meaningful for the
|
||||||
|
// next login.
|
||||||
|
if err := a.applyAssertionCounter(r.Context(), va); err != nil {
|
||||||
|
if errors.Is(err, errPasskeyClonedAuthenticator) {
|
||||||
|
a.audit(r, auditActor(p), "auth.passkey_clone_rejected", va.CredentialID)
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
|
||||||
|
"passkey confirmation could not be completed; begin again"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "passkey", a.now()); err != nil {
|
||||||
|
if errors.Is(err, ErrConflict) {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "already_confirmed",
|
||||||
|
"this migration has already been confirmed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.audit(r, auditActor(p), "account.migrate.confirmed", "")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"confirmed": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateIssueCodeRequest is the issue-code body: the id of the new account the source
|
||||||
|
// nominates to receive its servers.
|
||||||
|
type migrateIssueCodeRequest struct {
|
||||||
|
TargetUserID string `json:"target_user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMigrateIssueCode binds the named target and mints the one-time migrate code
|
||||||
|
// (spec §B3, external app face). Requires the migration to be 'confirmed' (step-up done).
|
||||||
|
// The target must be a live account other than the source. The code is returned once,
|
||||||
|
// out of band to the target; only its hash is stored.
|
||||||
|
func (a *API) handleMigrateIssueCode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
var req migrateIssueCodeRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetID := strings.TrimSpace(req.TargetUserID)
|
||||||
|
if targetID == "" {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "target_user_id is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if targetID == p.UserID {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "invalid_target",
|
||||||
|
"the target account must be different from the source"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := a.Repo.MigrationForSource(r.Context(), p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeError(w, r, newError(http.StatusNotFound, "no_migration",
|
||||||
|
"no migration is in progress; start one in-game with /felis migrate"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.State != "confirmed" {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "not_confirmed",
|
||||||
|
"confirm the migration before issuing a code"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The target must exist and be a live (non-deleted, non-disabled) account. Validate
|
||||||
|
// here so a typo'd id fails with a clear message rather than a bare FK error.
|
||||||
|
target, err := a.Repo.UserDetail(r.Context(), targetID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "target_not_found", "no account with that id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target.DeletedAt != nil || target.Disabled {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "target_unavailable",
|
||||||
|
"the target account is not available"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code, err := newLinkCode()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt := a.now().Add(migrateCodeTTL)
|
||||||
|
if err := a.Repo.IssueMigrationCode(r.Context(), p.UserID, targetID, otpCodeHash(code), expiresAt); err != nil {
|
||||||
|
if errors.Is(err, ErrConflict) {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "not_confirmed",
|
||||||
|
"confirm the migration before issuing a code"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.audit(r, auditActor(p), "account.migrate.code_issued", targetID)
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"code": code, "expires_at": expiresAt.UTC()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateRedeemRequest is the redeem body: the one-time code the target received.
|
||||||
|
type migrateRedeemRequest struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMigrateRedeem spends the migrate code as the named target (spec §B3, external
|
||||||
|
// app face). The redeemer must be authenticated as the account the code was bound to;
|
||||||
|
// a code whose target is a different account simply does not match (an intercepted code
|
||||||
|
// is useless). On success the source's servers are re-pointed to the caller and the
|
||||||
|
// source account is retired, atomically.
|
||||||
|
func (a *API) handleMigrateRedeem(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := principalFromContext(r.Context())
|
||||||
|
var req migrateRedeemRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Trim + uppercase so a target who typed the code with stray spaces or in lowercase
|
||||||
|
// still matches the minted value (the alphabet is uppercase); then hash — the raw
|
||||||
|
// code is never compared against the database.
|
||||||
|
code := strings.ToUpper(strings.TrimSpace(req.Code))
|
||||||
|
if code == "" {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sourceUserID, moved, err := a.Repo.RedeemMigration(r.Context(), p.UserID, otpCodeHash(code), a.now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrLinkCodeInvalid) {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "migrate code is invalid or expired"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.audit(r, auditActor(p), "account.migrate.redeemed", sourceUserID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"migrated": true,
|
||||||
|
"servers_moved": len(moved),
|
||||||
|
"servers": moved,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account-migration tests (spec §B3 inherit, scenario A) across BOTH faces. What they
|
||||||
|
// prove is the handler + state machine (initiated → confirmed → code_issued → redeemed)
|
||||||
|
// and the load-bearing security properties of the flow, against the fakeRepo and a fake
|
||||||
|
// PasskeyVerifier — not the pgrepo SQL nor the WebAuthn crypto (both mirrored here). The
|
||||||
|
// decisive properties, in flow order:
|
||||||
|
//
|
||||||
|
// - Step-up is a FRESH proof, and force-passkey is not downgradable: an account with a
|
||||||
|
// passkey cannot confirm by email (no OTP is even minted).
|
||||||
|
// - The one-time code is bound to the NAMED target at issue AND the redeemer must be
|
||||||
|
// that target, so an intercepted code is useless to anyone else.
|
||||||
|
// - The step-up is never weaker than the login door: a cloned authenticator the login
|
||||||
|
// door refuses is refused here too, and confirms nothing.
|
||||||
|
// - Redeem is a double-spend-safe, atomic transfer: the source's servers move to the
|
||||||
|
// target, the source is retired, and the code cannot be replayed.
|
||||||
|
|
||||||
|
// migrateEnv wires the migration doors over one shared fakeRepo: a capturing mailer (so a
|
||||||
|
// test can read the OTP that production only ever emails) and a fake passkey verifier
|
||||||
|
// primed with fixed options + a verified assertion. mk builds a face for a given
|
||||||
|
// principal — the internal handler ignores it, each external handler is scoped to one
|
||||||
|
// caller — so a test can drive the source and the target (and an interloper) against the
|
||||||
|
// same store.
|
||||||
|
func migrateEnv(repo *fakeRepo) (mk func(*Principal) *API, mailer *captureMailer, v *fakePasskeyVerifier) {
|
||||||
|
mailer = &captureMailer{}
|
||||||
|
v = &fakePasskeyVerifier{
|
||||||
|
options: json.RawMessage(`{"publicKey":{"challenge":"bWlncmF0ZQ"}}`),
|
||||||
|
assertion: VerifiedAssertion{CredentialID: "cred-1", UserVerified: true},
|
||||||
|
}
|
||||||
|
cl := newFakeCluster()
|
||||||
|
mk = func(p *Principal) *API {
|
||||||
|
a := newTestAPI(repo, cl)
|
||||||
|
a.External = staticExternal{p: p}
|
||||||
|
a.Mailer = mailer
|
||||||
|
a.Passkey = v
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return mk, mailer, v
|
||||||
|
}
|
||||||
|
|
||||||
|
// startMigrate drives the in-game /felis migrate call (internal face) for a linked UUID.
|
||||||
|
func startMigrate(t *testing.T, ih http.Handler, mcUUID string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
return do(ih, "POST", "/api/v1/internal/account/migrate/start", `{"mc_uuid":"`+mcUUID+`"}`, jsonHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrateVertical walks the whole slice: an in-game start, an email-OTP step-up
|
||||||
|
// (the source holds no passkey, so email is allowed), a code issued against a named
|
||||||
|
// target, and the target redeeming it — moving exactly the source's servers and retiring
|
||||||
|
// the source. The single-use property closes it: the spent code cannot be replayed.
|
||||||
|
func TestMigrateVertical(t *testing.T) {
|
||||||
|
const uuid = "11111111-1111-1111-1111-111111111111"
|
||||||
|
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||||
|
tgt := &Principal{UserID: "u2", Email: "[email protected]", Role: "user"}
|
||||||
|
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
|
||||||
|
repo.seedUser(UserView{ID: "u2", Username: "new", Email: "[email protected]", Role: "user"})
|
||||||
|
repo.links[uuid] = "u1"
|
||||||
|
repo.byName["alpha"] = &ServerRecord{Name: "alpha", OwnerID: "u1"}
|
||||||
|
repo.byName["beta"] = &ServerRecord{Name: "beta", OwnerID: "u1"}
|
||||||
|
repo.byName["other"] = &ServerRecord{Name: "other", OwnerID: "u2"} // the target's own — must NOT move
|
||||||
|
|
||||||
|
mk, mailer, _ := migrateEnv(repo)
|
||||||
|
ih := mk(src).InternalHandler()
|
||||||
|
ehSrc := mk(src).ExternalHandler()
|
||||||
|
ehTgt := mk(tgt).ExternalHandler()
|
||||||
|
|
||||||
|
// 1) in-game start puts the linked account into migrate mode.
|
||||||
|
if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("start: code = %d, want 201 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(ehSrc, "GET", "/api/v1/account/migrate", "", nil); acctBody(t, w)["state"] != "initiated" {
|
||||||
|
t.Fatalf("status after start = %s, want initiated", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) email-OTP step-up. The code is delivered out of band (captured here), never in
|
||||||
|
// the response, and verifying it advances the migration to confirmed.
|
||||||
|
w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader)
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("otp start: code = %d, want 202 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if _, leaked := acctBody(t, w)["code"]; leaked {
|
||||||
|
t.Error("otp start response must NEVER carry the code")
|
||||||
|
}
|
||||||
|
code := mailer.code
|
||||||
|
if code == "" {
|
||||||
|
t.Fatal("no OTP delivered")
|
||||||
|
}
|
||||||
|
w = do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/verify", `{"code":"`+code+`"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK || acctBody(t, w)["confirmed"] != true {
|
||||||
|
t.Fatalf("otp verify: code = %d body %s, want 200 confirmed", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if b := do(ehSrc, "GET", "/api/v1/account/migrate", "", nil); acctBody(t, b)["confirm_factor"] != "email_otp" {
|
||||||
|
t.Fatalf("status after confirm = %s, want confirm_factor email_otp", b.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) the source names the target and mints a one-time code.
|
||||||
|
w = do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"u2"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("issue-code: code = %d, want 201 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
mcode, _ := acctBody(t, w)["code"].(string)
|
||||||
|
if mcode == "" {
|
||||||
|
t.Fatal("issue-code returned no code")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) the target redeems: exactly the source's two servers move; the target's own is
|
||||||
|
// untouched; the source is retired.
|
||||||
|
w = do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("redeem: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
rb := acctBody(t, w)
|
||||||
|
if rb["migrated"] != true || rb["servers_moved"] != float64(2) {
|
||||||
|
t.Fatalf("redeem body = %v, want migrated:true servers_moved:2", rb)
|
||||||
|
}
|
||||||
|
if repo.byName["alpha"].OwnerID != "u2" || repo.byName["beta"].OwnerID != "u2" {
|
||||||
|
t.Fatalf("source servers not moved: alpha=%s beta=%s", repo.byName["alpha"].OwnerID, repo.byName["beta"].OwnerID)
|
||||||
|
}
|
||||||
|
if repo.byName["other"].OwnerID != "u2" { // was u2 already; a move would be a bug either way
|
||||||
|
t.Fatalf("target's own server changed owner to %s", repo.byName["other"].OwnerID)
|
||||||
|
}
|
||||||
|
if d, _ := repo.UserDetail(context.Background(), "u1"); d == nil || d.DeletedAt == nil || !d.Disabled {
|
||||||
|
t.Fatalf("source account not retired: %+v", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) single-use: the spent code cannot be replayed.
|
||||||
|
if w := do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
|
||||||
|
t.Fatalf("replay of spent code: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrateForcePasskey pins the contract's "若有 Passkey 强制 Passkey": an account with
|
||||||
|
// a passkey enrolled cannot step up by email — the OTP door refuses with passkey_required
|
||||||
|
// and mints NOTHING, so the strong factor is not downgradable for an identity transfer.
|
||||||
|
func TestMigrateForcePasskey(t *testing.T) {
|
||||||
|
const uuid = "22222222-2222-2222-2222-222222222222"
|
||||||
|
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||||
|
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
|
||||||
|
repo.links[uuid] = "u1"
|
||||||
|
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
|
||||||
|
|
||||||
|
mk, mailer, _ := migrateEnv(repo)
|
||||||
|
ih := mk(src).InternalHandler()
|
||||||
|
ehSrc := mk(src).ExternalHandler()
|
||||||
|
|
||||||
|
if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("start: code = %d, want 201 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader)
|
||||||
|
if w.Code != http.StatusConflict || decodeErr(t, w) != "passkey_required" {
|
||||||
|
t.Fatalf("otp start with passkey enrolled: code = %d body %s, want 409 passkey_required", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if mailer.calls != 0 {
|
||||||
|
t.Fatalf("an OTP was minted despite an enrolled passkey (calls=%d)", mailer.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigratePasskeyConfirm drives the passkey step-up: begin stashes exactly one
|
||||||
|
// migrate-purpose challenge for the caller, and finish verifies the assertion against the
|
||||||
|
// SERVER-STASHED session data (the body carries no challenge) and advances the migration
|
||||||
|
// to confirmed with factor 'passkey'.
|
||||||
|
func TestMigratePasskeyConfirm(t *testing.T) {
|
||||||
|
const uuid = "33333333-3333-3333-3333-333333333333"
|
||||||
|
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||||
|
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
|
||||||
|
repo.links[uuid] = "u1"
|
||||||
|
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
|
||||||
|
|
||||||
|
mk, _, v := migrateEnv(repo)
|
||||||
|
ih := mk(src).InternalHandler()
|
||||||
|
ehSrc := mk(src).ExternalHandler()
|
||||||
|
|
||||||
|
if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("start: code = %d, want 201 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// begin: exactly one migrate-purpose challenge stashed for u1, options returned verbatim.
|
||||||
|
w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/begin", "", jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("passkey begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.passkeyChallenges) != 1 {
|
||||||
|
t.Fatalf("begin must stash exactly one challenge, got %d", len(repo.passkeyChallenges))
|
||||||
|
}
|
||||||
|
for _, c := range repo.passkeyChallenges {
|
||||||
|
if c.userID != "u1" || c.purpose != passkeyPurposeMigrate {
|
||||||
|
t.Errorf("stashed challenge = %+v, want user u1 purpose %q", c, passkeyPurposeMigrate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finish: the body carries NO challenge; the stashed blob reaches the verifier only via
|
||||||
|
// store-stash → consume, and a verified assertion confirms the migration.
|
||||||
|
w = do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/finish",
|
||||||
|
`{"assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK || acctBody(t, w)["confirmed"] != true {
|
||||||
|
t.Fatalf("passkey finish: code = %d body %s, want 200 confirmed", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(v.lastSession) == 0 {
|
||||||
|
t.Error("finish must feed the verifier the server-stashed session data")
|
||||||
|
}
|
||||||
|
if m, _ := repo.MigrationForSource(context.Background(), "u1"); m == nil || m.State != "confirmed" || m.ConfirmFactor != "passkey" {
|
||||||
|
t.Fatalf("migration after passkey confirm = %+v, want state confirmed factor passkey", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigratePasskeyCloneRejected proves the step-up is never weaker than the login door:
|
||||||
|
// a cloned authenticator (rolled-back counter) is refused with the opaque envelope and
|
||||||
|
// confirms nothing — the migration stays in initiated.
|
||||||
|
func TestMigratePasskeyCloneRejected(t *testing.T) {
|
||||||
|
const uuid = "44444444-4444-4444-4444-444444444444"
|
||||||
|
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||||
|
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
|
||||||
|
repo.links[uuid] = "u1"
|
||||||
|
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
|
||||||
|
|
||||||
|
mk, _, v := migrateEnv(repo)
|
||||||
|
v.assertion = VerifiedAssertion{CredentialID: "cred-1", UserVerified: true, CloneWarning: true}
|
||||||
|
ih := mk(src).InternalHandler()
|
||||||
|
ehSrc := mk(src).ExternalHandler()
|
||||||
|
|
||||||
|
if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("start: code = %d, want 201 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/begin", "", jsonHeader); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("passkey begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/finish",
|
||||||
|
`{"assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
|
||||||
|
t.Fatalf("cloned authenticator: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if m, _ := repo.MigrationForSource(context.Background(), "u1"); m == nil || m.State != "initiated" {
|
||||||
|
t.Fatalf("migration after clone-rejected finish = %+v, want still initiated", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrateRedeemBinding proves the one-time code is bound to the NAMED target: an
|
||||||
|
// interloper who holds the correct code but is a different account cannot redeem it (it
|
||||||
|
// simply does not match), the transfer does not happen, and the code survives for the
|
||||||
|
// genuine target to spend.
|
||||||
|
func TestMigrateRedeemBinding(t *testing.T) {
|
||||||
|
const uuid = "55555555-5555-5555-5555-555555555555"
|
||||||
|
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||||
|
tgt := &Principal{UserID: "u2", Email: "[email protected]", Role: "user"}
|
||||||
|
interloper := &Principal{UserID: "u3", Email: "[email protected]", Role: "user"}
|
||||||
|
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
|
||||||
|
repo.seedUser(UserView{ID: "u2", Username: "new", Email: "[email protected]", Role: "user"})
|
||||||
|
repo.links[uuid] = "u1"
|
||||||
|
repo.byName["alpha"] = &ServerRecord{Name: "alpha", OwnerID: "u1"}
|
||||||
|
|
||||||
|
mk, mailer, _ := migrateEnv(repo)
|
||||||
|
ih := mk(src).InternalHandler()
|
||||||
|
ehSrc := mk(src).ExternalHandler()
|
||||||
|
|
||||||
|
// Drive to a code issued against u2.
|
||||||
|
if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("start: %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader); w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("otp start: %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/verify", `{"code":"`+mailer.code+`"}`, jsonHeader); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("otp verify: %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w := do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"u2"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("issue-code: %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
mcode, _ := acctBody(t, w)["code"].(string)
|
||||||
|
|
||||||
|
// The interloper holds the correct code but is not the named target: no match.
|
||||||
|
ehEvil := mk(interloper).ExternalHandler()
|
||||||
|
if w := do(ehEvil, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
|
||||||
|
t.Fatalf("interloper redeem: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if repo.byName["alpha"].OwnerID != "u1" {
|
||||||
|
t.Fatalf("server moved on a non-target redeem: owner=%s", repo.byName["alpha"].OwnerID)
|
||||||
|
}
|
||||||
|
if d, _ := repo.UserDetail(context.Background(), "u1"); d.DeletedAt != nil {
|
||||||
|
t.Fatal("source retired on a non-target redeem")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The genuine target still spends the same code — the failed attempt consumed nothing.
|
||||||
|
ehTgt := mk(tgt).ExternalHandler()
|
||||||
|
if w := do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+mcode+`"}`, jsonHeader); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("genuine target redeem: code = %d body %s, want 200", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if repo.byName["alpha"].OwnerID != "u2" {
|
||||||
|
t.Fatalf("server not moved to genuine target: owner=%s", repo.byName["alpha"].OwnerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrateGuards covers the input/state refusals: an unlinked UUID has no account to
|
||||||
|
// migrate; a code cannot be issued before confirmation; the target may be neither the
|
||||||
|
// source itself nor an unknown account.
|
||||||
|
func TestMigrateGuards(t *testing.T) {
|
||||||
|
const uuid = "66666666-6666-6666-6666-666666666666"
|
||||||
|
src := &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}
|
||||||
|
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", Role: "user"})
|
||||||
|
repo.links[uuid] = "u1"
|
||||||
|
|
||||||
|
mk, mailer, _ := migrateEnv(repo)
|
||||||
|
ih := mk(src).InternalHandler()
|
||||||
|
ehSrc := mk(src).ExternalHandler()
|
||||||
|
|
||||||
|
// start on an unlinked UUID → 404 not_linked.
|
||||||
|
if w := startMigrate(t, ih, "00000000-0000-0000-0000-000000000000"); w.Code != http.StatusNotFound || decodeErr(t, w) != "not_linked" {
|
||||||
|
t.Fatalf("start unlinked: code = %d body %s, want 404 not_linked", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real start, then issue-code BEFORE confirming → 409 not_confirmed.
|
||||||
|
if w := startMigrate(t, ih, uuid); w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("start: %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"u1"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_target" {
|
||||||
|
t.Fatalf("issue with target==source: code = %d body %s, want 400 invalid_target", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm (email; no passkey on this account), then the target guards apply.
|
||||||
|
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/start", "", jsonHeader); w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("otp start: %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/otp/verify", `{"code":"`+mailer.code+`"}`, jsonHeader); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("otp verify: %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"ghost"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "target_not_found" {
|
||||||
|
t.Fatalf("issue with unknown target: code = %d body %s, want 400 target_not_found", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1635,6 +1635,186 @@ func (p *PGRepo) LinkAccount(ctx context.Context, userID, mcUUID, authSource str
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- account migration (spec §B3 inherit, scenario A) ----
|
||||||
|
|
||||||
|
// StartMigration puts a live source account into migrate mode. It supersedes any
|
||||||
|
// earlier unfinished migration for the source (so re-running /felis migrate restarts
|
||||||
|
// cleanly, invalidating a prior outstanding code) and inserts a fresh 'initiated' row,
|
||||||
|
// both under one transaction so the partial unique index never sees two live rows.
|
||||||
|
func (p *PGRepo) StartMigration(ctx context.Context, id, sourceUserID string, now time.Time) error {
|
||||||
|
tx, err := p.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
||||||
|
|
||||||
|
// The source must be a live (non-deleted) account; a retired one can never
|
||||||
|
// re-initiate a migration.
|
||||||
|
var live bool
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND deleted_at IS NULL)`,
|
||||||
|
sourceUserID).Scan(&live); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !live {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`DELETE FROM account_migrations WHERE source_user_id = $1 AND state <> 'redeemed'`,
|
||||||
|
sourceUserID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO account_migrations (id, source_user_id, state, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, 'initiated', $3, $3)`,
|
||||||
|
id, sourceUserID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrationForSource loads the live (non-redeemed) migration for a source, or
|
||||||
|
// ErrNotFound when none is in flight.
|
||||||
|
func (p *PGRepo) MigrationForSource(ctx context.Context, sourceUserID string) (*MigrationView, error) {
|
||||||
|
const q = `SELECT id, source_user_id, COALESCE(target_user_id, ''), state,
|
||||||
|
COALESCE(confirm_factor, ''), confirmed_at, code_expires_at, created_at
|
||||||
|
FROM account_migrations
|
||||||
|
WHERE source_user_id = $1 AND state <> 'redeemed'`
|
||||||
|
var v MigrationView
|
||||||
|
switch err := p.db.QueryRowContext(ctx, q, sourceUserID).Scan(
|
||||||
|
&v.ID, &v.SourceUserID, &v.TargetUserID, &v.State,
|
||||||
|
&v.ConfirmFactor, &v.ConfirmedAt, &v.CodeExpiresAt, &v.CreatedAt); {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return nil, ErrNotFound
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmMigration advances 'initiated' → 'confirmed' for the source, stamping the
|
||||||
|
// step-up factor + time. It only advances from 'initiated' (0 rows → ErrConflict), so
|
||||||
|
// the step-up can never be replayed against a later state.
|
||||||
|
func (p *PGRepo) ConfirmMigration(ctx context.Context, sourceUserID, factor string, now time.Time) error {
|
||||||
|
res, err := p.db.ExecContext(ctx,
|
||||||
|
`UPDATE account_migrations
|
||||||
|
SET state = 'confirmed', confirm_factor = $2, confirmed_at = $3
|
||||||
|
WHERE source_user_id = $1 AND state = 'initiated'`,
|
||||||
|
sourceUserID, factor, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueMigrationCode advances 'confirmed' → 'code_issued', binding the target and
|
||||||
|
// storing the one-time code hash + TTL. The caller has already validated the target is
|
||||||
|
// a live account other than the source; the target FK is the backstop. Not-in-confirmed
|
||||||
|
// → ErrConflict.
|
||||||
|
func (p *PGRepo) IssueMigrationCode(ctx context.Context, sourceUserID, targetUserID, codeHash string, expiresAt time.Time) error {
|
||||||
|
res, err := p.db.ExecContext(ctx,
|
||||||
|
`UPDATE account_migrations
|
||||||
|
SET state = 'code_issued', target_user_id = $2, code_hash = $3, code_expires_at = $4
|
||||||
|
WHERE source_user_id = $1 AND state = 'confirmed'`,
|
||||||
|
sourceUserID, targetUserID, codeHash, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeemMigration performs the atomic transfer + retirement in one transaction. See
|
||||||
|
// the interface doc for the full contract.
|
||||||
|
func (p *PGRepo) RedeemMigration(ctx context.Context, targetUserID, codeHash string, now time.Time) (string, []string, error) {
|
||||||
|
tx, err := p.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() //nolint:errcheck // no-op after commit
|
||||||
|
|
||||||
|
// Find and lock the pending migration whose named target is exactly this user. A
|
||||||
|
// code whose target is a different user simply does not match — an intercepted
|
||||||
|
// code is useless to a non-target. FOR UPDATE serializes concurrent redeems.
|
||||||
|
var migID, sourceUserID string
|
||||||
|
switch err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT id, source_user_id FROM account_migrations
|
||||||
|
WHERE code_hash = $1 AND target_user_id = $2 AND state = 'code_issued'
|
||||||
|
AND code_expires_at > $3
|
||||||
|
FOR UPDATE`,
|
||||||
|
codeHash, targetUserID, now).Scan(&migID, &sourceUserID); {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return "", nil, ErrLinkCodeInvalid
|
||||||
|
case err != nil:
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-point every server the source owns to the target, collecting the names for
|
||||||
|
// the audit trail. Server ownership is the only thing that moves.
|
||||||
|
rows, err := tx.QueryContext(ctx,
|
||||||
|
`UPDATE servers SET owner_id = $2, claimed_at = now()
|
||||||
|
WHERE owner_id = $1 AND deleted_at IS NULL
|
||||||
|
RETURNING name`,
|
||||||
|
sourceUserID, targetUserID)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
var moved []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
moved = append(moved, name)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// Retire the source: revoke its live sessions and soft-delete it so it can neither
|
||||||
|
// log in nor start another migration (double-spend defense). The servers just moved
|
||||||
|
// away, so there is nothing left to release.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||||
|
sourceUserID); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE users SET disabled = true, deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
sourceUserID); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the migration terminal.
|
||||||
|
if _, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE account_migrations SET state = 'redeemed', redeemed_at = $2 WHERE id = $1`,
|
||||||
|
migID, now); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return sourceUserID, moved, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---- pre-session email login (spec §B) ----
|
// ---- pre-session email login (spec §B) ----
|
||||||
|
|
||||||
// UserByEmail resolves a VERIFIED email address to its login projection, or
|
// UserByEmail resolves a VERIFIED email address to its login projection, or
|
||||||
|
|||||||
@@ -141,6 +141,22 @@ type OpLoginRequest struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MigrationView is the live account-migration for a source user (spec §B3 inherit,
|
||||||
|
// scenario A): its state-machine position and the fields the web step-up, issue-code,
|
||||||
|
// and status paths read. TargetUserID is empty until a code is issued; ConfirmFactor
|
||||||
|
// and ConfirmedAt are empty/nil until the source completes step-up; CodeExpiresAt is
|
||||||
|
// nil until code_issued.
|
||||||
|
type MigrationView struct {
|
||||||
|
ID string
|
||||||
|
SourceUserID string
|
||||||
|
TargetUserID string
|
||||||
|
State string
|
||||||
|
ConfirmFactor string
|
||||||
|
ConfirmedAt *time.Time
|
||||||
|
CodeExpiresAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// Repo is the business-layer data access the API depends on. It is an interface
|
// Repo is the business-layer data access the API depends on. It is an interface
|
||||||
// so handlers are tested against an in-memory fake; the Postgres implementation
|
// so handlers are tested against an in-memory fake; the Postgres implementation
|
||||||
// (pgRepo) is integration-tested only — it requires a live database.
|
// (pgRepo) is integration-tested only — it requires a live database.
|
||||||
@@ -557,6 +573,43 @@ type Repo interface {
|
|||||||
// idempotent. authSource records which Yggdrasil established the UUID
|
// idempotent. authSource records which Yggdrasil established the UUID
|
||||||
// (mojang | thirdparty, spec §10 dual-Yggdrasil).
|
// (mojang | thirdparty, spec §10 dual-Yggdrasil).
|
||||||
LinkAccount(ctx context.Context, userID, mcUUID, authSource string) error
|
LinkAccount(ctx context.Context, userID, mcUUID, authSource string) error
|
||||||
|
|
||||||
|
// ---- account migration (spec §B3 inherit, scenario A) ----
|
||||||
|
|
||||||
|
// StartMigration puts a LIVE source account into migrate mode: it supersedes any
|
||||||
|
// earlier unfinished migration for the source (so re-running /felis migrate
|
||||||
|
// restarts cleanly, invalidating a prior outstanding code) and inserts a fresh row
|
||||||
|
// in state 'initiated'. id is the opaque handle. The source must be a live
|
||||||
|
// (non-deleted) account — ErrNotFound otherwise, so a retired account can never
|
||||||
|
// re-initiate. now stamps the row.
|
||||||
|
StartMigration(ctx context.Context, id, sourceUserID string, now time.Time) error
|
||||||
|
// MigrationForSource loads the live (non-redeemed) migration for a source, or
|
||||||
|
// ErrNotFound when none is in flight. The web status/confirm/issue paths use it to
|
||||||
|
// gate each step on the correct prior state.
|
||||||
|
MigrationForSource(ctx context.Context, sourceUserID string) (*MigrationView, error)
|
||||||
|
// ConfirmMigration records that the source proved control via a FRESH step-up
|
||||||
|
// (factor 'passkey' | 'email_otp'), advancing 'initiated' → 'confirmed'. It only
|
||||||
|
// advances from 'initiated'; any other current state (or no migration) → ErrConflict,
|
||||||
|
// so a confirmed/code_issued/redeemed migration can never be re-confirmed and the
|
||||||
|
// step-up cannot be replayed. now stamps confirmed_at.
|
||||||
|
ConfirmMigration(ctx context.Context, sourceUserID, factor string, now time.Time) error
|
||||||
|
// IssueMigrationCode binds the named target and stores the one-time code hash,
|
||||||
|
// advancing 'confirmed' → 'code_issued'. targetUserID must be a live account other
|
||||||
|
// than the source (validated by the caller before this call); the target FK also
|
||||||
|
// guarantees the row exists. codeHash is the sha-256 of the code; expiresAt is its
|
||||||
|
// TTL. A migration not in 'confirmed' → ErrConflict.
|
||||||
|
IssueMigrationCode(ctx context.Context, sourceUserID, targetUserID, codeHash string, expiresAt time.Time) error
|
||||||
|
// RedeemMigration is the ATOMIC transfer: keyed by (codeHash, targetUserID) it
|
||||||
|
// finds the 'code_issued', unexpired migration whose named target is exactly the
|
||||||
|
// redeeming user, re-points every server owned by the source to the target, retires
|
||||||
|
// the source account (disabled + soft-deleted, its live sessions revoked), and marks
|
||||||
|
// the migration 'redeemed' — all in one transaction. It returns the source user id
|
||||||
|
// and the moved server names for the audit trail. No matching or expired code, or a
|
||||||
|
// code whose named target is a different user → ErrLinkCodeInvalid (an intercepted
|
||||||
|
// code is useless to anyone but the named target). Server ownership is the only thing
|
||||||
|
// moved — the mc_uuid link and web credentials stay with their accounts. now drives
|
||||||
|
// expiry and the terminal timestamps.
|
||||||
|
RedeemMigration(ctx context.Context, targetUserID, codeHash string, now time.Time) (sourceUserID string, movedServers []string, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- user admin types ----
|
// ---- user admin types ----
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- Account migration (spec §B3 inherit): a LIVE old account hands its owned servers
|
||||||
|
-- to a new account and is then retired. This is scenario A (the source runs
|
||||||
|
-- /felis migrate in-game), distinct from the eviction-inherit hook on
|
||||||
|
-- player_data_holds (scenario B, a barred UUID's stashed data).
|
||||||
|
--
|
||||||
|
-- State machine (one live migration per source at a time):
|
||||||
|
-- initiated -- /felis migrate in-game put the source account into migrate mode
|
||||||
|
-- confirmed -- source proved control via a FRESH web step-up (passkey if any is
|
||||||
|
-- enrolled, else email-OTP) — never mere session possession
|
||||||
|
-- code_issued -- source named the target account by id and minted a one-time code
|
||||||
|
-- redeemed -- target logged in, entered the code; owned servers re-pointed to the
|
||||||
|
-- target and the source account disabled (terminal)
|
||||||
|
--
|
||||||
|
-- The transfer moves server ownership only. It does NOT move the mc_uuid link (the
|
||||||
|
-- target keeps the in-game identity it logged in with) nor web credentials (email /
|
||||||
|
-- passkeys stay with the source) — moving credentials would make migrate a
|
||||||
|
-- credential-theft primitive.
|
||||||
|
CREATE TABLE account_migrations (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
source_user_id text NOT NULL REFERENCES users(id),
|
||||||
|
target_user_id text REFERENCES users(id), -- NULL until code_issued (named at issue time)
|
||||||
|
state text NOT NULL, -- initiated|confirmed|code_issued|redeemed
|
||||||
|
confirm_factor text, -- 'passkey'|'email_otp'; NULL until confirmed
|
||||||
|
confirmed_at timestamptz, -- when the step-up proof landed
|
||||||
|
code_hash text, -- sha-256 of the one-time code; NULL until code_issued
|
||||||
|
code_expires_at timestamptz, -- one-time code TTL; NULL until code_issued
|
||||||
|
redeemed_at timestamptz, -- when the target spent the code (terminal)
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- At most one live (non-terminal) migration per source. Re-running /felis migrate
|
||||||
|
-- supersedes any earlier unfinished attempt (StartMigration deletes it first), so
|
||||||
|
-- this guards against two concurrent live migrations racing the same source.
|
||||||
|
CREATE UNIQUE INDEX idx_account_migrations_live_source
|
||||||
|
ON account_migrations (source_user_id)
|
||||||
|
WHERE state <> 'redeemed';
|
||||||
|
|
||||||
|
-- Redeem resolves a submitted code to its pending migration. Scoped to code_issued
|
||||||
|
-- so spent/superseded rows never match.
|
||||||
|
CREATE INDEX idx_account_migrations_code
|
||||||
|
ON account_migrations (code_hash)
|
||||||
|
WHERE code_hash IS NOT NULL AND state = 'code_issued';
|
||||||
|
|
||||||
|
-- Reuse the shared updated_at trigger installed in 0010_user_management.sql.
|
||||||
|
CREATE TRIGGER trg_account_migrations_updated_at
|
||||||
|
BEFORE UPDATE ON account_migrations
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION felis_set_updated_at();
|
||||||
Reference in new issue
Block a user