土法炼钢兴趣小组的算法知识备份

【身份与访问控制工程】身份系统迁移与事故响应

文章导航

分类入口
architecturesecurity
标签入口
#migration#identity#incident-response#password-migration#token-migration#runbook

目录

身份系统是在线业务的底座。它跟数据库一样,承载核心状态(用户凭据、会话、权限),但又比数据库更难迁移——因为它是一个”活”的系统,随时在接收登录请求,任何一次写入失败、一次签名不匹配、一次 cookie domain 不对,都会立刻被用户感知为”登不上”。

身份迁移不是一次 DB migration,而是一个需要数月、跨多个团队协作、必须带灰度与回滚的工程。本文把笔者在几次迁移中积累的教训梳理成一套可执行的方法论,覆盖动因、策略、密码与 Token 迁移、多 IdP 合并、事故案例、响应 Runbook。

迁移策略

一、为什么要迁移:动因分类

迁移从来不是为了迁移本身。每一次迁移都有明确的业务/技术触发点,搞清楚动因对后续决策至关重要——不同动因对应不同的风险容忍度和时间盒。

1.1 legacy → 现代:老旧 LDAP + 定制认证栈

最常见的场景。企业内部或早期 SaaS 产品,最初用 OpenLDAP/Active Directory + 自己写的认证服务,凭据验证逻辑散落在各个应用里。十年后出现以下问题:

此时迁移到 Keycloak、Auth0、Okta 这类现代 IdP,是一次”技术债一次性还清”的机会。风险在于:老系统承载的是多年沉淀的用户数据与业务语义,迁移过程中一旦丢失或错乱,代价极大。

1.2 自建 → 托管:维护成本高

团队原本自建了一套 IdP,随着业务变化:

迁移到 Auth0/Okta/AWS Cognito 等托管服务,把非差异化的工作外包。核心判断是”认证是否是业务差异化”。详见 平台选型:Buy vs Build

1.3 托管 → 自建:vendor lock-in 与价格

反向迁移也很常见。触发点一般是:

典型迁移目标是 Keycloak。这条路的难点是”你以为用到了 10% 的功能,实际到了开始迁移才发现依赖了 80%“。

1.4 合并收购:两套身份系统并存

公司 A 收购公司 B,A 用 Okta,B 用 Auth0。同一个员工在两边都有账号,同一个外部客户可能也在两边都注册过。

合并的第一个月往往是”双 IdP 共存 + SSO 单向信任”过渡态;后续要做的事:

1.5 合规驱动:出海/进入等保

出海进欧盟(GDPR)、进中国大陆(网络安全法/个保法/数据出境评估)、金融合规(等保三级)等场景,要求:

此时的迁移不一定是”全量替换”,而是”按地域拆分”。这是一种水平切分迁移,本文后续策略章节也适用。

二、迁移策略对比

核心策略有三种,下面详细比较。

2.1 Big-bang:一次性切换

在某个维护窗口(通常是业务低峰、周末凌晨),把所有流量从旧 IdP 切到新 IdP,旧 IdP 下线。

T-7d: 冻结旧 IdP 的配置变更
T-1d: 数据快照 + 校验
T0:   DNS/LB 切换
T+1h: 烟囱测试,观察成功率
T+24h: 正式下线旧 IdP

优点

缺点

适用场景

2.2 Coexistence / Lazy Migration:双轨并行 + 懒惰迁移

旧系统继续运行,新系统承接新用户注册。存量用户按需迁移:用户某次登录时触发迁移(“lazy migration”),迁移后再也不访问旧系统。

                用户登录请求
                      │
                      ▼
              身份路由(Router)
              ┌───────┴───────┐
              ▼               ▼
         新 IdP 存在?     在旧 IdP 验证
              │               │成功
              │               ▼
              │         shadow 写入新 IdP
              │               │
              └───────┬───────┘
                      ▼
                   颁发 token

优点

缺点

适用场景

2.3 Parallel Run:流量复制

新旧系统同时接收登录请求,但只有一个的响应真正返回给用户;另一套作为”影子”对比结果,用于验证新系统行为等价。

def login(username, password):
    old_result = old_idp.authenticate(username, password)
    try:
        new_result = new_idp.authenticate(username, password)
        if not equivalent(old_result, new_result):
            metrics.incr("login.parallel_diff",
                         tags=[f"reason={diff_reason(old_result, new_result)}"])
            log_diff(username, old_result, new_result)
    except Exception as e:
        metrics.incr("login.shadow_error")
    return old_result  # 仍然以旧系统为准

