Published on

会话只能有一个真相源:外部登录为什么不能在设备上存 token

Authors
  • avatar
    Name
    Jack Qin
    Twitter

接入外部身份提供方(Microsoft Entra,未来的 Apple / Google / SAML)时,最容易出问题的不是"怎么登进来"——那部分库帮你做好了。真正出问题的是一个更隐蔽的决定:会话到底以什么为准。 这个决定一旦做错,错误不会在登录时暴露,而会在"管理员禁用了一个用户、他却还能用"这种安全场景里浮出来。

这篇拆的核心只有一句话:后端签发的 Identity Cookie 是唯一的会话真相源。 听起来像一条武断的规定,但它背后是一笔很清楚的账——一旦你在设备上多存了一份 token 作为并行会话,你就不是多了一条便利路径,而是同时撕裂了撤销、冷启动、审计三个通道。理解了这三道裂缝,这条规范就从"团队要求"变成了"不这么做必然出安全漏洞"。

一个诱人的错误:把外部 token 当并行会话

移动端一旦开始接第三方登录,一个看起来很自然的做法就会冒出来:把外部提供方发的 id_token / access_token / refresh_token 存到设备上(SecureStore 或 AsyncStorage),当成一套并行的会话机制。"反正都拿到 token 了,存着以后用呗。"

问题在于,后端的整套认证机制是围绕单一会话真相源设计的,多存一份 token 等于在这套机制旁边并联了第二套,而这第二套绕开了第一套的所有保护。具体撕裂成三道:

  • 撤销通道被分裂:后端靠 SecurityStamp 轮换来"踢人"——改密码、禁用用户后,下一次请求时全客户端掉线。但如果存在一套并行 token,管理员"禁用用户"只作用在 Cookie 这个通道上,那条直接拿 token 联系 IdP 的路径照样工作。撤销失效,正是从这里漏的。
  • 冷启动路径被分裂GET /users/me 本是权威的会话检查,每次冷启动都过它一遍。并行 token 意味着第二条冷启动路径,带着自己独立的、未经后端校验的失败模式。
  • 审计面被分裂:后端既有的认证事件日志覆盖每一次登录。把外部 token 落到 SecureStore 会造出第二个凭证存储,它需要自己的审计和泄漏检测——而通常没人会为它建。

三道裂缝指向同一个根因:会话有了两个真相源,而第二个不受第一个的任何治理。 所以规范要做的,是把"会话以什么为准"钉死成唯一一个。

做什么:每一个移动端认证流程——密码、Microsoft Entra、未来的 Apple Sign-In——都必须以后端通过 SignInManager.SignInAsync 签发标准的 IdentityConstants.ApplicationScheme Cookie(app-auth)告终。这个 Cookie 是"用户是否已登录"的唯一真相源。移动端绝不把外部提供方 token 作为并行会话机制持久化。

怎么应用

  • 新的移动端认证流程 POST 到一个后端端点,由后端通过 SignInManager.SignInAsync 签发 Cookie。移动端客户端不解码外部 token、不存储、不在设备上刷新。
  • 移动端的 SecureStore 可以放仅供显示的提示(auth.userIdauth.displayName)用于快速冷启动渲染,但永远不放 token。这些提示必须在每次冷启动时被 me() 重新校验——它们只是渲染加速,不是会话依据。
  • 外部提供方库(如 expo-auth-session)把 id_token 直接交给后端的交换端点,POST 完成后就从内存里丢弃。

Cookie 过期由服务端控制且滑动续期(14 天)。平台原生 cookie jar(移动端用 react-native-nitro-cookies)负责持久化和重新附带,不需要应用代码插手。

const loginWithMicrosoft = useCallback(async (): Promise<void> => {
  clearAuthenticatedQueryDataBeforeLogin(queryClient)
  const { idToken } = await signInWithMicrosoft() // PKCE → id_token 在内存里
  await authClient.loginWithMicrosoft(idToken) // POST → 后端设置 Cookie
  // idToken 至此离开作用域;Cookie 才是持久会话
  const user = await queryClient.fetchQuery({ ...currentUserQueryOptions, staleTime: 0 })
  await saveSession({ userId: user.userId, displayName: user.displayName })
}, [queryClient])

明令禁止——这三条都是在堵上面那三道裂缝:

  • 在 SecureStore 或 AsyncStorage 里存 id_token / access_token / refresh_token
  • 提供 useAuth().getAccessToken() 这样的访问器,或任何期待客户端有 token 的代码路径
  • 第二条读取已存 token 并直接联系 IdP 的冷启动路径(会绕过 SecurityStamp 撤销)

后端实现拆成三块可复用契约

后端用 ASP.NET Core Identity + Cookie 认证作为唯一会话模型。外部提供方都终结在同一个 IdentityConstants.ApplicationScheme Cookie 里。实现拆成三块,每块都对应一个会反复出现的诱惑。

