springspring-securityspring-authorization-server

Spring Security: combining authentication and token assignment via /oauth2/token endpoint


The goal is to have both authentication and token assignment under /oauth2/token endpoint. There is an expectation, that for example when application receives correct credentials:

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

the application assigns a token:

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

When the credentials are incorrect, the application should not assign token and respond with:

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

Additional requirements:

After some analysis, I think, that there are chances, that this may be achievable by either:

  1. Setting my own validator using OAuth2ClientCredentialsAuthenticationProvider#setAuthenticationValidator. The validator could be similar to OAuth2ClientCredentialsAuthenticationValidator, but there would be additional code responsible for authentication at the beginning of validateScope method.

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. Changing filter processes url in UsernamePasswordAuthenticationFilter to /oauth2/token and placing this filter in the chain related to /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;
    }

}

At the moment, I have following configuration:

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

}

The problem is that tokens are assigned without checking the password.

Could you tell me if my proposed solution may do the job, provide a solution to the problem?

EDIT

This questions differs from Spring Authorization Server with AuthorizationGrantType.PASSWORD because using deprecated AuthorizationGrantType.PASSWORD is not an option (what was previously mentioned).


Solution

  • The OAuth 2.0 specification allows any suitable HTTP authentication scheme to authenticate requests to the token endpoint. However, what you're proposing is functionally equivalent to the Resource Owner Password Credentials grant, and has the same disadvantages that caused the Resource Owner Password Credentials grant to be unsupported.

    If you still want to proceed given this caveat, define this custom authentication provider class:

    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;
        var username = (String) clientAuthentication.getAdditionalParameters().get("username");
        var 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);
      }
    }
    

    Create a custom authentication provider:

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

    In the authorization server security filter chain, add the custom authentication provider to the token endpoint:

    http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
        .tokenEndpoint(endpoint -> endpoint
            .authenticationProvider(customClientCredentialsAuthenticationProvider)