Spring Security:通过 /oauth2/token 端点组合身份验证和令牌分配

问题描述 投票:0回答:1

目标是在

/oauth2/token
端点下进行身份验证和令牌分配。例如,当应用程序收到正确的凭据时:

POST http://localhost:9000/oauth2/token
Authorization: Basic bWVzc2FnaW5nLWNsaWVudDpzZWNyZXQ=
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials &
scope=access &
username=billy &
password=billypassword

应用程序分配一个令牌:

{"access_token":"1ZlxLr5-bDPUdK0x21e92GJQD6A","token_type":"bearer","expires_in":43199,"scope":"access"}

当凭据不正确时,应用程序不应分配令牌并响应:

{"error":"invalid_grant","error_description":"Bad credentials"}

额外要求:

  • Spring授权服务器、Spring资源服务器和应用程序应该在同一个域和端口下
  • 库版本:Spring Boot 3.4.x、spring.boot.starter.oauth2.authorization.server 3.4.x、Spring Security 6.4.x
  • OAuth2
  • LDAP
  • 没有弃用的代码

经过一番分析,我认为,这有可能通过以下任一方式实现:

  1. 使用
    OAuth2ClientCredentialsAuthenticationProvider#setAuthenticationValidator
    设置我自己的验证器。验证器可能类似于
    OAuth2ClientCredentialsAuthenticationValidator
    ,但在
    validateScope
    方法的开头会有额外的代码负责身份验证。

org.springframework.security.web.authentication.OAuth2ClientCredentialsAuthenticationValidator

public final class OAuth2ClientCredentialsAuthenticationValidator
        implements Consumer<OAuth2ClientCredentialsAuthenticationContext> {

    private static final Log LOGGER = LogFactory.getLog(OAuth2ClientCredentialsAuthenticationValidator.class);

    /**
     * The default validator for
     * {@link OAuth2ClientCredentialsAuthenticationToken#getScopes()}.
     */
    public static final Consumer<OAuth2ClientCredentialsAuthenticationContext> DEFAULT_SCOPE_VALIDATOR = OAuth2ClientCredentialsAuthenticationValidator::validateScope;

    private final Consumer<OAuth2ClientCredentialsAuthenticationContext> authenticationValidator = DEFAULT_SCOPE_VALIDATOR;

    @Override
    public void accept(OAuth2ClientCredentialsAuthenticationContext authenticationContext) {
        this.authenticationValidator.accept(authenticationContext);
    }

    private static void validateScope(OAuth2ClientCredentialsAuthenticationContext authenticationContext) {
        OAuth2ClientCredentialsAuthenticationToken clientCredentialsAuthentication = authenticationContext
            .getAuthentication();
        RegisteredClient registeredClient = authenticationContext.getRegisteredClient();

        Set<String> requestedScopes = clientCredentialsAuthentication.getScopes();
        Set<String> allowedScopes = registeredClient.getScopes();
        if (!requestedScopes.isEmpty() && !allowedScopes.containsAll(requestedScopes)) {
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug(LogMessage.format(
                        "Invalid request: requested scope is not allowed" + " for registered client '%s'",
                        registeredClient.getId()));
            }
            throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_SCOPE);
        }
    }

}
  1. UsernamePasswordAuthenticationFilter
    中的过滤器处理 url 更改为
    /oauth2/token
    并将此过滤器放入与
    /oauth2/token
    相关的链中。

