CustomPasswordAuthenticationProvider.java
package com.albertsilva.dev.asjcatalog.security.oauth2.grant.password;
import java.security.Principal;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
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.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClaimAccessor;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.OAuth2Token;
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
import org.springframework.util.Assert;
import com.albertsilva.dev.asjcatalog.domain.user.User;
import com.albertsilva.dev.asjcatalog.security.userdetails.AuthenticatedUser;
/**
* Autentica o usuário do grant {@code password} e emite o par de tokens
* (access token JWT e refresh token). É o segundo passo do login
* ({@code POST /oauth2/token}), depois do
* {@link CustomPasswordAuthenticationConverter}, e está registrado em
* {@code AuthorizationServerConfig}. <b>Não</b> trata o grant
* {@code refresh_token}, que é resolvido pelo provider padrão do framework.
*
* <p>
* <b>Sequência de {@link #authenticate(Authentication)}:</b>
* </p>
* <ol>
* <li>exige que o principal do pedido seja um {@code OAuth2ClientAuthenticationToken}
* autenticado (senão {@code invalid_client});</li>
* <li>carrega o usuário com {@code UserDetailsService.loadUserByUsername}
* (na aplicação, {@code UserService}: consulta nativa que devolve um
* {@code User} <b>parcial</b>: id, e-mail, hash da senha, {@code active} e
* roles);</li>
* <li>confere a senha com {@code PasswordEncoder.matches} e, depois, o status
* ({@code isEnabled} etc.); o <b>username digitado</b> não é comparado a mais
* nada;</li>
* <li>calcula os escopos autorizados: nomes das authorities do usuário que
* também sejam escopos do cliente ({@code read}, {@code write}) — os escopos
* pedidos na requisição são ignorados;</li>
* <li>cria {@link AuthenticatedUser}(id, username digitado, authorities) e o
* coloca nos {@code details} do token do cliente, além de substituir o
* {@code SecurityContext} da thread por um contexto com esse token (é dali que
* o customizador de JWT lê o usuário);</li>
* <li>gera o access token (JWT) e o refresh token com o {@code OAuth2TokenGenerator};
* falha na geração ⇒ {@code server_error};</li>
* <li>monta e salva em {@code OAuth2AuthorizationService} (memória) uma
* autorização cujo <b>{@code principalName} é o {@code clientId}</b> (não o
* usuário), com o token do cliente como atributo principal;</li>
* <li>devolve {@code OAuth2AccessTokenAuthenticationToken}.</li>
* </ol>
*
* <p>
* <b>Erros ({@link OAuth2AuthenticationException}):</b> usuário inexistente
* ({@code UsernameNotFoundException}) ou senha incorreta ⇒
* {@code invalid_grant} "Invalid credentials" (mesma mensagem); conta
* desativada ⇒ {@code invalid_grant} "Your account has not been activated yet.
* Please check your email." (só depois de a senha conferir); cliente inválido ⇒
* {@code invalid_client}.
* </p>
*/
public class CustomPasswordAuthenticationProvider implements AuthenticationProvider {
/** Referência (RFC 6749, seção 5.2) incluída nos erros OAuth2 emitidos por este provider. */
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2";
private final OAuth2AuthorizationService authorizationService;
private final UserDetailsService userDetailsService;
private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
private final PasswordEncoder passwordEncoder;
/**
* Cria o provider. Todos os argumentos são obrigatórios: {@code Assert.notNull}
* lança {@link IllegalArgumentException} se algum for {@code null}.
*
* @param authorizationService serviço onde a autorização emitida é salva
* @param tokenGenerator gerador do JWT e do refresh token
* @param userDetailsService carrega o usuário pelo e-mail
* @param passwordEncoder confere a senha informada com o hash
*/
public CustomPasswordAuthenticationProvider(OAuth2AuthorizationService authorizationService,
OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator, UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
Assert.notNull(authorizationService, "authorizationService cannot be null");
Assert.notNull(tokenGenerator, "TokenGenerator cannot be null");
Assert.notNull(userDetailsService, "UserDetailsService cannot be null");
Assert.notNull(passwordEncoder, "PasswordEncoder cannot be null");
this.authorizationService = authorizationService;
this.tokenGenerator = tokenGenerator;
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
}
/**
* Executa a autenticação e a emissão de tokens conforme a documentação da
* classe.
*
* <p>
* Efeitos colaterais: altera os {@code details} do token do cliente presente
* no {@code SecurityContext}, substitui o contexto da thread e grava a
* autorização (com o refresh token) no {@code OAuth2AuthorizationService}.
* Este método <b>sempre</b> emite um refresh token.
* </p>
*
* @param authentication deve ser um {@link CustomPasswordAuthenticationToken}
* @return token de acesso autenticado, com access e refresh token
* @throws AuthenticationException ({@link OAuth2AuthenticationException}) com
* {@code invalid_client}, {@code invalid_grant}
* ou {@code server_error}
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
CustomPasswordAuthenticationToken customPasswordAuthenticationToken = (CustomPasswordAuthenticationToken) authentication;
OAuth2ClientAuthenticationToken clientPrincipal = getAuthenticatedClientElseThrowInvalidClient(
customPasswordAuthenticationToken);
RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
String username = customPasswordAuthenticationToken.getUsername();
String password = customPasswordAuthenticationToken.getPassword();
UserDetails userDetails;
try {
userDetails = userDetailsService.loadUserByUsername(username);
validateCredentials(userDetails, password);
validateUserStatus(userDetails);
} catch (UsernameNotFoundException e) {
throw new OAuth2AuthenticationException(
new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT, "Invalid credentials", ERROR_URI));
}
Set<String> authorizedScopes = userDetails.getAuthorities().stream().map(scope -> scope.getAuthority())
.filter(scope -> registeredClient.getScopes().contains(scope)).collect(Collectors.toSet());
Long userId = ((User) userDetails).getId(); // Necessário para acessar o ID da entidade
// -----------Create a new Security Context Holder Context----------
OAuth2ClientAuthenticationToken oAuth2ClientAuthenticationToken = (OAuth2ClientAuthenticationToken) SecurityContextHolder
.getContext().getAuthentication();
AuthenticatedUser customPasswordUser = new AuthenticatedUser(userId, username, userDetails.getAuthorities());
oAuth2ClientAuthenticationToken.setDetails(customPasswordUser);
var newcontext = SecurityContextHolder.createEmptyContext();
newcontext.setAuthentication(oAuth2ClientAuthenticationToken);
SecurityContextHolder.setContext(newcontext);
// -----------TOKEN BUILDERS----------
DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
.registeredClient(registeredClient)
.principal(clientPrincipal)
.authorizationServerContext(AuthorizationServerContextHolder.getContext())
.authorizedScopes(authorizedScopes)
.authorizationGrantType(new AuthorizationGrantType("password"))
.authorizationGrant(customPasswordAuthenticationToken);
OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient)
.attribute(Principal.class.getName(), clientPrincipal)
.principalName(clientPrincipal.getName())
.authorizationGrantType(new AuthorizationGrantType("password"))
.authorizedScopes(authorizedScopes);
// -----------ACCESS TOKEN----------
OAuth2TokenContext accessTokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.ACCESS_TOKEN).build();
OAuth2Token generatedAccessToken = this.tokenGenerator.generate(accessTokenContext);
if (generatedAccessToken == null) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
"The token generator failed to generate the access token.", ERROR_URI);
throw new OAuth2AuthenticationException(error);
}
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
generatedAccessToken.getTokenValue(),
generatedAccessToken.getIssuedAt(),
generatedAccessToken.getExpiresAt(),
accessTokenContext.getAuthorizedScopes());
if (generatedAccessToken instanceof ClaimAccessor) {
authorizationBuilder.token(accessToken, metadata -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME,
((ClaimAccessor) generatedAccessToken).getClaims()));
} else {
authorizationBuilder.accessToken(accessToken);
}
// -----------REFRESH TOKEN----------
OAuth2TokenContext refreshTokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.REFRESH_TOKEN).build();
OAuth2Token generatedRefreshToken = this.tokenGenerator.generate(refreshTokenContext);
if (generatedRefreshToken == null) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
"The token generator failed to generate the refresh token.", ERROR_URI);
throw new OAuth2AuthenticationException(error);
}
OAuth2RefreshToken refreshToken = (OAuth2RefreshToken) generatedRefreshToken;
authorizationBuilder.refreshToken(refreshToken);
// -----------SAVE AUTHORIZATION----------
OAuth2Authorization authorization = authorizationBuilder.build();
this.authorizationService.save(authorization);
// -----------RETURN TOKENS----------
return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken, refreshToken);
}
/**
* @param authentication tipo de autenticação a avaliar
* @return {@code true} apenas para {@link CustomPasswordAuthenticationToken}
*/
@Override
public boolean supports(Class<?> authentication) {
return CustomPasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
/**
* Confere a senha informada com o hash do usuário por
* {@code PasswordEncoder.matches}. <b>Só a senha é verificada</b>: o username
* não é comparado ao do {@code UserDetails}.
*
* @param user usuário carregado (só o hash da senha é usado)
* @param password senha em texto informada
* @throws OAuth2AuthenticationException ({@code invalid_grant}, "Invalid
* credentials") se a senha não conferir
*/
private void validateCredentials(UserDetails user, String password) {
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new OAuth2AuthenticationException(
new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT, "Invalid credentials", ERROR_URI));
}
}
/**
* Extrai o cliente autenticado do principal da solicitação.
*
* @param authentication solicitação (o principal deve ser um
* {@link OAuth2ClientAuthenticationToken} autenticado)
* @return token do cliente autenticado
* @throws OAuth2AuthenticationException ({@code invalid_client}) se o principal
* não for um token de cliente
* autenticado
*/
private static OAuth2ClientAuthenticationToken getAuthenticatedClientElseThrowInvalidClient(
Authentication authentication) {
OAuth2ClientAuthenticationToken clientPrincipal = null;
if (OAuth2ClientAuthenticationToken.class.isAssignableFrom(authentication.getPrincipal().getClass())) {
clientPrincipal = (OAuth2ClientAuthenticationToken) authentication.getPrincipal();
}
if (clientPrincipal != null && clientPrincipal.isAuthenticated()) {
return clientPrincipal;
}
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT);
}
/**
* Verifica o status da conta, lançando {@code invalid_grant} para o primeiro
* critério que falhar, nesta ordem: {@code isEnabled()} ("Your account has not
* been activated yet. Please check your email."), {@code isAccountNonLocked()},
* {@code isAccountNonExpired()} e {@code isCredentialsNonExpired()}. Com a
* entidade {@code User} atual, só o primeiro pode falhar (os demais retornam
* sempre {@code true}); {@code isEnabled()} reflete o indicador {@code active}
* lido pela consulta de login.
*
* @param user usuário carregado
* @throws OAuth2AuthenticationException ({@code invalid_grant}) se a conta não
* puder autenticar
*/
private void validateUserStatus(UserDetails user) {
if (!user.isEnabled()) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT,
"Your account has not been activated yet. Please check your email.", ERROR_URI);
throw new OAuth2AuthenticationException(error);
}
if (!user.isAccountNonLocked()) {
throw new OAuth2AuthenticationException(
new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT, "Account is locked", ERROR_URI));
}
if (!user.isAccountNonExpired()) {
throw new OAuth2AuthenticationException(
new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT, "Account expired", ERROR_URI));
}
if (!user.isCredentialsNonExpired()) {
throw new OAuth2AuthenticationException(
new OAuth2Error(OAuth2ErrorCodes.INVALID_GRANT, "Credentials expired", ERROR_URI));
}
}
}