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

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密码校验。

遇到的问题

  1. 无法切换至ClientAuthenticationMethod.NONE,实际无需客户端认证;
  2. 未随Access Token一同生成Refresh Token;
  3. 使用获取到的Access Token调用接口时返回401;
  4. 不清楚如何提供认证时从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类型;
  • 修改UserDetailsService Bean,直接从当前安全上下文获取已认证的用户信息:
    @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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.27 08:35:55