Unverified Commit ee4065b9 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 9a892541
Loading
Loading
Loading
Loading
+20 −8
Changes for docs/openapi.yaml: 20 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -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' }
+57 −7
Changes for internal/api/account_notice.go: 57 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -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
}

+15 −5
Changes for internal/api/api_test.go: 15 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -1315,6 +1315,7 @@ type fakeMigration struct {
	targetUserID   string
	state          string
	confirmFactor  string
	confirmSession string
	confirmedAt    time.Time
	codeHash       string
	codeExpiresAt  time.Time
@@ -1325,7 +1326,8 @@ type fakeMigration struct {
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
Changes for internal/api/handlers_account_migrate.go: 67 added lines, 29 removed lines.
Original line number Diff line number Diff line
@@ -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

File changed.

Preview size limit exceeded, changes collapsed.

Loading