CustomPasswordAuthenticationConverter.java

package com.albertsilva.dev.asjcatalog.security.oauth2.grant.password;

import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.springframework.lang.Nullable;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;

import jakarta.servlet.http.HttpServletRequest;

/**
 * Converte a requisição do <em>token endpoint</em> com {@code grant_type=password}
 * em um {@link CustomPasswordAuthenticationToken}. É o primeiro passo do login
 * ({@code POST /oauth2/token}), registrado em
 * {@code AuthorizationServerConfig}.
 *
 * <p>
 * <b>Comportamento de {@link #convert(HttpServletRequest)}:</b>
 * </p>
 * <ol>
 * <li>{@code grant_type} diferente de {@code password} (inclusive
 * {@code refresh_token}) ⇒ devolve {@code null}, deixando a requisição para os
 * outros conversores do framework;</li>
 * <li>{@code scope}: opcional; se informado, deve aparecer <b>uma única vez</b>;</li>
 * <li>{@code username} e {@code password}: obrigatórios, não em branco
 * ({@code StringUtils.hasText}) e cada um <b>uma única vez</b>;</li>
 * <li>violação de qualquer regra acima ⇒
 * {@link OAuth2AuthenticationException} com {@code invalid_request};</li>
 * <li>os escopos solicitados viram um {@code Set} (separados por espaço);
 * todos os <b>outros</b> parâmetros (por exemplo, {@code client_id}) vão para
 * {@code additionalParameters}, com o primeiro valor;</li>
 * <li>o cliente já autenticado é lido de {@code SecurityContextHolder} e passado
 * como principal do token.</li>
 * </ol>
 *
 * <p>
 * Não autentica o usuário nem consulta o banco; apenas extrai e valida a forma
 * dos parâmetros. Os valores {@code username} e {@code password} não recebem
 * {@code trim}. Os escopos solicitados são guardados no token, mas o provider
 * atual não os utiliza.
 * </p>
 */
public class CustomPasswordAuthenticationConverter implements AuthenticationConverter {

	/**
	 * Converte os parâmetros da requisição em {@link CustomPasswordAuthenticationToken},
	 * conforme descrito na documentação da classe.
	 *
	 * @param request requisição HTTP do token endpoint
	 * @return token de autenticação do grant {@code password}, ou {@code null} se o
	 *         {@code grant_type} não for {@code password}
	 * @throws OAuth2AuthenticationException ({@code invalid_request}) se
	 *                                       {@code username} ou {@code password}
	 *                                       faltarem, estiverem em branco ou
	 *                                       repetidos, ou se {@code scope} estiver
	 *                                       repetido
	 */
	@Nullable
	@Override
	public Authentication convert(HttpServletRequest request) {

		String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);

		if (!"password".equals(grantType)) {
			return null;
		}

		MultiValueMap<String, String> parameters = getParameters(request);

		// scope (OPTIONAL)
		String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);
		if (StringUtils.hasText(scope) && parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {
			throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
		}

		// username (REQUIRED)
		String username = parameters.getFirst(OAuth2ParameterNames.USERNAME);
		if (!StringUtils.hasText(username) || parameters.get(OAuth2ParameterNames.USERNAME).size() != 1) {
			throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
		}

		// password (REQUIRED)
		String password = parameters.getFirst(OAuth2ParameterNames.PASSWORD);
		if (!StringUtils.hasText(password) || parameters.get(OAuth2ParameterNames.PASSWORD).size() != 1) {
			throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
		}

		Set<String> requestedScopes = null;
		if (StringUtils.hasText(scope)) {
			requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
		}

		Map<String, Object> additionalParameters = new HashMap<>();
		parameters.forEach((key, value) -> {
			if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) && !key.equals(OAuth2ParameterNames.SCOPE)
					&& !key.equals(OAuth2ParameterNames.USERNAME) && !key.equals(OAuth2ParameterNames.PASSWORD)) {
				additionalParameters.put(key, value.get(0));
			}
		});

		Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
		return new CustomPasswordAuthenticationToken(clientPrincipal, requestedScopes, additionalParameters, username,
				password);
	}

	/**
	 * Copia os parâmetros da requisição para um {@link MultiValueMap}, mantendo
	 * todos os valores de cada nome (necessário para detectar parâmetros
	 * repetidos).
	 *
	 * @param request requisição HTTP
	 * @return mapa com todos os parâmetros
	 */
	private static MultiValueMap<String, String> getParameters(HttpServletRequest request) {

		Map<String, String[]> parameterMap = request.getParameterMap();
		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>(parameterMap.size());
		parameterMap.forEach((key, values) -> {
			if (values.length > 0) {
				for (String value : values) {
					parameters.add(key, value);
				}
			}
		});
		return parameters;
	}
}