Angular + ASP.NET Core 8 如何实现跨端单会话登录?
结论
可以在服务端为每个用户保存唯一的当前会话标识 sid,并将它写入登录后签发的 JWT。新设备登录成功时,原子替换该用户的当前 sid。每次受保护请求都要核对 JWT 中的 sid 与服务端记录,不一致就拒绝访问。
如果希望旧设备在没有发起请求时也能立即显示退出提示,可以增加 SignalR 通知。后端会话校验负责阻止旧会话继续访问,通知负责更新旧端界面。
这一方案需要服务端会话状态。仅靠 JWT 的签名和过期时间,无法让已经签发且尚未过期的旧 token 因另一次登录自动失效。
服务端保存哪些状态
可以使用数据库,也可以使用由所有后端实例共享的 Redis。最小记录如下:
UserId -> ActiveSessionId
根据刷新和审计需求,还可以保存会话到期时间、refresh token 的哈希及撤销状态。sid 应由服务端使用安全随机数生成器创建,每次新登录都生成新值。
JWT 中可以包含:
{
"sub": "user-123",
"sid": "opaque-random-session-id",
"jti": "unique-access-token-id"
}
其中,sid 表示登录会话,jti 表示某一枚 token。一个会话可能刷新出多枚 access token,因此不宜直接把 jti 当作整个会话的标识。
数据库适合希望复用现有持久化设施的应用;Redis 适合需要频繁查询共享会话状态的应用。无论选择哪一种,所有 Web 和移动端登录入口都必须读写同一份权威状态。
登录、刷新和退出如何处理
新登录时,先验证凭据,再生成新的 sid,原子更新当前会话记录并撤销旧会话的 refresh token,最后签发新凭据。只有会话状态写入成功后,才能向客户端返回登录成功。
验证用户凭据
生成 newSid
原子执行:
读取 oldSid
将用户当前会话替换为 newSid
撤销旧会话的 refresh token
保存新会话的 refresh token 哈希
提交成功后签发并返回新凭据
按需通知 oldSid 对应的客户端退出
数据库可以通过事务及适当的并发控制完成这些操作;Redis 可以使用 Lua script 等原子操作。并发登录必须在服务端形成确定的更新顺序,否则可能出现两个登录流程都认为自己是当前会话的情况。
刷新 token 时,必须同时验证 refresh token 本身及其所属 sid 是否仍为当前会话。正常刷新通常沿用原会话的 sid,不能因为旧设备持有 refresh token 就重新激活已经失效的会话。刷新校验及 refresh token 轮换也需要考虑并发,避免登录替换与刷新操作相互覆盖。
退出时,只能撤销当前请求所属的会话。删除条件应同时包含 UserId 和 sid,避免旧设备延迟到达的退出请求删除新设备的会话。
在 ASP.NET Core 8 中校验 sid
如果应用使用 JWT Bearer authentication,可以在正常的签名、issuer、audience 和有效期校验通过后,通过 OnTokenValidated 检查会话。
下面是现有 AddJwtBearer 配置中的接入片段。假设服务端签发的 JWT 使用 sub 保存用户 ID、使用 sid 保存会话 ID;IActiveSessionStore 是应用自行实现并注册的存储服务,其 GetActiveSidAsync 方法返回当前有效会话的 ID,没有有效会话时返回 null。
// 放入现有 AddJwtBearer(options => { ... }) 配置中。
// 保留完整的签名、issuer、audience 和有效期校验配置。
options.MapInboundClaims = false;
options.Events ??= new JwtBearerEvents();
options.Events.OnTokenValidated = async context =>
{
var userId = context.Principal?.FindFirst("sub")?.Value;
var sid = context.Principal?.FindFirst("sid")?.Value;
if (string.IsNullOrWhiteSpace(userId) ||
string.IsNullOrWhiteSpace(sid))
{
context.Fail("Missing session claims.");
return;
}
var store = context.HttpContext.RequestServices
.GetRequiredService<IActiveSessionStore>();
var activeSid = await store.GetActiveSidAsync(
userId,
context.HttpContext.RequestAborted);
if (!string.Equals(activeSid, sid, StringComparison.Ordinal))
{
context.Fail("Session is no longer active.");
}
};
这个片段只展示附加会话校验,还需要完整的 JWT 配置。若已有 OnTokenValidated 逻辑,应把检查合并进去。受保护接口还需要正确启用 authentication、authorization,并配置 [Authorize] 或相应授权策略。
不要把存储异常当作“会话仍然有效”。如果无法查询权威状态,应按应用的故障策略拒绝本次受保护访问,并区分服务异常与会话已撤销。
Angular 和移动端如何退出
Angular 可以通过 HTTP interceptor 集中处理认证失败:清理本地认证状态、停止刷新流程,并跳转登录页。移动端需要实现相同处理。
如果需要准确展示“账号已在其他设备登录”,建议让后端返回明确的机器可读错误,例如:
{
"code": "SESSION_REPLACED",
"message": "你的账号已在其他设备登录,请重新登录。"
}
需要在后端的认证失败响应逻辑中明确生成这个响应。调用 context.Fail(...) 不意味着框架会自动返回上述 JSON。
客户端处理逻辑可以组织为:
收到 SESSION_REPLACED:
停止 refresh token 请求
清理本地认证状态
显示其他设备登录提示
跳转登录页
收到其他认证失败:
按现有规则刷新或要求重新登录
不要把所有 401 都解释为其他设备登录。会话被替换后也不要继续自动刷新,否则容易产生重复请求或跳转循环。
如何让旧端及时收到通知
仅做请求校验时,旧端会在下一次访问受保护接口时发现会话失效。如果用户一直停留在页面上,界面可能仍显示登录状态。
需要主动退出提示时,可以在新登录提交成功后,通过 SignalR 向旧 sid 对应的连接发送会话撤销事件。连接与会话的绑定必须来自经过验证的身份信息,不能任由客户端指定其他用户的会话。
通知可能因为断网、应用进入后台或连接中断而丢失,因此即使使用 SignalR,也必须保留每次请求的服务端检查。已有的长连接、流式请求或正在执行的请求不会仅因数据库中的 sid 改变就自动停止。如果这些操作也必须立即终止,需要额外设计连接关闭或执行过程中的撤销检查。
实施时的边界
- 所有业务 API 和 refresh token 入口都必须遵守同一套会话规则,否则旧会话可能通过遗漏的入口继续访问。
- 如果把会话记录缓存到各后端实例的本地内存,缓存有效期内可能继续接受旧会话。严格要求下,应查询共享权威状态,或明确设计并接受缓存失效窗口。
- “单会话”通常限制的是一个有效登录凭据集合。复制同一组有效凭据到另一台设备,并不会自动产生新的登录会话;需要区分会话限制与设备识别。
- 同一浏览器的多个标签页可能共享认证状态,因此它们通常属于同一个登录会话。若要限制到单个标签页,需要另行定义规则。
可以先实现共享会话记录、JWT 中的 sid 校验以及 refresh token 撤销。需要旧端主动弹出提示时,再接入 SignalR。
备注:内容仅供参考。
