ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Spring Security OAuth2单点登录:授权码+JWT+LDAP

Spring Security OAuth2单点登录:授权码+JWT+LDAP 简介本资源围绕 Spring Security OAuth2 实现单点登录SSO展开面向具备 Spring Boot 与 Spring Security 基础的 Java 后端开发者、微服务架构学习者以及需要为多应用统一身份认证方案选型的工程人员。内容以授权服务器加两个客户端应用的典型三角色结构为主线涉及授权码模式的认证委派流程、客户端依赖引入、EnableOAuth2Sso 与 WebSecurityConfigurerAdapter 的安全配置、application.yml 中的 clientId、clientSecret、accessTokenUri、userAuthorizationUri、userInfoUri 等关键参数以及 EnableAuthorizationServer 下令牌端点与客户端注册的写法能够帮助读者理解 SSO 在 Spring 生态中的落地方式与配置要点。资源包共 1 个 PDF 文件约 75KB篇幅精炼适合作为查阅型文档随时对照代码与配置。目前已有 3578 人学习说明该主题在实践群体中关注度较高可作为搭建单点登录原型与排查配置问题的参考材料。1. 一套 OA、一套报表、一套工单单点登录到底该由谁接管一套 OA、一套报表、一套工单再加 Git 和 Nexus每个系统一套账号员工记不住就写在便签上贴在显示器边框。运维每季度改一次密码策略第二天总有一批人打不开工单系统。这是很多团队决定上单点登录的真实起点。一个容易被忽略的结论是单点登录并不是让各家系统共享同一份密码而是把「证明你是谁」这件事从每个业务系统里抽出来交给一个独立的授权服务器完成业务系统只拿令牌判断这次请求该不该放行。Spring Security OAuth2 提供的正是这条链路——客户端走授权码模式换到 access_token资源服务器用 JWT 验签识别用户业务代码全程不碰用户密码。它适合正在拆微服务的团队也适合手里压着若干存量 Java Web 系统、想在不重写登录模块的前提下补上统一入口的人。后面按协议底座、服务搭建、会话细节、企业账号对接四段展开。2. 授权码模式与三个角色Spring Security OAuth2 单点登录的协议底座2.1 授权码模式为什么是浏览器单点登录的默认选项浏览器场景下能选的授权模式并不多。授权码模式的流程是客户端把浏览器重定向到授权端点用户在授权服务器上完成登录并确认授权授权服务器带着一次性 code 重定向回客户端的 redirect_uri客户端后端再拿 code 加 client_secret 去令牌端点换 access_token。关键点在于 access_token 从来没有经过浏览器地址栏。code 是一次性的、有效期通常只有几十秒即使被截获没有 client_secret 也换不出令牌。相比之下隐式模式直接把令牌拼在 URL 片段里令牌会落进浏览器历史、Referer 和日志密码模式让客户端直接接触用户密码一旦某个业务系统被拖库等于全站沦陷。所以 OAuth 2.1 干脆移除了隐式模式和密码模式Spring Authorization Server 默认也不注册 password 授权类型老教程里照抄 password 模式的配置会直接在启动阶段抛异常。授权模式是否需 client_secret令牌暴露面Spring Authorization Server 内置authorization_code是或由 PKCE 替代低令牌在服务端交换是authorization_code PKCE否低是client_credentials是无用户上下文仅服务间是password是高密码进客户端否已移除implicit否高令牌落浏览器否纯前端项目SPA 或移动端没有安全存储 client_secret 的地方这时用 PKCE 补位客户端每次授权前生成一串随机 code_verifier算出 code_challenge 随授权请求发出去换令牌时再带上原始 verifier授权服务器比对哈希值。这样即使有人截获了 code没有 verifier 也换不到令牌。2.2 授权服务器、客户端、资源服务器各自的边界三个角色的职责必须分清否则后期排错时会来回甩锅。授权服务器负责认证用户、签发令牌、对外暴露 JWK Set 端点默认在/oauth2/jwks和 OIDC 发现端点/.well-known/openid-configuration。客户端负责发起授权请求、维护自己那一份本地 session、拿令牌去访问后端。资源服务器只做一件事验签、解析 claims、把权限喂给 Spring Security 的授权决策。很多团队第一次接入时会把客户端和资源服务器写进同一个应用这在小规模下没问题但要注意 filter chain 的匹配顺序。授权服务器自己的/oauth2/**端点绝不能被业务侧的登录跳转逻辑拦截否则会出现「访问令牌端点却跳转到登录页」的死循环。真正让用户「感觉只登录了一次」的机制其实藏在授权服务器的会话 cookie 里。用户第一次访问系统 A被重定向到授权服务器登录授权服务器建立自己的 session之后访问系统 B浏览器带着授权服务器的 session cookie 过去授权服务器发现人已经登录过直接把 code 发回来用户完全不感知。所以单点登录的本质是「共享认证状态」不是「共享令牌」。2.3 Spring Boot 3 与 Spring Security 6 的依赖组合Spring Security 6 里已经找不到EnableAuthorizationServer了。旧的spring-security-oauth2-autoconfigure项目停在很早期跟 Spring Boot 3 的 Jakarta 命名空间完全不兼容硬引进来只会得到一串javax.servlet找不到的报错。当前的组合是spring-boot-starter-oauth2-client负责客户端spring-boot-starter-oauth2-resource-server负责资源服务器授权服务器的能力由独立的spring-security-oauth2-authorization-server提供。!-- 授权服务器模块 -- dependency groupIdorg.springframework.security/groupId artifactIdspring-security-oauth2-authorization-server/artifactId !-- 版本交由 Spring Boot BOM 统一管理不要手写避免与 Security 版本错位 -- /dependency !-- 客户端与资源服务器模块 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-client/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-resource-server/artifactId /dependency依赖管理上有两条经验值得说。其一spring-security-oauth2-authorization-server与 Spring Security 主版本强绑定混用不同来源的版本号是最常见的NoSuchMethodError来源交给 BOM 管最省事。其二如果项目里同时存在spring-boot-starter-security和上面两个 starter不需要额外排除但要注意 Jakarta 命名空间已经替换了全部javax.servlet引用任何依赖 Servlet API 的自研过滤器都得跟着改包名否则编译期就会断。3. spring security oauth2.0 服务搭建授权服务器与客户端的最小可跑配置3.1 授权服务器注册客户端与签名密钥授权服务器要做两件事定义有哪些客户端可以来申请令牌以及准备一对签名密钥用于签发 JWT。下面这段配置把客户端信息写在内存里生产环境换成 JDBC 实现即可。Configuration public class AuthorizationServerConfig { // 优先级高于业务安全链专管 /oauth2/** 与 OIDC 端点 Bean Order(1) public SecurityFilterChain authorizationServerChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); // 开启 OIDC客户端才能拿到 id_token http.exceptionHandling(e - e.defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint(/login), // 未登录时跳授权服务器自己的登录页 new MediaTypeRequestMatcher(MediaType.TEXT_HTML))); return http.build(); } Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient portal RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(portal-client) .clientSecret({noop}portal-secret) // 生产务必换成 BCrypt 编码 .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri(http://localhost:8080/login/oauth2/code/portal) // 必须逐字符匹配 .postLogoutRedirectUri(http://localhost:8080/) .scope(OidcScopes.OPENID) .scope(OidcScopes.PROFILE) .clientSettings(ClientSettings.builder() .requireAuthorizationConsent(false) // 内部系统可关闭授权确认页 .build()) .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofMinutes(30)) .refreshTokenTimeToLive(Duration.ofHours(8)) .reuseRefreshTokens(false) // 每次刷新都换新 refresh_token .build()) .build(); return new InMemoryRegisteredClientRepository(portal); } }逐项说明。clientSecret前缀{noop}表示不做哈希比对本地调试方便但一旦上线就是明文口令务必换成PasswordEncoder编码后的值。redirectUri是排错频率最高的一项授权服务器做的是字符串精确匹配localhost和127.0.0.1、有没有结尾斜杠、端口对不对任何一处不同都会返回redirect_uri_mismatch。requireAuthorizationConsent(false)关掉的是用户点「同意授权」的页面内部系统没必要让员工每次确认一遍。reuseRefreshTokens(false)配合刷新令牌轮换能降低 refresh_token 泄漏后的可用窗口。签名密钥用JWKSource提供启动时随机生成一对 RSA 密钥即可跑通多实例部署时要改成从固定密钥库或配置中心读取同一份密钥否则实例 A 签发的 JWT 到实例 B 验签必然失败。3.2 客户端接入oauth2-client 的 yml 配置客户端这边几乎不用写 Java 代码Spring Security 的自动配置会帮你建好整个授权码流程。spring: security: oauth2: client: registration: portal: client-id: portal-client client-secret: portal-secret authorization-grant-type: authorization_code # {baseUrl} 与 {registrationId} 由框架自动替换不要写死 redirect-uri: {baseUrl}/login/oauth2/code/{registrationId} scope: openid,profile client-name: 统一门户 provider: portal: # 只配 issuer-uri其余端点由 OIDC 发现自动补全 issuer-uri: http://auth-server:9000用issuer-uri而不是逐个手写authorization-uri、token-uri好处是授权服务器换域名或加前缀时客户端零改动坏处是启动阶段必须能连通授权服务器否则应用直接起不来。本地联调时如果授权服务器还没启动先把客户端停了别怀疑是自己配置写错。配置完成后把客户端应用里所有需要保护的路径交给authorizeHttpRequests管未登录的请求会被自动重定向到授权服务器。/login/**和/error要放行否则登录回调自己就被拦住了。3.3 资源服务器只验签不登录资源服务器是独立部署的后端服务它不参与登录流程只认令牌。Bean public SecurityFilterChain resourceServerChain(HttpSecurity http) throws Exception { http.securityMatcher(/api/**) // 只管 API页面路由交给别的链 .authorizeHttpRequests(a - a .requestMatchers(/api/public/**).permitAll() .anyRequest().hasAuthority(SCOPE_profile)) .oauth2ResourceServer(o - o.jwt(jwt - jwt .jwtAuthenticationConverter(jwtAuthConverter()))); // 自定义权限映射 return http.build(); } private ConverterJwt, AbstractAuthenticationToken jwtAuthConverter() { JwtAuthenticationConverter converter new JwtAuthenticationConverter(); // 把 JWT 里的 authorities 声明映射成 Spring Security 的权限 converter.setJwtGrantedAuthoritiesConverter(jwt - ((ListString) jwt.getClaim(authorities)).stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList())); return converter; }securityMatcher划定这条链的作用范围避免资源服务器的令牌校验逻辑误伤管理端点。默认情况下 JWT 里的scope声明会被映射成SCOPE_xxx权限如果业务权限来自自定义 claim就得像上面这样换一个转换器。issuer-uri同样只配一处资源服务器会自己去拉 JWK Set第一次拉取失败会缓存一段时间的失败状态改完配置记得重启不要反复刷新接口。3.4 联调顺序与四类高频报错联调按「授权服务器 → 客户端 → 资源服务器」的顺序走每一步都用 curl 或浏览器单独验证问题定位会快很多。先访问http://auth-server:9000/.well-known/openid-configuration能返回 JSON 说明授权服务器活着再访问客户端受保护页面看是否跳到授权服务器登录页最后拿真令牌打资源服务器接口。现象常见原因排查动作redirect_uri_mismatch注册值与实际请求不逐字符相同比对协议、主机名、端口、结尾斜杠invalid_client密钥编码方式不匹配检查注册时用的是{noop}明文还是 BCrypt401 且日志提示无法解析 JWK资源服务器拉不到 JWK Setcurl 发现端点确认 issuer-uri 可达登录成功后无限重定向客户端 session 未建立或 cookie 被丢看浏览器是否带上 JSESSIONID检查 SameSite提示报错信息优先看授权服务器和资源服务器的 DEBUG 日志org.springframework.security包调到 DEBUG 后令牌校验失败的原始原因基本都能直接读出来比盯着 401 猜要快得多。4. 单点登录的会话细节登出、令牌有效期与前后端分离4.1 单点登出不是删掉一个本地 session业务系统里调session.invalidate()只清掉了本系统的会话授权服务器的 session 还在用户下一次点进来照样免登录进去。真正的单点登出要走 OIDC 的 RP-Initiated Logout客户端先把用户重定向到授权服务器的end_session_endpoint带上id_token_hint和post_logout_redirect_uri授权服务器清掉自己的 session再跳回客户端指定地址。Spring Security 提供了现成的成功处理器Bean public SecurityFilterChain clientChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(a - a.anyRequest().authenticated()) .oauth2Login(Customizer.withDefaults()) .logout(l - l.logoutSuccessHandler(oidcLogoutSuccessHandler())); return http.build(); } private LogoutSuccessHandler oidcLogoutSuccessHandler() { OidcClientInitiatedLogoutSuccessHandler handler new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository); // 登出后回到客户端首页必须是授权服务器已注册的 postLogoutRedirectUri handler.setPostLogoutRedirectUri({baseUrl}/); return handler; }id_token_hint是授权服务器用来确认「你确实是要登出的那个用户」的凭据所以客户端必须开启 OIDC 并拿到 id_token只配 OAuth2 不配openidscope 的话这个参数拿不到登出会失败。另外postLogoutRedirectUri同样要在授权服务器端注册跟redirectUri一样做精确匹配。必须提醒一句这种登出只保证授权服务器的会话被清掉已经签发出去的 access_token 在有效期内依然可用。要做得更彻底得引入令牌吊销或者把 access_token 有效期压到几分钟用 refresh_token 续期。4.2 access_token 与 refresh_token 的有效期怎么定有效期是整个方案里最需要权衡的参数配得太长安全性差配得太短用户体验差、刷新请求压力大。参数建议值影响accessTokenTimeToLive15 到 30 分钟越长越省刷新泄漏后风险窗口越大refreshTokenTimeToLive8 到 12 小时约等于一个工作日超时要求重新登录reuseRefreshTokensfalse开启轮换旧 refresh_token 用一次即失效授权服务器 session 超时与 refresh_token 对齐用户感知的「多久要重新登录一次」时钟偏移容忍60 秒多实例部署时避免 nbf/exp 误判内部系统的常见做法是把 access_token 设成 30 分钟、refresh_token 设成 8 小时并开启轮换。这样员工上班期间基本无感中午离开两小时回来也不用重登。令牌时间设得太短会出现另一个问题前端并发请求同时触发刷新旧 refresh_token 被用了两次第二次直接失败用户莫名其妙被踢出去。前端侧要加一个刷新请求的互斥锁同一时刻只允许一个刷新在跑。4.3 前后端分离时的 Cookie、CORS 与 CSRF前后端分离是踩坑最集中的场景。三条线要一起看Cookie 的域、跨域请求的凭据、以及 CSRF 防护。Bean public SecurityFilterChain clientChain(HttpSecurity http, CorsConfigurationSource corsSource) throws Exception { http.cors(c - c.configurationSource(corsSource)) .csrf(csrf - csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // 前端要读到 XSRF-TOKEN .ignoringRequestMatchers(/api/**)) // 纯令牌接口免 CSRF .authorizeHttpRequests(a - a.anyRequest().authenticated()) .oauth2Login(Customizer.withDefaults()); return http.build(); }三处配置的逻辑分别是CorsConfigurationSource里必须显式setAllowCredentials(true)并且allowedOrigins不能写成*浏览器不允许带凭据的通配来源会直接拒掉CookieCsrfTokenRepository.withHttpOnlyFalse()让前端能读到 CSRF token 并回填到请求头ignoringRequestMatchers(/api/**)是因为纯令牌访问的接口本来就不依赖 CookieCSRF 攻击面不存在。跨域场景下 Cookie 的SameSite属性最容易出问题。前端与后端不同站时SameSiteLax的 cookie 不会在跨站请求里带上表现为「授权回来 session 没了」。这时要把SameSite设成None并配合Secure前提是整条链路都跑在 HTTPS 上。5. 接到企业账号体系LDAP 统一用户认证与排错技巧多数企业的账号事实标准不是数据库里的用户表而是 LDAP 或 Active Directory。授权服务器要做的只是把认证委派过去OAuth2 继续管授权两者分工不要混。Bean public AuthenticationProvider ldapAuthenticationProvider() { DefaultSpringSecurityContextSource contextSource new DefaultSpringSecurityContextSource(ldap://ldap.corp.example:389/dccorp,dcexample); contextSource.setUserDn(cnreadonly,dccorp,dcexample); // 查询用只读账号 contextSource.setPassword(readonly-pwd); contextSource.afterPropertiesSet(); BindAuthenticator authenticator new BindAuthenticator(contextSource); // {0} 会被替换成用户输入的用户名格式必须与目录结构一致 authenticator.setUserDnPatterns(new String[]{uid{0},oupeople}); return new LdapAuthenticationProvider(authenticator); }这段配置在授权服务器的安全链里注册后用户在授权服务器登录页提交的用户名口令会走 LDAP bind 校验校验通过再签发 JWT。BindAuthenticator的机制是拿用户自己的凭据去 bind 一次能 bind 成功就说明密码正确全程不需要读取密码字段。setUserDnPatterns里的 DN 模板要和目录实际结构对上OpenLDAP 一般是uid{0},oupeople,...AD 场景则常用sAMAccountName{0}DN 拼接方式完全不同抄错一个就是InvalidCredentialsException。想给下游系统做按组织授权就在认证成功后把部门和工号塞进令牌 claims。可以在LdapAuthenticationProvider外面再包一层自定义的AuthenticationProviderbind 成功后用同一个 contextSource 查一次用户属性把department、employeeId写进UserDetails授权服务器签发 JWT 时通过OAuth2TokenCustomizer注入自定义 claim资源服务器侧就能用hasAuthority(DEPT_FINANCE)这类表达式做细粒度控制。排错时不要一上来就改代码先用命令行把 LDAP 侧单独验通# 1. 用只读账号验证连接与搜索基准是否正确 ldapsearch -x -H ldap://ldap.corp.example:389 \ -D cnreadonly,dccorp,dcexample -w readonly-pwd \ -b oupeople,dccorp,dcexample (uidzhangsan) dn # 2. 用用户自己的口令验证 bind 是否通过-W 交互输入密码 ldapsearch -x -H ldap://ldap.corp.example:389 \ -D uidzhangsan,oupeople,dccorp,dcexample -W \ -b dccorp,dcexample -s base第一条命令验证的是服务连通性和搜索路径第二条验证的是用户 DN 拼接是否与实际条目一致。两条都通过而应用里仍然报认证失败问题基本就落在 Spring 侧的 DN 模板或密码编码器上了。另外注意 LDAP 密码永远不要做本地哈希后再比对bind 操作本身就要求明文口令如果应用里给 LDAP 认证配了PasswordEncoder认证一定失败。AD 环境还要留意一个细节部分域控要求 DN 里的属性值大小写与目录完全一致用户名统一toLowerCase()之后再拼 DN能省掉一批只在特定账号上复现的偶发失败。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表