第三方登录(OAuth2)
第三方登录(OAuth2)
已内置,无需手工集成
第三方登录是框架内置能力,渠道抽象与授权库(CollectiveOAuthCore)已拆到独立项目 ZR.OAuth,ZR.ServiceCore 通过项目引用使用。控制器、Provider、state 缓存、绑定表都已就位。不要再手工拷贝 AuthRequestFactory / OAuthController / 旧版 ThirdAccountService 代码,那套实现没有 state 校验、没有租户、没有 2FA,与当前版本不兼容。
涉及的代码:
| 层 | 位置 |
|---|---|
| 控制器 | ZR.Admin.WebApi/Controllers/System/OAuthController.cs |
| 业务服务 | ZR.ServiceCore/Services/SysThirdAccountService.cs、SysThirdAccountService.Bind.cs(绑定、建号、2FA 在这里) |
| 渠道契约 | ZR.OAuth/IOAuthProvider.cs、ExternalUserInfo.cs |
| 渠道工厂 | ZR.OAuth/OAuthProviderFactory.cs(静态类,按租户动态取配置,不做 DI 单例) |
| 渠道实现 | ZR.OAuth/Providers/(CollectiveOAuthProvider、WeChatMiniProvider) |
| 渠道标识 | ZR.OAuth/OAuthSources.cs(19 个渠道常量) |
| state | ZR.OAuth/OAuthStateCache.cs(走 ICache,启用 Redis 后跨节点可用) |
| 配置 | ZR.OAuth/OAuthSettings.cs(对应 appsettings.json 的 OAuth 节,支持 渠道@租户 覆盖) |
| 实体 | ZR.Model/System/Model/SysThirdAccount.cs(表 sys_third_account) |
ZR.ServiceCore下已没有 OAuth 实现代码,只有业务层的SysThirdAccountService。新增渠道只改ZR.OAuth,业务层不动。
一、整体流程
1. 浏览器(授权码模式)
前端 GET {api}/oauth/authorize?authSource=GITHUB&tenantId=&clientId= → 返回 { url }
window.location.href = url
第三方 用户在授权页确认 → 平台 302 到 {api}/oauth/callback?code=..&state=..
后端 /oauth/callback 不换 token:按 state 读出上下文,约定参数 302 到前端回调页
{frontend}/sociallogin?code=..&state=..&scene=login&authSource=GITHUB&tenantId=..
前端 POST {api}/oauth/login { code, state, deviceToken }
后端 校验 state(一次性)→ code 换身份 → 查绑定/自动建号 → 2FA 闸门 → 签发 token
关键设计:token 绝不进 URL。后端回调只做一次 302 把 code + state 交给前端页面,由前端 POST 换取 token,否则 token 会落进浏览器历史、Referer 和网关日志。
2. App / 小程序(端内 code)
端上 POST {api}/oauth/state { authSource: "WECHAT_MINI", tenantId, clientId } → { state, expireMinutes }
uni.login() 等方式拿到 code
端上 POST {api}/oauth/login { code, state, deviceToken }
小程序这类渠道没有授权页(SupportsWebAuthorize = false),调 /oauth/authorize 会明确报错提示改用端内登录。
3. 绑定已有账号
绑定与登录用不同的 state,不能互换(防授权码注入):
前端(已登录)GET {api}/oauth/bind/authorize?authSource=GITHUB → { url }
跳转第三方授权 → 回调到前端 /sociallogin?...&scene=bind
前端 POST {api}/oauth/bind { code, state, authSource }
绑定成功只建立关系,不重签 token;用户身份取自 token,不取请求体。
二、配置
appsettings.json → OAuth 节(全部字段可缺省,缺省值见下表):
"OAuth": {
"Enabled": true, // 总开关,关闭后所有入口拒绝(绑定数据保留)
"StateMinutes": 10, // state 有效期(分钟),给用户扫码/授权的时间,不建议超过 15
"FrontendCallbackUrl": "https://admin.example.com/sociallogin", // 前端回调页完整地址(单租户/统一入口)
"FrontendCallbackPath": "/sociallogin", // 与租户域名拼接时用的路径(默认 /sociallogin)
"FrontendCallbackUrls": {
"1001": "https://acme.example.com/sociallogin" // 按租户显式指定,优先级最高
},
"AutoCreate": 0, // 0=BindOnly(默认,只允许已绑定用户登录)1=AutoCreate(首次登录自动建号)
"AutoCreateRoleIds": [2, 5], // 自动建号授予的角色;为空即使 AutoCreate=1 也不建号
"AutoCreateDeptId": 0, // 自动建号归属部门,0=未分配
"Clients": {
"GITHUB": {
"ClientId": "xxxxxxxx",
"ClientSecret": "xxxxxxxx", // 只在服务端用,禁止下发前端、禁止进日志
"RedirectUri": "https://api.example.com/oauth/callback", // 与开放平台登记的完全一致
"Scope": "user:email"
},
"WECHAT_ENTERPRISE@1001": {
// 租户 1001 专用应用("渠道@租户",优先于共用配置)
"ClientId": "ww-xxxxxxxx",
"ClientSecret": "xxxxxxxx",
"RedirectUri": "https://api.example.com/oauth/callback",
"AgentId": "1000002" // 企业微信网页应用专用
},
"WECHAT_MINI": {
"ClientId": "wx-appid",
"ClientSecret": "wx-appsecret" // 小程序只用于 code2session,不需要 RedirectUri
}
}
}
| 字段 | 默认 | 说明 |
|---|---|---|
Enabled | false | 总开关;关闭后 /oauth/* 统一提示"第三方登录未启用" |
StateMinutes | 10 | state 有效期,也是"用户拒绝授权后多久内点击链接才有效"的窗口 |
FrontendCallbackUrl | 空 | 前端回调页完整地址;为空且租户未绑定域名时浏览器回调直接报错 |
FrontendCallbackPath | /sociallogin | 与租户绑定域名拼成实际地址,免去给每个租户配完整 URL |
FrontendCallbackUrls | 空 | 按键为 TenantId 覆盖完整地址,优先级最高(独立端口、子路径、一租户多域名时用) |
AutoCreate | 0(BindOnly) | 1 时首次第三方登录自动建号,仍需 AutoCreateRoleIds 至少一个有效角色 |
AutoCreateRoleIds | 空 | 自动建号授予的角色 ID 数组 |
AutoCreateDeptId | 0 | 自动建号归属部门 |
Clients | 空 | 渠道应用,Key 支持 渠道 与 渠道@租户 两种写法 |
OAuthSettings.Current每次读取都重新绑定,改了appsettings.json不需要重启。ClientSecret建议用环境变量OAuth__Clients__GITHUB__ClientSecret覆盖,勿明文提交。
开放平台侧要登记什么
| 平台 | 回调地址 |
|---|---|
| GitHub/Gitee | https://{api域名}/oauth/callback |
| 微信公众号 | 同上(网页授权域名需配置到公众号后台) |
| 企业微信 | 同上(可信域名 + 应用主页) |
| 微信小程序 | 无需回调地址(code2session) |
协议、端口、路径必须与 RedirectUri 完全一致,差一个 / 都会报 redirect_uri 不匹配。
三、渠道支持
渠道标识沿用授权库命名(大写),前端与配置共用同一套字符串。
| 状态 | 渠道 |
|---|---|
| 已接入 | WECHAT_OPEN、WECHAT_MP、WECHAT_ENTERPRISE、WECHAT_ENTERPRISE_SCAN、WECHAT_MINI、GITHUB、GITEE、GITLAB、QQ、WEIBO、DOUYIN、MICROSOFT、GOOGLE、LINKEDIN、BAIDU、HUAWEI |
| 未接入 | DINGTALK / DINGTALK_SCAN(上游是已废弃协议,需按新版 OAuth2 重写)、ALIPAY_MP(需显式引用支付宝 SDK) |
新增渠道的完整步骤(全部在 ZR.OAuth 内,业务层与前端不用改):
| 步骤 | 落点 | 说明 |
|---|---|---|
| 1. 加渠道标识 | ZR.OAuth/OAuthSources.cs | 新增 public const string Xxx = "XXX";,前端与配置共用同一大写字符串 |
| 2. 实现渠道 | ZR.OAuth/Providers/XxxProvider.cs | sealed class : IOAuthProvider,只负责拿可信第三方身份,不查库、不建号、不签 token |
| 3a. 上游库已支持 | CollectiveOAuthProvider.CreateRequest 的 switch | 加一行 OAuthSources.Xxx => new XxxAuthRequest(config, cache) |
| 3b. 需独立协议 | OAuthProviderFactory 的 switch (source) | 加 case OAuthSources.Xxx: return new XxxProvider(...)(code2session、新版 OAuth2、端内免跳转走这条) |
| 4. 配置 | appsettings.json 的 OAuth:Clients:XXX | ClientId / ClientSecret / RedirectUri / Scope / AgentId;多租户用 "XXX@租户ID" 覆盖 |
| 5. 回调 | 第三方后台登记 {api}/oauth/callback | 后端再 302 到 OAuth:FrontendCallbackUrl(前端 /sociallogin) |
无需 DI 注册:OAuthProviderFactory 是静态类,每次请求按租户重新解析配置(注册成单例会读到错误的 AppId)。
四、接口清单
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | /oauth/authorize | 匿名 | 取登录用授权地址,返回 { url } |
| GET | /oauth/bind/authorize | 登录 | 取绑定用授权地址,返回 { url } |
| GET | /oauth/callback | 匿名 | 第三方回调入口,只做 302,不签发 token |
| POST | /oauth/state | 匿名 | App/小程序预发 state,返回 { state, expireMinutes } |
| POST | /oauth/login | 匿名 | code + state 换 token(端上与 Web 共用) |
| POST | /oauth/bind | 登录 | 绑定第三方账号,返回 true |
| POST | /oauth/unbind?authSource=GITHUB | 登录 | 解绑(软删除),返回影响行数 |
| GET | /oauth/binds | 登录 | 当前用户已绑定列表(不含第三方原始 ID) |
授权相关端点(匿名,最容易被刷)在 iprate.json 里单独限流:
| 端点 | 周期 | 上限 |
|---|---|---|
/oauth/authorize | 60s | 10 |
/oauth/state | 60s | 10 |
/oauth/bind/authorize | 60s | 10 |
/oauth/callback | 60s | 20 |
/oauth/login 没有专属规则,走 POST 通用规则(5 秒 1 次)。超限返回 {"code":429,"msg":"访问过于频繁,请稍后重试"}。
返回结构
三方登录与账号密码登录共用同一份结果映射(LoginResponseMapper),前端只需一套判断:
// 成功:data 就是 token 字符串
{ "code": 200, "msg": "success", "data": "eyJhbGciOi...", "deviceToken": "xxxx", "notice": "本次登录地点与常用地不同" }
// 需要双因子:token 尚未签发
{ "code": 203, "msg": "需要双因子验证", "mfaRequired": true, "ticket": "xxx", "expireMinutes": 5,
"methods": ["totp"], "preferredMethod": "totp" }
// 第三方身份有效但未绑定本系统账号
{ "code": 110, "msg": "该第三方账号尚未绑定系统账号,请先登录后在账号绑定中完成绑定", "bindRequired": true }
| 场景 | 处理 |
|---|---|
200 | data 存为 token;有 deviceToken 时本地保存(仅返回一次) |
203 | 弹出双因子输入,走 POST /login/2fa/verify(详见《双因子认证》) |
bindRequired | 引导"先登录 → 个人中心 → 账号绑定",不要原地重试(code 会被用掉) |
| 其他错误 | 直接展示 msg(state 失效、租户不匹配、渠道未配置等都有明确文案) |
五、数据表
sys_third_account([Tenant("0")],落在系统库,不要在各租户库另建):
| 字段 | 类型/长度 | 说明 |
|---|---|---|
Id | bigint | 主键(自增) |
UserId | bigint | 关联的本系统用户 ID |
AuthSource | varchar(32) | 渠道标识,如 GITHUB |
ThirdKey | varchar(128) | 渠道内唯一标识:UnionId 优先,无则 OpenId |
OpenId | varchar(128) | 第三方 openid(换应用即变,仅存档排查) |
UnionId | varchar(128) | 同主体跨应用稳定 |
NickName | varchar(64) | 第三方昵称(只做展示副本,不覆盖系统昵称) |
Avatar | varchar(512) | 第三方头像 |
Email | varchar(128) | 第三方邮箱(部分平台不返回) |
BindTime | datetime | 绑定时间 |
LastLoginTime | datetime | 最近一次用该身份登录的时间(识别僵尸绑定) |
Status | int | 0 正常 / 1 已解绑 |
- 唯一索引
uk_sys_third_account_key建在(AuthSource, ThirdKey):不同渠道的 openid 可能撞车,只按 openid 查会绑错人; - 解绑是软删除(
Status=1),保留"谁在何时用哪个身份登录过"的审计线索;重新绑定会复活旧行,避免撞唯一索引; - 同一用户同一渠道只允许一条正常绑定(换绑需先解绑)。
六、前端集成(ZRAdmin-Vue3)
| 文件 | 职责 |
|---|---|
src/api/system/oauth.js | 封装 8 个接口 |
src/views/components/Login/oauthLogin.vue | 登录页渠道按钮(取地址 → 同标签页跳转,带跳转互斥) |
src/views/socialLogin.vue | 回调页(路由 /sociallogin),区分登录/绑定、处理 203 |
src/views/system/user/profile/accountBind.vue | 个人中心账号绑定/解绑 |
src/store/modules/user.js | oauthLogin() 收尾、deviceToken / tenantId / clientId 持久化 |
发起授权:
const res = await getAuthorizeUrl({
authSource: 'GITHUB',
tenantId: userStore.tenantId !== '0' ? userStore.tenantId : undefined, // 主租户不传
clientId: userStore.clientId // 只用于 2FA 可信设备判定与审计
})
window.location.href = res.data.url
回调页:
const { code, state, error, error_description, scene, tenantId } = route.query
if (tenantId) userStore.setTenantId(tenantId) // 后端按 state 透传,换浏览器也不会指错库
if (error) return fail('授权未完成', error_description) // 用户点了"拒绝"
if (scene === 'bind') {
await bindThirdAccount({ code, state })
} else {
const res = await userStore.oauthLogin({ code, state }) // 自动带 deviceToken
if (res?.code == 203) openMfaDialog(res) // res.ticket
else afterLoginSuccess(res)
}
回调页路由必须与 FrontendCallbackPath 一致(默认 /sociallogin,全小写)。前端是 history 模式,不能写成 /#/sociallogin —— hash 会被浏览器当锚点,路由不跳转,页面白屏。
七、多租户
应用配置按租户隔离:
OAuth:Clients的 Key 支持渠道@租户,先找租户专用、再回落共用配置(OAuthSettings.Resolve)。客户各自申请应用是刚需,否则只能改代码。租户随 state 走,不走 URL:
/oauth/authorize把tenantId写进 state 载荷;回调时后端从 state 读出,再透传给前端页面。URL 上的租户参数可被随意改动,等于能拿别家租户的账号进系统。前端必须把
tenantId写进请求头:/oauth/login的tenantId头决定后端实例连哪个租户库,只靠本地缓存的话换浏览器就会指错库(现象是"授权成功却查不到绑定/表不存在")。回调目标按租户解析(
ResolveFrontendCallbackUrl),优先级:OAuth:FrontendCallbackUrls:{tenantId} 显式配置,最高 → 租户绑定域名(sys_tenant.Domain) + FrontendCallbackPath → OAuth:FrontendCallbackUrl 全局兜底三个来源都是配置或主库数据,不接受请求参数,因此不构成开放重定向。
租户不一致直接拒绝:请求租户与 state 租户不同会抛"登录租户与授权信息不匹配",避免用自己的授权在别人的租户库建号。
八、安全要点
| 机制 | 说明 |
|---|---|
| state 服务端签发 | 上下文(渠道/租户/设备/场景/绑定人)存缓存,URL 只带 state 值 |
| state 一次性 | ICache.TrySet 抢占 used 标记,并发两个回调只有一个能继续;上游 containsKey → remove 非原子,故额外加锁 |
| 跨节点可用 | state 走 CacheStore:启用 Redis 后多节点共享,否则 A 节点发起、B 节点回调会报"state 非法" |
| 场景隔离 | 绑定 state 不能调登录接口、登录 state 不能调绑定接口;绑定还校验 BindUserId == 当前用户 |
| token 不进 URL | 后端回调只 302 转发 code + state,token 由前端 POST 换取 |
| 身份不取自请求体 | 绑定/解绑的用户人身份取自 token,避免拿别人的 code 绑到自己账号 |
| 敏感信息不落日志 | 异常只记摘要(LogMsg),code、ClientSecret、session_key 均不落日志 |
| 限流 | 授权入口与回调节流,防止匿名请求刷 state 缓存 |
| 审计 | 登录日志 AuthType = oauth:{渠道小写}(如 oauth:gitee)、ClientId、2FA 字段与账号密码登录一致 |
九、常见问题
1. 报"授权已失效或已使用,请重新发起登录"
- state 超过
StateMinutes;或用户把回调页刷新/回退重放(state 已消费);或该 state 已被并发请求抢走。 - 多节点部署且
RedisServer:open = 0:state 只存在发起节点内存里,另一节点必然报这个错。多实例必须启用 Redis。
2. 授权成功但提示"该第三方账号尚未绑定系统账号"
- 默认策略是
BindOnly。先让用户用账号密码登录 → 个人中心 → 账号绑定。 - 确实要放开 C 端自助注册:
AutoCreate = 1且AutoCreateRoleIds至少配一个有效角色,否则仍按未绑定处理(只配开关不配角色会建出空白后台账号,因此刻意不生效)。
3. 302 到了别的租户域名 / 回调页白屏
- SaaS 下未配
FrontendCallbackUrls且租户sys_tenant.Domain为空时,会落到全局FrontendCallbackUrl; - 前端回调页必须是 history 路由
/sociallogin,不能带#。
4. 改了 appsettings.json 不生效
OAuth节支持热读取(每次OAuthSettings.Current重新绑定),但RedirectUri变更后还要去开放平台同步改回调地址。
5. 小程序登录拿不到昵称头像
- 微信基础库 2.27.1 起
getUserProfile不再返回真实信息,后端刻意留空,需前端用"头像昵称填写能力"采集后另行补全。
6. 钉钉 / 支付宝报"尚未接入"
- 上游库的钉钉实现是已废弃协议,支付宝 SDK 需要显式引用(
PrivateAssets=all)。两者需按新版 OAuth2 单独实现 Provider。
7. 同一渠道能绑多个账号吗
- 不能。同一用户同一渠道只允许一条正常绑定,换绑要先解绑;一个第三方身份也只能属于一个用户。
十、旧版迁移对照
| 旧版 | 当前版本 |
|---|---|
appsettings.json 的 OAuthConfig 节 | OAuth 节(Enabled/StateMinutes/FrontendCallbackUrl/Clients…) |
GET /auth/Authorization(直接 302) | GET /oauth/authorize(返回 { url },由前端跳转) |
POST /auth/callback(直接签 token) | POST /oauth/login(code + state);浏览器回调走 GET /oauth/callback 中转 |
ThirdAccountService.OAuthLogin | SysThirdAccountService.LoginByCode(统一走 SysLoginService.SignIn,含 2FA/单会话/设备登记) |
表 third_account | 表 sys_third_account(新增 AuthSource/ThirdKey/Status 与唯一索引) |
手工拷贝 Come.CollectiveOAuth 源码/dll | NuGet 包 CollectiveOAuthCore(在 ZR.OAuth 引用) |
升级时注意:旧的
third_account只有ThirdUniqueAcount一列,需要按渠道回填AuthSource,并把 unionid/openid 归一化进ThirdKey后建唯一索引;解绑语义从"删记录"改为"置Status=1"。
