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:
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);
}
}
}
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).
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)