ASP.NET Core 认证与授权(二):登录与令牌签发

认证管道只能验证客户端带来的凭证,不能凭空产生凭证。用户第一次还没有 Token,只能先提交用户名和密码;登录接口验证成功后,才会签发 Access Token 和 Refresh Token。

这篇只回答登录阶段的问题:

text 复制代码
用户名和密码
    -> 验证用户
    -> 生成 Claims(身份声明)
    -> 签发 Access Token
    -> 创建 Refresh Token
    -> 返回客户端

请求如何验证 Access Token、Refresh Token 如何续期,分别放在后续两篇。


登录不是 JWT 验证

登录请求通常允许匿名访问:

http 复制代码
POST /api/auth/login
Content-Type: application/json

{
  "username": "zhang",
  "password": "******"
}

此时客户端没有 Access Token,因此 JwtBearerHandler 没有东西可验证。登录接口走的是另一条链:

text 复制代码
LoginController
    -> 根据用户名查用户
    -> 使用密码哈希算法验证密码
    -> 检查账号是否禁用、锁定
    -> 生成 Claims
    -> TokenService 签发 Token

所以必须分清:

动作 谁负责
验证用户名和密码 Identity、用户服务或外部身份系统
生成 Access Token 登录服务中的 TokenService 或身份提供方
验证后续 Bearer Token JwtBearerHandler(JWT 认证处理器)
判断能否访问接口 Authorization Middleware

AddJwtBearer() 只注册验证 Bearer Token 的处理器,不会帮登录接口生成 Token。


密码验证不是直接比较字符串

数据库不应保存明文密码,也不应自己简单做一次 SHA256。密码应该经过专门的 PasswordHasher 计算后再保存。

先理解两个词。

Salt(随机盐)

Salt 是为每个密码单独生成的一段随机数据。即使张三和李四设置了相同密码 123456,因为 Salt 不同,最终哈希也不同:

text 复制代码
张三:123456 + random-A -> hash-AAA
李四:123456 + random-B -> hash-BBB

若没有 Salt,相同密码会得到相同哈希。攻击者一眼就能看出哪些用户密码相同,还能用提前计算好的常见密码哈希表批量破解。

Salt 不需要保密,可以和哈希一起存进数据库。它的作用不是加密密码,而是保证每个用户的计算结果不同。

可调成本

这里的成本不是钱,而是计算一次密码要消耗多少 CPU 时间和内存

普通哈希可能只算一次:

text 复制代码
hash = SHA256(密码 + Salt)

速度非常快,攻击者也能高速尝试:

text 复制代码
猜 123456 -> 算一次
猜 abc123 -> 算一次
猜 password -> 算一次
一秒可以猜很多次

密码哈希会把同一类计算重复很多次。下面的 100,000 只是为了说明原理,不代表固定配置:

text 复制代码
result = 密码 + Salt

重复 100,000 次:
    result = Hash(result)

正常用户登录只验证一个密码,即使耗时 50 毫秒也能接受;攻击者想猜一百万个密码,每一个都要重复 100,000 次,整体时间会变得非常长。

所谓可调,就是这个计算强度可以调整:

text 复制代码
现在的服务器:重复 100,000 次
几年后硬件更快:提高到 300,000 次

重复 1 次和重复 10 次,结果当然不同。因此数据库不能只保存最终 Hash,还要把当时使用的算法、Salt 和迭代次数一起保存。PasswordHasher 会把这些信息编码进同一个字符串。

假设张三注册时使用 100,000 次:

text 复制代码
数据库记录:
算法版本 + 迭代 100,000 次 + Salt-A + 最终 Hash

几年后系统把新标准提高到 300,000 次。不能把数据库里的旧 Hash 再算 200,000 次,因为服务端已经没有张三的原始密码,而且输入一变,含义就不一样了。

正确升级发生在张三下一次登录时:

text 复制代码
1. 浏览器通过 HTTPS 提交张三输入的原始密码
2. 服务端读取旧记录,知道它使用 100,000 次和 Salt-A
3. 服务端按旧参数计算,和旧 Hash 对比
4. 验证成功,说明输入密码正确
5. 服务端拿这次输入的原始密码,生成新的 Salt-B
6. 按新标准计算 300,000 次,得到新 Hash
7. 用新记录覆盖旧记录

如果张三一直不登录,他的旧记录仍保持 100,000 次;不能在后台批量升级,因为后台拿不到用户原始密码。也不是登录页面先 Hash 300,000 次再传给服务器,通常由浏览器通过 HTTPS 传输密码,密码哈希在服务端完成。

有些算法除了增加重复次数,还能要求每次计算占用更多内存,让显卡并行破解也变得更贵。

ASP.NET Core Identity 的 PasswordHasher 会把算法版本、迭代次数、Salt 和计算结果一起编码进最终字符串。项目通常只保存这一列,不需要自己额外设计 Salt 字段:

