在企业级微服务架构中,单点登录(SSO)是不可或缺的基础设施。网上的 OAuth2.0 教程大多停留在"Demo 级别":总是会弹出一个框架自带的简陋登录页面让用户输入账密。
但在真实的生产环境中,我们的业务场景往往是这样的:
用户已经使用账号密码登录了我们的主系统(门户/工作台),进入首页后,点击页面上的"第三方业务系统"图标,要求直接免密、无感地跳转进入子系统。
很多开发者在看完原理图后,一到敲代码的环节就懵了:图上的线条在代码里到底长啥样?到底是前端发请求还是后端发请求?框架底层到底提供了哪些接口?
今天,我们将基于 Spring 生态最新推荐的 Spring Authorization Server (SAS) ,手把手带你落地这套架构。本文最大的特色是:将设计时序图与具体的代码实现进行 1:1 的精准映射,并深度剖析底层的核心接口!
注:本文基于 Spring Boot 3.x 与 Spring Security 6.x 编写。
🏗️ 核心架构与分水岭设计
在动手之前,必须理清本场景中最关键的架构抉择:SP-Initiated(业务系统发起模式)。
因为用户登录的入口是主系统(门户 8080),它不是发证机关(IdP 9000),没有资格自己生成 Code。当用户点击"跳转子系统 8081"时,8080 必须引导用户去找 9000"要一张去 8081 的门票"。
这决定了:跳转的第一步,必须是前端执行 window.open,让浏览器带着已有的全局 Cookie,主动去敲 9000 的大门请求静默授权。 ### 角色划分
auth-server(IdP 认证中心) :9000端口。全局会话的管理者。portal-client(SP 主系统) :8080端口。用户发起跳转的起点。sub-client(SP 子系统) :8081端口。接收 Code 换票的最终目的地。
🗺️ 架构设计:全链路交互时序图
请牢记这张图,接下来的每一行核心代码,都将严丝合缝地贴合图上的步骤。
子系统 (8081) 认证中心 (9000) 主系统 (8080) 用户浏览器 子系统 (8081) 认证中心 (9000) 主系统 (8080) 用户浏览器 【阶段一:前置条件与身份基石】 此时浏览器已拥有 IdP(9000) 的 Session Cookie 【阶段二:触发无感跳转 (SP-Initiated)】 校验 Cookie 有效,跳过登录页,静默授权 【阶段三:子系统接力与本地开户】 同步用户资料,签发本地业务 JWT 1. 登录主系统 (已完成) 1 2. 点击"进入第三方子系统"按钮 2 3. 前端执行 window.open(IdP授权页) 3 4. 携带 Cookie 访问 /oauth2/authorize 4 5. 302 重定向至子系统回调地址 (携带 code) 5 6. 重定向至 /login/oauth2/code/sub-app 6 7. [后台隐秘通信] 携带 Secret 和 Code 换取 Token 7 8. 返回 Access Token 与 UserInfo 8 9. 302 重定向回子系统前端首页 (携带 JWT) 9 10. Vue/React 渲染,实现无感免密登录! 10
💻 落地实现:代码与设计的 1:1 映射
阶段一:前置条件与身份基石
要让后面的跳转能跑通,认证中心(9000)必须提前认识主系统和子系统。
📍 对应工程:auth-server (9000)
在 AuthorizationServerConfig 配置类中注册客户端:
java
@Bean
public RegisteredClientRepository registeredClientRepository() {
// 【映射时序图 步骤 1 前置条件】:主系统注册
RegisteredClient portalClient = RegisteredClient.withId("portal-id")
.clientId("portal-app")
.clientSecret("{noop}portal-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/portal-app")
// ...省略 scope 等配置
.build();
// 【映射时序图 步骤 5 回调目标】:子系统注册
// 只有在这里注册了,IdP 才会允许给 8081 发放 Code
RegisteredClient subAppClient = RegisteredClient.withId("sub-id")
.clientId("sub-app")
.clientSecret("{noop}sub-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("http://127.0.0.1:8081/login/oauth2/code/sub-app")
.build();
return new InMemoryRegisteredClientRepository(portalClient, subAppClient);
}
阶段二:触发无感跳转 (SP-Initiated)
主系统前端发力,利用浏览器自身的机制,打通跨域身份校验。
📍 对应工程:portal-client (8080) 的前端 Vue/React 页面
javascript
// 【映射时序图 步骤 2】:绑定到跳转按钮的 click 事件
function handleJumpToSubApp() {
// 构造标准的 OAuth2 授权请求地址
// ⚠️ 严苛细节:我们要去子系统,所以 client_id 必须是 8081 的标识
const authUrl = "http://127.0.0.1:9000/oauth2/authorize" +
"?response_type=code" +
"&client_id=sub-app" +
"&redirect_uri=http://127.0.0.1:8081/login/oauth2/code/sub-app" +
"&scope=openid profile";
// 【映射时序图 步骤 3 & 4】:让浏览器自己去请求!
// window.open 会在新标签页打开。
// 核心魔法:浏览器访问 9000 端口时,会自动把之前存的 9000 的 Cookie 带上!
// 9000 后端收到请求,一看有 Cookie 认识你,连登录框都不弹,直接 302 把你扔给 8081。
window.open(authUrl, "_blank");
}
注:时序图中的 Step 4 (静默授权) 和 Step 5 (下发 Code) 全部由 Spring Authorization Server 的底层引擎全自动完成,无需我们手写一行 Controller!
阶段三:子系统接力与本地开户
浏览器被 302 扔到了子系统(8081),子系统开始后台"暗箱操作"。
📍 对应工程:sub-client (8081) 的后端 Spring Boot
首先,在 YML 中配置好自己的身份。
【映射时序图 步骤 6、7、8】 :这三步的高危操作(拦截 Code、发 HTTP 请求换 Token),由于我们引入了 spring-boot-starter-oauth2-client,Spring 底层的 OAuth2LoginAuthenticationFilter 会全自动搞定!我们只需在 YML 里填好参数:
yaml
spring:
security:
oauth2:
client:
registration:
sub-app: # 【映射 Step 6】:这里的名字对应回调 URL 的尾巴
client-id: sub-app
client-secret: sub-secret
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
sub-app:
# 【映射 Step 7】:Spring 会自动向这里发 POST 请求换票
issuer-uri: http://127.0.0.1:9000
接着,当 Spring 在底层偷偷拉取到用户信息后,我们需要接管最后一棒。
【映射时序图 步骤 9】:自定义登录成功处理器
java
@Component
public class SSOAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
// 【映射 Step 8 结束】:从 Authentication 中提取 9000 传来的身份信息
OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal();
String username = oauth2User.getAttribute("preferred_username");
// 🌟 业务核心逻辑:如果本地数据库没这个用户,直接根据资料开户
// userService.syncUserFromIdp(username, oauth2User.getAttributes());
// 签发 8081 子系统自己认的本地 JWT Token
String localJwtToken = JwtUtils.generateToken(username);
// 【映射 Step 9】:最后一次 302!带着 Token 重定向回 8081 的前端 Vue 页面
String frontendUrl = "http://localhost:3001/dashboard?token=" + localJwtToken;
response.sendRedirect(frontendUrl);
}
}
最后,在前端的 dashboard 页面(【映射时序图 步骤 10】),Vue 组件在 mounted 生命周期中提取 URL 上的 token 存入 localStorage,完美的无感跳转正式闭环!
🔍 进阶剖析:框架底层的"魔法"与核心接口扩展
为什么上面我们只写了几行配置,连 Controller 都不用写,整个流程就能跑通?那是因为 Spring Authorization Server 已经为你开箱即用地准备好了标准的端点(Endpoints)和高度抽象的核心接口。
1. 认证中心默认暴露的标准端点 (Endpoints)
当你启动 9000 端口时,以下接口就已经生效了,前端和子系统后端就是在和这些接口打交道:
/oauth2/authorize(授权端点) :我们在前端window.open调用的就是它。它接收response_type=code、client_id、redirect_uri。负责校验用户登录状态(也就是为什么能无感的核心),并签发 Code。/oauth2/token(令牌端点) :子系统 8081 后端静默调用的接口。它接收grant_type=authorization_code、code、client_secret,负责核对暗号并发放 Access Token。/oauth2/jwks(JWK 公钥端点):资源服务器用来获取验证 JWT 签名的 RSA 公钥。/userinfo(OIDC 用户信息端点):子系统 8081 拿到 Token 后,调用它来获取当前用户的详细资料(头像、昵称、角色等)。
2. 核心接口:实战中必须重写的 Bean
在 Demo 中我们用了各种 InMemory... 的内存实现,但在生产环境中,你需要通过实现以下接口将数据存入 MySQL 或 Redis:
📌 IdP 端(认证中心)核心接口:
RegisteredClientRepository:第三方应用管家。生产中必须实现它(或使用自带的JdbcRegisteredClientRepository),把业务系统的client_id和回调地址存到数据库中,实现动态增删应用。OAuth2AuthorizationService:授权状态大管家。负责持久化存储 Code 和 Access Token。如果不替换为 Redis 或 DB 实现,IdP 一旦重启,所有用户发下去的 Token 就全失效了。OAuth2TokenCustomizer:JWT 雕刻刀。如果你想在发给子系统的 Access Token 中塞入用户的部门 ID 或角色权限,就需要实现该接口,动态修改 JWT Payload。
📌 SP 端(业务子系统)核心接口:
OAuth2UserService<OAuth2UserRequest, OAuth2User>:用户信息拉取工。这是 SP 端最常被重写的接口! 当底层从/userinfo拉回 JSON 后,默认只是转成了 Map。你需要继承DefaultOAuth2UserService,重写loadUser()方法,在这里将其转换为你本地系统的SysUser实体类,或者处理接口超时等容错逻辑。
💡 落地避坑指南(架构师 Checklist)
- 域名极度敏感(Issuer Mismatch) :
如果YML里的issuer-uri是127.0.0.1,那么主系统前端window.open的地址也必须是127.0.0.1,绝不能用localhost!否则 Spring 验证 Token 签名时必报"签发者不匹配"。建议统一配 Host 域名。 - 跨域 Cookie 拦截(SameSite) :
无感 SSO 的灵魂在于浏览器带 Cookie。如果你的 8080 和 9000 不在同一个主域名下,高版本浏览器可能会拦截第三方 Cookie。必须通过 Nginx 将它们代理到同源域名(如portal.company.com和sso.company.com)。 - 不要在前端用 AJAX 要 Code :
永远不要用axios.get去请求/oauth2/authorize!CORS 机制不仅会拦截 302 重定向,更无法顺利让浏览器发生页面的实际位移。必须用window.open或<a href>。
结语
单点登录的本质,是利用认证中心作为整个架构的"信任锚点"。主系统负责开启全局 Session,子系统负责验证授权码。通过本文的 1:1 映射拆解以及底层源码接口的剖析,希望能帮你彻底扫清从 OAuth2 抽象协议到代码落地的最后一道障碍。
如果这篇文章解答了你心中的疑惑,欢迎点赞收藏,转发给团队的兄弟们一起避坑!