优点

缺点

2.4 渐进式切流:按 userID 哈希灰度

在 parallel run 建立信心后,按用户 ID 哈希分批切真流量到新系统:

def pick_idp(user_id: str) -> str:
    bucket = int(hashlib.sha256(user_id.encode()).hexdigest(), 16) % 100
    if bucket < rollout_percent:   # rollout_percent: 1 → 5 → 20 → 50 → 100
        return "new"
    return "old"

关键实践:

三、密码迁移:最难的部分

密码是唯一不能”备份恢复”就搞定的数据——它以单向 hash 存在,新系统往往不支持旧系统的 hash 算法。这一章展开讨论各种方案。

3.1 明文密码:极少数遗留系统

如果旧系统存的是明文密码(是的,2026 年还有这种系统),直接导入新系统时用新算法重新哈希即可。

for user in legacy_db.query("SELECT id, email, password FROM users"):
    new_hash = bcrypt.hashpw(user.password.encode(), bcrypt.gensalt(12))
    new_idp.create_user(email=user.email, password_hash=new_hash)

本文不展开,重点:做完之后务必在旧系统侧彻底清除明文字段,包括备份、日志、oplog。

3.2 Hash 兼容:直接迁移

如果新旧系统都支持相同的算法(例如都支持 bcrypt $2b$ 格式),直接迁移 hash 字符串即可,用户完全无感:

INSERT INTO new_idp.users (email, password_hash, created_at)
SELECT email, password_hash, created_at FROM legacy.users;

Keycloak 支持导入多种 hash(bcrypt、pbkdf2、scrypt)。Auth0 支持通过 Bulk User Import 导入 bcrypt/pbkdf2 hash。

注意 bcrypt 的 cost 参数:如果旧系统用 cost=10,新系统默认 cost=12,不必强制 re-hash,用户下次登录时再按需升级即可。

3.3 Hash 不兼容:三种主流方案

早期系统常用 md5(password + salt)sha1(password),新系统(Keycloak、Auth0)只支持现代算法(bcrypt/pbkdf2/argon2)。

方案一:包装 hash(Nested Hash)

新系统存储 bcrypt(md5(password + old_salt))。登录时由新系统重算整个嵌套:

def verify_nested(password: str, stored_hash: str, old_salt: str) -> bool:
    inner = hashlib.md5((password + old_salt).encode()).hexdigest()
    return bcrypt.checkpw(inner.encode(), stored_hash.encode())

这样可以一次性把所有用户的 hash 在线下迁移完毕,无需等待用户登录。

优点:迁移一次完成,无双轨。

缺点

方案二:Shadow Migration(拦截登录请求)

用户下次登录时,新系统尚无 hash:

1. 用户提交 email + password
2. 新系统查不到该用户的 hash
3. 新系统调用旧系统 verify(email, password)
4. 旧系统返回 OK
5. 新系统用明文 password 生成 bcrypt hash 存入新 DB
6. 下次登录,新系统直接用本地 hash 验证

核心代码框架:

def authenticate(email: str, password: str) -> AuthResult:
    user = new_db.find_user(email)
    if user and user.password_hash:
        if bcrypt.checkpw(password.encode(), user.password_hash):
            return AuthResult(success=True, user=user)
        return AuthResult(success=False, reason="bad_password")

    legacy_ok = legacy_idp.verify(email, password)
    if not legacy_ok:
        return AuthResult(success=False, reason="bad_password")

    new_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt(12))
    new_db.upsert_user(email=email, password_hash=new_hash,
                       migrated_at=datetime.utcnow())
    metrics.incr("migration.lazy.success")
    return AuthResult(success=True, user=new_db.find_user(email))

优点

缺点

长尾处理:给 N 个月后仍未登录的用户发密码重置邮件,或强制下线。

方案三:强制重置

给所有用户发邮件”因为安全升级,请点击链接重置密码”。

优点:干净,新系统完全没有遗留。

缺点

一般只在”前任系统有安全事件泄露了 hash”或”实在没有其他路径”时采用。

3.4 Auth0 的 password_migration Hook

Auth0 为方案二提供了官方机制:Custom Database Connection + Login Script

