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:
flyemoji committed 2026-07-05 20:50:48 +09:00
1 parent bbcfaeb6c4
commit fdb6efbd88
8 files changed
+1692

No files matched your search

+18
View File
@@ -243,6 +243,11 @@ func (a *API) internalAPIRoutes() []apiRoute {
// and keyed by the verified UUID (not the scanned code), so it consumes nothing
// and is safe to poll repeatedly.
{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
// 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 —
@@ -365,6 +370,19 @@ func (a *API) externalAPIRoutes() []apiRoute {
{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: "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
// 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
+135
View File
@@ -94,6 +94,12 @@ type fakeRepo struct {
// pingErr, when non-nil, is returned by Ping to simulate DB liveness check
// failures in /readyz tests.
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
@@ -213,6 +219,7 @@ func newFakeRepo() *fakeRepo {
discoverableChallenges: map[string]*fakeDiscoverableChallenge{},
fakeQuotas: map[string]*QuotaView{},
migrations: map[string]*fakeMigration{},
}
}
@@ -921,6 +928,134 @@ func (f *fakeRepo) SetUserDisabled(_ context.Context, userID string, disabled bo
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 ----
func (f *fakeRepo) GetQuotas(_ context.Context, userID string) (*QuotaView, error) {
+541
View File
@@ -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())
}
}
+180
View File
@@ -1635,6 +1635,186 @@ func (p *PGRepo) LinkAccount(ctx context.Context, userID, mcUUID, authSource str
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) ----
// UserByEmail resolves a VERIFIED email address to its login projection, or
+53
View File
@@ -141,6 +141,22 @@ type OpLoginRequest struct {
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
// so handlers are tested against an in-memory fake; the Postgres implementation
// (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
// (mojang | thirdparty, spec §10 dual-Yggdrasil).
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 ----
@@ -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();