1. 外部登录的 find-or-create 辅助器

问题:每个外部提供方都要跳同一支舞:按 email 查用户 → 不存在则自动创建 → 通过 AddLoginAsync 幂等关联 → 调用 SignInManager.SignInAsync。在各端点里复制这段,会招来提供方特定的漂移(默认 Role 不一致、EmailConfirmed 默认不一致、漏了 security-stamp 轮换)——而这类漂移正是安全 bug 的温床。

解法:每个 Identity 模块一个辅助器,接收规范化的 principal,返回类型化 Result。放在 Modules/Identity/Application/,注册为 Scoped(匹配 UserManager / SignInManager 生命周期)。

public sealed record MicrosoftPrincipal(string Email, string ProviderKey, string? DisplayName);

public sealed class MicrosoftAccountLinkingResult
{
    public User? User { get; }
    public MicrosoftAccountLinkingErrorCode? ErrorCode { get; }
    public static MicrosoftAccountLinkingResult Success(User user) => new(user, null);
    public static MicrosoftAccountLinkingResult Failure(MicrosoftAccountLinkingErrorCode code) => new(null, code);
}

public enum MicrosoftAccountLinkingErrorCode { AccountDeactivated, UserCreateFailed }

自动创建默认值(项目约定):IsActive=trueRole="user"(User 实体的字段,不是 AspNetRoles)、EmailConfirmed=trueFullName=<displayName claim>、密码设为一个长随机的不可用 hash(杜绝密码重置劫持路径)、无站点/分组权限(由管理员事后配给)。最后两条尤其是安全考量:不可用密码堵死了"通过密码重置接管外部账号"的路径,零默认权限确保新账号在管理员显式授权前什么都看不到。

复用注意:未来的 Apple Sign-In 会定义同样形状的 AppleAccountLinking不要在凑齐三个提供方之前,就泛化成"一个带 provider 字符串的辅助器"——提供方特定的怪癖(claim 名、oid vs sub、租户检查)会让过早泛化代价高昂。这是 DRY 的一个反向边界:在只有一两个实例、且实例间差异未知时,重复比错误的抽象便宜。

2. 原生 id_token 交换的 JWT 校验

问题:移动/原生客户端没法走服务端的 OIDC 重定向流程(iOS ASWebAuthenticationSession 的 cookie store 与 URLSession 隔离——auth session 期间后端的 Set-Cookie 永远到不了应用的 fetch jar)。所以模式只能是:移动端客户端侧做 PKCE 拿到 id_token,POST 给后端端点,后端校验 JWT 并签发项目标准的 Identity Cookie。

既然后端要凭一个客户端递来的 token 签发会话,这个 token 的校验就成了整个信任链的根。它必须过这张加固清单——每一行都在拒绝一类伪造:

设置要求值为什么
ValidateIssuertrue,精确匹配拒绝其它租户/提供方的 token
ValidateAudiencetrue,精确匹配 Entra:MobileClientId拒绝为别的 app 签发的 token
ValidateLifetimetrue拒绝过期/尚未生效的 token
ClockSkewTimeSpan.FromMinutes(5)Microsoft / OAuth WG 推荐默认
RequireSignedTokenstrue拒绝未签名/none 算法 token
ValidAlgorithms["RS256"](或显式白名单)防御 HS256 算法混淆攻击——没有它,攻击者可用 JWKS 公钥当对称密钥签 token,校验器会放行
IssuerSigningKeys来自 ConfigurationManager<OpenIdConnectConfiguration>(单例,自动刷新)JWKS 轮换自动处理;不要手搓 JWKS 拉取
显式 tid claim 检查(校验后)与配置租户 id 比对纵深防御——万一 authority URL 意外允许多租户,tid 检查仍能拒绝跨租户 token

最值得单独讲的是 ValidAlgorithms 那行,因为它防的是一个反直觉的攻击。JWKS 里的公钥是公开的。如果校验器不锁定算法,攻击者可以用这把公开的公钥当作 HMAC 的对称密钥,用 HS256 自己签一个 token——而一个既接受 RS256 又接受 HS256 的校验器,会用同一把公钥去验这个 HS256 签名,然后通过。锁死 ValidAlgorithms = ["RS256"] 就是切断这条混淆路径。漏掉它,门户大开。

校验器必须注册为单例,这样 ConfigurationManager 的 JWKS 缓存能跨请求共享(缓存 + 自动轮换才是昂贵的部分;每请求新建一个就把这层缓存浪费掉了)。

