编辑
2026-10-25
学习记录
00

目录

0. 本文适合谁
1. 先选接入模式
2. 启动 Keycloak
3. Keycloak 配置
4. Spring Boot 依赖
5. application.yml
6. SecurityConfig:Session 模式
7. 登录、用户信息与注销
8. 角色映射
9. 前后端分离怎么接
9.1 方案 A:BFF(推荐给内部系统)
9.2 方案 B:SPA + PKCE + 资源服务器
9.3 方案 C:网关统一认证
9.4 CORS 与 CSRF
10. 微服务之间的 Token 传递
10.1 用户态透传
10.2 服务间调用
10.3 Spring Boot 代码:Token 中继
11. 十个高频坑
坑 1:redirect_uri 不匹配
坑 2:issuer-uri 在容器里不可达
坑 3:只校验了签名,没校验 audience
坑 4:角色没进 token
坑 5:注销只清了本地 Session
坑 6:SPA 把 refresh token 放 localStorage
坑 7:Session 模式关掉 CSRF
坑 8:跨域 Cookie 的 SameSite 配置错误
坑 9:时钟偏移导致 token 校验失败
坑 10:把 SSO 当成权限系统
补充:用户信息变更不同步
12. 上线前验收清单
13. 总结
参考资料

SSO 实战系列(2/3):上一篇聊选型,这一篇直接给 Spring Boot 接 Keycloak 的完整配置。

示例环境:Spring Boot 3.5.x、Spring Security 6.5.x、Keycloak 26.8.x、Java 21、Maven。如果你在使用 Spring Boot 4.x / Spring Security 7,核心配置基本一致,但部分旧 DSL 已移除,迁移时以官方指南为准;如果项目还在 Spring Boot 2.x / Spring Security 5.x,OAuth2 登录流程相同,但安全配置 API 差异较大,不要直接复制本文的 SecurityFilterChain。

上篇我们对比了 Keycloak 和 MaxKey。这一篇不再讨论选型,直接回答一个问题:

一个 Spring Boot 项目,怎么用最少的代码接入 Keycloak,并正确处理角色、注销、前后端分离和微服务调用?

TL;DR

  • 单体后台:spring-boot-starter-oauth2-client + oauth2Login,浏览器只拿 Cookie,Session 存在服务端。
  • 角色:把 Keycloak 的 realm_access.roles 映射成 Spring 的 ROLE_xxx,再用 @PreAuthorize 控制接口。
  • 注销:OidcClientInitiatedLogoutSuccessHandler 负责登出 IdP,Keycloak 侧要配好 post logout redirect URI。
  • 前后端分离优先 BFF;SPA 直连时用 PKCE + 资源服务器,Access Token 只放内存,别把 Refresh Token 丢进 localStorage。
  • 本文代码按 Spring Security 6.5 的 DSL 整理,但没有在真实 Keycloak 实例上做端到端联调,上线前请按第 12 节的清单逐项验证。

0. 本文适合谁

  • 后端是 Spring Boot,前端可能是 Thymeleaf、Vue、React;
  • 需要用户名密码登录 + 角色控制;
  • 需要多个系统共享登录态;
  • 需要单点注销;
  • 希望尽量使用 Spring Security 原生能力,不引入额外 SDK。

如果你最后选的是 MaxKey,登录、Session、权限控制这些结构可以复用,但 provider 要改成显式端点,角色要从业务库取,注销要自己跳转——这些差异在《Spring Boot 3 接入 MaxKey 实战》里单独写。


1. 先选接入模式

不要把“SSO”理解成一种固定架构。Spring Boot 常见有三种接法:

模式浏览器拿什么后端角色适合
服务端 SessionCookieOAuth2 Client单体后台、Thymeleaf/JSP、管理系统
BFFCookieOAuth2 Client + 反向代理 API前后端分离,推荐给内部系统
SPA + 资源服务器Access TokenResource Server纯 API、移动端、需要无状态
网关统一认证Cookie 或 TokenOAuth2 Client / Resource Server微服务、多后端

本文先把最经典的服务端 Session 模式讲清楚,再讲 BFF 和 SPA 模式。

整体流程:

