前言
第三方登录(OAuth2)看似简单:用户扫码,登录成功。但在实际工程落地中,涉及到底层网络代理、参数配置管理、安全校验(State)、以及**“未绑定账号如何优雅处理”**等复杂的业务逻辑。
本文将基于JustAuth开源库与Yudao框架的源码实现,抽丝剥茧,还原一个企业级第三方登录的全流程。
流程图
流程图方便配合后面具体的文字细节,帮助理解
情况一:用户已将第三方与本系统账户绑定
情况二:用户未将第三方与本系统账户绑定
流程细节
第一阶段:发起授权(The Request)
核心逻辑:前端索要“通行证”,后端组装“申请单”。
1. 前端发起请求
用户点击“GitHub 登录”,前端向后端接口/social-auth-redirect发起请求。
- 关键参数:
type=20:标识社交平台类型(如 GitHub)。redirectUri:回调地址。这是 GitHub 办完事后要把用户送回来的地址(必须与 GitHub 后台白名单一致)。
2. 后端构建 AuthRequest(核心工厂模式)
后端接收到请求后,通过AuthRequestFactory构建核心请求对象。这里采用了一个混合配置策略:
Step 2.1:加载基础架构配置(YAML)
通过
JustAuthProperties读取application.yaml中的配置。作用:初始化HTTP 代理(Proxy)和超时时间。这是为了解决国内服务器访问 GitHub 经常超时的问题。
Step 2.2:加载业务配置(Database)
根据
type查询数据库social_client表。作用:获取最新的
ClientId和ClientSecret。设计亮点:利用反射机制,将 DB 中的业务参数覆盖到 YAML 的基础配置对象中。这样既保证了网络连通性,又实现了业务参数的动态热更新(换 ID 无需重启服务)。
3. 生成 State 与 授权 URL
生成防伪印章(State):后端调用
AuthStateUtils.createState()生成随机字符串。缓存 State(Redis):通过 IOC 注入的
RedisStateCache,将state存入 Redis,设置短暂过期时间(如 3 分钟)。目的:防止 CSRF 攻击。
组装 URL:调用
AuthRequest.authorize(state)(缓存state其实是在这个函数内被执行的),生成最终的跳转链接:https://github.com/login/oauth/authorize?client_id=...&redirect_uri=...&state=xyz...返回前端:后端将这个长链接直接返回给前端。
第二阶段:用户交互(The Interaction)
核心逻辑:用户在第三方平台操作,系统完全不可见。
- 浏览器跳转:前端拿到 URL,执行
window.location.href跳转。 - 用户授权:用户在 GitHub 页面输入账号密码或扫码,点击“同意授权”。
- 携带信物返回:GitHub 生成一个临时的
code,并将浏览器重定向回前端传入的redirectUri:
- 地址变为:
/social-login?code=temp_code_123&state=xyz...
第三阶段:后端回调与分流策略
核心逻辑:优先查库“复用”,兜底请求“换票”。
前端监听到回调路由,提取 URL 中的code和state,调用后端登录接口。后端authSocialUser方法执行以下严格顺序:
1. 优先查库
后端首先根据code+state查询social_user表。这是为了处理“账号绑定”等需要复用 Code 的场景。
分支 A:命中缓存(查到了)
- 场景:用户处于“绑定账号”的第二步操作,或者是极短时间内的重试。
- 逻辑:既然数据库里有记录,说明该请求在不久前已经通过了第三方校验。直接返回数据库中的
SocialUserDO信息。 - 结果:跳过Redis 校验,跳过GitHub 请求(因为 Code 已失效),直接进入绑定或登录流程。
分支 B:未命中(没查到)
- 场景:这是用户第一次发起登录,或者是全新的请求。
- 逻辑:代码继续向下执行,调用 JustAuth 的核心方法
authRequest.login(callback)。
2. 标准校验与换票
进入分支 B 后,authRequest.login()内部会自动执行标准流程:
- 验明正身 (State Check):
- SDK 内部自动调用
RedisStateCache查找state。 - 如果 Redis 中不存在(或已过期),抛出异常,拦截攻击。
- 以码换人 (Exchange):
- 校验通过后,请求 GitHub 接口,换取 AccessToken 和 UserInfo。
- 数据落库 (Snapshot):
- Yudao 获取到 GitHub 信息后,立即将其插入/更新到
social_user表(包含code和state)。 - 目的:这就是为了让下一次请求能进入“分支 A”。
3. 业务结果判定的后续处理
核心逻辑:基于关联表的三岔路口——是直接放行,还是通过异常机制触发“填表流程”?
当后端经过第二步的分支A或B拿到SocialUserDO(包含 OpenID 等社交信息)后,认证流程并未结束,反而刚刚开始。系统需要判断:“这个拿着 GitHub 身份证的人,到底是不是我公司的员工?”
这一步的逻辑严格分为两个分支:
Step 3.1:跨表查询关联关系
后端使用socialType(社交平台类型)和socialUserId(即刚才获取到的SocialUserDO的主键或 OpenID),去查询sys_social_user_bind关联表。
SELECTuser_idFROMsys_social_user_bindWHEREsocial_type=20ANDsocial_user_id={当前GitHub用户的ID}Step 3.2:分支 A —— 已绑定(老用户丝滑入境)
如果查询到了关联记录,说明该 GitHub 账号已经与本系统的一个UserId(例如1024)绑定过了。
- 查询主账号:根据查到的
user_id,去sys_user表查询系统用户详情。 - 状态校验:检查该用户是否被禁用、是否离职等(安全风控)。
- 记录日志:写入登录日志表(记录“用户通过 GitHub 登录成功”)。
- 发放令牌:
- 基于
UserId创建LoginUser对象。 - 生成系统的AccessToken和RefreshToken。
- 返回结果:直接返回 HTTP 200 和 Token,前端收到后直接跳转首页。
- 用户感知:扫码后屏幕一闪,直接进系统,无感知。
Step 3.3:分支 B —— 未绑定(触发“中断”机制)
如果sys_social_user_bind表里查不到记录,说明这是一个“熟悉的陌生人”——我们认识这个 GitHub 账号(sys_social_user里有),但他还没关联到任何内部账号。
此时,后端绝对不能返回成功,但也不能返回普通的 500 错误。
- 定义异常类型:后端会抛出一个特定的业务异常,例如
ServiceException,并携带一个特殊的错误码(例如AUTH_THIRD_PARTY_NOT_BIND)。 - 中断流程:该异常会打断当前的登录链路,Token 生成步骤不会执行。
- 返回给前端:
- 前端收到这个异常响应,解析错误码。
- 关键动作:前端识别出“未绑定”信号,自动跳转到**“账号绑定/注册页面”**。
- 携带上下文:抛出异常时,不需要返回具体的 OpenID,因为前端手里还捏着那个
code和state(或者是后端的reqId),这将在下一步绑定时作为凭证。
给读者的提示
💡 源码划重点:
在部分实现中,并不是先校验 State。这是因为social_user表充当了“持久化的 State 缓存”。只有当 DB 里没有记录时,才会回退到标准的 OAuth2 流程(校验 Redis -> 请求 GitHub)。这种设计完美兼容了“Code 一次性”与“业务多步走”的矛盾。
第四阶段:异常处理与账号绑定(The Binding)
核心逻辑:复用失效的 Code,完成逻辑闭环。
1. 前端处理异常
前端捕获到“未绑定异常”,跳转到“账号绑定/登录页”,提示用户输入本系统的账号密码。
2. 发起绑定请求
用户点击“绑定并登录”,前端发送:username+password+code(复用之前的参数)。
3. 后端“偷天换日”
后端再次接收到带有code的请求:
- 再次查库:根据
code+state查询social_user表。 - 命中缓存:由于第三阶段已经落库,这次查到了记录!
- 直接返回:代码直接返回数据库中的
SocialUser对象(里面包含 OpenID),跳过了向 GitHub 请求的步骤。
- 妙处:规避了“Code 已失效”的错误,利用本地数据完成了逻辑接力。
- 完成绑定:
- 验证账号密码正确。
- 在
social_user_bind表中插入UserId与SocialUserId的关联。 - 生成 JWT Token,返回给前端。
关键变量数据流向
变量流程图
具体流向
在 OAuth2 的复杂的交互中,有 4 个关键变量贯穿始终。理解它们的流转路径,是掌握整个安全体系的钥匙。
1.ClientId&ClientSecret(身份凭证)
角色:应用的“身份证”和“密码”。
来源:
静态:
application.yaml(兜底配置)。动态:MySQL
social_client表 (业务配置,优先级更高)。流向:
- 配置加载:后端启动或请求时,通过
AuthRequestFactory从 DB 读取。 - 分道扬镳:
- ClientId:拼接到授权 URL 中 ->暴露给前端/浏览器-> 发送给 GitHub。
- ClientSecret:严禁泄露给前端。它只停留在后端服务器,由后端通过 HTTP 请求直接发给 GitHub 用于换取 Token。
2.State(防伪印章 & 联合主键)
在 部分系统 的设计中,state拥有双重身份:前半生是CSRF 令牌,后半生是本地业务索引键。
1. 诞生与缓存 (Creation & Cache)
- 执行位置:
AuthController->AuthRequestFactory->AuthRequest.authorize() - 代码细节:
- 调用
AuthStateUtils.createState()生成一个 32 位随机字符串(例如uuid-1234)。 AuthRequest内部调用RedisStateCache.cache(state)。
物理存储:
位置:Redis 服务器。
Key 格式:
justauth:state:{socialType}:{state值}(例如justauth:state:20:uuid-1234)。TTL:默认为 3 分钟(
auth.cache.timeout配置)。作用:此时它是“门票存根”,证明请求是由本系统发出的。
2. 漂流 (Transmission)
- 载体:HTTP 302 重定向 URL。
- 形态:
https://github.com/login...?state=uuid-1234。 - 流转:从服务器内存->用户浏览器地址栏->GitHub 服务器。
- 注意:GitHub 服务器仅仅是“保管”一下这个参数,回调时原样退回。
3. 验证与销毁 (Verify & Delete)
- 执行位置:
SocialUserServiceImpl->AuthRequest.login() - 代码细节:
- SDK 内部调用
RedisStateCache.get(state)。 - 比对:Redis 里有没有这个 Key?
有:验证通过,并立即执行
redisTemplate.delete(key)(防止重放攻击)。无:抛出
AuthException("Illegal state")。状态变更:至此,
state在 Redis 中的使命结束。
4. 持久化与转生 (Persistence)
执行位置:
SocialUserServiceImpl.authSocialUser代码细节:
socialUserMapper.insert(newSocialUserDO().setState(state)// state 被写入 MySQL);物理存储:MySQL
system_social_user表的state字段。作用变更:此时
state不再用于防 CSRF,而是和code一起组成了本次社交会话的唯一标识。
2.Code(一次性令牌 & 绑定凭证)
code是 OAuth2 的核心
1. 诞生 (Generation)
- 执行位置:第三方服务器(GitHub/DingTalk)。
- 触发时机:用户在第三方页面点击“同意授权”的一瞬间。
- 性质:极其短暂的有效期(通常 1-5 分钟),且只能交换一次 Token。
2. 漂流 (Transmission)
- 载体:HTTP 回调 URL(Callback URL)。
- 形态:
http://yoursite.com/social-login?code=gh_xc9...&state=...。 - 流转:GitHub 服务器->用户浏览器->Nginx->Spring Boot Controller。
3. 消费 (Consumption)
执行位置:
AuthRequest.login()->AuthGithubRequest.getAccessToken()动作:
后端构造 HTTP POST 请求发送给 GitHub。
参数包含:
client_id,client_secret,code。结果:
成功:换回
access_token和用户信息。GitHub 端状态:该
code在 GitHub 侧被标记为**“已失效”**。
4. 落库 (Persistence - 关键一步)
执行位置:
SocialUserServiceImpl.authSocialUser代码细节:
// 即使 code 已经在 GitHub 失效了,我们依然把它存进数据库socialUser.setCode(code);socialUserMapper.insertOrUpdate(socialUser);物理存储:MySQL
system_social_user表的code字段。战略意义:这相当于给这次“登录行为”拍了一张快照。
5. 复用 (Reuse - 绑定阶段)
场景:用户在前端“绑定账号”页面,点击“绑定”。
入参:前端将之前的
code和state再次传给后端接口/auth/login。执行位置:
SocialUserServiceImpl.authSocialUser第一行代码。逻辑:
// 拿着前端传来的失效 Code,去 MySQL 里找快照selectByTypeAndCodeAnState(type,code,state);结果:查到了之前存的
SocialUserDO(里面含有 OpenID)。后端假装Code 还没失效,直接返回了用户信息,从而绕过了 GitHub 的校验。
实现代码
1. 接口层 (Controller)
提供两个核心入口:social-login用于已绑定用户的直接登录,login用于常规登录或未绑定用户的“账号绑定+登录”。
// AuthController.java@RestController@RequestMapping("/system/auth")publicclassAuthController{@ResourceprivateAuthServiceauthService;/** * 场景A:社交快捷登录 * 适合已绑定社交账号的老用户,前端直接携带 code + state 请求 */@PostMapping("/social-login")@PermitAllpublicCommonResult<AuthLoginRespVO>socialQuickLogin(@RequestBody@ValidAuthSocialLoginReqVOreqVO){returnsuccess(authService.socialLogin(reqVO));}/** * 场景B:账号密码登录(兼具绑定功能) * 1. 常规登录:只传 username + password * 2. 绑定并登录:传 username + password + socialType + code + state */@PostMapping("/login")@PermitAllpublicCommonResult<AuthLoginRespVO>login(@RequestBody@ValidAuthLoginReqVOreqVO){returnsuccess(authService.login(reqVO));}}2. 登录业务层
这里处理登录的分流逻辑:是直接颁发 Token,还是抛出异常引导绑定,亦或是处理绑定逻辑。
// AdminAuthServiceImpl.java@ServicepublicclassAdminAuthServiceImplimplementsAuthService{@OverridepublicAuthLoginRespVOsocialLogin(AuthSocialLoginReqVOreqVO){// 1. 使用 code 换取社交用户信息// 注意:底层 authSocialUser 会自动处理 code 的持久化,实现“一次性 code”的复用准备SocialUserRespDTOsocialUser=socialUserService.getSocialUserByCode(UserTypeEnum.ADMIN.getValue(),reqVO.getType(),reqVO.getCode(),reqVO.getState());// 2. 关键判断:如果该社交账号未绑定系统用户,抛出特定异常if(socialUser==null||socialUser.getUserId()==null){// 前端捕获此异常后,跳转到绑定页面throwexception(AUTH_THIRD_LOGIN_NOT_BIND);}// 3. 如果已绑定,直接获取系统用户并登录AdminUserDOuser=userService.getUser(socialUser.getUserId());returncreateTokenAfterLoginSuccess(user.getId(),user.getUsername(),LoginLogTypeEnum.LOGIN_SOCIAL);}@OverridepublicAuthLoginRespVOlogin(AuthLoginReqVOreqVO){// 1. 账号密码常规验证AdminUserDOuser=authenticate(reqVO.getUsername(),reqVO.getPassword());// 2. 绑定逻辑:如果请求中携带了社交参数 (socialType 非空)if(reqVO.getSocialType()!=null){// 执行绑定:内部会复用之前存储在 DB 中的 code/state 记录,无需再次请求第三方socialUserService.bindSocialUser(newSocialUserBindReqDTO(user.getId(),getUserType().getValue(),reqVO.getSocialType(),reqVO.getSocialCode(),reqVO.getSocialState()));}// 3. 创建 Token 返回returncreateTokenAfterLoginSuccess(user.getId(),reqVO.getUsername(),LoginLogTypeEnum.LOGIN_USERNAME);}}3. 核心业务层
这是整个架构中最精髓的部分。authSocialUser方法通过优先查询数据库,实现了对 OAuth2 Code 的“拦截与复用”。
// SocialUserServiceImpl.java@ServicepublicclassSocialUserServiceImplimplementsSocialUserService{/** * 核心方法:获取社交用户信息 * 策略:DB 缓存优先 -> 兜底请求第三方 -> 落库保存 */@NotNullpublicSocialUserDOauthSocialUser(IntegersocialType,IntegeruserType,Stringcode,Stringstate){// 1. 【优先查库】尝试从 DB 获取(解决 Code 二次使用问题)// 如果用户是在“绑定”阶段,前端传来的 code 已经在之前的请求中落库了,这里直接返回,避免报错SocialUserDOsocialUser=socialUserMapper.selectByTypeAndCodeAnState(socialType,code,state);if(socialUser!=null){returnsocialUser;}// 2. 【请求第三方】如果 DB 没查到,说明是新请求。调用 JustAuth 请求第三方平台// 内部会校验 Redis State,通过 code 换取 access_token 和 openidAuthUserauthUser=socialClientService.getAuthUser(socialType,userType,code,state);// 3. 【落库保存】将第三方返回的信息(OpenID, Token)以及本次的 Code + State 持久化到 DB// 这一步是后续能够复用 Code 的关键socialUser=socialUserMapper.selectByTypeAndOpenid(socialType,authUser.getUuid());if(socialUser==null){socialUser=newSocialUserDO();socialUserMapper.insert(socialUser.setType(socialType).setCode(code).setState(state).setOpenid(authUser.getUuid()).setToken(authUser.getToken().getAccessToken()));}else{// 更新 Code 和 State,确保最新的请求也能被缓存socialUserMapper.updateById(socialUser.setCode(code).setState(state).setToken(authUser.getToken().getAccessToken()));}returnsocialUser;}/** * 绑定逻辑 */publicStringbindSocialUser(SocialUserBindReqDTOreqDTO){// 1. 获取社交用户(复用 authSocialUser 逻辑,此时走 DB 缓存分支)SocialUserDOsocialUser=authSocialUser(reqDTO.getSocialType(),reqDTO.getUserType(),reqDTO.getCode(),reqDTO.getState());// 2. 清理旧绑定关系(防止脏数据)socialUserBindMapper.deleteByUserTypeAndSocialUserId(reqDTO.getUserType(),socialUser.getId());// 3. 插入新的绑定关系SocialUserBindDOsocialUserBind=SocialUserBindDO.builder().userId(reqDTO.getUserId()).userType(reqDTO.getUserType()).socialUserId(socialUser.getId()).socialType(socialUser.getType()).build();socialUserBindMapper.insert(socialUserBind);returnsocialUser.getOpenid();}}