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 存在服务端。realm_access.roles 映射成 Spring 的 ROLE_xxx,再用 @PreAuthorize 控制接口。OidcClientInitiatedLogoutSuccessHandler 负责登出 IdP,Keycloak 侧要配好 post logout redirect URI。如果你最后选的是 MaxKey,登录、Session、权限控制这些结构可以复用,但 provider 要改成显式端点,角色要从业务库取,注销要自己跳转——这些差异在《Spring Boot 3 接入 MaxKey 实战》里单独写。
不要把“SSO”理解成一种固定架构。Spring Boot 常见有三种接法:
| 模式 | 浏览器拿什么 | 后端角色 | 适合 |
|---|---|---|---|
| 服务端 Session | Cookie | OAuth2 Client | 单体后台、Thymeleaf/JSP、管理系统 |
| BFF | Cookie | OAuth2 Client + 反向代理 API | 前后端分离,推荐给内部系统 |
| SPA + 资源服务器 | Access Token | Resource Server | 纯 API、移动端、需要无状态 |
| 网关统一认证 | Cookie 或 Token | OAuth2 Client / Resource Server | 微服务、多后端 |
本文先把最经典的服务端 Session 模式讲清楚,再讲 BFF 和 SPA 模式。
整体流程:
text1. 浏览器 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
开发环境用 Docker Compose:
yamlservices:
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"
启动:
bashdocker compose up -d
访问 http://localhost:8080,用 admin / admin 登录管理控制台。
start-dev只用于开发。生产环境要用start,接 PostgreSQL,配置 HTTPS、KC_HOSTNAME和反向代理,并替换默认管理员密码。Keycloak 26 之前的管理员环境变量是
KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD,升级时注意。
按下面顺序操作:
blog。alice → Create → Credentials → Set password,关闭 Temporary。blog-webhttp://localhost:8081/login/oauth2/code/keycloakhttp://localhost:8081http://localhost:8081/*admin、user,然后在 Users → alice → Role mapping 里分配。几个容易配错的地方:
localhost 和 127.0.0.1 在 Keycloak 眼里是两个不同的地址;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:
groovyimplementation '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>
yamlserver:
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 没启动时应用会启动失败,这是预期行为;localhost 可能指向应用容器自己。要让浏览器和后端都能访问同一个 issuer,生产环境推荐配置固定的 KC_HOSTNAME;redirect-uri 的默认模板就是 {baseUrl}/login/oauth2/code/{registrationId},要和 Keycloak 里填的地址保持一致;javapackage 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;
}
}
这段配置做了三件事:
ROLE_xxx;end_session_endpoint。补充一个容易误解的点:oauth2Login 建立的是本地 Session。Access Token 过期不会自动让 Session 失效;只有当你用 OAuth2AuthorizedClientManager 调用下游时,才会触发刷新。要严格控制登录时长,配置 server.servlet.session.timeout 和 Keycloak 的 SSO Session 超时。
登录入口是 Spring Security 自动生成的:
texthttp://localhost:8081/oauth2/authorization/keycloak
可以放一个链接:
html<a href="/oauth2/authorization/keycloak">登录</a>
读取当前用户:
javapackage 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,否则容易被诱导退出。
Keycloak 默认把 realm 角色放在 realm_access.roles,客户端角色放在 resource_access.<client-id>.roles。
确认 token 里有没有角色:
realm_access,去 Client scopes → roles → Mappers → realm roles,把 Add to ID token 打开;然后在接口上使用:
java@GetMapping("/admin")
@PreAuthorize("hasRole('admin')")
public String admin() {
return "admin";
}
记得开启方法级安全:
java@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
建议:
sub 是用户在 Realm 内的稳定唯一标识,业务表存 sub,不要存邮箱;Spring Boot 作为 BFF(Backend for Frontend):
oauth2Login 建立 Session;/api/** 时带 Cookie;优点:浏览器不接触 token,XSS 拿不到长期凭证;注销、刷新、下游调用都好处理。
代价:需要处理 CSRF,Session 需要共享存储(多实例时用 Spring Session + Redis)。
这种模式下,前端不需要 keycloak-js,也不需要把 token 放 localStorage。
Vue / React 直接用 keycloak-js 或 oidc-client-ts 登录,拿到 Access Token,后端只做资源服务器。
Keycloak 客户端配置:
http://localhost:5173/*http://localhost:5173后端配置:
yamlspring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/blog
安全配置:
javapackage 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;
}
}
注意:
issuer-uri 同样要求后端能访问 Keycloak;@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,再补一个校验器:
javaString 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"))));
Spring Cloud Gateway 作为 OAuth2 Client:
TokenRelay 过滤器透传 Access Token。如果网关把 token 解析后注入 X-User-Id 这类请求头,必须保证:
只靠一个 X-User-Id 头就认为用户已认证,是最常见的越权入口。
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 可以这样配:
javahttp.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 调试时注意浏览器策略。
用户请求带着 Access Token 进入网关,网关校验后透传给下游,下游继续用 oauth2ResourceServer 校验。
后台任务、定时任务、服务间调用没有用户上下文,用 client_credentials:
如果希望下游调用自动带上当前用户的 Access Token:
javapackage 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:
javaString result = webClient.get()
.uri("http://order-service/api/orders")
.retrieve()
.bodyToMono(String.class)
.block();
如果是 client_credentials:
javawebClient.get()
.uri("http://order-service/api/orders")
.attributes(clientRegistrationId("order-service-client"))
.retrieve()
.bodyToMono(String.class)
.block();
需要静态导入:
javaimport static org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.clientRegistrationId;
注意:WebClient 需要 spring-boot-starter-webflux,即使主应用是 Spring MVC 也可以引入,不会改变 Web 容器。
Keycloak 要求精确匹配。http://localhost:8081 和 http://localhost:8081/ 不是一回事,localhost 和 127.0.0.1 也不是。
排查:看 Keycloak 日志里的 invalid_redirect_uri,对照客户端配置逐字符检查。
应用在 Docker 里,配置 http://localhost:8080,结果访问的是应用容器自己。
解决:用浏览器和后端都能解析的域名;生产环境配置 KC_HOSTNAME=https://sso.example.com,不要依赖请求头动态生成。
Spring 默认校验 iss、exp、nbf,不校验 aud。多个 API 共用一个 Realm 时,A 系统的 token 可能被 B 系统接受。
解决:给每个 API 配 Audience mapper,并加 audience 校验。
Keycloak 的 realm 角色默认不一定在 ID Token 里。
解决:检查 Client scopes → roles → Mappers,确认 realm_access 出现在 ID Token 或 UserInfo;或者改用 client roles。
Spring 的 /logout 清掉本地 Session,但 Keycloak 的 SSO Session 还在,用户再点登录会直接免登录进来。
解决:使用 OidcClientInitiatedLogoutSuccessHandler;如果要让其他系统立即退出,开启 Keycloak Backchannel Logout,并在各客户端配置对应的处理器。
注意:单点登出比单点登录难。分布式系统里,其他系统的本地 Session 可能一直活到过期。常见策略是短 Session + 后端注销通知 + 关键操作重新鉴权。
XSS 一旦发生,攻击者可以长期冒用身份。
解决:优先 BFF;否则 Access Token 只放内存,Refresh Token 用轮换机制,并尽量缩短有效期。
前后端分离不等于可以无脑关 CSRF。只要认证凭证是 Cookie,就有 CSRF 风险。
解决:保留 CSRF,配置 CookieCsrfTokenRepository,并按官方文档实现 SPA 的 SpaCsrfTokenRequestHandler,前端带 X-XSRF-TOKEN。
前端 localhost:5173、后端 localhost:8081 时,Cookie 的域和 SameSite 策略会影响携带。
解决:开发环境用代理保持同源;生产环境统一域名或正确配置 SameSite=None; Secure。
JWT 的 exp / nbf 对时间敏感,服务器时间不同步会出现莫名其妙的 401。
解决:所有节点接 NTP;容器基础镜像保证时间同步。
认证中心只回答“你是谁”。如果所有业务权限都堆在 Keycloak,角色会爆炸,变更也会失控。
解决:IdP 管身份和粗粒度角色,业务系统管自己的权限;需要细粒度授权时再考虑 Keycloak Authorization Services 或本地策略引擎。
Keycloak 改了用户邮箱、禁用用户,业务库还留着旧数据。
解决:登录时按 sub 做一次 upsert;或用事件监听/定时同步;不要永久缓存用户信息。
admin 角色能访问管理接口,普通用户返回 403sub / sid,不打印完整 token 和 client secretKC_HOSTNAME、反向代理头配置正确给一个 Spring Boot 项目的最小落地路径:
spring-boot-starter-oauth2-client + oauth2Login;realm_access.roles 映射成 Spring 的 ROLE_xxx,用 @PreAuthorize;OidcClientInitiatedLogoutSuccessHandler,Keycloak 配好 post logout redirect URI;client_credentials;如果以后从 Keycloak 换到 MaxKey,Spring 侧的登录和会话结构仍然可以复用,但要改 provider 配置、角色来源和注销跳转。具体差异见系列的下一篇。
本文作者:Weee
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!