function login(email, password, callback) {
  const request = require("request");
  request.post({
    url: "https://legacy.example.com/internal/verify",
    json: { email, password },
    headers: { "X-Internal-Token": configuration.LEGACY_TOKEN }
  }, function (err, resp, body) {
    if (err) return callback(err);
    if (resp.statusCode !== 200) return callback(new WrongUsernameOrPasswordError(email));
    callback(null, {
      user_id: body.user_id,
      email: body.email,
      email_verified: body.email_verified
    });
  });
}

Auth0 首次验证成功后,会自动把用户写入 Auth0 自身的用户库,之后不再回调旧系统。这就是一套开箱即用的 shadow migration。

Keycloak 的 User Storage SPI 提供类似机制。

四、Token 迁移

密码搞定了,Token 是第二道坎。JWT/session cookie 的变更会影响所有已登录会话。

4.1 JWT issuer 变更

迁移前:

{ "iss": "https://auth.old.example.com", "sub": "12345", "exp": 1713456789 }

迁移后:

{ "iss": "https://auth.new.example.com", "sub": "12345", "exp": 1713456789 }

所有验证 JWT 的服务(API Gateway、微服务中间件)都必须同时信任新旧 issuer 的 JWKS。

4.2 Grace Period:双发/双认期

典型做法是维护 30~90 天的 grace period,验证中间件同时信任两套 JWKS:

TRUSTED_ISSUERS = {
    "https://auth.old.example.com": "https://auth.old.example.com/.well-known/jwks.json",
    "https://auth.new.example.com": "https://auth.new.example.com/.well-known/jwks.json",
}

def verify_jwt(token: str) -> Claims:
    unverified = jwt.decode(token, options={"verify_signature": False})
    issuer = unverified.get("iss")
    if issuer not in TRUSTED_ISSUERS:
        raise InvalidToken("unknown_issuer")

    jwks = jwks_cache.get(TRUSTED_ISSUERS[issuer])
    claims = jwt.decode(token, jwks, algorithms=["RS256"], issuer=issuer,
                        audience="api.example.com")
    return claims

grace period 的长度由 token TTL 决定。如果 access token TTL=1h、refresh token TTL=30d,则至少留 30d + 一周缓冲。

关于 JWT 与 JWKS 的更多细节见 JWT 与 JWKS 密钥轮换

4.3 客户端更新节奏(移动 App)

Web 前端好办,刷新页面就拿到新版。移动 App 最难:用户可能几个月不更新 App。

关键原则:App 发版时间线必须早于 grace period 结束

T-60d: App 新版发布(支持同时处理新旧 IdP 返回格式)
T-30d: 新 IdP 开始发 token
T+0:   旧 IdP 停发新 token(grace 开始)
T+30d: 强制升级门槛:老于某版本的 App 启动时弹窗要求升级
T+60d: 旧 IdP JWKS 下线(grace 结束)

“强制升级”必须配合 grace period,否则老版 App 持有的 token 会被拒绝,用户看到的是登录页反复跳转。

4.4 Refresh Token 迁移

Refresh token 不在客户端可见 JWT 里,而是在 IdP 服务端的 session 表里。新旧两套 IdP 不会共享这张表。

处理方法:

笔者的经验是:接受一次全站重新登录比搞复杂的 refresh token 同步划算得多。用户每几个月被迫登录一次是可以接受的,但同步逻辑 bug 会让你赔上几周。

吊销相关可参考 Session 吊销设计

如果登录入口的域名变了(从 auth.old.example.com 迁到 auth.new.example.com),session cookie 的 domain 属性变了,浏览器就不会把旧 cookie 送给新系统:

Set-Cookie: sid=abc; Domain=.old.example.com; HttpOnly; Secure

浏览器不会把这个 cookie 发给 new.example.com。此时唯一合理的处理是让用户重新登录,不要试图在客户端做什么 cookie 转换——这会引入 CSRF 风险。

五、多 IdP 合并时的用户身份映射

合并收购场景下,两套 IdP 的用户数据要合成一套。最难的不是技术,是”同一个人在两边账号怎么合并”。

5.1 通过 email 匹配

前提是 email 已验证且唯一。伪代码:

for a in idp_a.users():
    b = idp_b.find_by_email(a.email)
    if b and b.email_verified and a.email_verified:
        merge_candidates.append((a, b))
    else:
        new_idp.create(a)

