You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Boot集成Keycloak:OAuth2自定义登录接口实现咨询

自定义登录接口的OAuth2替代方案与Spring Security实现

为什么不推荐资源所有者密码凭证流

OAuth 2.1已废弃该授权流,核心原因包括:

  • 客户端需直接处理并存储用户密码,大幅提升凭证泄露风险
  • 无法兼容多因素认证(MFA)、验证码等现代安全机制
  • 违背OAuth2“客户端不接触用户敏感凭证”的设计初衷

合规替代方案

1. 自定义Keycloak登录页面(最推荐)

无需修改现有授权码流架构,完全符合OAuth2规范:

  • 利用Keycloak的主题定制功能,开发专属登录页面替换默认页
  • 用户仍通过授权码流重定向到Keycloak,但展示的是你的自定义登录界面,验证通过后正常返回授权码至客户端(资源服务器)
  • 天然支持MFA、风险验证等安全特性,适配企业级安全需求

2. 授权码流(带PKCE)+前端自定义登录页

适合前后端分离场景,将登录交互逻辑交给前端:

  • 前端开发自定义登录页收集用户凭证
  • 前端采用授权码流(带PKCE)与Keycloak交互:
    1. 生成PKCE挑战码
    2. 携带client_id、redirect_uri、code_challenge等参数重定向至Keycloak授权端点
    3. 用户在自定义登录页输入凭证,Keycloak验证后返回授权码
    4. 前端用授权码+code_verifier请求Token
    5. 前端存储Token(如localStorage),后续请求资源时携带
  • 后端仅需作为资源服务器验证Token有效性,无需处理登录逻辑

3. 后端代理授权码流请求(适合必须后端处理登录的场景)

若前端无法处理OAuth2流程,后端可提供登录接口并代理Keycloak请求:

  • 后端暴露/login接口接收用户名/密码
  • 后端作为OAuth客户端,模拟用户授权流程向Keycloak请求授权码与Token
  • 后端存储Token(会话或Cookie),后续自动为用户请求附加Token

Spring Security实现自定义登录接口(合规适配)

以下是方案3的具体实现步骤,若需完全规避密码流,建议优先选择方案1或2:

1. 依赖配置

在pom.xml中引入Spring Security OAuth2相关依赖:

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

2. Keycloak客户端配置

在application.yml中配置OAuth2客户端与资源服务器:

spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: your-client-id
            client-secret: your-client-secret
            scope: openid, profile, email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/keycloak"
        provider:
          keycloak:
            issuer-uri: https://your-keycloak-server/auth/realms/your-realm
            authorization-uri: ${spring.security.oauth2.client.provider.keycloak.issuer-uri}/protocol/openid-connect/auth
            token-uri: ${spring.security.oauth2.client.provider.keycloak.issuer-uri}/protocol/openid-connect/token
            user-info-uri: ${spring.security.oauth2.client.provider.keycloak.issuer-uri}/protocol/openid-connect/userinfo
            jwk-set-uri: ${spring.security.oauth2.client.provider.keycloak.issuer-uri}/protocol/openid-connect/certs
      resource-server:
        jwt:
          issuer-uri: ${spring.security.oauth2.client.provider.keycloak.issuer-uri}

3. 自定义登录接口实现