org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";

    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
            "POST");

    private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;

    private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;

    private boolean postOnly = true;

    public UsernamePasswordAuthenticationFilter() {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
    }

    public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        String username = obtainUsername(request);
        username = (username != null) ? username.trim() : "";
        String password = obtainPassword(request);
        password = (password != null) ? password : "";
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
                password);
        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    /**
     * Enables subclasses to override the composition of the password, such as by
     * including additional values and a separator.
     * <p>
     * This might be used for example if a postcode/zipcode was required in addition to
     * the password. A delimiter such as a pipe (|) should be used to separate the
     * password and extended value(s). The <code>AuthenticationDao</code> will need to
     * generate the expected password in a corresponding manner.
     * </p>
     * @param request so that request attributes can be retrieved
     * @return the password that will be presented in the <code>Authentication</code>
     * request token to the <code>AuthenticationManager</code>
     */
    @Nullable
    protected String obtainPassword(HttpServletRequest request) {
        return request.getParameter(this.passwordParameter);
    }

    /**
     * Enables subclasses to override the composition of the username, such as by
     * including additional values and a separator.
     * @param request so that request attributes can be retrieved
     * @return the username that will be presented in the <code>Authentication</code>
     * request token to the <code>AuthenticationManager</code>
     */
    @Nullable
    protected String obtainUsername(HttpServletRequest request) {
        return request.getParameter(this.usernameParameter);
    }

    /**
     * Provided so that subclasses may configure what is put into the authentication
     * request's details property.
     * @param request that an authentication request is being created for
     * @param authRequest the authentication request object that should have its details
     * set
     */
    protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }

    /**
     * Sets the parameter name which will be used to obtain the username from the login
     * request.
     * @param usernameParameter the parameter name. Defaults to "username".
     */
    public void setUsernameParameter(String usernameParameter) {
        Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
        this.usernameParameter = usernameParameter;
    }

    /**
     * Sets the parameter name which will be used to obtain the password from the login
     * request..
     * @param passwordParameter the parameter name. Defaults to "password".
     */
    public void setPasswordParameter(String passwordParameter) {
        Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
        this.passwordParameter = passwordParameter;
    }

    /**
     * Defines whether only HTTP POST requests will be allowed by this filter. If set to
     * true, and an authentication request is received which is not a POST request, an
     * exception will be raised immediately and authentication will not be attempted. The
     * <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed
     * authentication.
     * <p>
     * Defaults to <tt>true</tt> but may be overridden by subclasses.
     */
    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public final String getUsernameParameter() {
        return this.usernameParameter;
    }

    public final String getPasswordParameter() {
        return this.passwordParameter;
    }

}

目前,我有以下配置:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
            throws Exception {
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
                OAuth2AuthorizationServerConfigurer.authorizationServer();

        http.csrf(AbstractHttpConfigurer::disable)
                .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
                .with(authorizationServerConfigurer, (authorizationServer) ->
                        authorizationServer
                                .oidc(Customizer.withDefaults())
                )
                .exceptionHandling((exceptions) -> exceptions
                        .defaultAuthenticationEntryPointFor(
                                new LoginUrlAuthenticationEntryPoint("/login"),
                                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                        )
                )
                .oauth2ResourceServer((oauth2ResourceServer) ->
                        oauth2ResourceServer
                                .jwt((jwt) ->
                                        jwt
                                                .decoder(jwtDecoder(jwkSource()))
                                )
                )
                .authenticationManager(authenticationManager(http));
        SecurityFilterChain o = http.build();

        return o;
    }



    @Bean
    @Order(3)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
            throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests((authorize) -> authorize
                        .anyRequest().authenticated()
                )
                .formLogin(Customizer.withDefaults());

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder =
                http.getSharedObject(AuthenticationManagerBuilder.class);
         authenticationManagerBuilder.authenticationProvider(activeDirectoryLdapAuthenticationProvider());
        AuthenticationManager o = authenticationManagerBuilder.build();
        return o;
    }

    @Bean
    public AuthenticationProvider activeDirectoryLdapAuthenticationProvider() {
        ActiveDirectoryLdapAuthenticationProvider provider =
                new ActiveDirectoryLdapAuthenticationProvider("", "LDAP://localhost" + ":" + "389", "DC=example,dc=org");

        provider.setConvertSubErrorCodesToExceptions(true);
        provider.setUseAuthenticationRequestCredentials(true);
        provider.setSearchFilter("(uid=billy)");
        return provider;
    }

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails userDetails = User.withDefaultPasswordEncoder()
                .username("user")
                .password("password")
                .roles("USER")
                .build();

        return new InMemoryUserDetailsManager(userDetails);
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        RegisteredClient messagingClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("messaging-client")
                .clientSecret("{noop}secret")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .redirectUri("http://127.0.0.1:9000/login/oauth2/code/messaging-client")
                .postLogoutRedirectUri("http://127.0.0.1:9000/")
                .scope(OidcScopes.OPENID)
                .scope(OidcScopes.PROFILE)
                .scope("message:read")
                .scope("message:write")
                .scope("access")
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
                .build();

        return new InMemoryRegisteredClientRepository(messagingClient);
    }

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

    private ClientRegistration myClientRegistration() {
        return ClientRegistration.withRegistrationId(UUID.randomUUID().toString())
                .clientId("messaging-client")
                .clientSecret("{noop}secret")
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .authorizationUri("http://127.0.0.1:9000/login")
                .tokenUri("http://127.0.0.1:9000/oauth2/token")
                .userInfoUri("https://127.0.0.1:9000/oauth2/v3/userinfo")
                .scope("message:read")
                .scope("access")
                .build();
    }

    @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();
    }

}