测试契约:有效 token 往返;过期/错误 audience/错误 issuer → 不同错误码;HS256 算法混淆攻击(用 JWKS 公钥当对称密钥签)→ InvalidTokentid 不匹配 → TenantMismatch;JWKS 不可达(网络故障)→ MetadataUnavailable(不崩溃)。这条 HS256 测试是清单里那行配置的守门人——谁哪天把 ValidAlgorithms 删了,它立刻红。

3. 按环境配置的条件式端点注册

问题:外部提供方端点依赖随环境变化的配置(Entra:TenantIdEntra:MobileClientId 等)。一种偷懒做法是无论如何都注册、再从 handler 内部返回 503。但这会让 API 表面膨胀、复杂化客户端检测,还造出"功能已禁用但仍在收请求"的尴尬状态——一个收着请求却只会拒绝的端点,是个纯负担。

解法:条件式注册。当必需配置缺失时,路由根本不存在(404),而不是存在但返 503。前端通过一个单独的 providers 端点发现哪些被启用,据此控制 UI。

var tenantId = config["Entra:TenantId"];
if (!string.IsNullOrEmpty(tenantId))
{
    group.MapGet("/login/microsoft", ...);
    group.MapGet("/callback/microsoft", ...);
    // 移动端端点额外要求 MobileClientId
    if (!string.IsNullOrEmpty(config["Entra:MobileClientId"]))
        group.MapPost("/microsoft/mobile", ...);
}

// 可发现性端点 —— 始终存在,返回已启用集合
group.MapGet("/providers", (IConfiguration cfg) =>
{
    var providers = new List<string> { "credentials" };
    if (!string.IsNullOrEmpty(cfg["Entra:TenantId"])) providers.Add("microsoft");
    return Results.Ok(new { providers });
});

这个设计顺手带来一条零发版的回滚路径:要在整个设备群禁用移动端 Microsoft 登录,只需在后端取消设置 Entra:MobileClientId——端点停止映射,providers 列表掉 microsoft,移动端客户端在下次冷启动时隐藏按钮。无需重新发版。前端的 providers 查询在网络失败时回退到 ["credentials"],所以密码表单永远可用。把"功能是否存在"绑定到配置,比绑定到代码分支或客户端版本,回滚要快得多。

反例

// 反例:把外部 token 当并行会话存到设备上
const { idToken, accessToken, refreshToken } = await signInWithMicrosoft()
await SecureStore.setItemAsync('id_token', idToken) // ❌
await SecureStore.setItemAsync('access_token', accessToken) // ❌
// 之后某处用它直接联系 IdP,绕过 SecurityStamp 撤销 —— 安全黑洞
// 反例:JWT 校验没锁算法 —— 对 HS256 混淆攻击门户大开
var parameters = new TokenValidationParameters
{
    ValidateIssuer = true,
    ValidateAudience = true,
    ValidateLifetime = true,
    // ❌ 缺 ValidAlgorithms = ["RS256"] 和 RequireSignedTokens = true
};
// 反例:功能禁用时仍注册端点、内部返 503
group.MapPost("/microsoft/mobile", (IConfiguration cfg) =>
{
    if (string.IsNullOrEmpty(cfg["Entra:MobileClientId"]))
        return Results.StatusCode(503); // ❌ "禁用但仍收请求"
    // ...
});
// 正确:配置缺失时根本不映射这条路由(404)

落地建议

  1. Cookie 是唯一真相源:任何新认证流程,最后一步都必须是后端 SignInManager.SignInAsync 签发 Cookie;客户端不留 token。
  2. id_token 用完即焚:交给后端交换端点后立即丢弃,SecureStore 只放显示用的 userId / displayName,且每次冷启动用 me() 重新校验。
  3. JWT 校验照清单逐条对:尤其别漏 ValidAlgorithmsRequireSignedTokens——HS256 混淆攻击就是冲着这个缺口来的。校验器注册成单例。
  4. find-or-create 抽一个、别过早泛化:每个提供方一个 *AccountLinking,凑满三个再谈通用化。
  5. 端点按配置条件注册:缺配置就 404,不要 503;配 providers 端点让前端按需 gate UI,顺手获得"改配置即回滚"的能力。
  6. 认证边界双向清缓存:配合 login 和 logout/401 都清 React Query 缓存(详见前端状态管理篇)。

可迁移的那一层

抛开 OIDC 和 ASP.NET Core 的具体 API,这套契约真正可迁移的认知是:会话的安全性,等于它最弱那个真相源的安全性。 你可以把 Cookie 这条通道的撤销、审计、过期都做得滴水不漏,但只要设备上还并联着第二份 token,整个系统的撤销时效就由那条没人治理的通道决定。

设计任何认证系统时,与其逐个加固每条路径,不如先问一句:这套系统里"用户是否已登录"有几个答案的来源?如果不止一个,它们是否都受同一套撤销和审计的约束? 把会话收敛到唯一一个真相源,是这一整类安全设计的共同地基。