text
1. 浏览器 GET /me ↓ 2. 未登录,Spring Boot 302 到 /oauth2/authorization/keycloak ↓ 3. 浏览器跳到 Keycloak 登录页,输入账号密码 / MFA ↓ 4. Keycloak 302 回 /login/oauth2/code/keycloak?code=xxx ↓ 5. Spring Boot 后端用 code + client_secret 换 token(secret 不出浏览器) ↓ 6. Keycloak 返回 ID Token + Access Token ↓ 7. Spring Boot 建立本地 Session,Set-Cookie: JSESSIONID ↓ 8. 后续请求只带 Cookie

2. 启动 Keycloak

开发环境用 Docker Compose:

yaml
services: keycloak: image: quay.io/keycloak/keycloak:26.8.1 command: start-dev environment: KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin ports: - "8080:8080"

启动:

bash
docker compose up -d

访问 http://localhost:8080,用 admin / admin 登录管理控制台。

start-dev 只用于开发。生产环境要用 start,接 PostgreSQL,配置 HTTPS、KC_HOSTNAME 和反向代理,并替换默认管理员密码。

Keycloak 26 之前的管理员环境变量是 KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD,升级时注意。


3. Keycloak 配置

按下面顺序操作:

  1. 创建 Realm:左上角下拉 → Create Realm → 名称 blog。
  2. 创建用户:Users → Create user → 用户名 alice → Create → Credentials → Set password,关闭 Temporary。
  3. 创建客户端:Clients → Create client
    • Client ID:blog-web
    • Client authentication:On(机密客户端)
    • Standard flow:On
    • Valid redirect URIs:http://localhost:8081/login/oauth2/code/keycloak
    • Web origins:http://localhost:8081
    • Valid post logout redirect URIs:http://localhost:8081/*
  4. 复制密钥:Clients → blog-web → Credentials → Client secret。
  5. 创建角色(可选):Realm roles → Create role → admin、user,然后在 Users → alice → Role mapping 里分配。

几个容易配错的地方:

  • Redirect URI 必须精确匹配,包括端口、路径和末尾斜杠;
  • localhost 和 127.0.0.1 在 Keycloak 眼里是两个不同的地址;
  • 不配置 post logout redirect URI,注销后会停在 Keycloak 的错误页;
  • Web origins 只影响浏览器跨域,不等于 redirect URI。

4. Spring Boot 依赖

Maven:

xml
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.5.0</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency> <!-- 可选:服务端渲染页面 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity6</artifactId> </dependency> </dependencies>

Gradle:

groovy
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

如果还要做纯 API 资源服务器,再加上:

xml
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency>

5. application.yml

yaml
server: port: 8081 spring: security: oauth2: client: registration: keycloak: provider: keycloak client-id: blog-web client-secret: ${KEYCLOAK_CLIENT_SECRET} authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" scope: openid,profile,email provider: keycloak: issuer-uri: http://localhost:8080/realms/blog

说明:

  • issuer-uri 会让 Spring 在启动时请求 http://localhost:8080/realms/blog/.well-known/openid-configuration。Keycloak 没启动时应用会启动失败,这是预期行为;
  • 如果 IdP 在容器网络里,localhost 可能指向应用容器自己。要让浏览器和后端都能访问同一个 issuer,生产环境推荐配置固定的 KC_HOSTNAME;
  • redirect-uri 的默认模板就是 {baseUrl}/login/oauth2/code/{registrationId},要和 Keycloak 里填的地址保持一致;
  • client secret 不要提交到 Git,用环境变量或配置中心。

6. SecurityConfig:Session 模式

