Spring Security 6.2与授权服务迁移及配置问题求助
Spring Security 6.x迁移与OAuth2授权服务器配置问题
当前架构
- 用户为移动端应用,采用用户名/密码进行认证/授权;
- 后端通过用户凭据对接LDAP服务器(实际为AD)完成认证/授权;
- Access Token与Refresh Token同时生成并存储至本地数据库;
- 存在基于auth_id/api_keys的简化认证流程,无需密码;
- 所有配置均定义在spring-security.xml中;
- 未使用Spring Boot,非必要情况下不想引入。
期望目标
- 保持原有简洁性:无跳转,单次请求同时获取Access Token和Refresh Token,继续使用LDAP认证;
- 后续新增资源/授权服务器时,可便捷使用spring-security-oauth2-authorization-server的全部功能。
现有假设与配置
- 需采用Client Credentials授权类型;
- 需在令牌端点的认证提供者中添加LDAP密码校验。
遇到的问题
- 无法切换至ClientAuthenticationMethod.NONE,实际无需客户端认证;
- 未随Access Token一同生成Refresh Token;
- 使用获取到的Access Token调用接口时返回401;
- 不清楚如何提供认证时从LDAP获取的用户详情(UserDetailsService),是否需存入oauth2_authorization表?
配置代码
package accessmanager; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.jose.jws.JwsAlgorithm; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.*; import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken; import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; import org.springframework.security.oauth2.server.authorization.token.*; import org.springframework.security.web.SecurityFilterChain; import org.springframework.util.StringUtils; import javax.sql.DataSource; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; import static org.springframework.security.config.Customizer.withDefaults; @Configuration @EnableWebSecurity public class SecurityConfig { private final CheckRolesLdapAuthenticationProvider checkRolesLdapAuthenticationProvider; private final DataSource dataSource; private final UserRepository userRepository; @Autowired public SecurityConfig(CheckRolesLdapAuthenticationProvider checkRolesLdapAuthenticationProvider, DataSource dataSource, UserRepository userRepository) { this.checkRolesLdapAuthenticationProvider = checkRolesLdapAuthenticationProvider; this.dataSource = dataSource; this.userRepository = userRepository; } @Bean public JdbcTemplate jdbcTemplate() { return new JdbcTemplate(dataSource); } @Bean public JdbcOAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); } @Bean public PasswordEncoder noOpPasswordEncoder() { return new PasswordEncoder() { @Override public String encode(CharSequence rawPassword) { return rawPassword.toString(); } @Override public boolean matches(CharSequence rawPassword, String encodedPassword) { return rawPassword.equals(encodedPassword); } }; } @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .tokenEndpoint(tokenEndpoint -> tokenEndpoint .accessTokenRequestConverter( request -> { final Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); return new OAuth2ClientCredentialsAuthenticationToken( clientPrincipal, Collections.singleton("write"), request.getParameterMap() .entrySet() .stream() .collect( Collectors.toMap(Map.Entry::getKey, p -> p.getValue()[0]))); }) ) .clientAuthentication(auth -> auth.authenticationProviders(list -> list.add(checkRolesLdapAuthenticationProvider) )) .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 http // .exceptionHandling((exceptions) -> exceptions // .defaultAuthenticationEntryPointFor( // new BasicAuthenticationEntryPoint(), // new MediaTypeRequestMatcher(MediaType.ALL) // ) // ) .oauth2ResourceServer((resourceServer) -> resourceServer.jwt(Customizer.withDefaults())); return http.build(); } @Bean @Order(2) public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(CsrfConfigurer::disable) .authorizeHttpRequests((authorize) -> authorize .requestMatchers("/oauth2/**", "/api/v*/auth").permitAll() .requestMatchers("/api/v*/**").authenticated() ) .httpBasic(withDefaults()); return http.build(); } @Bean OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) { final JwtEncoder jwtEncoder = new NimbusJwtEncoder(jwkSource); final OAuth2TokenGenerator<Jwt> jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource)); final OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator(); final OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator(); return new DelegatingOAuth2TokenGenerator( jwtGenerator, accessTokenGenerator, refreshTokenGenerator); } @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("mobile") .clientSecret("{noop}secret") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("http://127.0.0.1:8081/login/oauth2/code/mobile") .postLogoutRedirectUri("http://127.0.0.1:8081/") .scope("write") .clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build()) .build(); return new InMemoryRegisteredClientRepository(oidcClient); } //https://docs.spring.io/spring-authorization-server/reference/getting-started.html @Bean public JWKSource<SecurityContext> jwkSource() { KeyPair keyPair = generateRsaKey(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); JWKSet jwkSet = new JWKSet(rsaKey); return new ImmutableJWKSet<>(jwkSet); } private static KeyPair generateRsaKey() { KeyPair keyPair; try { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); keyPair = keyPairGenerator.generateKeyPair(); } catch (Exception ex) { throw new IllegalStateException(ex); } return keyPair; } @Bean public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); } @Bean public UserDetailsService userDetailsService() { // how to route this to details received from LDAP on authorization? return username -> { return new UserDetails() { @Override public Collection<? extends GrantedAuthority> getAuthorities() { return Collections.emptyList(); } @Override public String getPassword() { return null; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }; }; } }
问题解决方案
1. 切换至ClientAuthenticationMethod.NONE
要禁用客户端认证,需修改注册客户端配置并调整令牌端点的请求处理逻辑:
- 在
registeredClientRepository()中,将客户端认证方式改为NONE,同时移除客户端密钥配置:RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("mobile") .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .scope("write") .clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build()) .build(); - 调整令牌端点的请求转换器,不再依赖
SecurityContext中的客户端认证信息,直接从请求参数提取数据:.tokenEndpoint(tokenEndpoint -> tokenEndpoint .accessTokenRequestConverter(request -> { Map<String, String> params = request.getParameterMap() .entrySet() .stream() .collect(Collectors.toMap(Map.Entry::getKey, p -> p.getValue()[0])); return new OAuth2ClientCredentialsAuthenticationToken( params.get("client_id"), Collections.singleton("write"), params); }) )
2. 生成Refresh Token(Client Credentials模式下)
Client Credentials模式默认不生成Refresh Token,如需强制生成,需自定义令牌生成器:
- 修改
tokenGenerator()Bean,为Refresh Token生成器配置有效参数:@Bean OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) { JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource)); OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator(); OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator(); // 配置Refresh Token格式与有效期 refreshTokenGenerator.setRefreshTokenSettings(OAuth2TokenSettings.builder() .tokenFormat(OAuth2TokenFormat.SELF_CONTAINED) .build()); return new DelegatingOAuth2TokenGenerator(jwtGenerator, accessTokenGenerator, refreshTokenGenerator); } - 确保注册客户端已声明
AuthorizationGrantType.REFRESH_TOKEN授权类型。
3. 解决Access Token调用接口401问题
401通常由JWT签名验证失败或权限不匹配导致,按以下步骤排查:
- 确认资源服务器的
JwtDecoder与授权服务器的JWKSource完全一致,确保签名验证通过; - 自定义JWT生成逻辑,将用户权限添加到令牌声明中:
jwtGenerator.setJwtCustomizer(jwtCustomizer -> { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication.getPrincipal() instanceof UserDetails userDetails) { Collection<String> authorities = userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .toList(); jwtCustomizer.claim("authorities", authorities); } }); - 配置资源服务器的JWT权限转换器,解析令牌中的权限声明:
.oauth2ResourceServer(resourceServer -> resourceServer .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) ); private Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); grantedAuthoritiesConverter.setAuthoritiesClaimName("authorities"); grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_"); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); return jwtAuthenticationConverter; }
4. 处理LDAP获取的用户详情
LDAP认证后的用户详情无需存入oauth2_authorization表,只需确保认证成功后Authentication中包含UserDetails实例:
- 确保
CheckRolesLdapAuthenticationProvider认证成功后,返回的Authentication的principal为UserDetails类型; - 修改
UserDetailsServiceBean,直接从当前安全上下文获取已认证的用户信息:@Bean public UserDetailsService userDetailsService() { return username -> { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth != null && auth.getPrincipal() instanceof UserDetails userDetails) { return userDetails; } // 降级逻辑 return new org.springframework.security.core.userdetails.User( username, "", Collections.emptyList()); }; }
内容的提问来源于stack exchange,提问作者nnl
相关产品推荐
相关产品推荐