text 复制代码
注册时:
密码 -> PasswordHasher -> hash -> 存数据库

登录时:
用户输入密码 + 数据库中的 hash
    -> PasswordHasher.VerifyHashedPassword
    -> 失败:拒绝登录
    -> 成功且参数仍够强:正常登录
    -> 成功但参数已落后:返回需要重新哈希,再更新数据库

ASP.NET Core Identity 已经提供密码哈希、锁定、失败次数、密码规则等能力。生产项目优先使用 Identity 或成熟身份系统,不要自己拼一套密码安全协议。

登录失败时,不应分别提示用户不存在和密码错误,否则会帮助攻击者枚举账号。对外统一返回账号或密码错误,内部日志再记录具体原因。


验证成功后,先形成 Claims(身份声明)

Token 不是把整张用户表塞进去,而是放后续认证和授权真正需要的声明:

csharp 复制代码
var claims = new[]
{
    new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
    new Claim(JwtRegisteredClaimNames.Name, user.UserName),
    new Claim(ClaimTypes.Role, "Admin"),
    new Claim("permission", "order:read")
};

常见 Claims:

Claim 英文全称 含义
sub Subject 当前 Token 代表的主体,通常放用户唯一标识
name Name 展示名称或用户名
role Role 角色,如 Admin
permission Permission 细粒度权限,如 order:read
jti JWT ID 当前 Access Token 的唯一编号
iat Issued At Token 的签发时间
nbf Not Before 在这个时间之前,Token 还不能使用
exp Expiration Time Token 的过期时间
iss Issuer Token 的签发者,例如统一登录中心
aud Audience Token 的接收方,例如订单 API

subjtiiatnbfexpissaud 是 JWT 标准中定义的名称;rolepermission 等通常由项目或身份系统约定。

Claims 是签发端和授权端之间的契约。签发时写入什么,后续 HttpContext.User 才能读到什么。

不要把手机号、身份证号、密码哈希等敏感数据写进 JWT。Payload(载荷,即存放 Claims 的部分)只是 Base64Url 编码,不是加密,拿到 Token 的人可以直接查看。


Access Token 是怎样签出来的

JWT 由三部分组成:

text 复制代码
Header.Payload.Signature
  • Header 说明算法和密钥编号
  • Payload 保存 Claims
  • Signature(签名)证明内容由可信签发方产生,且未被修改

签发过程:

text 复制代码
Claims
    -> 生成 Header 和 Payload
    -> 使用密钥计算 Signature
    -> 得到 Access Token

简化代码:

csharp 复制代码
public string CreateAccessToken(IEnumerable<Claim> claims)
{
    var key = new SymmetricSecurityKey(
        Encoding.UTF8.GetBytes(_options.SigningKey));

    var credentials = new SigningCredentials(
        key,
        SecurityAlgorithms.HmacSha256);

    var token = new JwtSecurityToken(
        issuer: _options.Issuer,
        audience: _options.Audience,
        claims: claims,
        notBefore: DateTime.UtcNow,
        expires: DateTime.UtcNow.AddMinutes(15),
        signingCredentials: credentials);

    return new JwtSecurityTokenHandler().WriteToken(token);
}

这段代码展示的是签发机制,不代表生产项目必须自己维护身份系统。若接入 Entra ID、Keycloak、OpenIddict 等,Token 由身份提供方签发,业务 API 只负责验证。


对称密钥和非对称密钥

方式 签发 验证 适用
HMAC,如 HS256 同一密钥 同一密钥 单体或少量可信服务
RSA/ECDSA,如 RS256 私钥 公钥 多 API、独立身份服务

对称方案中,任何能验证 Token 的服务也拥有签发能力。服务较多时,更常用非对称方案:

text 复制代码
身份服务保存私钥 -> 负责签发
业务 API 保存公钥 -> 只能验证

私钥不应进入业务 API,也不要硬编码在仓库。生产环境应放到 Key Vault、KMS、证书存储等安全位置,并设计密钥轮换。JWT Header 中的 kid 可帮助验证端选择正确公钥。


为什么还要生成 Refresh Token

Access Token 一般设得较短,例如 15 分钟。它泄露后,攻击者可利用的时间有限;但用户不应每 15 分钟重新输入密码,于是登录时还会生成 Refresh Token。

text 复制代码
Access Token
    短期
    每次业务请求携带
    资源 API 本地验签

Refresh Token
    长期
    只发送给刷新接口
    服务端查询会话记录

Refresh Token 最适合使用密码学安全的随机数,也叫不透明令牌:

csharp 复制代码
public string CreateRefreshToken()
{
    return WebEncoders.Base64UrlEncode(
        RandomNumberGenerator.GetBytes(64));
}

它不需要包含用户信息,因为服务端会通过会话记录找到用户。


