跳转到内容

认证

认证解决「你是谁」。这一章讲令牌怎么签发与校验、密码怎么存、第三方登录与二次验证怎么接、会话失效怎么表达。

「你能做什么」在 授权

在管道里的位置

text
… → UseAuthentication → 租户解析 → 会话闸门 → UseAuthorization → 端点
      ↑ 你是谁              ↑ 哪个租户   ↑ 会话还有效吗  ↑ 你能做什么

顺序有讲究:租户解析要读令牌里的租户 claim,所以排在认证之后;授权判定要在租户上下文里进行,所以排在租户解析之后。

安装与启用

bash
dotnet add package XiHan.Framework.Authentication
csharp
[DependsOn(typeof(XiHanAuthenticationModule))]
public class MyAppModule : XiHanModule { }

包内分六块:Jwt(令牌)、Users(认证流程与用户存储)、OAuth(第三方登录)、Oidc(OIDC 签发)、Otp(TOTP)、OneTimeCode(一次性验证码)。

JWT 令牌

配置

配置节 XiHan:Authentication:Jwt

默认说明
SecretKey签名密钥。生产务必改为高强度随机值,走环境变量或密钥库
Issuer / Audience签发者 / 受众,校验时比对
AccessTokenExpirationMinutes60访问令牌有效期
RefreshTokenExpirationDays7刷新令牌有效期
ClockSkewMinutes5允许的时钟偏差,多节点部署必需

IJwtTokenService

成员说明
GenerateAccessToken(List<Claim>)签发访问令牌,返回 JwtTokenResult
GenerateRefreshToken()生成刷新令牌(不透明随机串)
ValidateToken(string)校验并返回 ClaimsPrincipal,失败返回 null
GetClaimsFromToken(string)取出 Claim 列表
IsTokenExpired(string)是否已过期
RefreshAccessToken(accessToken, refreshToken)用旧访问令牌 + 刷新令牌换新的一套

刷新令牌的持久化走 IRefreshTokenStore。默认 DefaultRefreshTokenStore 是可直接使用的单实例实现:最多保留 100000 条,并在写入过程中批量清理过期项;达到上限后明确拒绝新增,不会继续挤占进程内存。框架不提供该端口的 Redis 实现,多实例共享或持久化由应用层实现 IRefreshTokenStoreReplace 默认注册。

别把权限码冻结进令牌

把权限清单写进 JWT 有两个后果:授予或回收后要等令牌过期才生效;权限清单随令牌一起泄露。

推荐只放身份信息(用户、会话、租户、角色),权限交给服务端实时判定——判定可以走缓存快照,性能不是问题。见 授权

刷新端点必须匿名

访问令牌过期后再带着它去刷新,如果刷新端点本身要求认证就永远进不去。刷新端点应当 [AllowAnonymous],并同时校验旧访问令牌与刷新令牌——两者成对才算合法。

密码

哈希算法

配置节 XiHan:Authentication:PasswordHasher,算法是 PBKDF2

默认说明
Version1方案版本号,参数升级后老密码仍按旧版本校验
Iterations600000迭代次数,OWASP 对 PBKDF2-SHA256 的推荐量级
SaltSize32盐长度(字节)
HashSize32输出长度(字节)
HashAlgorithmSHA256哈希算法

存储格式是自描述串:

text
version:iterations:algorithm:base64(salt):base64(hash)

自描述的好处是参数可以随时调大而不影响存量密码——每条哈希都记着自己是用什么参数算的。校验用定长比较抵抗时序攻击;参数变更后 NeedsRehash 会提示该密码需要在下次登录成功时透明重算。

认证流程

IAuthenticationService 是认证主流程的门面:

方法用途
AuthenticateAsync(username, password)主认证,返回 AuthenticationResult
ValidatePasswordStrengthAsync(password, customBlacklist?)密码强度校验
ChangePasswordAsync / ResetPasswordAsync改密 / 重置
EnableTwoFactorAuthenticationAsync / DisableTwoFactorAuthenticationAsync开关二次验证
VerifyTwoFactorCodeAsync校验二次验证码
GenerateRecoveryCodesAsync / VerifyRecoveryCodeAsync恢复码
RecordFailedLoginAttemptAsync记录失败尝试(锁定与风控的输入)

用户数据的读写走 IUserStore——框架只定义契约,业务侧必须 Replace 成自己的实现(通常是数据库)。

一次性验证码

IOneTimeCodeService 用于「签发 → 一次性消费」的场景:邮箱/短信登录验证码、改绑联系方式验证码等。

