diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 3f5e9f2..b4ce7da 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -5104,9 +5104,12 @@ paths: 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. + confirmation step-up is still needed, which factor confirmed it and until + when, the named target, and the one-time code's expiry once issued. The + step-up counts only for the session that gave it and for 10 minutes, so a + confirmation made in another session, one that lapsed, and a code that + expired unspent all read as initiated. active:false when the caller has no + live migration. x-felis-face: [external] x-felis-tier: app security: [{ sessionCookie: [] }] @@ -5128,6 +5131,10 @@ paths: confirm_factor: type: string enum: [passkey, email_otp] + confirm_expires_at: + type: string + format: date-time + description: Present while state is confirmed; the code must be issued before it. code_expires_at: { type: string, format: date-time } '401': $ref: '#/components/responses/Unauthorized' @@ -5335,10 +5342,13 @@ paths: 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. + For a migration confirmed by a step-up in this same session within the last + 10 minutes, 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. The + source's verified address is sent a notice naming the target and the expiry, + and another when the code is redeemed. x-felis-face: [external] x-felis-tier: app security: [{ sessionCookie: [] }] @@ -5377,7 +5387,9 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } '409': - description: The migration has not been confirmed by a step-up yet (not_confirmed). + description: > + No step-up from this session within the last 10 minutes, or a code is + already out (not_confirmed). content: application/json: schema: { $ref: '#/components/schemas/Error' } diff --git a/internal/api/account_notice.go b/internal/api/account_notice.go index 526aef3..dac70c5 100644 --- a/internal/api/account_notice.go +++ b/internal/api/account_notice.go @@ -11,10 +11,11 @@ import ( ) // Account change notices tell the owner of an account, at the verified address, -// that a way into it was just added, removed or moved: a passkey registered or -// removed, the email replaced (that notice goes to the OLD address, which is the -// one the owner still reads if someone else made the change). They carry the time -// and the source address and say what to do if the change was not theirs. +// that a way into it, or what it owns, was just added, removed or moved: a passkey +// registered or removed, the email replaced (that notice goes to the OLD address, +// which is the one the owner still reads if someone else made the change), a +// migration code issued against it or redeemed. They carry the time and the source +// address and say what to do if the change was not theirs. // Best effort, like the lock notice: the change already happened. // notifyAccountChange mails one notice to the given address. @@ -91,9 +92,58 @@ func (a *API) noticeIP(r *http.Request) string { return "" } +// notifyMigrateCodeIssued tells the source account's owner that a code now stands to +// hand its servers to target. A code the owner did not issue still has to be redeemed, +// and running /felis migrate again voids it. +func (a *API) notifyMigrateCodeIssued(r *http.Request, to, target string, expires time.Time) { + exp := expires.UTC().Format("2006-01-02 15:04 MST") + subject, body := renderNotice( + "已签发迁移码", "migration code issued", + "你的 Felis 账户刚刚签发了迁移码。账户「"+target+"」在 "+exp+" 前兑换后,你名下的全部服务器会转给它,本账户随即停用。", + "A migration code was just issued on your Felis account. If the account \""+target+"\" redeems it before "+exp+", every server you own moves to it and this account is retired.", + "请立即在游戏里重新执行 /felis migrate 让这个迁移码作废,再登录 Felis 在账户页退出其它设备,然后联系服务器管理员。", + "run /felis migrate in game right away to void this code, sign in to Felis and sign out other devices on the Account page, then contact the server operator.", + a.now(), a.noticeIP(r)) + a.notifyAccountChange(r, to, subject, body) +} + +// notifyMigrateRedeemed tells the retired source account where its servers went. The +// account can no longer sign in, so the notice goes to the address it had proved. +func (a *API) notifyMigrateRedeemed(r *http.Request, sourceUserID string, target *Principal, moved []string) { + src, err := a.Repo.UserDetail(r.Context(), sourceUserID) + if err != nil { + log.Printf("auth: migration notice to the source account was not sent (request_id=%s): %v", + requestIDFromContext(r.Context()), err) + return + } + if !src.EmailVerified { + return + } + zhWhat := "你的 Felis 账户刚刚迁移给了账户「" + target.Username + "」,本账户已停用,所有登录已退出。" + enWhat := "Your Felis account was just migrated to the account \"" + target.Username + "\". This account is retired and every device was signed out." + if len(moved) > 0 { + list := strings.Join(moved, ", ") + zhWhat += fmt.Sprintf("转过去的 %d 台服务器:%s。", len(moved), list) + enWhat += fmt.Sprintf(" The %d servers that moved: %s.", len(moved), list) + } + subject, body := renderNotice("服务器已迁出", "servers migrated away", zhWhat, enWhat, + "请立即联系服务器管理员。", "contact the server operator right away.", + a.now(), a.noticeIP(r)) + a.notifyAccountChange(r, src.Email, subject, body) +} + // accountChangeNotice renders a bilingual notice. zhUndo/enUndo name the step // that reverses the change, for the "if this wasn't you" line. func accountChangeNotice(zhTitle, enTitle, zhWhat, enWhat, zhUndo, enUndo string, at time.Time, ip string) (subject, body string) { + return renderNotice(zhTitle, enTitle, zhWhat, enWhat, + "请立即登录 Felis,在账户页"+zhUndo+"并退出其它设备,然后联系服务器管理员。", + "sign in to Felis now, "+enUndo+" and sign out other devices on the Account page, then contact the server operator.", + at, ip) +} + +// renderNotice lays out a bilingual notice; zhIfNot/enIfNot finish the "if this +// wasn't you" line. +func renderNotice(zhTitle, enTitle, zhWhat, enWhat, zhIfNot, enIfNot string, at time.Time, ip string) (subject, body string) { when := at.UTC().Format("2006-01-02 15:04 MST") zhIP, enIP := ip, ip if ip == "" { @@ -103,13 +153,13 @@ func accountChangeNotice(zhTitle, enTitle, zhWhat, enWhat, zhUndo, enUndo string body = fmt.Sprintf(`%s 时间:%s 来源 IP:%s -如果不是你本人操作,请立即登录 Felis,在账户页%s并退出其它设备,然后联系服务器管理员。 +如果不是你本人操作,%s %s Time: %s From IP: %s -If this wasn't you, sign in to Felis now, %s and sign out other devices on the Account page, then contact the server operator. -`, zhWhat, when, zhIP, zhUndo, enWhat, when, enIP, enUndo) +If this wasn't you, %s +`, zhWhat, when, zhIP, zhIfNot, enWhat, when, enIP, enIfNot) return subject, body } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index fdcec1c..677a788 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1310,22 +1310,24 @@ func (f *fakeRepo) SetUserDisabled(_ context.Context, userID string, disabled bo // 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 + id string + sourceUserID string + targetUserID string + state string + confirmFactor string + confirmSession 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, + State: m.state, ConfirmFactor: m.confirmFactor, ConfirmSession: m.confirmSession, + CreatedAt: m.createdAt, } if !m.confirmedAt.IsZero() { t := m.confirmedAt @@ -1373,21 +1375,29 @@ func (f *fakeRepo) MigrationForSource(_ context.Context, sourceUserID string) (* return nil, ErrNotFound } -func (f *fakeRepo) ConfirmMigration(_ context.Context, sourceUserID, factor string, now time.Time) error { +func (f *fakeRepo) ConfirmMigration(_ context.Context, sourceUserID, factor, session string, now time.Time) error { for _, m := range f.migrations { - if m.sourceUserID == sourceUserID && m.state == "initiated" { + if m.sourceUserID != sourceUserID { + continue + } + lapsed := m.state == "confirmed" && (m.confirmSession != session || !m.confirmedAt.After(now.Add(-migrateConfirmWindow))) + expired := m.state == "code_issued" && !m.codeExpiresAt.After(now) + if m.state == "initiated" || lapsed || expired { m.state = "confirmed" m.confirmFactor = factor + m.confirmSession = session m.confirmedAt = now + m.targetUserID, m.codeHash, m.codeExpiresAt = "", "", time.Time{} return nil } } return ErrConflict } -func (f *fakeRepo) IssueMigrationCode(_ context.Context, sourceUserID, targetUserID, codeHash string, expiresAt time.Time) error { +func (f *fakeRepo) IssueMigrationCode(_ context.Context, sourceUserID, targetUserID, session, codeHash string, now, expiresAt time.Time) error { for _, m := range f.migrations { - if m.sourceUserID == sourceUserID && m.state == "confirmed" { + if m.sourceUserID == sourceUserID && m.state == "confirmed" && + m.confirmSession == session && m.confirmedAt.After(now.Add(-migrateConfirmWindow)) { m.state = "code_issued" m.targetUserID = targetUserID m.codeHash = codeHash diff --git a/internal/api/handlers_account_migrate.go b/internal/api/handlers_account_migrate.go index 17b75be..2ed1eaf 100644 --- a/internal/api/handlers_account_migrate.go +++ b/internal/api/handlers_account_migrate.go @@ -28,16 +28,21 @@ import ( // (reauth.go). // 3. web issue code + name target → handleMigrateIssueCode: the source names the // target account by id and mints a one-time code -// ('code_issued'). +// ('code_issued'). Only the session that gave the +// step-up may, within migrateConfirmWindow of it, +// and the source's mailbox is told. // 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'). +// source is retired ('redeemed'), and told so. // // 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. +// that target, so an intercepted code is useless to anyone else. Binding the step-up to +// its session and a short window keeps a confirmation from outliving the moment: any +// other live session of the source (a shared machine, a stolen cookie) meets the +// step-up again instead of a door left open. 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. @@ -53,8 +58,30 @@ const ( // 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 + // migrateConfirmWindow is how long the step-up lets the session that gave it issue + // the code. Enough to paste the target's account id. + migrateConfirmWindow = 10 * time.Minute ) +// migrationStage is where the caller on session stands in m at now: the stored state, +// except that a confirmation this session cannot use (another session's, or older +// than migrateConfirmWindow) and a code that expired unspent put the caller back at +// the step-up, "initiated". ConfirmMigration and IssueMigrationCode apply the same +// rules in SQL. +func migrationStage(m *MigrationView, session string, now time.Time) string { + switch m.State { + case "confirmed": + if m.ConfirmSession != session || m.ConfirmedAt == nil || !now.Before(m.ConfirmedAt.Add(migrateConfirmWindow)) { + return "initiated" + } + case "code_issued": + if m.CodeExpiresAt == nil || !now.Before(*m.CodeExpiresAt) { + return "initiated" + } + } + return m.State +} + // 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) { @@ -123,7 +150,9 @@ func (a *API) handleMigrateStart(w http.ResponseWriter, r *http.Request) { } // 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}. +// next step (spec §B3, external app face). No migration in flight → {active:false}. The +// state is migrationStage's, so a confirmation made elsewhere or lapsed reads as +// "initiated" and the panel asks for the step-up again. func (a *API) handleMigrateStatus(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) m, err := a.Repo.MigrationForSource(r.Context(), p.UserID) @@ -135,22 +164,24 @@ func (a *API) handleMigrateStatus(w http.ResponseWriter, r *http.Request) { 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 != "" { + stage := migrationStage(m, currentSessionHash(r), a.now()) + resp := map[string]any{"active": true, "state": stage} + switch stage { + case "confirmed": resp["confirm_factor"] = m.ConfirmFactor - } - if m.CodeExpiresAt != nil { + resp["confirm_expires_at"] = m.ConfirmedAt.Add(migrateConfirmWindow).UTC() + case "code_issued": + resp["confirm_factor"] = m.ConfirmFactor + resp["target_user_id"] = m.TargetUserID 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. +// requireInitiatedMigration loads the caller's live migration and requires migrationStage +// to put the caller at 'initiated' — the only stage 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 { @@ -162,7 +193,7 @@ func (a *API) requireInitiatedMigration(w http.ResponseWriter, r *http.Request, writeError(w, r, err) return nil, false } - if m.State != "initiated" { + if migrationStage(m, currentSessionHash(r), a.now()) != "initiated" { writeError(w, r, newError(http.StatusConflict, "already_confirmed", "this migration has already been confirmed")) return nil, false @@ -229,7 +260,7 @@ func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Reque if !a.verifyStepUpOTP(w, r, p, otpPurposeMigrate, "migrate_confirm", code) { return } - if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "email_otp", a.now()); err != nil { + if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "email_otp", currentSessionHash(r), a.now()); err != nil { if errors.Is(err, ErrConflict) { writeError(w, r, newError(http.StatusConflict, "already_confirmed", "this migration has already been confirmed")) @@ -283,7 +314,7 @@ func (a *API) handleMigrateConfirmPasskeyFinish(w http.ResponseWriter, r *http.R if !a.finishStepUpPasskey(w, r, p, passkeyPurposeMigrate, "migrate_passkey", req.Assertion) { return } - if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "passkey", a.now()); err != nil { + if err := a.Repo.ConfirmMigration(r.Context(), p.UserID, "passkey", currentSessionHash(r), a.now()); err != nil { if errors.Is(err, ErrConflict) { writeError(w, r, newError(http.StatusConflict, "already_confirmed", "this migration has already been confirmed")) @@ -303,9 +334,11 @@ type migrateIssueCodeRequest struct { } // 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. +// (spec §B3, external app face). Requires the step-up done on this session within +// migrateConfirmWindow. 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. The +// source's verified address is told, so a code its owner did not issue does not go +// unnoticed. func (a *API) handleMigrateIssueCode(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) var req migrateIssueCodeRequest @@ -333,9 +366,9 @@ func (a *API) handleMigrateIssueCode(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } - if m.State != "confirmed" { - writeError(w, r, newError(http.StatusConflict, "not_confirmed", - "confirm the migration before issuing a code")) + session, now := currentSessionHash(r), a.now() + if migrationStage(m, session, now) != "confirmed" { + writeError(w, r, errMigrateNotConfirmed) return } // The target must exist and be a live (non-deleted, non-disabled) account. Validate @@ -359,20 +392,24 @@ func (a *API) handleMigrateIssueCode(w http.ResponseWriter, r *http.Request) { 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 { + expiresAt := now.Add(migrateCodeTTL) + if err := a.Repo.IssueMigrationCode(r.Context(), p.UserID, targetID, session, otpCodeHash(code), now, expiresAt); err != nil { if errors.Is(err, ErrConflict) { - writeError(w, r, newError(http.StatusConflict, "not_confirmed", - "confirm the migration before issuing a code")) + writeError(w, r, errMigrateNotConfirmed) return } writeError(w, r, err) return } a.audit(r, "account.migrate.code_issued", targetID) + a.notifyMigrateCodeIssued(r, verifiedEmail(p), target.Username, expiresAt) writeJSON(w, http.StatusCreated, map[string]any{"code": code, "expires_at": expiresAt.UTC()}) } +// errMigrateNotConfirmed refuses a code to a caller without a usable step-up. +var errMigrateNotConfirmed = newError(http.StatusConflict, "not_confirmed", + "confirm the migration on this browser first; a confirmation lasts 10 minutes") + // migrateRedeemRequest is the redeem body: the one-time code the target received. type migrateRedeemRequest struct { Code string `json:"code"` @@ -408,6 +445,7 @@ func (a *API) handleMigrateRedeem(w http.ResponseWriter, r *http.Request) { return } a.audit(r, "account.migrate.redeemed", sourceUserID) + a.notifyMigrateRedeemed(r, sourceUserID, p, moved) writeJSON(w, http.StatusOK, map[string]any{ "migrated": true, "servers_moved": len(moved), diff --git a/internal/api/handlers_account_migrate_test.go b/internal/api/handlers_account_migrate_test.go index 37c2e3a..a886423 100644 --- a/internal/api/handlers_account_migrate_test.go +++ b/internal/api/handlers_account_migrate_test.go @@ -5,7 +5,9 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + "time" ) // Account-migration tests (spec §B3 inherit, scenario A) across BOTH faces. What they @@ -24,13 +26,13 @@ import ( // 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 +// test can read the OTP that production only ever emails, and the notices) 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{} +func migrateEnv(repo *fakeRepo) (mk func(*Principal) *API, mailer *noticeMailer, v *fakePasskeyVerifier) { + mailer = ¬iceMailer{} v = &fakePasskeyVerifier{ options: json.RawMessage(`{"publicKey":{"challenge":"bWlncmF0ZQ"}}`), assertion: VerifiedAssertion{CredentialID: "cred-1", UserVerified: true}, @@ -137,6 +139,11 @@ func TestMigrateVertical(t *testing.T) { 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()) } + + // 6) the source never proved its address, so no notice went there. + if len(mailer.notices) != 0 { + t.Fatalf("notices to an unverified address = %q, want none", mailer.notices) + } } // TestMigrateForcePasskey pins the contract's "若有 Passkey 强制 Passkey": an account with @@ -347,3 +354,117 @@ func TestMigrateGuards(t *testing.T) { t.Fatalf("issue with unknown target: code = %d body %s, want 400 target_not_found", w.Code, w.Body.String()) } } + +// TestMigrateConfirmHoldsForOneSessionBriefly pins who may issue the code: only the +// browser session that gave the step-up, and only for migrateConfirmWindow. Any other +// live session of the source, or the same one later, meets the step-up again; a code +// that expires unspent does too. The source's mailbox hears about each code and about +// the redeem. +func TestMigrateConfirmHoldsForOneSessionBriefly(t *testing.T) { + const uuid = "77777777-7777-7777-7777-777777777777" + src := &Principal{UserID: "u1", Username: "old", Email: "old@example.net", EmailVerified: true, Role: "user", ViaSession: true} + tgt := &Principal{UserID: "u2", Username: "new", Email: "new@example.net", EmailVerified: true, Role: "user", ViaSession: true} + + repo := newFakeRepo() + repo.seedUser(UserView{ID: "u1", Username: "old", Email: "old@example.net", EmailVerified: true, Role: "user"}) + repo.seedUser(UserView{ID: "u2", Username: "new", Email: "new@example.net", EmailVerified: true, Role: "user"}) + repo.links[uuid] = "u1" + repo.byName["alpha"] = &ServerRecord{Name: "alpha", OwnerID: "u1"} + repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", UserVerified: true, CreatedAt: frozenNow} + + mk, mail, _ := migrateEnv(repo) + now := time.Unix(1_700_000_000, 0) + external := func(p *Principal) http.Handler { + a := mk(p) + a.Now = func() time.Time { return now } + return a.ExternalHandler() + } + ehSrc, ehTgt := external(src), external(tgt) + onA := map[string]string{"Content-Type": "application/json", "Cookie": sessionCookieName + "=cookie-a"} + onB := map[string]string{"Content-Type": "application/json", "Cookie": sessionCookieName + "=cookie-b"} + + confirm := func(h map[string]string) { + t.Helper() + if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/begin", "", h); w.Code != http.StatusOK { + t.Fatalf("passkey begin: %d (%s)", w.Code, w.Body.String()) + } + if w := do(ehSrc, "POST", "/api/v1/account/migrate/confirm/passkey/finish", + `{"assertion":{"id":"cred-1","type":"public-key"}}`, h); w.Code != http.StatusOK { + t.Fatalf("passkey finish: %d (%s)", w.Code, w.Body.String()) + } + } + status := func(h map[string]string) map[string]any { + t.Helper() + return acctBody(t, do(ehSrc, "GET", "/api/v1/account/migrate", "", h)) + } + issue := func(h map[string]string) *httptest.ResponseRecorder { + return do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"u2"}`, h) + } + refused := func(what string, w *httptest.ResponseRecorder) { + t.Helper() + if w.Code != http.StatusConflict || decodeErr(t, w) != "not_confirmed" { + t.Fatalf("%s: code = %d body %s, want 409 not_confirmed", what, w.Code, w.Body.String()) + } + } + + if w := startMigrate(t, mk(src).InternalHandler(), uuid); w.Code != http.StatusCreated { + t.Fatalf("start: %d (%s)", w.Code, w.Body.String()) + } + confirm(onA) + if st := status(onA); st["state"] != "confirmed" || st["confirm_expires_at"] != now.Add(migrateConfirmWindow).UTC().Format(time.RFC3339) { + t.Fatalf("status on a after its confirmation = %v, want confirmed until +%v", st, migrateConfirmWindow) + } + if st := status(onB); st["state"] != "initiated" { + t.Fatalf("status on b = %v, want initiated: b has not confirmed", st) + } + refused("issue from b", issue(onB)) + refused("issue with no session", issue(jsonHeader)) + + now = now.Add(migrateConfirmWindow) + refused("issue from a once its window closed", issue(onA)) + refused("issue to an unknown account from a once its window closed", + do(ehSrc, "POST", "/api/v1/account/migrate/issue-code", `{"target_user_id":"ghost"}`, onA)) + if st := status(onA); st["state"] != "initiated" { + t.Fatalf("status on a after its window = %v, want initiated", st) + } + confirm(onA) + if len(mail.notices) != 0 { + t.Fatalf("notices before any code = %q, want none", mail.notices) + } + w := issue(onA) + if w.Code != http.StatusCreated { + t.Fatalf("issue from a inside its window: %d (%s)", w.Code, w.Body.String()) + } + first, _ := acctBody(t, w)["code"].(string) + exp := now.Add(migrateCodeTTL).UTC().Format("2006-01-02 15:04 MST") + if len(mail.notices) != 1 || !strings.HasPrefix(mail.notices[0], "old@example.net|") || + !strings.Contains(mail.notices[0], "「new」") || !strings.Contains(mail.notices[0], exp) || + !strings.Contains(mail.notices[0], "/felis migrate") { + t.Fatalf("notices after the code = %q, want one to old@example.net naming new, %s and how to void it", mail.notices, exp) + } + if st := status(onA); st["state"] != "code_issued" || st["target_user_id"] != "u2" { + t.Fatalf("status after the code = %v, want code_issued for u2", st) + } + + // The code expires unspent: back to the step-up, and the old code is dead. + now = now.Add(migrateCodeTTL) + if st := status(onA); st["state"] != "initiated" { + t.Fatalf("status after the code expired = %v, want initiated", st) + } + confirm(onA) + if w := do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+first+`"}`, jsonHeader); w.Code != http.StatusBadRequest { + t.Fatalf("redeem of the expired code: %d (%s), want 400", w.Code, w.Body.String()) + } + w = issue(onA) + if w.Code != http.StatusCreated { + t.Fatalf("issue after the expired code: %d (%s)", w.Code, w.Body.String()) + } + second, _ := acctBody(t, w)["code"].(string) + if w := do(ehTgt, "POST", "/api/v1/account/migrate/redeem", `{"code":"`+second+`"}`, jsonHeader); w.Code != http.StatusOK { + t.Fatalf("redeem: %d (%s)", w.Code, w.Body.String()) + } + if n := len(mail.notices); n != 3 || !strings.HasPrefix(mail.notices[2], "old@example.net|") || + !strings.Contains(mail.notices[2], "「new」") || !strings.Contains(mail.notices[2], "alpha") { + t.Fatalf("notices after the redeem = %q, want a third to old@example.net naming new and alpha", mail.notices) + } +} diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index c52827c..10a99cd 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -2259,13 +2259,14 @@ func (p *PGRepo) StartMigration(ctx context.Context, id, sourceUserID string, no // 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 + COALESCE(confirm_factor, ''), COALESCE(confirm_session, ''), 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); { + &v.ConfirmFactor, &v.ConfirmSession, &v.ConfirmedAt, &v.CodeExpiresAt, &v.CreatedAt); { case errors.Is(err, sql.ErrNoRows): return nil, ErrNotFound case err != nil: @@ -2274,15 +2275,20 @@ func (p *PGRepo) MigrationForSource(ctx context.Context, sourceUserID string) (* 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 { +// ConfirmMigration moves the source's migration to 'confirmed', stamping the step-up +// factor, time and session. It advances only from where migrationStage puts a caller +// back at the step-up (0 rows → ErrConflict): 'initiated', a confirmation that lapsed +// or belongs to another session, or a code that expired unspent, which it clears. +func (p *PGRepo) ConfirmMigration(ctx context.Context, sourceUserID, factor, session 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) + SET state = 'confirmed', confirm_factor = $2, confirmed_at = $3, confirm_session = $4, + target_user_id = NULL, code_hash = NULL, code_expires_at = NULL + WHERE source_user_id = $1 AND ( + state = 'initiated' + OR (state = 'confirmed' AND (confirm_session IS DISTINCT FROM $4 OR confirmed_at <= $5)) + OR (state = 'code_issued' AND code_expires_at <= $3))`, + sourceUserID, factor, now, session, now.Add(-migrateConfirmWindow)) if err != nil { return err } @@ -2297,15 +2303,17 @@ func (p *PGRepo) ConfirmMigration(ctx context.Context, sourceUserID, factor stri } // 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 { +// storing the one-time code hash + TTL. The confirmation must be this session's and +// younger than migrateConfirmWindow at now. The caller has already validated the +// target is a live account other than the source; the target FK is the backstop. +// Anything else → ErrConflict. +func (p *PGRepo) IssueMigrationCode(ctx context.Context, sourceUserID, targetUserID, session, codeHash string, now, 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) + WHERE source_user_id = $1 AND state = 'confirmed' + AND confirm_session = $5 AND confirmed_at > $6`, + sourceUserID, targetUserID, codeHash, expiresAt, session, now.Add(-migrateConfirmWindow)) if err != nil { return err } diff --git a/internal/api/repo.go b/internal/api/repo.go index 82c155b..d1d1e3b 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -233,18 +233,20 @@ type NewOpLoginRequest struct { // 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. +// and status paths read. TargetUserID is empty until a code is issued; ConfirmFactor, +// ConfirmSession and ConfirmedAt are empty/nil until the source completes step-up; +// CodeExpiresAt is nil until code_issued. ConfirmSession is the token hash of the +// session that gave the step-up. type MigrationView struct { - ID string - SourceUserID string - TargetUserID string - State string - ConfirmFactor string - ConfirmedAt *time.Time - CodeExpiresAt *time.Time - CreatedAt time.Time + ID string + SourceUserID string + TargetUserID string + State string + ConfirmFactor string + ConfirmSession 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 @@ -762,17 +764,20 @@ type Repo interface { // 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 + // (factor 'passkey' | 'email_otp') on the session whose token hash is session, + // advancing to 'confirmed'. It advances only from where migrationStage puts the + // caller back at the step-up: 'initiated', a 'confirmed' that lapsed + // (migrateConfirmWindow) or belongs to another session, or a 'code_issued' whose + // code expired at now, which it clears. A live confirmation of this session, a live + // code, a redeemed migration or none → ErrConflict. now stamps confirmed_at. + ConfirmMigration(ctx context.Context, sourceUserID, factor, session 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 + // advancing 'confirmed' → 'code_issued'. The confirmation must be session's and + // younger than migrateConfirmWindow at now. 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. Anything else → ErrConflict. + IssueMigrationCode(ctx context.Context, sourceUserID, targetUserID, session, codeHash string, now, 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 diff --git a/internal/pgint/accounts_test.go b/internal/pgint/accounts_test.go index cec0a63..28c2091 100644 --- a/internal/pgint/accounts_test.go +++ b/internal/pgint/accounts_test.go @@ -53,10 +53,10 @@ func startToCode(t *testing.T, sourceID, targetID, codeHash string, now, expires if err := repo.StartMigration(ctx, "mig-"+suffix(t), sourceID, now); err != nil { t.Fatalf("StartMigration: %v", err) } - if err := repo.ConfirmMigration(ctx, sourceID, "passkey", now); err != nil { + if err := repo.ConfirmMigration(ctx, sourceID, "passkey", "sess-src", now); err != nil { t.Fatalf("ConfirmMigration: %v", err) } - if err := repo.IssueMigrationCode(ctx, sourceID, targetID, codeHash, expiresAt); err != nil { + if err := repo.IssueMigrationCode(ctx, sourceID, targetID, "sess-src", codeHash, now, expiresAt); err != nil { t.Fatalf("IssueMigrationCode: %v", err) } } @@ -82,7 +82,7 @@ func TestMigrationStateMachine(t *testing.T) { if _, err := repo.MigrationForSource(ctx, src.ID); !errors.Is(err, api.ErrNotFound) { t.Fatalf("status before start = %v, want ErrNotFound", err) } - if err := repo.ConfirmMigration(ctx, src.ID, "passkey", t0); !errors.Is(err, api.ErrConflict) { + if err := repo.ConfirmMigration(ctx, src.ID, "passkey", "sess-src", t0); !errors.Is(err, api.ErrConflict) { t.Fatalf("confirm before start = %v, want ErrConflict", err) } if err := repo.StartMigration(ctx, "mig1-"+sfx, src.ID, t0); err != nil { @@ -92,23 +92,23 @@ func TestMigrationStateMachine(t *testing.T) { if err != nil || m.ID != "mig1-"+sfx || m.State != "initiated" || m.TargetUserID != "" || m.ConfirmedAt != nil { t.Fatalf("after start = %+v, %v; want mig1 initiated, no target, unconfirmed", m, err) } - if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, "h-skip", t0.Add(10*time.Minute)); !errors.Is(err, api.ErrConflict) { + if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, "sess-src", "h-skip", t0, t0.Add(10*time.Minute)); !errors.Is(err, api.ErrConflict) { t.Fatalf("issue before confirm = %v, want ErrConflict", err) } - if err := repo.ConfirmMigration(ctx, src.ID, "email_otp", t0); err != nil { + if err := repo.ConfirmMigration(ctx, src.ID, "email_otp", "sess-src", t0); err != nil { t.Fatalf("ConfirmMigration: %v", err) } - if err := repo.ConfirmMigration(ctx, src.ID, "passkey", t0.Add(time.Minute)); !errors.Is(err, api.ErrConflict) { + if err := repo.ConfirmMigration(ctx, src.ID, "passkey", "sess-src", t0.Add(time.Minute)); !errors.Is(err, api.ErrConflict) { t.Fatalf("second confirm = %v, want ErrConflict", err) } m, err = repo.MigrationForSource(ctx, src.ID) if err != nil || m.State != "confirmed" || m.ConfirmFactor != "email_otp" || m.ConfirmedAt == nil || !m.ConfirmedAt.Equal(t0) { t.Fatalf("after confirm = %+v, %v; want confirmed by email_otp at %v", m, err, t0) } - if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, "h-first", t0.Add(10*time.Minute)); err != nil { + if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, "sess-src", "h-first", t0, t0.Add(10*time.Minute)); err != nil { t.Fatalf("IssueMigrationCode: %v", err) } - if err := repo.IssueMigrationCode(ctx, src.ID, bystander.ID, "h-again", t0.Add(10*time.Minute)); !errors.Is(err, api.ErrConflict) { + if err := repo.IssueMigrationCode(ctx, src.ID, bystander.ID, "sess-src", "h-again", t0, t0.Add(10*time.Minute)); !errors.Is(err, api.ErrConflict) { t.Fatalf("second issue = %v, want ErrConflict", err) } m, err = repo.MigrationForSource(ctx, src.ID) @@ -130,10 +130,10 @@ func TestMigrationStateMachine(t *testing.T) { t.Fatalf("code from the superseded attempt = %v, want ErrLinkCodeInvalid", err) } - if err := repo.ConfirmMigration(ctx, src.ID, "passkey", t0); err != nil { + if err := repo.ConfirmMigration(ctx, src.ID, "passkey", "sess-src", t0); err != nil { t.Fatalf("confirm the restart: %v", err) } - if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, "h-second", t0.Add(10*time.Minute)); err != nil { + if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, "sess-src", "h-second", t0, t0.Add(10*time.Minute)); err != nil { t.Fatalf("issue the restart: %v", err) } for _, bad := range []struct { @@ -204,6 +204,76 @@ func TestMigrationStateMachine(t *testing.T) { } } +// The step-up lets only the session that gave it issue the code, and only for +// migrateConfirmWindow (10 minutes). Another session, or the same one later, starts over +// at the step-up; so does a code that expired unspent, which the new confirmation clears. +func TestMigrationConfirmBelongsToOneSessionBriefly(t *testing.T) { + ctx := context.Background() + src := newUser(t, "user", "migw-src") + dst := newUser(t, "user", "migw-dst") + t0 := mustNow().Truncate(time.Second) + const window = 10 * time.Minute + if err := repo.StartMigration(ctx, "migw-"+suffix(t), src.ID, t0); err != nil { + t.Fatalf("StartMigration: %v", err) + } + if err := repo.ConfirmMigration(ctx, src.ID, "passkey", "sess-a", t0); err != nil { + t.Fatalf("confirm on a: %v", err) + } + if m, err := repo.MigrationForSource(ctx, src.ID); err != nil || m.ConfirmSession != "sess-a" { + t.Fatalf("after confirm on a = %+v, %v; want it recorded against sess-a", m, err) + } + for _, bad := range []struct { + what, session string + at time.Time + }{ + {"another session", "sess-b", t0.Add(time.Minute)}, + {"a caller with no session", "", t0.Add(time.Minute)}, + {"the same session once the window closed", "sess-a", t0.Add(window)}, + } { + if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, bad.session, "h-"+bad.session, bad.at, bad.at.Add(10*time.Minute)); !errors.Is(err, api.ErrConflict) { + t.Fatalf("issue from %s = %v, want ErrConflict", bad.what, err) + } + } + if err := repo.ConfirmMigration(ctx, src.ID, "passkey", "sess-a", t0.Add(window-time.Second)); !errors.Is(err, api.ErrConflict) { + t.Fatalf("confirm again on a while its confirmation holds = %v, want ErrConflict", err) + } + + // Another session proves a factor of its own and takes the confirmation over. + t1 := t0.Add(time.Minute) + if err := repo.ConfirmMigration(ctx, src.ID, "email_otp", "sess-b", t1); err != nil { + t.Fatalf("confirm on b: %v", err) + } + if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, "sess-a", "h-a", t1, t1.Add(10*time.Minute)); !errors.Is(err, api.ErrConflict) { + t.Fatalf("issue from a after b confirmed = %v, want ErrConflict", err) + } + issueAt := t1.Add(window - time.Second) + if err := repo.IssueMigrationCode(ctx, src.ID, dst.ID, "sess-b", "h-b", issueAt, issueAt.Add(10*time.Minute)); err != nil { + t.Fatalf("issue from b inside its window: %v", err) + } + + // A live code stands until it expires; then a new step-up clears it. + expiry := issueAt.Add(10 * time.Minute) + if err := repo.ConfirmMigration(ctx, src.ID, "passkey", "sess-a", expiry.Add(-time.Second)); !errors.Is(err, api.ErrConflict) { + t.Fatalf("confirm over a live code = %v, want ErrConflict", err) + } + if err := repo.ConfirmMigration(ctx, src.ID, "passkey", "sess-a", expiry); err != nil { + t.Fatalf("confirm once the code expired: %v", err) + } + m, err := repo.MigrationForSource(ctx, src.ID) + if err != nil || m.State != "confirmed" || m.ConfirmSession != "sess-a" || m.TargetUserID != "" || m.CodeExpiresAt != nil { + t.Fatalf("after confirming over the expired code = %+v, %v; want confirmed on a, no target, no code", m, err) + } + var hash sql.NullString + if err := db.QueryRow(`SELECT code_hash FROM account_migrations WHERE id = $1`, m.ID).Scan(&hash); err != nil || hash.Valid { + t.Fatalf("code hash after the new confirmation = %v, %v; want NULL", hash, err) + } + + // The same session's own confirmation lapses too, and a fresh one replaces it. + if err := repo.ConfirmMigration(ctx, src.ID, "passkey", "sess-a", expiry.Add(window)); err != nil { + t.Fatalf("confirm again on a after its window: %v", err) + } +} + // Racing redeems of one code move the servers once; racing starts for one source // all succeed and leave one live attempt. func TestMigrationUnderConcurrency(t *testing.T) { diff --git a/internal/store/migrations/0033_migration_confirm_session.sql b/internal/store/migrations/0033_migration_confirm_session.sql new file mode 100644 index 0000000..9b6c5bd --- /dev/null +++ b/internal/store/migrations/0033_migration_confirm_session.sql @@ -0,0 +1,6 @@ +-- The migration step-up counts for the session that gave it, and only for a few +-- minutes (migrateConfirmWindow in handlers_account_migrate.go). confirm_session is +-- that session's token hash ('' for a caller the proxy authenticates on every +-- request). Another signed-in session of the source account, or the same one later, +-- must prove a factor again before it can issue the code that hands the servers away. +ALTER TABLE account_migrations ADD COLUMN confirm_session text; diff --git a/panel/src/i18n/resources/en-US/account.json b/panel/src/i18n/resources/en-US/account.json index 88a0d86..ebb6f97 100644 --- a/panel/src/i18n/resources/en-US/account.json +++ b/panel/src/i18n/resources/en-US/account.json @@ -78,6 +78,7 @@ "migration_confirm_otp_btn": "Send a code to my email", "migration_issue_title": "Name the destination account", "migration_issue_desc": "Paste the Account ID shown on the destination account's own page here, then issue a one-time transfer code.", + "migration_issue_deadline": "Issue it before {{time}}. The confirmation counts only in this browser; after that, confirm again. A verified email on this account gets a notice once the code is issued.", "migration_target_placeholder": "Destination Account ID", "migration_issuing": "Issuing…", "migration_issue_btn": "Issue code", diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 4415dfd..1bfdf90 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -73,7 +73,7 @@ "backup_unavailable": "Backups aren't available right now.", "account_retired": "This account has been retired — sign in with the account it was migrated to.", "no_migration": "There is no migration in progress.", - "not_confirmed": "This migration hasn't been confirmed yet.", + "not_confirmed": "Confirm it's you in this browser first. A confirmation lasts 10 minutes.", "already_confirmed": "This migration has already been confirmed.", "invalid_target": "The migration target must be a different account.", "target_not_found": "No account matches that migration target.", diff --git a/panel/src/i18n/resources/zh-CN/account.json b/panel/src/i18n/resources/zh-CN/account.json index 644c10e..1e71d6a 100644 --- a/panel/src/i18n/resources/zh-CN/account.json +++ b/panel/src/i18n/resources/zh-CN/account.json @@ -77,6 +77,7 @@ "migration_confirm_otp_btn": "发送验证码到我的邮箱", "migration_issue_title": "指定目标账户", "migration_issue_desc": "将目标账户本页面显示的「账户 ID」粘贴到此处,然后签发一次性转移码。", + "migration_issue_deadline": "请在 {{time}} 前签发。确认只在当前浏览器有效,过时需要重新确认。签发后,已验证的邮箱会收到一封通知。", "migration_target_placeholder": "目标账户 ID", "migration_issuing": "签发中…", "migration_issue_btn": "签发转移码", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index a088f44..7b7d814 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -73,7 +73,7 @@ "backup_unavailable": "备份功能当前不可用。", "account_retired": "该账户已退役——请使用迁移后的账户登录。", "no_migration": "当前没有进行中的迁移。", - "not_confirmed": "该迁移尚未完成确认。", + "not_confirmed": "请先在当前浏览器完成身份确认,确认 10 分钟内有效。", "already_confirmed": "该迁移已经确认过了。", "invalid_target": "迁移目标账户不能与来源账户相同。", "target_not_found": "找不到迁移目标账户。", diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 4f93eac..72864a2 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -744,6 +744,7 @@ export const api = rejectingSync({ state?: string; target_user_id?: string; confirm_factor?: string; + confirm_expires_at?: string; code_expires_at?: string; }>("GET", "/account/migrate"), diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 44a9dac..c398fac 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -1701,7 +1701,7 @@ export interface paths { }; /** * 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. + * @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 and until when, the named target, and the one-time code's expiry once issued. The step-up counts only for the session that gave it and for 10 minutes, so a confirmation made in another session, one that lapsed, and a code that expired unspent all read as initiated. active:false when the caller has no live migration. */ get: operations["migrateStatus"]; put?: never; @@ -1803,7 +1803,7 @@ export interface paths { put?: never; /** * 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. + * @description For a migration confirmed by a step-up in this same session within the last 10 minutes, 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. The source's verified address is sent a notice naming the target and the expiry, and another when the code is redeemed. */ post: operations["migrateIssueCode"]; delete?: never; @@ -7049,6 +7049,11 @@ export interface operations { target_user_id?: string; /** @enum {string} */ confirm_factor?: "passkey" | "email_otp"; + /** + * Format: date-time + * @description Present while state is confirmed; the code must be issued before it. + */ + confirm_expires_at?: string; /** Format: date-time */ code_expires_at?: string; }; @@ -7315,7 +7320,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description The migration has not been confirmed by a step-up yet (not_confirmed). */ + /** @description No step-up from this session within the last 10 minutes, or a code is already out (not_confirmed). */ 409: { headers: { [name: string]: unknown; diff --git a/panel/src/pages/Account.test.tsx b/panel/src/pages/Account.test.tsx index 997a366..29e798a 100644 --- a/panel/src/pages/Account.test.tsx +++ b/panel/src/pages/Account.test.tsx @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({ passkeyList: vi.fn(), passkeyDelete: vi.fn(), listMySessions: vi.fn(), + migrateStatus: vi.fn(), + migrateIssueCode: vi.fn(), identity: null as Identity | null, })); @@ -21,7 +23,8 @@ vi.mock("@/lib/api", async (importOriginal) => { api: { ...actual.api, linkStatus: () => Promise.resolve({ linked: true }), - migrateStatus: () => Promise.resolve({ active: false }), + migrateStatus: mocks.migrateStatus, + migrateIssueCode: mocks.migrateIssueCode, passkeyList: mocks.passkeyList, passkeyDelete: mocks.passkeyDelete, listMySessions: mocks.listMySessions, @@ -69,6 +72,9 @@ beforeEach(() => { mocks.passkeyDelete.mockReset(); mocks.listMySessions.mockReset(); mocks.listMySessions.mockResolvedValue([thisMac]); + mocks.migrateStatus.mockReset(); + mocks.migrateStatus.mockResolvedValue({ active: false }); + mocks.migrateIssueCode.mockReset(); mocks.identity = identity(true); }); @@ -178,3 +184,25 @@ describe("Account passkey delete", () => { expect(mocks.listMySessions).toHaveBeenCalledTimes(1); }); }); + +describe("Account migration", () => { + it("shows until when the confirmation holds, and asks for it again once issuing is refused", async () => { + mocks.passkeyList.mockResolvedValue({ credentials: [] }); + const until = "2026-09-27T10:10:00Z"; + mocks.migrateStatus + .mockResolvedValueOnce({ active: true, state: "confirmed", confirm_factor: "email_otp", confirm_expires_at: until }) + .mockResolvedValue({ active: true, state: "initiated" }); + mocks.migrateIssueCode.mockRejectedValue({ status: 409, code: "not_confirmed", message: "confirm first" }); + renderAccount(); + + const deadline = t("account:migration_issue_deadline", { time: new Date(until).toLocaleTimeString() }); + expect(await screen.findByText(deadline)).toBeTruthy(); + await userEvent.type(screen.getByPlaceholderText(t("account:migration_target_placeholder")), "u-2"); + await userEvent.click(screen.getByRole("button", { name: t("account:migration_issue_btn") })); + + expect(mocks.migrateIssueCode).toHaveBeenCalledWith("u-2"); + expect(await screen.findByText(t("account:migration_confirm_title"))).toBeTruthy(); + expect(screen.getByText(t("errors:not_confirmed"))).toBeTruthy(); + expect(screen.queryByPlaceholderText(t("account:migration_target_placeholder"))).toBeNull(); + }); +}); diff --git a/panel/src/pages/Account.tsx b/panel/src/pages/Account.tsx index 7c07d4c..0d65d92 100644 --- a/panel/src/pages/Account.tsx +++ b/panel/src/pages/Account.tsx @@ -666,10 +666,12 @@ function LinkForm({ * The flow is born in-game (/felis migrate proves the player) and driven here: * status → step-up confirm (passkey when one is enrolled — the server 409s the * OTP door in that case — else email-OTP) → issue-code (the source names the - * target account and reads a one-time code) → redeem (the TARGET account spends - * the code; the source's servers move over and the source is retired). Both - * roles render on every account: the redeem form is always offered, and the - * account id is always shown so a target can hand it to the source. */ + * target account and reads a one-time code; the confirmation counts only in this + * browser and for 10 minutes, after which the status asks for it again) → redeem + * (the TARGET account spends the code; the source's servers move over and the + * source is retired). Both roles render on every account: the redeem form is + * always offered, and the account id is always shown so a target can hand it to + * the source. */ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: boolean }) { const { t } = useTranslation("account"); const mig = useAsync(() => api.migrateStatus(), []); @@ -775,6 +777,10 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo e.preventDefault(); void run(async () => { await api.migrateConfirmOTPVerify(otpCode.trim()); + // The code is spent; a later step-up (the confirmation lapsed) + // starts from a fresh one. + setOtpSent(false); + setOtpCode(""); await mig.reload(); }); }} @@ -795,19 +801,31 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo )} - {state === "confirmed" && !issued && ( + {state === "confirmed" && (
{ e.preventDefault(); void run(async () => { - setIssued(await api.migrateIssueCode(targetId.trim())); - await mig.reload(); + // Reload either way: a refusal usually means the confirmation + // lapsed, and the status then asks for the step-up again. + try { + setIssued(await api.migrateIssueCode(targetId.trim())); + } finally { + await mig.reload(); + } }); }} >

{t("migration_issue_title")}

{t("migration_issue_desc")}

+ {mig.data?.confirm_expires_at && ( +

+ {t("migration_issue_deadline", { + time: new Date(mig.data.confirm_expires_at).toLocaleTimeString(), + })} +

+ )}
)} - {issued && ( + {issued && state === "code_issued" && (

{t("migration_code_title")}