坑:email 未验证的账号不能参与匹配,否则任何人都能”抢注”他人邮箱获取合并。

5.2 外部 ID mapping 表

维护一张 identity_mapping 表:

CREATE TABLE identity_mapping (
    canonical_user_id  UUID PRIMARY KEY,
    old_idp_a_user_id  TEXT,
    old_idp_b_user_id  TEXT,
    merged_at          TIMESTAMPTZ,
    merge_strategy     TEXT  -- 'email_match' / 'manual' / 'phone_match'
);

下游所有业务系统都通过 canonical_user_id 引用用户;迁移过程中用这张表做翻译。

5.3 合并冲突处理

两个账号合并时,哪一侧的数据留?规则示例:

这些规则必须写在产品文档里并在合并前告知用户,不要悄悄做合并。

5.4 孤儿账号处理

迁移后可能发现:新系统里存在某个账号,但旧系统里没有对应记录。典型原因:

处置流程:

  1. 日报扫描 diff,人工审核
  2. 标记为 orphan=true,暂时不允许登录(或要求二次验证)
  3. 联系用户确认身份后恢复或删除

六、事故案例:工程师视角的真实教训

6.1 全站登出事故

2022 年 Okta 的一次配置误操作曾短时间影响多家公司 SSO,Cloudflare 事后复盘中强调:核心服务必须能在 IdP 不可用时继续运行

工程上常见的脆弱点:

防御:本地 JWT 验证 + JWKS 缓存、至少一个不走 IdP 的 break-glass 路径、定期故障演练。相关讨论见 认证系统架构

6.2 Login Storm:雷鸣般的登录风暴

事故过程

教训

# 客户端重试必须带抖动
def retry_with_backoff(attempt):
    base = min(30, 2 ** attempt)
    jitter = random.uniform(0, base * 0.5)
    time.sleep(base + jitter)

6.3 CSRF 配置丢失

迁移到新 IdP 后,OAuth 流程里的 state parameter 校验逻辑因为配置项命名变了(csrf.state.verifyoauth.state.required),默认值为 false,上线前没人注意到。

结果:攻击者可以构造一个 OAuth 回调 URL,把受害者”登录”到攻击者的账号(Session Fixation / Cross-Account CSRF),反之亦然。

教训

登录页从 auth.old.example.com 迁到 auth.new.example.com,新登录成功后 Set-Cookie 还写着 Domain=.old.example.com

浏览器表现:登录接口返回 200,但下一次请求 Cookie header 是空的。用户体感:“登录成功又跳回登录页”。

教训

6.5 logout URL 没改

IdP 侧配置的”允许的 post-logout redirect URI”列表,在新 IdP 上漏了 /logout/success。用户退出后 IdP 返回 redirect_uri_not_allowed,跳到 404 页面。

用户以为”这个系统退出不了”,反复刷新,留下了活 session,反而更危险。

教训

七、迁移的可观测性

迁移最怕”盲飞”。以下是必须上线的监控。

7.1 登录成功率 dashboard

按维度拆分:

如果某一组合的成功率突然下跌,立刻能定位。

sum by (idp, method) (rate(login_attempts_total{result="success"}[5m]))
  /
sum by (idp, method) (rate(login_attempts_total[5m]))

7.2 Token 验证失败率

按失败原因拆分,不要只有一个笼统”验证失败”:

7.3 渐进式切流的监控卡口

核心指标有清晰阈值:

rollout_guards:
  - metric: login_success_rate_new
    threshold: 0.995
    window: 5m
    action: pause_rollout
  - metric: token_invalid_signature_rate
    threshold: 0.001
    window: 5m
    action: rollback_to_previous_bucket
  - metric: p99_login_latency_ms
    threshold: 800
    window: 10m
    action: pause_rollout

卡口要能自动触发,不要等人看 dashboard。

7.4 DB migration 进度追踪

SELECT
    COUNT(*) FILTER (WHERE migrated_at IS NOT NULL) AS migrated,
    COUNT(*) AS total,
    COUNT(*) FILTER (WHERE migrated_at IS NOT NULL) * 1.0 / COUNT(*) AS ratio
FROM users;

把这个数字打到 Prometheus,每小时一条,形成趋势图。长尾就能一眼看出来。