创建AuthController提供登录接口,手动与Keycloak交互获取Token:

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final OAuth2AuthorizedClientService authorizedClientService;
    private final ClientRegistrationRepository clientRegistrationRepository;

    public AuthController(OAuth2AuthorizedClientService authorizedClientService,
                          ClientRegistrationRepository clientRegistrationRepository) {
        this.authorizedClientService = authorizedClientService;
        this.clientRegistrationRepository = clientRegistrationRepository;
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request, 
                                   HttpServletRequest servletRequest, 
                                   HttpServletResponse servletResponse) {
        // 获取Keycloak客户端配置
        ClientRegistration keycloakRegistration = clientRegistrationRepository.findByRegistrationId("keycloak");
        if (keycloakRegistration == null) {
            return ResponseEntity.badRequest().body("Keycloak client not configured");
        }

        // 构建Token请求参数
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "password");
        params.add("client_id", keycloakRegistration.getClientId());
        params.add("client_secret", keycloakRegistration.getClientSecret());
        params.add("username", request.getUsername());
        params.add("password", request.getPassword());
        params.add("scope", String.join(" ", keycloakRegistration.getScopes()));

        // 发送请求到Keycloak Token端点
        RestTemplate restTemplate = new RestTemplate();
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(params, headers);

        try {
            ResponseEntity<OAuth2AccessTokenResponse> response = restTemplate.exchange(
                    keycloakRegistration.getProviderDetails().getTokenUri(),
                    HttpMethod.POST,
                    requestEntity,
                    new ParameterizedTypeReference<>() {}
            );

            if (response.getStatusCode().is2xxSuccessful()) {
                OAuth2AccessTokenResponse tokenResponse = response.getBody();
                if (tokenResponse == null) {
                    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Failed to retrieve token");
                }

                // 创建OAuth2认证对象
                Map<String, Object> userAttributes = new HashMap<>();
                userAttributes.put("sub", tokenResponse.getAdditionalParameters().get("sub"));
                userAttributes.put("email", request.getUsername());
                
                OAuth2User oauth2User = new DefaultOAuth2User(
                        Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")),
                        userAttributes,
                        "sub"
                );

                OAuth2AuthenticationToken authenticationToken = new OAuth2AuthenticationToken(
                        oauth2User,
                        Collections.emptyList(),
                        "keycloak"
                );

                // 通知Spring Security完成登录
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);

                // 将ID Token存入HttpOnly Cookie
                Cookie idTokenCookie = new Cookie("id_token", tokenResponse.getIdToken().getTokenValue());
                idTokenCookie.setHttpOnly(true);
                idTokenCookie.setSecure(true); // 生产环境必须启用HTTPS后开启
                idTokenCookie.setPath("/");
                servletResponse.addCookie(idTokenCookie);

                // 返回Access Token信息
                return ResponseEntity.ok(Map.of(
                        "access_token", tokenResponse.getAccessToken().getTokenValue(),
                        "expires_in", tokenResponse.getAccessToken().getExpiresAt().getEpochSecond() - System.currentTimeMillis() / 1000
                ));
            } else {
                return ResponseEntity.status(response.getStatusCode()).body("Invalid username or password");
            }
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Login failed: " + e.getMessage());
        }
    }

    // 登录请求DTO
    public static class LoginRequest {
        private String username;
        private String password;

        public String getUsername() { return username; }
        public void setUsername(String username) { this.username = username; }
        public String getPassword() { return password; }
        public void setPassword(String password) { this.password = password; }
    }
}

4. Spring Security过滤器配置

配置SecurityFilterChain,允许登录接口访问并处理Token验证:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable()) // 前后端分离场景可禁用,根据实际需求调整
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/login").permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2Client(oauth2 -> oauth2
                        .authorizedClientService(authorizedClientService())
                )
                .oauth2ResourceServer(oauth2 -> oauth2
                        .jwt(jwt -> jwt.decoder(jwtDecoder()))
                );
        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withIssuerLocation("https://your-keycloak-server/auth/realms/your-realm").build();
    }

    @Bean
    public OAuth2AuthorizedClientService authorizedClientService() {
        return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository());
    }

    @Bean
    public ClientRegistrationRepository clientRegistrationRepository() {
        return new InMemoryClientRegistrationRepository(keycloakClientRegistration());
    }

    private ClientRegistration keycloakClientRegistration() {
        return ClientRegistration.withRegistrationId("keycloak")
                .clientId("your-client-id")
                .clientSecret("your-client-secret")
                .scope("openid", "profile", "email")
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .redirectUri("{baseUrl}/login/oauth2/code/keycloak")
                .authorizationUri("https://your-keycloak-server/auth/realms/your-realm/protocol/openid-connect/auth")
                .tokenUri("https://your-keycloak-server/auth/realms/your-realm/protocol/openid-connect/token")
                .userInfoUri("https://your-keycloak-server/auth/realms/your-realm/protocol/openid-connect/userinfo")
                .jwkSetUri("https://your-keycloak-server/auth/realms/your-realm/protocol/openid-connect/certs")
                .clientName("Keycloak")
                .build();
    }
}

生产环境注意事项

  • 务必启用HTTPS,开启Cookie的Secure属性,防止Token明文传输
  • 避免将Access Token返回给前端,建议存储在后端会话或分布式缓存(如Redis)中
  • 定期轮换Client Secret,避免泄露
  • 若需支持集群部署,需替换InMemoryOAuth2AuthorizedClientService为分布式实现

内容的提问来源于stack exchange,提问作者Hendrik Hübner

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.10 13:32:03