fix(migrate): 迁移确认只对做确认的会话在 10 分钟内有效,签发和兑换迁移码都给源账户邮箱发通知
This commit is contained in:
17 files changed
+496
-122
No files matched your search
+20
-8
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+25
-15
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: "[email protected]", EmailVerified: true, Role: "user", ViaSession: true}
|
||||
tgt := &Principal{UserID: "u2", Username: "new", Email: "[email protected]", EmailVerified: true, Role: "user", ViaSession: true}
|
||||
|
||||
repo := newFakeRepo()
|
||||
repo.seedUser(UserView{ID: "u1", Username: "old", Email: "[email protected]", EmailVerified: true, Role: "user"})
|
||||
repo.seedUser(UserView{ID: "u2", Username: "new", Email: "[email protected]", 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], "[email protected]|") ||
|
||||
!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 [email protected] 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], "[email protected]|") ||
|
||||
!strings.Contains(mail.notices[2], "「new」") || !strings.Contains(mail.notices[2], "alpha") {
|
||||
t.Fatalf("notices after the redeem = %q, want a third to [email protected] naming new and alpha", mail.notices)
|
||||
}
|
||||
}
|
||||
+23
-15
@@ -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
|
||||
}
|
||||
|
||||
+26
-21
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "签发转移码",
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"backup_unavailable": "备份功能当前不可用。",
|
||||
"account_retired": "该账户已退役——请使用迁移后的账户登录。",
|
||||
"no_migration": "当前没有进行中的迁移。",
|
||||
"not_confirmed": "该迁移尚未完成确认。",
|
||||
"not_confirmed": "请先在当前浏览器完成身份确认,确认 10 分钟内有效。",
|
||||
"already_confirmed": "该迁移已经确认过了。",
|
||||
"invalid_target": "迁移目标账户不能与来源账户相同。",
|
||||
"target_not_found": "找不到迁移目标账户。",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === "confirmed" && !issued && (
|
||||
{state === "confirmed" && (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="font-medium text-foreground">{t("migration_issue_title")}</p>
|
||||
<p className="text-muted-foreground">{t("migration_issue_desc")}</p>
|
||||
{mig.data?.confirm_expires_at && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("migration_issue_deadline", {
|
||||
time: new Date(mig.data.confirm_expires_at).toLocaleTimeString(),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 max-w-md">
|
||||
<Input
|
||||
value={targetId}
|
||||
@@ -823,7 +841,7 @@ function MigrationCard({ userId, hasPasskey }: { userId?: string; hasPasskey: bo
|
||||
</form>
|
||||
)}
|
||||
|
||||
{issued && (
|
||||
{issued && state === "code_issued" && (
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-foreground">{t("migration_code_title")}</p>
|
||||
<code className="block w-fit rounded bg-muted px-3 py-2 font-mono text-base tracking-[0.2em] text-foreground select-all">
|
||||
|
||||
Reference in new issue
Block a user