八、事故响应 Runbook:身份系统宕机

身份系统挂了跟 DB 挂了一样严重。以下是值班工程师必须提前演练的动作。

8.1 灰度降级:只允许已登录用户

目标:保住已在线的用户体验,不接受新登录。

动作:

8.2 降级登录:关闭 MFA

前提:事先设计好降级路径。

注意:降级必须是一个事先准备好的预案,不是事故中临时写的代码。临时写的代码 100% 会漏洞百出。

8.3 冻结写入:只允许读

当 IdP 的数据层异常(如主从延迟过大、DB 不可写),立即冻结写操作:

防止脏数据在恢复时与 backlog 冲突。

8.4 回滚策略:DB snapshot vs 双写

选哪个取决于”新 IdP 在线多久”和”双写链路质量”。

8.5 通讯链路:状态页与客服通知

触发条件必须事先写死在 Runbook 里:

关键:状态页必须独立于身份系统。不要把状态页也挂在需要登录才能访问的后台里——身份系统挂了连状态页都上不去。

九、工程坑点

按事故等级排序的高频坑。

9.1 时间同步

JWT exp/nbf 对时钟敏感。IdP 与应用服务器时钟差 5 分钟,就会出现”刚签的 token 被拒为 not yet valid”。

防御:NTP 强制;JWT 验证允许 clock_skew=60s

9.2 JWKS 缓存

IdP 在密钥轮换时,应用服务端的 JWKS 缓存如果太激进(24h),会导致新签发的 token 在应用侧验证失败。

建议:缓存 TTL 5~15 分钟;遇到 kid 未知时触发一次强制刷新。

9.3 邮件送达率

密码迁移走强制重置路径时,邮件送达率决定成败。

坑:

9.4 移动 App 审核时延

iOS 审核 1~7 天,Android 较快但也有回滚。迁移时间线必须考虑”最慢的 App 版本什么时候能覆盖 95% 用户”。

9.5 联邦登录(Social / SSO)的 client_id 变更

之前在 Google Cloud Console、Apple Developer、企业 SAML IdP 注册的 redirect_uriclient_id 都跟旧 IdP 绑定。迁移时必须:

9.6 审计日志的连续性

合规要求审计日志保留几年,迁移跨越系统边界后:

必须有一张映射表,出审计报告时能还原一个人在两侧系统里的所有操作。

9.7 密码策略差异

旧系统允许 6 位密码,新系统最短要求 12 位。迁移时:

9.8 第三方 webhook 的签名密钥

很多业务系统订阅 IdP 的 webhook(用户创建、用户删除、权限变更)。webhook 签名密钥变更时,订阅方必须同步更新。

这个清单是最容易漏的,因为很多 webhook 订阅方不在身份团队管辖内。

十、选型建议

给一组判断标准,帮助选择迁移策略。

10.1 用户规模

10.2 用户活跃度

10.3 可用性预算

10.4 回滚窗口

10.5 团队能力

10.6 决策树摘要

     用户量
   /        \
 < 5k      > 5k
   |         |
Big-bang   可停机?
            /     \
          是       否
           |       |
        Big-bang  Coexistence + Shadow + 渐进切流

10.7 一个务实的清单

迁移前必须完成:

缺一项都别开始。

十一、结语

身份迁移是一场综合考验。考验的不仅是认证协议的理解,还有:数据一致性、系统可观测性、客户端生命周期管理、事故响应纪律、跨部门协作。

记住三条原则:

  1. 可回滚优先于可前进。迁移的每一步都要能退回上一步
  2. 可观测优先于可优化。没有指标就是在盲飞,别让优化代替监控
  3. 演练优先于预案。没演练过的预案不是预案,是幻觉

其他相关阅读:

十二、参考资料


上一篇PAM、IGA 与审计合规

下一篇返回系列索引

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-21 · architecture / security

【身份与访问控制工程】IAM 全景:为什么这是高价值赛道

身份与访问控制从一个登录框演进为横跨合规、运维、平台工程和安全的系统工程。本文从一家 SaaS 公司被大客户卡在 SOC 2 合规的真实触发器切入,拆解 IAM、CIAM、IGA、PAM、SSO、目录服务六个子领域的边界,分析 Okta、Entra ID、Auth0、Keycloak、Ping 等主流厂商的定位与落差,给出工程师视角的介入判据与选型路径。


By .