问题是在没有检查密码的情况下分配令牌。

您能告诉我我提出的解决方案是否可以完成工作,提供问题的解决方案吗?

编辑

这个问题与 Spring Authorization Server with AuthorizationGrantType.PASSWORD 不同,因为使用已弃用的

AuthorizationGrantType.PASSWORD
不是一个选项(前面提到过)。

spring spring-security spring-authorization-server
1个回答
0
投票

OAuth 2.0 规范允许任何合适的 HTTP 身份验证方案 对令牌端点的请求进行身份验证。 但是,您提议的功能在功能上等同于资源所有者密码凭据授予,并且具有导致资源所有者密码凭据授予不受支持的相同缺点。

如果考虑到此警告您仍想继续,请定义此自定义身份验证提供程序类:

public class CustomClientCredentialsAuthenticationProvider implements AuthenticationProvider {

  private final AuthenticationManager authenticationManager;
  private final OAuth2ClientCredentialsAuthenticationProvider delegate;

  public CustomClientCredentialsAuthenticationProvider(
      AuthenticationManager authenticationManager,
      OAuth2AuthorizationService authorizationService,
      OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator
  ) {
    this.authenticationManager = authenticationManager;
    this.delegate =
        new OAuth2ClientCredentialsAuthenticationProvider(authorizationService, tokenGenerator);
  }

  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    var clientAuthentication = (OAuth2ClientCredentialsAuthenticationToken) authentication;
    String username = (String) clientAuthentication.getAdditionalParameters().get("username");
    String password = (String) clientAuthentication.getAdditionalParameters().get("password");
    var usernamePassword = new UsernamePasswordAuthenticationToken(username, password);
    try {
      authenticationManager.authenticate(usernamePassword);
    } catch (AuthenticationException e) {
      // Throw this exception type to prevent ProviderManager trying subsequent providers.
      throw new InternalAuthenticationServiceException(e.getMessage(), e);
    }

    return delegate.authenticate(clientAuthentication);
  }

  @Override
  public boolean supports(Class<?> authentication) {
    return OAuth2ClientCredentialsAuthenticationToken.class.isAssignableFrom(authentication);
  }
}

定义自定义身份验证提供程序:

@Bean
CustomClientCredentialsAuthenticationProvider customClientCredentialsAuthenticationProvider(
    AuthenticationConfiguration authenticationConfiguration,
    OAuth2AuthorizationService authorizationService,
    OAuth2TokenGenerator<?> tokenGenerator
) throws Exception {
  return new CustomClientCredentialsAuthenticationProvider(
      authenticationConfiguration.getAuthenticationManager(),
      authorizationService,
      tokenGenerator);
}

在授权服务器安全过滤器链中,将身份验证提供程序添加到令牌端点:

http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
    .tokenEndpoint(endpoint -> endpoint
        .authenticationProvider(customClientCredentialsAuthenticationProvider)
© www.soinside.com 2019 - 2024. All rights reserved.