java
package com.example.sso; import java.util.Collection; import java.util.HashSet; import java.util.Map; import java.util.Set; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; @Configuration public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain( HttpSecurity http, ClientRegistrationRepository clientRegistrationRepository) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/public/**", "/error").permitAll() .anyRequest().authenticated()) .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo .userAuthoritiesMapper(keycloakAuthoritiesMapper()))) .logout(logout -> logout .logoutSuccessHandler( oidcLogoutSuccessHandler(clientRegistrationRepository))); return http.build(); } private GrantedAuthoritiesMapper keycloakAuthoritiesMapper() { return authorities -> { Set<GrantedAuthority> mapped = new HashSet<>(authorities); for (GrantedAuthority authority : authorities) { if (authority instanceof OidcUserAuthority oidcAuthority) { Map<String, Object> realmAccess = realmAccess(oidcAuthority); if (realmAccess != null && realmAccess.get("roles") instanceof Collection<?> roles) { roles.forEach(role -> mapped.add(new SimpleGrantedAuthority("ROLE_" + role))); } } } return mapped; }; } @SuppressWarnings("unchecked") private Map<String, Object> realmAccess(OidcUserAuthority authority) { Object value = authority.getIdToken() == null ? null : authority.getIdToken().getClaims().get("realm_access"); if (value == null && authority.getUserInfo() != null) { value = authority.getUserInfo().getClaims().get("realm_access"); } return value instanceof Map<?, ?> map ? (Map<String, Object>) map : null; } private LogoutSuccessHandler oidcLogoutSuccessHandler( ClientRegistrationRepository repository) { OidcClientInitiatedLogoutSuccessHandler handler = new OidcClientInitiatedLogoutSuccessHandler(repository); handler.setPostLogoutRedirectUri("{baseUrl}"); return handler; } }

这段配置做了三件事:

  1. 除公开路径外,所有请求都要登录;
  2. 使用 OIDC 登录,并把 Keycloak 的 realm 角色转换成 Spring 的 ROLE_xxx;
  3. 注销时跳转到 Keycloak 的 end_session_endpoint。

补充一个容易误解的点:oauth2Login 建立的是本地 Session。Access Token 过期不会自动让 Session 失效;只有当你用 OAuth2AuthorizedClientManager 调用下游时,才会触发刷新。要严格控制登录时长,配置 server.servlet.session.timeout 和 Keycloak 的 SSO Session 超时。


7. 登录、用户信息与注销

登录入口是 Spring Security 自动生成的:

text
http://localhost:8081/oauth2/authorization/keycloak

可以放一个链接:

html
<a href="/oauth2/authorization/keycloak">登录</a>

读取当前用户:

java
package com.example.sso; import java.util.LinkedHashMap; import java.util.Map; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class MeController { @GetMapping("/me") public Map<String, Object> me(@AuthenticationPrincipal OidcUser user) { Map<String, Object> result = new LinkedHashMap<>(); result.put("sub", user.getSubject()); result.put("username", user.getClaims().get("preferred_username")); result.put("email", user.getClaims().get("email")); result.put("roles", user.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .toList()); return result; } }

注销:

html
<form th:action="@{/logout}" method="post"> <button type="submit">退出登录</button> </form>

Spring Security 默认开启 CSRF 时,/logout 只接受 POST。用 Thymeleaf 的 th:action 会自动带上 CSRF token;如果写原生 HTML,需要手动加隐藏字段:

html
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">

不要把注销改成 GET,否则容易被诱导退出。


8. 角色映射

Keycloak 默认把 realm 角色放在 realm_access.roles,客户端角色放在 resource_access.<client-id>.roles。

确认 token 里有没有角色:

  1. 管理台 Clients → blog-web → Client scopes → Evaluate;
  2. 选择用户 alice,查看 ID Token / UserInfo / Access Token 的 claims;
  3. 如果 ID Token 里没有 realm_access,去 Client scopes → roles → Mappers → realm roles,把 Add to ID token 打开;
  4. 如果只在 UserInfo 里有,上面的代码也能读到。

然后在接口上使用:

java
@GetMapping("/admin") @PreAuthorize("hasRole('admin')") public String admin() { return "admin"; }

记得开启方法级安全:

java
@Configuration @EnableMethodSecurity public class MethodSecurityConfig { }

建议:

  • sub 是用户在 Realm 内的稳定唯一标识,业务表存 sub,不要存邮箱;
  • 业务权限尽量在本地系统做映射,不要把所有细粒度权限都塞进 Keycloak;
  • 多客户端场景优先用 client roles,并配 audience mapper,避免角色串系统。

9. 前后端分离怎么接

9.1 方案 A:BFF(推荐给内部系统)

Spring Boot 作为 BFF(Backend for Frontend):

  • 浏览器只和 Spring Boot 同源通信;
  • Spring Boot 用 oauth2Login 建立 Session;
  • SPA 调 /api/** 时带 Cookie;
  • Access Token 始终留在服务端,由后端转发给下游服务。

优点:浏览器不接触 token,XSS 拿不到长期凭证;注销、刷新、下游调用都好处理。

代价:需要处理 CSRF,Session 需要共享存储(多实例时用 Spring Session + Redis)。

这种模式下,前端不需要 keycloak-js,也不需要把 token 放 localStorage。

9.2 方案 B:SPA + PKCE + 资源服务器

Vue / React 直接用 keycloak-js 或 oidc-client-ts 登录,拿到 Access Token,后端只做资源服务器。

Keycloak 客户端配置:

  • Client authentication:Off(公共客户端)
  • Standard flow:On
  • PKCE Code Challenge Method:S256
  • Valid redirect URIs:前端地址,例如 http://localhost:5173/*
  • Web origins:http://localhost:5173

后端配置:

yaml
spring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:8080/realms/blog

安全配置:

java
package com.example.sso; import java.util.ArrayList; import java.util.Collection; import java.util.Map; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.web.SecurityFilterChain; @Configuration public class ApiSecurityConfig { @Bean SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt.jwtAuthenticationConverter(keycloakJwtConverter()))) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .csrf(csrf -> csrf.disable()) .cors(Customizer.withDefaults()); return http.build(); } @Bean JwtAuthenticationConverter keycloakJwtConverter() { JwtGrantedAuthoritiesConverter scopeConverter = new JwtGrantedAuthoritiesConverter(); JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setPrincipalClaimName("preferred_username"); converter.setJwtGrantedAuthoritiesConverter(jwt -> { Collection<GrantedAuthority> authorities = new ArrayList<>(scopeConverter.convert(jwt)); Map<String, Object> realmAccess = jwt.getClaim("realm_access"); if (realmAccess != null && realmAccess.get("roles") instanceof Collection<?> roles) { roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role))); } return authorities; }); return converter; } }

注意:

  • 这是纯 JWT 校验,服务端不保存会话,必须禁用 Session;
  • 浏览器里只把 Access Token 放内存,不要放 localStorage;
  • Refresh Token 不要长期放 localStorage,优先用 BFF 或短期 token + 静默续期;
  • issuer-uri 同样要求后端能访问 Keycloak;
  • 如果同一个 Spring Boot 应用既要页面登录又要 API,给 API 链加 @Order(1) 和 securityMatcher("/api/**"),页面链加 @Order(2)。

默认不校验 audience。 Spring 的 JwtValidators.createDefaultWithIssuer 默认校验 iss、exp、nbf,但不校验 aud。Keycloak 的 Access Token 默认 aud 往往是 account,不是你的 API。如果安全要求高,在 Keycloak 里加 Audience mapper,把 API 的 client id 加进 aud,再补一个校验器:

java
String issuer = "http://localhost:8080/realms/blog"; NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer); decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( JwtValidators.createDefaultWithIssuer(issuer), new JwtClaimValidator<List<String>>( JwtClaimNames.AUD, aud -> aud != null && aud.contains("blog-api"))));

9.3 方案 C:网关统一认证

Spring Cloud Gateway 作为 OAuth2 Client:

  • 网关负责登录和 token;
  • 下游服务只信任网关转发的 token 或用户头;
  • 用 TokenRelay 过滤器透传 Access Token。

如果网关把 token 解析后注入 X-User-Id 这类请求头,必须保证:

  • 下游服务只能被网关访问,不能被客户端直连;
  • 网关要覆盖而不是追加客户端传来的同名头;
  • 最好再加一层签名或 mTLS。

只靠一个 X-User-Id 头就认为用户已认证,是最常见的越权入口。

9.4 CORS 与 CSRF

BFF 模式同源部署最省事。跨域时:

java
@Bean CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("http://localhost:5173")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN")); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", config); return source; }

Session + Cookie 模式不能随便关 CSRF。SPA 可以这样配:

java
http.csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));

前端从 XSRF-TOKEN Cookie 读取 token,再放到 X-XSRF-TOKEN 请求头。

Spring Security 6 默认使用延迟加载的 XOR token,某些情况下首次请求不会立刻写出 Cookie。如果前端拿不到 XSRF-TOKEN,可以增加一个 GET /csrf 端点返回 CsrfToken,或参考官方文档实现 SpaCsrfTokenRequestHandler。

跨站 Cookie 在 HTTPS 下需要 SameSite=None; Secure,本地 HTTP 调试时注意浏览器策略。


10. 微服务之间的 Token 传递

10.1 用户态透传

用户请求带着 Access Token 进入网关,网关校验后透传给下游,下游继续用 oauth2ResourceServer 校验。

  • 每一层都校验,别只在网关校验;
  • 下游不要重新解析 JWT 却不验签;
  • 不要把 ID Token 当 API 凭证,ID Token 是给客户端证明身份的,Access Token 才是给资源服务器的。

10.2 服务间调用

后台任务、定时任务、服务间调用没有用户上下文,用 client_credentials:

  • 在 Keycloak 里给调用方建一个 confidential client;
  • 只授予必要的 scope;
  • token 有效期尽量短;
  • 不要多个服务共用一个 client secret。

10.3 Spring Boot 代码:Token 中继

如果希望下游调用自动带上当前用户的 Access Token:

java
package com.example.sso; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.client.DefaultOAuth2AuthorizedClientManager; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction; import org.springframework.web.reactive.function.client.WebClient; @Configuration public class WebClientConfig { @Bean OAuth2AuthorizedClientManager authorizedClientManager( ClientRegistrationRepository registrations, OAuth2AuthorizedClientRepository authorizedClients) { OAuth2AuthorizedClientProvider provider = OAuth2AuthorizedClientProviderBuilder.builder() .authorizationCode() .refreshToken() .clientCredentials() .build(); DefaultOAuth2AuthorizedClientManager manager = new DefaultOAuth2AuthorizedClientManager(registrations, authorizedClients); manager.setAuthorizedClientProvider(provider); return manager; } @Bean WebClient webClient(OAuth2AuthorizedClientManager manager) { ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2 = new ServletOAuth2AuthorizedClientExchangeFilterFunction(manager); oauth2.setDefaultOAuth2AuthorizedClient(true); return WebClient.builder() .apply(oauth2.oauth2Configuration()) .build(); } }

调用时默认使用当前登录用户的 token:

java
String result = webClient.get() .uri("http://order-service/api/orders") .retrieve() .bodyToMono(String.class) .block();

如果是 client_credentials:

java
webClient.get() .uri("http://order-service/api/orders") .attributes(clientRegistrationId("order-service-client")) .retrieve() .bodyToMono(String.class) .block();

需要静态导入:

java
import static org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.clientRegistrationId;

注意:WebClient 需要 spring-boot-starter-webflux,即使主应用是 Spring MVC 也可以引入,不会改变 Web 容器。


11. 十个高频坑

坑 1:redirect_uri 不匹配

Keycloak 要求精确匹配。http://localhost:8081 和 http://localhost:8081/ 不是一回事,localhost 和 127.0.0.1 也不是。

排查:看 Keycloak 日志里的 invalid_redirect_uri,对照客户端配置逐字符检查。

坑 2:issuer-uri 在容器里不可达

应用在 Docker 里,配置 http://localhost:8080,结果访问的是应用容器自己。

解决:用浏览器和后端都能解析的域名;生产环境配置 KC_HOSTNAME=https://sso.example.com,不要依赖请求头动态生成。

坑 3:只校验了签名,没校验 audience

Spring 默认校验 iss、exp、nbf,不校验 aud。多个 API 共用一个 Realm 时,A 系统的 token 可能被 B 系统接受。

解决:给每个 API 配 Audience mapper,并加 audience 校验。

坑 4:角色没进 token

Keycloak 的 realm 角色默认不一定在 ID Token 里。

解决:检查 Client scopes → roles → Mappers,确认 realm_access 出现在 ID Token 或 UserInfo;或者改用 client roles。

坑 5:注销只清了本地 Session

Spring 的 /logout 清掉本地 Session,但 Keycloak 的 SSO Session 还在,用户再点登录会直接免登录进来。

解决:使用 OidcClientInitiatedLogoutSuccessHandler;如果要让其他系统立即退出,开启 Keycloak Backchannel Logout,并在各客户端配置对应的处理器。

注意:单点登出比单点登录难。分布式系统里,其他系统的本地 Session 可能一直活到过期。常见策略是短 Session + 后端注销通知 + 关键操作重新鉴权。

坑 6:SPA 把 refresh token 放 localStorage

XSS 一旦发生,攻击者可以长期冒用身份。

解决:优先 BFF;否则 Access Token 只放内存,Refresh Token 用轮换机制,并尽量缩短有效期。

坑 7:Session 模式关掉 CSRF

前后端分离不等于可以无脑关 CSRF。只要认证凭证是 Cookie,就有 CSRF 风险。

解决:保留 CSRF,配置 CookieCsrfTokenRepository,并按官方文档实现 SPA 的 SpaCsrfTokenRequestHandler,前端带 X-XSRF-TOKEN。

前端 localhost:5173、后端 localhost:8081 时,Cookie 的域和 SameSite 策略会影响携带。

解决:开发环境用代理保持同源;生产环境统一域名或正确配置 SameSite=None; Secure。

坑 9:时钟偏移导致 token 校验失败

JWT 的 exp / nbf 对时间敏感,服务器时间不同步会出现莫名其妙的 401。

解决:所有节点接 NTP;容器基础镜像保证时间同步。

坑 10:把 SSO 当成权限系统

认证中心只回答“你是谁”。如果所有业务权限都堆在 Keycloak,角色会爆炸,变更也会失控。

解决:IdP 管身份和粗粒度角色,业务系统管自己的权限;需要细粒度授权时再考虑 Keycloak Authorization Services 或本地策略引擎。

补充:用户信息变更不同步

Keycloak 改了用户邮箱、禁用用户,业务库还留着旧数据。

解决:登录时按 sub 做一次 upsert;或用事件监听/定时同步;不要永久缓存用户信息。


12. 上线前验收清单

  • 未登录访问受保护页面 → 跳转登录 → 登录后回到原页面(SavedRequest 生效)
  • 已登录访问第二个接入系统 → 无需再次输入密码
  • admin 角色能访问管理接口,普通用户返回 403
  • 注销后两个系统都退出,且再次访问需要重新登录
  • Access Token 过期后,下游调用能刷新或重新授权;本地 Session 的行为符合预期
  • API 未认证返回 401 JSON,而不是 302 到登录页
  • 跨域预检 OPTIONS 返回正确,Cookie 能正常携带
  • Keycloak 重启后,已登录用户的预期行为明确(Session 是否保留)
  • 多实例部署时 Session 已共享(Spring Session + Redis)或使用无状态 JWT
  • 日志记录 sub / sid,不打印完整 token 和 client secret
  • HTTPS、证书、KC_HOSTNAME、反向代理头配置正确
  • 客户端 secret 可轮换,且有回滚方案
  • Keycloak 管理台不使用默认管理员密码,限制访问来源

13. 总结

给一个 Spring Boot 项目的最小落地路径:

  1. 单体后台:Keycloak + spring-boot-starter-oauth2-client + oauth2Login;
  2. 角色控制:把 Keycloak 的 realm_access.roles 映射成 Spring 的 ROLE_xxx,用 @PreAuthorize;
  3. 注销:用 OidcClientInitiatedLogoutSuccessHandler,Keycloak 配好 post logout redirect URI;
  4. 前后端分离:优先 BFF;必须 SPA 直连时,用 PKCE + 资源服务器 + 短期 token;
  5. 微服务:网关认证 + 下游继续校验 JWT,服务间调用用 client_credentials;
  6. 多实例:Session 模式上 Spring Session + Redis,或改成无状态 JWT。

如果以后从 Keycloak 换到 MaxKey,Spring 侧的登录和会话结构仍然可以复用,但要改 provider 配置、角色来源和注销跳转。具体差异见系列的下一篇。


参考资料

本文作者:Weee

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!