Refresh Token 应如何保存

不要把原始 Refresh Token 直接存进数据库。数据库泄露后,攻击者可以立即拿它换 Access Token。

更稳妥的方式:

text 复制代码
客户端拿到原始 refreshToken
服务端只保存 SHA-256(refreshToken)

刷新时:
客户端提交原始值
    -> 服务端计算哈希
    -> 查询匹配的会话记录

一条 Refresh 会话可以包含:

text 复制代码
Id
UserId
TokenHash
FamilyId
CreatedAt
ExpiresAt
RevokedAt
ReplacedByTokenId
DeviceName / UserAgent
IpAddress

为什么要记录设备和会话?假设张三在手机和电脑上分别登录:

text 复制代码
用户张三
    -> 手机会话:Refresh Token A
    -> 电脑会话:Refresh Token B

服务端保存两条独立记录。若手机丢失,只需吊销手机对应的 Refresh Token A:

text 复制代码
手机:Access Token 过期后,A 已被吊销,无法再续期
电脑:Refresh Token B 仍然有效,继续正常使用

这就是按设备管理登录会话。用户可以查看当前登录设备、退出某一台设备,也可以在修改密码或发现账号被盗时一次吊销全部会话。

需要注意:吊销 Refresh Token 后,该设备已经拿到的 Access Token 通常仍能使用到过期;若要求立即失效,还需要 Access Token 黑名单或用户安全版本号等机制。


登录接口最终返回什么

若客户端是移动端或可信桌面客户端,可以在响应体返回两种 Token:

json 复制代码
{
  "accessToken": "eyJhbGciOiJSUzI1NiIs...",
  "refreshToken": "5HnMRm9...",
  "expiresIn": 900
}

浏览器前端要更加谨慎:

  • Access Token 可只放内存,刷新页面后通过 Refresh Token 重新获取
  • Refresh Token 更适合放 HttpOnly + Secure + SameSite Cookie,避免 JavaScript 读取
  • 放 Cookie 后要结合 SameSite 和 CSRF 防护设计刷新接口
  • 将 Token 放进 localStorage 很方便,但 XSS 成功后脚本可以直接读取

不存在适合所有项目的唯一存储方案。SPA(单页应用)、BFF(前端专用后端)、移动端、服务间调用的威胁模型不同,不能只背客户端保存 Token。


登录完成时发生了什么

text 复制代码
POST /api/auth/login
    -> 用户名查用户
    -> PasswordHasher 验密码
    -> 检查锁定和账号状态
    -> 加载角色和权限
    -> 形成 Claims
    -> 私钥或对称密钥签发短期 Access Token
    -> 生成高熵 Refresh Token
    -> 服务端保存 Refresh Token 哈希和会话信息
    -> 返回 Access Token 和 Refresh Token

到这里,客户端终于有了可以调用受保护 API 的凭证。下一篇继续跟随 Authorization: Bearer <accessToken>,看 ASP.NET Core 如何把它变成 HttpContext.User,以及 [Authorize] 如何决定返回 200、401 还是 403。

下一篇:请求认证与权限校验


容易混淆的地方

问题 回答
Token 是谁生成的 登录或刷新服务主动签发,不是认证中间件生成
AddJwtBearer 会生成 Token 吗 不会,它注册的是验证 Access Token 的 Handler
JWT Payload 是加密的吗 不是,默认只是编码,不能放敏感数据
Refresh Token 一定是 JWT 吗 不需要,随机不透明令牌通常更容易吊销
服务端要保存 Refresh Token 吗 要保存会话记录,推荐只存哈希
Claims 从哪里来 登录时从用户、角色和权限数据中组装
相关推荐
Sinclair1 小时前
安企CMS的安装-服务器手动部署
运维·后端
Sinclair2 小时前
安企CMS的安装-PHPStudy(小皮面板)部署
前端·后端
Sinclair2 小时前
安企CMS的安装-宝塔面板部署
服务器·后端
会飞的特洛伊2 小时前
IAM身份认证
前端·后端
开飞机的舒克_2 小时前
FastAPI 异步 ORM 实战:从核心特性到图书 CRUD 最佳实践
后端
码栈研说2 小时前
Go 语言大白话入门 7 - 函数:把重复代码收起来
后端·程序员
程序员天天困2 小时前
后端视角看 EventBus:发布订阅总线的原理、场景与用法
java·后端
ServBay2 小时前
WordPress 核心漏洞 wp2shell,波及超 5 亿网站
后端·php
分布式存储与RustFS3 小时前
RustFS Beta.10 性能解读:PUT 全面反超 MinIO,Rust 重写对象存储成了?
开发语言·后端·rust
Reart3 小时前
Leetcode 309.买卖股票的最佳时期含冷冻期(你也想成为股票高手吗o〃ω〃o,718)
后端