在自定义Spring Security认证系统中集成Keycloak实现双认证流
解决方案
一、同时保留自定义认证与Keycloak认证两种独立流程
Spring Security完全支持多认证流程并行,核心是配置多个SecurityFilterChain,为不同路径分配对应认证逻辑:
- 自定义认证FilterChain:指定匹配路径(如
/custom/login/**),沿用原有的LoginFilter、LoginProvider,保留Login表的用户校验逻辑不变。 - Keycloak认证FilterChain:使用Spring Security的OAuth2 Client模块对接Keycloak,指定匹配路径(如
/oauth2/**、/login/oauth2/**),配置Keycloak客户端信息,让用户通过Keycloak跳转至Azure AD完成登录。 - 统一用户映射:两种认证成功后都转换为Spring Security的
UserDetails对象,确保后续权限逻辑无需修改。可自定义UserDetailsService,从Login表加载用户信息,关联同一登录记录。
示例代码片段:
// 自定义认证的FilterChain @Bean public SecurityFilterChain customLoginFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/custom/login/**") .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .addFilterBefore(new LoginFilter(), UsernamePasswordAuthenticationFilter.class) .authenticationProvider(new LoginProvider()); return http.build(); } // Keycloak认证的FilterChain @Bean public SecurityFilterChain keycloakFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/oauth2/**", "/login/oauth2/**") .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .oauth2Login(oauth2 -> oauth2 .clientRegistrationRepository(clientRegistrationRepository()) .authorizedClientService(authorizedClientService()) .userInfoEndpoint(userInfo -> userInfo.userService(customOidcUserService()))); return http.build(); } // Keycloak客户端注册配置 @Bean public ClientRegistrationRepository clientRegistrationRepository() { return new InMemoryClientRegistrationRepository(keycloakClientRegistration()); } private ClientRegistration keycloakClientRegistration() { return ClientRegistration.withRegistrationId("keycloak") .clientId("你的Keycloak客户端ID") .clientSecret("你的Keycloak客户端密钥") .authorizationUri("https://你的Keycloak域名/auth/realms/你的领域/protocol/openid-connect/auth") .tokenUri("https://你的Keycloak域名/auth/realms/你的领域/protocol/openid-connect/token") .userInfoUri("https://你的Keycloak域名/auth/realms/你的领域/protocol/openid-connect/userinfo") .redirectUri("{baseUrl}/login/oauth2/code/keycloak") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .scope("openid", "profile", "email") .userNameAttributeName(IdTokenClaimNames.SUB) .build(); }
二、将Keycloak令牌存入Login对象,适配现有认证流程
这种方案更贴合现有架构,无需大幅改动原有逻辑,步骤如下:
- 自定义OidcUserService同步用户与令牌
Keycloak认证成功后,提取用户唯一标识(如邮箱),查询Login表:存在则更新令牌,不存在则创建新记录,将Keycloak的ID Token存入Login对象的新增字段(如keycloakToken)。
@Service public class CustomOidcUserService extends OidcUserService { private final LoginRepository loginRepository; public CustomOidcUserService(LoginRepository loginRepository) { this.loginRepository = loginRepository; } @Override public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { OidcUser oidcUser = super.loadUser(userRequest); String email = oidcUser.getEmail(); Login login = loginRepository.findByEmail(email) .orElseGet(() -> { Login newLogin = new Login(); newLogin.setEmail(email); newLogin.setUsername(oidcUser.getFullName()); return newLogin; }); login.setKeycloakToken(oidcUser.getIdToken().getTokenValue()); loginRepository.save(login); return new CustomOidcUser(oidcUser, login); } }
适配原有认证逻辑
修改LoginProvider,新增令牌校验分支:若请求携带Keycloak令牌,从Login表查询对应用户,调用Keycloak的令牌校验端点验证有效性,通过后生成Authentication对象。自定义登出逻辑
实现LogoutSuccessHandler,登出时:清理Login表中的Keycloak令牌,重定向至Keycloak登出端点销毁外部会话,最后销毁本地应用会话。
@Component public class CustomLogoutSuccessHandler implements LogoutSuccessHandler { private final LoginRepository loginRepository; private final String keycloakLogoutUri = "https://你的Keycloak域名/auth/realms/你的领域/protocol/openid-connect/logout"; public CustomLogoutSuccessHandler(LoginRepository loginRepository) { this.loginRepository = loginRepository; } @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { if (authentication.getPrincipal() instanceof UserDetails userDetails) { Login login = loginRepository.findByUsername(userDetails.getUsername()).orElse(null); if (login != null && login.getKeycloakToken() != null) { login.setKeycloakToken(null); loginRepository.save(login); String redirectUri = UriComponentsBuilder.fromHttpUrl(keycloakLogoutUri) .queryParam("redirect_uri", request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + "/") .build().toUriString(); response.sendRedirect(redirectUri); return; } } response.sendRedirect("/login"); } } // 在Security配置中绑定登出处理器 http.logout(logout -> logout .logoutSuccessHandler(customLogoutSuccessHandler()) .invalidateHttpSession(true) .deleteCookies("JSESSIONID"));
注意事项
- 多流程方案需控制会话隔离或共享,避免权限冲突;
- 存储Keycloak令牌时建议加密,防止明文泄露;
- 定时清理Login表中过期的令牌;
- Keycloak对接Azure AD需在Keycloak后台添加Azure AD为身份提供者(选择OpenID Connect类型,填入Azure AD的端点信息即可)。
内容的提问来源于stack exchange,提问作者iddys
相关产品推荐
相关产品推荐

