认证管道只能验证客户端带来的凭证,不能凭空产生凭证。用户第一次还没有 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 |
sub、jti、iat、nbf、exp、iss、aud 是 JWT 标准中定义的名称;role、permission 等通常由项目或身份系统约定。
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 + SameSiteCookie,避免 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 从哪里来 | 登录时从用户、角色和权限数据中组装 |