开源SSO单点登录前后端分离完整方案:基于Spring Security与OIDC的工程实践
1. 项目概述为什么我们需要一个开源的SSO前后端分离方案在任何一个稍具规模的企业级应用生态里你总会遇到一个让人头疼的问题用户需要记住多少个账号密码市场部的同事登录CRM系统用一套登录OA系统用另一套登录数据分析平台又得重新输入。这不仅用户体验糟糕对运维和安全团队来说更是噩梦——账号分散管理、密码策略不统一、离职员工账号清理困难。单点登录Single Sign-On, SSO就是为了解决这个“多套密码”的痛点而生的。它允许用户在一个中心化的认证服务比如公司的统一门户登录一次就可以访问所有相互信任的应用系统无需再次输入凭证。然而找到一套趁手的SSO实现方案并不容易。市面上的商业方案如Okta, Auth0功能强大但价格不菲且深度定制困难。一些开源框架如Keycloak, CAS虽然免费但往往架构庞大、学习曲线陡峭与当下主流的前后端分离技术栈如Spring Boot Vue.js集成时配置繁琐文档也未必跟得上最新的技术实践。更重要的是很多方案在前后端分离场景下的处理并不“地道”——比如如何优雅地处理跨域、如何安全地传递令牌、前端路由如何与认证状态联动这些细节在通用方案里常常语焉不详。因此一个开源的、专为现代前后端分离架构设计的、完整可运行的SSO实现就成为了很多开发团队迫切需要的“轮子”。它不应该只是一个概念演示而应该是一个从数据库设计、认证服务器搭建、资源服务器保护到前端登录页面和状态管理的全栈解决方案。开发者拿到手能快速理解SSO的核心流程能基于清晰的代码进行二次开发能直接部署到自己的测试甚至生产环境。这正是“SSO单点登录前后端分离完整版·开源”这个项目标题背后所承载的期望。它意味着一个立即可用的、符合现代开发范式的工程实践而不仅仅是一篇理论文章。2. 核心架构与认证流程深度解析要理解一个SSO系统我们必须先抛开代码从协议和流程层面看透它。目前主流的SSO实现大多基于OAuth 2.0和OpenID Connect (OIDC)协议族。OAuth 2.0解决了授权Authorization问题即“应用A能否代表用户去访问他在应用B的数据”而OIDC在OAuth 2.0之上增加了认证Authentication的标准即“告诉应用A用户是谁”。我们的SSO系统本质上就是一个OIDC身份提供商Identity Provider, IdP。2.1 核心角色与交互流程在一个典型的前后端分离SSO场景中通常涉及以下几个角色用户User 最终使用浏览器进行操作的人。客户端应用Client Application 即我们的前端如Vue/React应用。它运行在用户的浏览器中不直接处理密码只负责引导用户登录和展示受保护的数据。认证服务器Authorization Server / IdP 即我们的SSO服务后端。它负责验证用户身份如校验账号密码并颁发访问令牌Access Token和身份令牌ID Token。这是整个系统的核心。资源服务器Resource Server 即我们的业务API后端如Spring Boot提供的RESTful API。它托管着受保护的资源用户数据、订单信息等负责验证客户端携带的访问令牌并决定是否返回资源。用户代理User Agent 通常就是浏览器负责在用户、客户端和各个服务器之间转发请求和重定向。基于OIDC授权码模式Authorization Code Flow with PKCE这是目前为SPA推荐的最安全模式的完整交互时序如下1. 用户访问前端应用如 https://app.example.com。 2. 前端检查本地无有效令牌将用户重定向至SSO认证服务器的登录端点如 https://sso.example.com/oauth/authorize并携带客户端ID、重定向URI、随机挑战码code_challenge等参数。 3. 用户在SSO认证服务器的页面上输入用户名和密码。 4. 认证服务器验证凭证成功后将授权码Authorization Code通过重定向传回前端指定的回调地址如 https://app.example.com/callback。 5. 前端应用在回调页面中获取到URL中的授权码。 6. 前端应用在浏览器中向认证服务器的令牌端点/oauth/token发起POST请求提交授权码和之前生成的验证码code_verifier。 7. 认证服务器验证授权码和验证码通过后返回访问令牌Access Token、刷新令牌Refresh Token和ID令牌ID Token给前端。 8. 前端将令牌安全存储例如在内存或HttpOnly Cookie中。 9. 此后前端调用业务API时在HTTP请求头Authorization: Bearer access_token中携带访问令牌。 10. 业务API资源服务器接收到请求向认证服务器的令牌自省端点/oauth/introspect或使用公钥验证JWT签名以校验令牌的有效性和范围。 11. 校验通过后业务API执行请求并返回数据给前端。 12. 当访问令牌过期前端使用刷新令牌向认证服务器请求新的令牌对。注意 第6步中前端直接与认证服务器交换令牌这要求认证服务器必须正确配置CORS策略以允许前端应用的源Origin进行跨域请求。这是前后端分离架构下的一个关键配置点。2.2 为什么选择“授权码模式 PKCE”这是项目设计中的一个关键决策。早期SPA可能使用过“隐式模式”它直接将令牌通过URL片段#传回前端省略了授权码交换步骤。但这种方式存在令牌在浏览器历史记录和Referer头中泄漏的风险。OAuth 2.1标准已明确废弃隐式模式。授权码模式Authorization Code Flow本身更安全因为令牌是通过后端对后端的通信前端回调页面到其服务端来获取的不会经过浏览器重定向。但对于纯粹的前后端分离SPA没有自己的后端来处理回调我们让前端直接交换授权码这引入了新的风险如果授权码被拦截攻击者可以冒充前端应用来兑换令牌。PKCEProof Key for Code Exchange发音“pixy”就是为了解决这个问题而生的。它的原理很简单前端在发起授权请求时生成一个随机的code_verifier验证码并计算其哈希值得到code_challenge挑战码将challenge和所用算法如S256随请求发送。认证服务器记录这个challenge。当前端用授权码来兑换令牌时必须同时附上原始的code_verifier。认证服务器会重新计算verifier的哈希与之前存储的challenge比对。只有匹配才发放令牌。这样一来即使授权码在传输中被截获攻击者没有原始的code_verifier也无法兑换令牌。因此“授权码模式 PKCE”成为了保护公共客户端如SPA、移动App的黄金标准。我们的开源项目必须完整实现这一流程。3. 技术栈选型与项目结构剖析一个“完整版”的SSO项目其技术栈必须覆盖认证服务器、资源服务器和前端客户端。以下是一个典型且流行的选型方案也是很多开源项目采用的组合3.1 后端技术栈认证服务器 资源服务器核心框架Spring Boot 2.x / 3.x理由 Java生态事实上的标准提供极快的启动和部署体验自动配置极大地简化了SSO相关组件的集成。其强大的社区和丰富的Starter让开发如虎添翼。安全与OAuth 2.0实现Spring Security OAuth 2.0 Spring Security OIDC理由 Spring Security是Java安全领域的权威。从Spring Security 5.x开始其对OAuth 2.0和OIDC的支持已经非常成熟和规范。相比于直接使用更底层的spring-security-oauth2-autoconfigure直接使用Spring Security的OAuth 2.0 Client和Resource Server模块以及OIDC支持是更现代、更受官方推荐的方式。它原生支持PKCE、JWT等特性。替代方案提及 输入中提到了“.NET基于OpenIddict的SSO实现”。OpenIddict是.NET平台上优秀的OIDC服务器实现。这说明了SSO方案的跨语言通用性。我们的项目以Java为例但架构思想完全相通。令牌格式JWT (JSON Web Token)理由 自包含、紧凑、适合分布式场景。访问令牌和ID令牌都采用JWT格式资源服务器无需每次查询认证服务器即可通过公钥验证签名减轻了IdP的压力提升了性能。需要妥善管理密钥对RSA。数据存储MySQL / PostgreSQL Redis理由关系型数据库MySQL 存储用户基本信息、客户端注册信息、授权同意记录等结构化数据。Redis 作为高性能缓存用于存储授权码code、刷新令牌以及用于令牌黑名单或撤销列表。授权码是短期的用Redis存储并设置TTL非常合适。API文档与测试SpringDoc OpenAPI 3 (Swagger UI)理由 自动生成美观的API文档方便前后端协作和接口调试对于开源项目而言清晰的API文档至关重要。3.2 前端技术栈客户端核心框架Vue 3 TypeScript 或 React 18理由 二者都是当前最主流的前端框架拥有巨大的社区和丰富的生态。Vue 3的组合式API或React Hooks都能很好地管理复杂的登录状态和令牌逻辑。TypeScript能提供更好的类型安全和开发体验。状态管理Pinia (Vue) 或 Zustand/Redux Toolkit (React)理由 用于集中管理用户登录状态、令牌信息、个人资料等全局状态。当令牌刷新或用户登出时状态管理库能确保所有组件同步更新。路由管理Vue Router 或 React Router理由 实现导航守卫Route Guards。在用户访问需要认证的页面如/dashboard时路由守卫会检查本地是否存在有效令牌若无则自动跳转到SSO登录页。HTTP客户端Axios理由 强大的HTTP库可以方便地配置请求拦截器自动为每个API请求添加Authorization头和响应拦截器统一处理401未授权错误触发令牌刷新或跳转登录。UI组件库Element Plus (Vue) 或 Ant Design (React)理由 提供丰富的预制组件快速搭建出美观的登录页、主界面提升开发效率。3.3 项目目录结构示意一个清晰的项目结构有助于理解和维护。通常一个完整的SSO解决方案会拆分为多个子模块或微服务。这里以一个多模块Maven项目为例sso-open-source-project/ ├── sso-auth-server/ # 【核心】认证服务器模块 │ ├── src/main/java/.../config/ # 安全配置、OAuth2授权服务器配置 │ ├── src/main/java/.../controller/ # 用户信息端点、令牌自省端点等 │ ├── src/main/java/.../service/ # 用户详情服务、客户端详情服务 │ └── src/main/resources/application.yml # 数据库、Redis、JWT密钥配置 ├── sso-resource-server/ # 资源服务器模块示例业务API │ ├── src/main/java/.../config/ # 资源服务器安全配置 │ ├── src/main/java/.../controller/ # 受保护的业务API如 /api/users/me │ └── application.yml # 配置认证服务器地址、资源ID等 ├── sso-client-vue/ # 前端Vue客户端 │ ├── src/views/Login.vue # 登录回调页面 │ ├── src/router/index.ts # 路由守卫配置 │ ├── src/store/auth.ts # Pinia store管理认证状态 │ ├── src/utils/request.ts # Axios实例与拦截器配置 │ └── .env.development # 环境变量如VUE_APP_SSO_CLIENT_ID ├── sql/ # 数据库初始化脚本 ├── docker-compose.yml # 一键启动MySQL, Redis等服务 └── README.md # 项目说明、快速启动指南这种分离的架构使得认证服务、业务服务和客户端可以独立开发、部署和扩展符合微服务的设计理念。4. 关键实现细节与避坑指南有了架构和选型我们深入到代码层面看看几个最容易出问题的关键环节如何实现。4.1 认证服务器Spring Security核心配置认证服务器的配置是重中之重。以下是一个高度简化的Java配置类核心片段展示了如何启用OAuth2授权服务器并支持PKCE。Configuration EnableWebSecurity public class AuthServerSecurityConfig { Bean Order(Ordered.HIGHEST_PRECEDENCE) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http // 指定使用基于表单的登录 .formLogin(Customizer.withDefaults()) // 异常处理返回JSON格式错误而非跳转页面 .exceptionHandling(exceptions - exceptions .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint(/login)) .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) ); return http.build(); } // 配置客户端详情服务可以从数据库加载 Bean public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) { // 这里简化实际应从数据库查询 RegisteredClient ssoClient RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(sso-vue-client) // 客户端ID .clientSecret({bcrypt}$2a$10$...) // 客户端密钥建议BCrypt加密存储 .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) // 支持刷新令牌 .redirectUri(http://localhost:8081/callback) // 前端回调地址 .scope(openid) // OIDC必须 .scope(profile) .scope(email) .clientSettings(ClientSettings.builder() .requireProofKey(true) // 【关键】强制要求PKCE .build()) .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofHours(1)) // AT有效期1小时 .refreshTokenTimeToLive(Duration.ofDays(7)) // RT有效期7天 .build()) .build(); return new InMemoryRegisteredClientRepository(ssoClient); // 生产环境用JdbcRegisteredClientRepository } // 配置JWT编码器用于生成和验证令牌 Bean public JWKSourceSecurityContext jwkSource() { KeyPair keyPair generateRsaKey(); // 生成RSA密钥对 RSAPublicKey publicKey (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey (RSAPrivateKey) keyPair.getPrivate(); RSAKey rsaKey new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); JWKSet jwkSet new JWKSet(rsaKey); return (jwkSelector, securityContext) - jwkSelector.select(jwkSet); } // ... 其他Bean如JwtDecoder, UserDetailsService等 }避坑指南1客户端密钥与PKCE对于公共客户端SPAclient_secret实际上无法保密因为前端代码是公开的。因此绝不能依赖client_secret作为唯一的安全凭证。这就是为什么必须启用requireProofKey(true)。此时认证服务器应配置为允许公共客户端不验证client_secret或使用none认证方式。更安全的做法是在注册客户端时将其认证方法设为ClientAuthenticationMethod.NONE并完全依赖PKCE进行保护。避坑指南2CORS配置前端http://localhost:8081需要直接向认证服务器http://localhost:9000的令牌端点/oauth2/token发起POST请求以交换令牌。这涉及跨域。必须在认证服务器的安全配置中显式允许前端源。http.cors(cors - cors.configurationSource(request - { CorsConfiguration config new CorsConfiguration(); config.setAllowedOrigins(Arrays.asList(http://localhost:8081)); // 前端地址 config.setAllowedMethods(Arrays.asList(GET, POST, OPTIONS)); config.setAllowedHeaders(Arrays.asList(*)); config.setAllowCredentials(true); // 如果需要携带Cookie return config; }));4.2 前端路由守卫与Axios拦截器实现前端负责引导登录流程和管理令牌生命周期。核心在于路由守卫和HTTP拦截器。路由守卫Vue Router示例// router/index.ts import { createRouter, createWebHistory } from vue-router; import { useAuthStore } from /stores/auth; const router createRouter({ history: createWebHistory(), routes: [ { path: /, component: Home, meta: { requiresAuth: false } }, { path: /dashboard, component: Dashboard, meta: { requiresAuth: true } }, // 需要认证 { path: /callback, component: Callback }, // SSO回调页面 ], }); router.beforeEach(async (to, from, next) { const authStore useAuthStore(); // 检查目标路由是否需要认证 if (to.meta.requiresAuth) { // 检查本地是否有有效的访问令牌 if (authStore.isAuthenticated) { // 可选检查令牌是否即将过期进行静默刷新 if (authStore.isAccessTokenExpiringSoon) { try { await authStore.refreshToken(); } catch (error) { // 刷新失败跳转到登录 authStore.logout(); window.location.href authStore.ssoLoginUrl; return; } } next(); } else { // 未登录重定向到SSO登录页 window.location.href authStore.ssoLoginUrl; } } else { next(); // 不需要认证的路由直接放行 } });Axios请求/响应拦截器// utils/request.ts import axios from axios; import { useAuthStore } from /stores/auth; const service axios.create({ baseURL: process.env.VUE_APP_API_BASE_URL, timeout: 10000, }); // 请求拦截器自动添加Token service.interceptors.request.use( (config) { const authStore useAuthStore(); if (authStore.accessToken) { config.headers.Authorization Bearer ${authStore.accessToken}; } return config; }, (error) { return Promise.reject(error); } ); // 响应拦截器统一处理401错误 service.interceptors.response.use( (response) response, async (error) { const originalRequest error.config; const authStore useAuthStore(); // 如果是401错误且未尝试过刷新 if (error.response?.status 401 !originalRequest._retry) { originalRequest._retry true; try { // 尝试刷新令牌 await authStore.refreshToken(); // 刷新成功后用新令牌重试原请求 originalRequest.headers.Authorization Bearer ${authStore.accessToken}; return service(originalRequest); } catch (refreshError) { // 刷新失败彻底登出 authStore.logout(); window.location.href /; // 或跳转到登录页 return Promise.reject(refreshError); } } return Promise.reject(error); } ); export default service;避坑指南3令牌存储安全绝对不要将access_token或refresh_token存储在localStorage或sessionStorage中。它们可以通过JavaScript访问容易受到XSS攻击。相对更安全的做法是存储在内存中 应用关闭即消失最安全但页面刷新会丢失。通常结合“静默刷新”机制在令牌过期前用refresh_token获取新令牌。存储在HttpOnly Cookie中 可以防止XSS读取但需防范CSRF攻击。对于SPA可以将令牌存储在Cookie中并确保认证服务器在设置Cookie时使用SameSiteStrict或Lax以及Secure和HttpOnly标志。前端通过withCredentials: true发送请求自动携带Cookie后端资源服务器从Cookie中读取令牌。这种方式更复杂需要前后端紧密配合。避坑指南4正确处理登录回调前端需要有一个专门的路由如/callback来处理SSO认证服务器的重定向。这个页面的逻辑是从URL查询参数中解析出code授权码和可能的state用于防止CSRF攻击的随机值需要与发起登录时存储的值比对。调用本地的/oauth2/token端点或直接向认证服务器发起请求用code和code_verifier交换令牌。交换成功后将令牌存储起来并跳转到应用首页或之前尝试访问的页面。 这个页面应该尽可能简单加载后立即执行上述逻辑然后重定向走用户几乎感知不到它的存在。5. 部署、运维与安全加固建议一个可以运行的原型只是第一步要让其具备生产可用性还需要考虑部署、监控和安全加固。5.1 部署架构对于小型项目可以将认证服务器、资源服务器和前端打包后使用Docker Compose部署在同一台主机上。对于中大型项目建议采用更分离的架构认证服务器集群 无状态可以水平扩展。关键是要共享同一个JWK Set公钥集以便所有实例签发的JWT都能被资源服务器验证。可以将公钥集发布到一个统一的端点如/.well-known/jwks.json或者使用配置中心共享密钥。Redis集群 用于存储授权码、刷新令牌和黑名单必须高可用。数据库集群 存储用户和客户端信息。前端静态资源 通过Nginx或CDN分发。API网关 在资源服务器前部署网关如Spring Cloud Gateway, Kong统一处理CORS、限流、鉴权将JWT验证逻辑前置等跨领域问题。5.2 关键安全配置清单HTTPS everywhere 生产环境必须全程使用HTTPS包括前端、认证服务器和API服务器。明文传输令牌和授权码是致命的。严格的CORS策略 仅允许可信的前端源访问认证服务器的令牌端点和用户信息端点。安全的Cookie属性 如果使用Cookie存储会话或令牌必须设置Secure、HttpOnly、SameSiteLax|Strict。密钥管理 JWT签名密钥RSA私钥是最高机密绝不能硬编码在代码中。应使用环境变量、云厂商的密钥管理服务如AWS KMS, Azure Key Vault或专门的密钥管理工具如HashiCorp Vault来注入。令牌有效期 设置合理的短有效期访问令牌如15-30分钟和相对较长的刷新令牌如7天。使用刷新令牌轮换访问令牌减少长期有效的令牌暴露风险。令牌撤销 实现令牌黑名单或使用令牌自省端点。当用户登出或管理员禁用用户时应立即使其刷新令牌失效。输入验证与防攻击 在登录接口防止暴力破解如使用限流、验证码对所有输入进行严格的验证和清理防止SQL注入和XSS。日志与监控 详细记录认证成功/失败日志、令牌颁发日志并接入监控告警系统及时发现异常登录行为。5.3 常见问题排查速查表问题现象可能原因排查步骤前端重定向到SSO登录页后登录成功又跳回登录页1. 前端回调地址 (redirect_uri) 与认证服务器注册的不完全匹配多了/少了端口、路径。2. 前端生成的state参数与回调时验证的不一致。3. 会话Session丢失常见于认证服务器集群未做会话共享。1. 仔细比对redirect_uri确保完全一致包括协议、域名、端口、路径。2. 检查前端state的生成和存储逻辑确保回调时能正确取出并比对。3. 检查认证服务器的会话存储如使用Spring Session集成Redis实现共享。前端兑换令牌时返回invalid_grant1. 授权码 (code) 已过期或被使用过。2. PKCE验证失败code_verifier与发起授权请求时的code_challenge不匹配。3. 回调时传递的redirect_uri与申请授权码时的不一致。1. 授权码有效期通常很短如30秒检查是否处理超时。2. 确认前端在兑换令牌时发送了正确的code_verifier且哈希算法与申请时一致通常为S256。3. 确保两次请求的redirect_uri完全相同。调用API返回401 Unauthorized1. 请求未携带Authorization头。2. 访问令牌已过期。3. 令牌签名无效资源服务器无法验证。4. 令牌权限不足scope不符。1. 检查前端Axios拦截器是否正常工作请求头是否正确添加。2. 检查令牌过期时间并触发刷新流程。3. 确认资源服务器配置的JWT解码器JwtDecoder使用的公钥与认证服务器签名私钥匹配。4. 检查API要求的权限与令牌中的scope声明。跨域CORS错误认证服务器或资源服务器的CORS头未正确配置不允许前端源进行请求。1. 浏览器开发者工具查看Network标签确认错误详情。2. 检查服务器端CORS配置确保Access-Control-Allow-Origin包含前端地址。3. 对于携带凭证的请求如Cookie还需设置Access-Control-Allow-Credentials: true。刷新令牌失败1. 刷新令牌已过期。2. 刷新令牌已被撤销用户登出。3. 刷新令牌请求的客户端身份验证失败。1. 检查刷新令牌的过期时间。2. 检查认证服务器的令牌存储如Redis确认该刷新令牌是否已被删除或加入黑名单。3. 确认刷新请求中包含了正确的客户端身份信息如client_id和client_secret对于公共客户端可能不需要。6. 项目扩展与生态集成思路一个优秀的开源SSO项目其价值不仅在于本身功能的完整更在于其可扩展性和与其他系统的集成能力。1. 多租户支持 许多SaaS应用需要支持多个独立客户租户每个租户有自己的用户体系。可以在认证服务器层面引入“租户”概念在客户端注册、用户登录和令牌颁发时携带租户标识如域名、子路径、请求头。数据库设计上用户表和客户端表需要增加租户ID字段。2. 多种登录方式 除了用户名密码集成社会化登录微信、钉钉、GitHub、Google、手机验证码登录、企业微信/钉钉扫码登录等。Spring Security通过OAuth2LoginConfigurer可以很方便地集成多种OAuth2社会化登录提供商。对于扫码登录通常需要建立一套临时的票据Ticket系统前端轮询认证状态。3. 与现有用户系统集成 很多公司已有LDAP、Active Directory或自建的用户数据库。我们的SSO认证服务器不应重复造轮子而应通过实现Spring Security的UserDetailsService接口从这些现有系统中加载和验证用户信息实现平滑迁移。4. 审计与合规 记录所有关键操作日志登录、登出、令牌颁发、敏感信息修改并确保日志不可篡改以满足等保、GDPR等合规要求。5. 管理后台 开发一个独立的管理后台供管理员管理注册的OAuth客户端、查看用户登录日志、手动禁用用户或令牌等。这可以通过为认证服务器暴露一套受保护的管理API来实现。6. 与云原生生态集成 将认证服务器容器化编写Kubernetes Helm Chart或Docker Compose部署文件。集成服务网格如Istio的认证策略将JWT验证下沉到Sidecar代理。