尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringSecurity5 OAuth2客户端SSO单点登录实战

SpringSecurity5 OAuth2客户端SSO单点登录实战 做企业级应用开发这几年最常被业务方提的一个需求就是“一套账号所有系统通用”。过去我搭过不少基于Session共享的方案维护成本高不说碰到跨域、跨技术栈的项目基本就废了。后来切到SpringBoot整合SpringSecurity5和OAuth2做SSO单点登录客户端接入这块的坑踩了一遍之后才算是真正把整套链路跑顺。这篇文章就专门写OAuth2客户端这一侧也就是SSO单点登录时应用作为客户端的完整落地过程。把话说在前面这篇不是那种HelloWorld级别的演示而是从实际项目里提炼出来的接入方案。我会把客户端如何对接授权服务器、如何配置OAuth2登录、如何获取用户信息、如何做会话管理包括那些配置文件里不会写明的坑全部掰开揉碎讲清楚。适合正在做SSO改造、需要把多个应用统一接入统一认证中心的开发同学参考。1. 为什么用OAuth2客户端做SSO场景与方案选型1.1 SSO的常规做法与OAuth2角色的定位单点登录的原理说穿了就一句话多个应用共享同一个认证中心。用户只要在认证中心登录一次再去访问其他系统就不需要重新输账号密码。早年我做过基于Cookie跨域共享的方案把会话标识写在一级域名下所有子系统共享部署倒是简单但一到跨域、HTTPS、第三方系统接入就头疼安全问题也多。后来SpringSecurity5对OAuth2客户端的原生支持越来越成熟我开始把整个SSO体系改成标准OAuth2架构。这里要理清三个角色授权服务器就是统一认证中心负责登录和发Token客户端是各个业务系统也就是我们这篇文章的主角资源服务器则是提供用户信息查询的服务。SSO场景下业务系统就是典型的OAuth2 Client通过授权码模式跟授权服务器交互拿到Token后再去资源服务器拉取用户信息。这个方案的优点在于所有交互都是标准HTTP重定向和JSON数据不依赖Cookie跨域也不要求所有系统同一个域名。只要支持HTTP任何语言、任何部署方式都能接入。SpringBoot做客户端时SpringSecurity5甚至已经把整个授权码模式的交互流程都封装好了咱们只需要做好配置和必要的定制。1.2 客户端模式与授权码模式的区别说到这儿顺便把OAuth2几种授权模式捋一下方便新手理解为什么要选授权码模式。客户端凭证模式是应用自己拿ClientId和ClientSecret去换Token整个过程没有用户参与适合后台服务间调用。密码模式需要用户直接把账号密码给客户端由客户端去授权服务器换Token安全要求高现在已经不太推荐了。SSO单点登录场景用的是授权码模式。整个过程中用户的浏览器始终跟授权服务器交互账号密码只提交给授权服务器客户端全程不接触用户密码。授权服务器登录完成后通过浏览器重定向把授权码带回客户端客户端再用授权码加上ClientSecret去后端换Token。这个流程保证了业务系统拿不到用户密码同时又能获取用户身份信息是OAuth2体系里最适合SSO的授权方式。SpringSecurity5的OAuth2Client底层已经把授权码模式的细节全部封装我们要做的就是写配置、写用户信息映射、看日志排查问题。这也是为什么我说现在做SSO不再需要自己造轮子站在SpringSecurity的肩膀上把业务做好就行。2. 开发环境与依赖准备2.1 版本选型稳定优先先说版本这个最容易踩坑。SpringBoot版本太高或者太低都会碰到OAuth2配置项对不上的问题。我自己生产环境一直用的是SpringBoot 2.7.x对应SpringSecurity 5.7.x这个组合的OAuth2客户端支持最稳定网上资料也最多遇到问题基本都能查到解决方案。如果你的项目已经升到SpringBoot 3.x那对应的是SpringSecurity 6.x配置方式和类名都有不少调整SecurityFilterChain的写法虽然差不多但很多废弃方法得换新写法。我建议新项目直接用3.x但如果是存量项目改造先稳定压到2.7.x跑通业务流程后面再平滑升级不要一边升级框架一边搞SSO出了问题排查维度太多容易崩溃。我用的环境大致是JDK 8或11SpringBoot 2.7.14SpringSecurity 5.7.x。这套组合在Tomcat 9下跑得很稳各种开源组件兼容性也比较成熟。如果公司有强制要求JDK17那SpringBoot 2.7.x也支持JDK17只是需要用17的语法特性就要注意SpringSecurity类的兼容性。2.2 Maven依赖怎么加在SpringBoot项目里加入OAuth2客户端支持核心依赖就一个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-client/artifactId /dependency这个Starter会自动把SpringSecurity相关依赖、OAuth2客户端的自动配置类全部引入。如果项目中还需要做接口鉴权、方法级权限控制那就把spring-boot-starter-security也加上不过通常oauth2-client里面已经把spring-security核心带进来了保险起见可以在依赖树里确认一下。另外提醒一句如果业务系统还需要作为资源服务器对外提供API那还要加spring-boot-starter-oauth2-resource-server但这个不属于本次SSO客户端的范畴。SSO客户端场景下一般业务系统只是认身份API鉴权是另外的事。顺便把热词里提到的“springboot version太高”这个坑说一下。遇到过不少同学新创建项目直接选SpringBoot 3.3然后去抄2.x的配置发现OAuth2Client的配置项变了登录流程怎么都跑不通。我的建议很直接看官方文档的时候注意版本对应的说明别拿3.x的项目套2.x的配置反之亦然。3. 接入授权服务器配置文件逐项拆解3.1 注册客户端信息OAuth2客户端接入授权服务器第一步要在授权服务器那边把你这个业务系统登记成一个合法客户端。通常会拿到两个关键信息ClientId和ClientSecret。这两个值一个相当于身份证号一个相当于密码。生产环境务必妥善保管ClientSecret建议放到环境变量或者配置中心不要硬编码提交到Git仓库。拿到凭据之后SpringBoot这边的配置长这样spring: security: oauth2: client: registration: sso-client: provider: sso-server client-id: client-demo client-secret: ${SSO_CLIENT_SECRET} authorization-grant-type: authorization_code redirect-uri: {baseUrl}/login/oauth2/code/{registrationId} scope: openid, profileregistration下的sso-client是我们给这个客户端起的名字随便取但后面很多地方会用到。provider字段指向下面配置的provider名称相当于把注册信息和授权服务器的连接信息挂上钩。authorization-grant-type明确是authorization_code授权码模式scope按需声明如果要获取用户基本信息openid和profile是比较通用的配置具体看授权服务器支持哪些scope。redirect-uri这里非常关键{baseUrl}是占位符SpringSecurity会自动替换成当前部署的域名根路径{registrationId}会替换成我们注册的客户端名称sso-client。所以最终回调地址就是http://你的域名/login/oauth2/code/sso-client。这个回调地址必须和授权服务器那边登记的回调地址完全一致一个字符都不能差否则授权服务器会直接拒绝授权请求这是SSO接入最常见的报错之一。3.2 Provider端点的灵活配置接下来配置provider部分告诉客户端授权服务器在哪几个端点做事情spring: security: oauth2: client: provider: sso-server: authorization-uri: http://sso.example.com/oauth2/authorize token-uri: http://sso.example.com/oauth2/token user-info-uri: http://sso.example.com/userinfo user-name-attribute: sub jwk-set-uri: http://sso.example.com/oauth2/jwksauthorization-uri是授权服务器登录页面的地址用户未登录时客户端会重定向到这里。token-uri是客户端用授权码换Token的后端接口地址这个请求由客户端服务器发起浏览器看不到。user-info-uri是获取当前登录用户信息的接口客户端拿到AccessToken后调用它拿用户详情。user-name-attribute是用户信息里哪个字段作为当前登录用户的唯一标识这个非常关键配置错了会导致登录后用户信息解析失败。如果你的授权服务器支持OIDC发现机制也就是启动时能通过http://sso.example.com/.well-known/openid-configuration拿到所有端点地址那配置可以更简单直接写一个issuer-uri就行。但我在实际生产项目里更推荐手动指定各个URI原因很简单有些企业内部的授权服务器OIDC发现配置不完整或者网络策略不允许客户端访问发现端点手动配置虽然多写几行但可控性最好排错也直观。3.3 部署环境相关的配置坑还有一类配置看起来跟OAuth2没关系但直接影响SSO是否成功就是反向代理的转发头设置。现在的业务系统基本都会放在Nginx后面Nginx做HTTPS终结这个时候SpringBoot应用本身收到的是HTTP请求{baseUrl}占位符会解析成http://内网地址授权服务器那边肯定不认识这个回调地址。解决办法是在application.yml里把forward-headers-strategy配置为framework让SpringSecurity在处理请求时参考X-Forwarded-Proto和X-Forwarded-Host这些头来生成正确的回调地址server: forward-headers-strategy: frameworkNginx那边也要确保这几行配置存在proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-For $remote_addr;我印象里有一次SSO在测试环境怎么都跳不过去排查半天发现就是Nginx没传X-Forwarded-Proto客户端拿到的回调地址一直是http开头授权服务器拒绝了。加上这几行头问题当场解决。4. 安全策略与用户信息处理4.1 自定义SecurityFilterChain配置文件写完后需要定义一下安全过滤链告诉SpringSecurity哪些接口放行、哪些需要登录、OAuth2登录怎么触发。SpringSecurity 5.7之后推荐用SecurityFilterChain的Bean方式替代继承WebSecurityConfigurerAdapter的老写法Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize - authorize .requestMatchers(/, /login, /error, /static/**).permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2 - oauth2 .loginPage(/login) .defaultSuccessUrl(/home, true) ); return http.build(); } }这段配置的核心作用有两个一是所有非白名单请求必须经过认证才能访问二是用户未登录时自动发起OAuth2登录流程。oauth2Login方法就是整个SSO客户端的入口SpringSecurity会拦截所有未认证的请求然后重定向到授权服务器的登录页。这里有个细节如果配置了loginPage(/login)那你还需要提供一个登录入口页面页面上放一个链接指向/oauth2/authorization/sso-client用户点击这个链接才会触发OAuth2授权跳转。如果你不配置loginPageSpringSecurity会使用默认的自动生成登录页长什么样是系统自带的可以用来快速测试但正式项目基本都要换成自己的登录页。4.2 用户信息映射与本地会话OAuth2登录成功后SpringSecurity会把从user-info-uri拿到的用户信息封装成OAuth2User对象放到SecurityContext里。默认情况下这个OAuth2User就是当前认证主体的principal。但在实际业务系统里通常需要把这个OAuth2用户映射成系统自己的用户对象再存到本地数据库或者Session里。我的做法是实现一个OAuth2UserService在用户首次登录时自动判断本地有没有这个用户没有就创建一条用户记录有就更新最后登录时间然后把本地用户ID和OAuth2用户关联起来Service public class CustomOAuth2UserService extends DefaultOAuth2UserService { Autowired private UserRepository userRepository; Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oauth2User super.loadUser(userRequest); String username oauth2User.getAttribute(sub); // 根据授权服务器的唯一标识查本地用户不存在则自动创建 LocalUser localUser userRepository.findBySsoUserId(username); if (localUser null) { localUser new LocalUser(); localUser.setSsoUserId(username); localUser.setUsername(oauth2User.getAttribute(preferred_username)); localUser.setEmail(oauth2User.getAttribute(email)); userRepository.save(localUser); } // 返回一个包装后的OAuth2User塞入本地用户信息 return new CustomOAuth2User(oauth2User, localUser); } }写完之后把这个Service注册到SecurityFilterChain里.oauth2Login(oauth2 - oauth2 .userInfoEndpoint(userInfo - userInfo .userService(customOAuth2UserService) ) )还有一类需求是登录成功之后跳转到用户原来想访问的页面。比如用户访问某个深链接被拦截跳去登录登录完应该回到那个深链接而不是固定跳首页。SpringSecurity本身有默认的跳转逻辑记录原始请求URL但如果你在oauth2Login里强制指定了defaultSuccessUrl它会覆盖这个逻辑。我的做法是用RequestCache来恢复原始请求.oauth2Login(oauth2 - oauth2 .loginPage(/login) .successHandler((request, response, authentication) - { SavedRequest savedRequest new HttpSessionRequestCache().getRequest(request, response); if (savedRequest ! null) { String targetUrl savedRequest.getRedirectUrl(); response.sendRedirect(targetUrl); } else { response.sendRedirect(/home); } }) )5. 登录流程完整走一遍5.1 请求链路配置和代码都写完我把一次完整的SSO登录请求链路从头到尾串一遍方便大家在排查问题时脑子里有个全貌。第一步用户打开业务系统首页点了一个需要登录的功能前端发起请求后端SecurityFilterChain发现这个请求没有认证于是往浏览器返回302重定向。第二步重定向地址是授权服务器的authorization-uri后面带了一堆参数client_id、redirect_uri、response_typecode、scope等。浏览器拿到这个302后跳转到授权服务器用户看到的是授权服务器的登录页。第三步用户在授权服务器上输入账号密码授权服务器验证通过生成一个一次性授权码通过302重定向回客户端的回调地址/login/oauth2/code/sso-client?codexxx。第四步浏览器携带这个code请求回调地址SpringSecurity的OAuth2LoginAuthenticationFilter拦下这个请求用code加上ClientSecret去请求授权服务器的token-uri授权服务器返回AccessToken、RefreshToken和IDToken。第五步客户端拿着AccessToken去请求user-info-uri拿到用户信息通过我们自己定义的OAuth2UserService做本地用户映射。第六步SpringSecurity把OAuth2User存入SecurityContext创建Session然后跳转到登录成功页面。之后用户访问任意接口都会带着这个Session不再需要重新登录。很多新手容易在这个流程里犯糊涂授权码到底在哪里换Token记住换Token这个动作是客户端服务器后端发的请求浏览器全程看不到Token这也是授权码模式安全性的核心。5.2 如何自定义登录成功后的行为默认的登录成功后跳转虽然能用但真实业务场景里我们几乎都要定制。除了前面说的恢复原始请求之外常见的还有登录后的初始化工作比如把用户权限加载到Session、记录操作日志、给前端返回用户信息。SpringSecurity的OAuth2LoginAuthenticationFilter在认证成功后会走到AuthenticationSuccessHandler。我可以直接注入一个自定义Handler做这些事情Component public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { OAuth2AuthenticationToken token (OAuth2AuthenticationToken) authentication; OAuth2User user token.getPrincipal(); // 登录日志 loginLogService.record(user.getName(), request.getRemoteAddr()); // 初始化用户权限塞入Session ListString permissions permissionService.loadPermissions(user.getName()); request.getSession().setAttribute(permissions, permissions); // 跳转 response.sendRedirect(/home); } }记得在SecurityFilterChain里把successHandler指过来.oauth2Login(oauth2 - oauth2 .successHandler(oauth2LoginSuccessHandler) )这里还有一个容易忽略的点OAuth2LoginAuthenticationFilter默认处理的路径是/login/oauth2/code/*如果你改了context-path或者自定义了回调路径要注意保持配置和实际一致否则会走到错误处理逻辑。6. 登出与会话处理6.1 本地登出与SSO登出SSO体系里登出比登录更容易被忽略但实际踩坑特别多。如果只做业务系统本地登出把Session清掉那用户下次访问授权服务器时因为授权服务器那边的会话还在会直接静默登录回来根本达不到“退出登录”的效果。所以业务系统登出时要调用授权服务器的登出端点把授权服务器的会话一起清掉。OIDC协议里有个end_session_endpoint标准做法是GET http://sso.example.com/logout?id_token_hintxxxpost_logout_redirect_urihttp://业务系统/logout控制层可以这么写GetMapping(/logout) public String logout(HttpServletRequest request, HttpServletResponse response, AuthenticationPrincipal OAuth2AuthenticationToken token) throws Exception { // 先销毁本地会话 request.getSession().invalidate(); SecurityContextHolder.clearContext(); // 跳转到授权服务器的登出地址 String idToken ; if (token ! null token.getPrincipal() instanceof OidcUser) { idToken ((OidcUser) token.getPrincipal()).getIdToken().getTokenValue(); } String logoutUrl http://sso.example.com/logout?id_token_hint idToken post_logout_redirect_uri URLEncoder.encode(http://业务系统/logout-success, UTF-8); return redirect: logoutUrl; }注意如果你的授权服务器不支持OIDC的end_session_endpoint那就需要看它自己提供的登出接口规范。不同的授权服务器实现差异较大这属于对接联调时必须确认的点不然登出会变成“形同虚设”。6.2 多应用会话共享问题这里的会话共享指的是客户端之间怎么维护SSO状态跟传统的分布式Session共享不是一回事。在OAuth2授权码模式下每个客户端应用都有自己的Session用户登录了应用A再访问应用B时SpringSecurity发现B没有本地会话就跳转到授权服务器授权服务器发现用户全局会话还在直接发授权码B拿着授权码换Token后建立自己的本地会话。整个过程用户几乎感知不到这就是SSO的效果。理解了这一步你就会明白SSO并不需要业务系统之间共享Session各自维护各自的本地会话即可。真正的全局会话在授权服务器那边。所以如果你的业务系统是做集群部署那么本地Session需要解决集群环境下Session共享的问题这是另一个话题可以通过SpringSession或者把网关层的会话做成统一存储来解决。我个人更推荐网关统一处理业务系统无状态化但对旧系统改造比较大看团队节奏吧。7. 常见问题与排查技巧7.1 我能想到的典型故障速查问题现象原因解决方案循环重定向浏览器一直在业务系统和授权服务器之间来回跳回调地址不一致确认redirect-uri和授权服务器登记的一致授权失败授权服务器返回errorinvalid_requestclient_id或redirect_uri不匹配核对client_id和回调地址参数登录后报错typeOAuth2AuthenticationExceptionuser-name-attribute配置错误或用户信息字段缺失调user-info接口确认返回的JSON字段回调地址变成内网IP授权服务器拒收回调反向代理头未传递配置forward-headers-strategy和Nginx头信息Token过期用户用着用着突然跳登录页AccessToken过期且无刷新机制配置refresh_token授权模式或重新授权Session丢失集群部署下登录状态不稳定集群Session未共享引入SpringSession方案拿不到用户邮箱scope权限不够授权服务器未返回email字段申请开对应scope权限7.2 排查思路与日志分析SSO问题最怕的是没有头绪地乱试。我个人的排查顺序是第一步看浏览器地址栏到底跳到了哪里能快速定位是客户端的问题还是授权服务器的问题。第二步开开发者工具的Network面板看302响应的Location头确认重定向URL是否带了正确的client_id、redirect_uri。第三步去业务系统的日志里搜OAuth2AuthorizationException或者AuthenticationException异常堆栈SpringSecurity报错信息一般都带比较明确的原因。另外强烈建议开发阶段把SpringSecurity的日志级别调成DEBUGlogging: level: org.springframework.security: DEBUG这样能看到OAuth2登录全过程的细节包括每个过滤器的处理逻辑、Token请求的响应信息。问题定位效率能提升一个量级。线上环境记得调回INFO或WARN不然日志量太大会把磁盘打爆别问我怎么知道的。还有就是时刻记得看授权服务器那边的日志客户端和授权服务器是两个系统问题可能出现在任何一侧。联调阶段两边开发要拉一个群实时同步日志很多时候看起来是客户端的问题实际上是授权服务器返回的字段跟约定不一致。8. 经验心得与后续扩展8.1 自己踩过最值钱的几个坑做了好几个项目的SSO接入之后我发现最容易被忽视的问题是技术债。很多开发同学接入SSO只图功能跑通配置全部写死在yml里ClientSecret直接提交到Git。这在一开始没什么等公司安全审计要求密钥加密存储、过期轮换的时候改动成本会让你痛不欲生。建议从第一天就用配置中心管理这些敏感信息环境变量引用密钥而不是硬编码。另一个深刻的体会是OAuth2客户端这块SpringSecurity已经做得很好了去理解它默认帮我们做的事情反而是重点。因为一旦出现问题不理解底层流程面对各种Filter链的日志会非常无措。花一两个小时看一遍OAuth2LoginAuthenticationFilter的源码比自己瞎猜半天效率高得多。这也是为什么我在前面花了这么长篇幅把完整的请求链路讲清楚流程明白了问题定位就只是时间问题。8.2 可以怎么继续扩展这套体系客户端接好之后后面自然延伸的方向很多。如果业务系统还需要对外提供API可以把OAuth2资源服务器也加进来用同一套Token体系保护接口。如果需要做前端的SPA单页应用可以用OAuth2的PKCE授权码模式配合代理转发或者引入oauth2Login结合前后端分离网关来处理。多租户场景下还可以配置多个registration对应多个授权服务器一套代码统一走SpringSecurity配置。再就是监控和审计。SSO是基础设施一旦出问题影响范围是所有业务系统。建议把OAuth2的登录失败次数、授权服务器可用性、Token换取耗时这些指标都接入监控平台设置告警。我合作过的业务方甚至要求把每次SSO登录的审计日志单独存到ES方便后续安全事故溯源。这些东西看起来跟客户端无关但真正稳定落地的SSO体系恰恰是这些细节拼出来的。最后再分享一个小技巧开发环境用本地授权服务器调试的时候可以先用curl脚本模拟一下授权服务器的核心接口把授权码换Token、Token换用户信息这两步通了再调业务系统的代码能省下不少集成阶段的时间。我就是靠这个办法把每次联调的错误从半小时缩小到五分钟。
返回列表