四条设计特征:

特征说明
加密安全随机RandomNumberGenerator 生成,不是 Random
存分布式缓存状态放 IDistributedCache,宿主接了 Redis 就天然支持多实例
消费即销毁无论校验成功与否,读取后即删除——杜绝重放与暴力穷举
可携带负载如改绑场景暂存待生效的新邮箱/新手机号,消费成功后取回

同「用途 + 目标」重复签发会覆盖旧码

用途标识(如 auth:email-login)参与存储键隔离。对同一个邮箱重复请求验证码,后一次会把前一次覆盖掉——用户拿旧码来验会失败。前端要做发送频率限制。

第三方登录

配置节 XiHan:Authentication:OAuth

说明
Enabled总开关
FrontendCallbackUrl第三方授权完成后跳回的前端页面
Providers[]各提供商配置

每个 provider 的字段:

字段说明
NameAuthenticationScheme 名,参与回调路由(默认 /signin-{Name}),不要随意改
Provider提供商类型;留空时取 Name。同一家要同时开两种登录方式时,两条配置用不同的 Name、相同的 Provider
ModeQrCode(默认)/ Account,只对微信、企业微信、飞书、钉钉生效
DisplayName前端展示名
Enabled / ClientId / ClientSecret / CallbackPath同标准 OAuth2
Scopes[]申请的权限范围,在提供商默认值之外追加;微信与企业微信例外,两种登录方式的范围互斥,配置非空时整体替换
AgentId企业微信自建应用 AgentId
LoadMemberProfile企业微信是否额外读通讯录补姓名
CorpId钉钉企业 CorpId,随授权请求带出;要拿到用户选定的组织,还须在 Scopes 里加 corpid,钉钉只在权限范围含它时才回传,框架据此写出 urn:dingtalk:corpid 声明
AuthorizationEndpoint逃生舱:直接指定授权页地址,覆盖按 Provider + Mode 的推导
AuthorizationParameters逃生舱:追加到授权地址上的任意参数,如 Google 的 access_type

包内建八个:google / github / gitee / qq / weixin / workweixin / feishu / dingtalk(微信、企业微信、飞书分别可写成 wechat / wecom / lark)。绑定关系的读写走 IExternalLoginStore

账号授权与扫码登录

两种方式只差授权页地址与申请的权限范围,换令牌与拉用户信息的接口是同一套,所以不需要另建一条登录路径——同一家注册两个 scheme 就行:

json
"Providers": [
  { "Name": "wechat-qr", "Provider": "wechat", "Mode": "QrCode",  "ClientId": "开放平台网站应用 AppId", "ClientSecret": "..." },
  { "Name": "wechat-mp", "Provider": "wechat", "Mode": "Account", "ClientId": "公众号 AppId",          "ClientSecret": "..." }
]

各家两种方式落到的授权页:

提供商扫码登录账号授权
微信开放平台网站应用 connect/qrconnectscope=snsapi_login公众号网页授权 connect/oauth2/authorizescope=snsapi_userinfo
企业微信login.work.weixin.qq.com/wwlogin/sso/login应用内网页授权 connect/oauth2/authorizescope=snsapi_privateinfo
钉钉login.dingtalk.com/oauth2/challenge.htmlogin.dingtalk.com/oauth2/auth
飞书passport.feishu.cn/suite/passport/oauth/authorizeaccounts.feishu.cn/open-apis/authen/v1/authorize

微信两种方式用的是两个应用

扫码登录属于开放平台网站应用,账号授权属于公众号,AppId / AppSecret 不通用,所以要写成两条配置。

飞书两套端点不可交叉

扫码走 passport.*、账号授权走 accounts/open-apis,各自的授权、令牌、用户信息三个接口成套;一套拿到的授权码不能拿去另一套换令牌。框架按 Mode 整套切换,不必手工拼。

账号授权的地址里 state=_oauthstate 不是 bug

微信公众号与企业微信应用内的网页授权页限制 state 长度,容不下受保护的认证属性。框架把真实状态挪进回调地址的 _oauthstate 参数、state 位上只留一个哨兵,回调时再还原。只在账号授权时出现,扫码链路不受影响。

实现来源

八家全部由框架自研,不依赖任何第三方 OAuth 提供商包——认证包现在只剩 Microsoft.AspNetCore.Authentication.JwtBearer 一个 NuGet 依赖。

