fix(migrate): 迁移确认只对做确认的会话在 10 分钟内有效,签发和兑换迁移码都给源账户邮箱发通知

This commit is contained in:
Lemon-miaow committed 2026-09-27 02:14:22 +08:00
1 parent 9a89254146
commit ee4065b986
17 files changed
+496 -122

No files matched your search

+57 -7
View File
@@ -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
View File
@@ -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
+67 -29
View File
@@ -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),
+124 -3
View File
@@ -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 = &noticeMailer{}
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
View File
@@ -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
View File
@@ -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
+80 -10
View File
@@ -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;