分类提供商说明
走通用形态Google直接用基类 XiHanOAuthHandler<TOptions>,无需单独的处理器
只多一步GitHub、Gitee覆写 AfterClaimActionsAsync,在用户信息没给出邮箱时补取一次
协议有偏离QQ、微信、企业微信、飞书、钉钉各自覆写 BuildChallengeUrl / ExchangeCodeAsync / CreateTicketAsync

新增一家的成本按偏离程度递增:只有端点不同 → 只写一个 Options;多一跳或字段名不同 → 再写一个 Handler;最后在 RegisterProvider 里加一个 case

各家偏离 OAuth2 通用约定的地方

提供商偏离点
微信令牌接口用 appid/secret 而非 client_id/client_secret;用 errcode 而非 HTTP 状态码表达失败;授权地址必须以 #wechat_redirect 结尾
企业微信换到的是企业凭证而不是用户令牌,成员身份要再用授权码换一次,敏感资料还要凭 user_ticket 取第三次
飞书用响应体里的 code/msg(开放平台)或 error/error_description(passport)表达失败;开放平台把用户信息包在 data 节点里
钉钉令牌接口收 JSON 体、返回 accessToken/expireIn 小驼峰字段;用户信息接口用 x-acs-dingtalk-access-token 私有头而非 Authorization
QQ令牌与用户标识接口默认返回表单文本与 JSONP,靠 fmt=json 换成纯 JSON;要先换 openid 再取资料

已知边界

边界说明
企业微信要填 AgentId扫码页与应用内授权页都要带
企业微信默认拿不到姓名授权链路只给 userid。要姓名就开 LoadMemberProfile 走通讯录读取;读不到时姓名为空,不影响登录本身
登录标识优先取 union 类字段微信 unionid、钉钉 unionId、飞书 union_id,缺失时退回 openid;企业微信取 userid,非企业成员退回 openid
远端失败会抛异常授权码无效、用户信息拉取失败等由 RemoteAuthenticationHandler 抛出;要变成跳转到错误页,需在 provider 的 Events.OnRemoteFailure 里处理
Scopes 默认是追加语义在提供商默认值之外追加,配置里只写增量即可;微信与企业微信例外,两种方式的范围互斥所以整体替换

首次第三方登录不要按邮箱并号

拿第三方返回的邮箱去匹配既有账号并直接登录,等于把「谁控制这个邮箱」的判断外包给了第三方。第三方邮箱未必经过验证,这会成为账号接管的入口。

安全做法是按 (Provider, ProviderKey) 精确定位;找不到就新建账号,让用户在登录态下主动完成绑定。

TOTP 二次验证

IOtpService 遵循 RFC 6238:

成员说明
GenerateTotpSecret()生成 Base32 密钥
生成 provisioning URI供认证器扫码,形如 otpauth://totp/{issuer}:{account}?secret=...
校验验证码带窗口容差,容忍客户端与服务端的时钟差

TOTP 由认证器本地生成,服务端不需要下发——这是它与邮箱/短信验证码在流程上的关键区别:判定需要二次验证时,TOTP 直接等用户输入,邮箱/短信则要先发码。

会话闸门:401 与 423

XiHanSessionStateMiddleware 位于认证之后、授权之前,判定委托给 ISessionStateGate(框架默认实现一律放行)。业务侧 Replace 后可产出两种结果:

结果语义客户端该怎么做
401会话已失效(登出 / 被踢 / 撤销 / 过期)尝试刷新令牌,失败则跳登录页
423会话被锁定,身份仍然有效引导解锁,不是跳登录页

为什么框架不定义锁定原因

锁屏、风控挂起、强制改密、二次验证未完成都可能导致「身份有效但当前不能操作」。框架只提供 423 这个信号位,原因与解锁方式由应用侧定义。

位置也是有讲究的:在认证之后(要读 session_id claim)、在租户解析之后(会话表通常是多租户实体,租户上下文没解析会被全局过滤器挡掉)、在授权之前423 / 401 要先于权限评估短路,不能和 403 混淆)。

常见问题

现象原因
多节点部署偶发令牌校验失败时钟偏差;调大 ClockSkewMinutes 或给节点校时
刷新令牌一直失败刷新端点没设匿名;或没有同时提交旧访问令牌与刷新令牌
验证码验不过已被消费(读取即销毁);或同目标重复签发把旧码覆盖了
匿名端点里拿不到用户匿名端点不经过认证中间件,ICurrentUser 是空的
收到 423 却跳了登录页客户端把 423 当成 401 处理了
改了 Iterations 后老用户登录失败不应该失败——哈希串自描述,老密码按串里记录的旧参数校验。若真失败,检查是不是连存储格式一起改了

下一步

Released under The MIT License