wip - added UsernameAttribute enum for useAsUsername

This commit is contained in:
DarioGii 2025-01-31 13:20:31 +00:00
parent bc57977f9e
commit eeb7498fe1
11 changed files with 157 additions and 73 deletions

View File

@ -16,8 +16,8 @@ import stirling.software.SPDF.config.security.LoginAttemptService;
import stirling.software.SPDF.config.security.UserService; import stirling.software.SPDF.config.security.UserService;
import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2.Client;
import stirling.software.SPDF.model.User; import stirling.software.SPDF.model.User;
import stirling.software.SPDF.model.UsernameAttribute;
@Slf4j @Slf4j
public class CustomOAuth2UserService implements OAuth2UserService<OidcUserRequest, OidcUser> { public class CustomOAuth2UserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
@ -41,28 +41,12 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
@Override @Override
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
OAUTH2 oauth2 = applicationProperties.getSecurity().getOauth2();
String usernameAttribute = oauth2.getUseAsUsername();
if (usernameAttribute == null || usernameAttribute.isEmpty()) {
Client client = oauth2.getClient();
if (client != null && client.getKeycloak() != null) {
usernameAttribute = client.getKeycloak().getUseAsUsername();
} else {
usernameAttribute = "email";
}
}
try { try {
OidcUser user = delegate.loadUser(userRequest); OidcUser user = delegate.loadUser(userRequest);
String username = user.getUserInfo().getClaimAsString(usernameAttribute); OAUTH2 oauth2 = applicationProperties.getSecurity().getOauth2();
UsernameAttribute usernameAttribute =
// Check if the username claim is null or empty UsernameAttribute.valueOf(oauth2.getUseAsUsername().toUpperCase());
if (username == null || username.isBlank()) { String username = usernameAttribute.getName();
throw new IllegalArgumentException(
"Claim '" + usernameAttribute + "' cannot be null or empty");
}
Optional<User> internalUser = userService.findByUsernameIgnoreCase(username); Optional<User> internalUser = userService.findByUsernameIgnoreCase(username);
@ -78,10 +62,7 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
// Return a new OidcUser with adjusted attributes // Return a new OidcUser with adjusted attributes
return new DefaultOidcUser( return new DefaultOidcUser(
user.getAuthorities(), user.getAuthorities(), userRequest.getIdToken(), user.getUserInfo(), username);
userRequest.getIdToken(),
user.getUserInfo(),
usernameAttribute);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
log.error("Error loading OIDC user: {}", e.getMessage()); log.error("Error loading OIDC user: {}", e.getMessage());
throw new OAuth2AuthenticationException(new OAuth2Error(e.getMessage()), e); throw new OAuth2AuthenticationException(new OAuth2Error(e.getMessage()), e);

View File

@ -28,6 +28,7 @@ import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2.Client; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2.Client;
import stirling.software.SPDF.model.User; import stirling.software.SPDF.model.User;
import stirling.software.SPDF.model.UsernameAttribute;
import stirling.software.SPDF.model.exception.NoProviderFoundException; import stirling.software.SPDF.model.exception.NoProviderFoundException;
import stirling.software.SPDF.model.provider.GitHubProvider; import stirling.software.SPDF.model.provider.GitHubProvider;
import stirling.software.SPDF.model.provider.GoogleProvider; import stirling.software.SPDF.model.provider.GoogleProvider;
@ -92,7 +93,7 @@ public class OAuth2Configuration {
.clientId(keycloak.getClientId()) .clientId(keycloak.getClientId())
.clientSecret(keycloak.getClientSecret()) .clientSecret(keycloak.getClientSecret())
.scope(keycloak.getScopes()) .scope(keycloak.getScopes())
.userNameAttributeName(keycloak.getUseAsUsername()) .userNameAttributeName(keycloak.getUseAsUsername().name())
.clientName(keycloak.getClientName()) .clientName(keycloak.getClientName())
.build()) .build())
: Optional.empty(); : Optional.empty();
@ -123,7 +124,7 @@ public class OAuth2Configuration {
.authorizationUri(google.getAuthorizationUri()) .authorizationUri(google.getAuthorizationUri())
.tokenUri(google.getTokenUri()) .tokenUri(google.getTokenUri())
.userInfoUri(google.getUserInfoUri()) .userInfoUri(google.getUserInfoUri())
.userNameAttributeName(google.getUseAsUsername()) .userNameAttributeName(google.getUseAsUsername().name())
.clientName(google.getClientName()) .clientName(google.getClientName())
.redirectUri(REDIRECT_URI_PATH + google.getName()) .redirectUri(REDIRECT_URI_PATH + google.getName())
.authorizationGrantType(AUTHORIZATION_CODE) .authorizationGrantType(AUTHORIZATION_CODE)
@ -156,7 +157,7 @@ public class OAuth2Configuration {
.authorizationUri(github.getAuthorizationUri()) .authorizationUri(github.getAuthorizationUri())
.tokenUri(github.getTokenUri()) .tokenUri(github.getTokenUri())
.userInfoUri(github.getUserInfoUri()) .userInfoUri(github.getUserInfoUri())
.userNameAttributeName(github.getUseAsUsername()) .userNameAttributeName(github.getUseAsUsername().name())
.clientName(github.getClientName()) .clientName(github.getClientName())
.redirectUri(REDIRECT_URI_PATH + github.getName()) .redirectUri(REDIRECT_URI_PATH + github.getName())
.authorizationGrantType(AUTHORIZATION_CODE) .authorizationGrantType(AUTHORIZATION_CODE)
@ -171,29 +172,36 @@ public class OAuth2Configuration {
return Optional.empty(); return Optional.empty();
} }
if (isStringEmpty(oauth.getIssuer())
|| isStringEmpty(oauth.getClientId())
|| isStringEmpty(oauth.getClientSecret())
|| isCollectionEmpty(oauth.getScopes())
|| isStringEmpty(oauth.getUseAsUsername())) {
return Optional.empty();
}
String name = oauth.getProvider(); String name = oauth.getProvider();
String firstChar = String.valueOf(name.charAt(0)); String firstChar = String.valueOf(name.charAt(0));
String clientName = name.replaceFirst(firstChar, firstChar.toUpperCase()); String clientName = name.replaceFirst(firstChar, firstChar.toUpperCase());
return Optional.of( Provider oidcProvider =
ClientRegistrations.fromIssuerLocation(oauth.getIssuer()) new Provider(
.registrationId(name) oauth.getIssuer(),
.clientId(oauth.getClientId()) name,
.clientSecret(oauth.getClientSecret()) clientName,
.scope(oauth.getScopes()) oauth.getClientId(),
.userNameAttributeName(oauth.getUseAsUsername()) oauth.getClientSecret(),
.clientName(clientName) oauth.getScopes(),
.redirectUri(REDIRECT_URI_PATH + name) UsernameAttribute.valueOf(oauth.getUseAsUsername().toUpperCase()),
.authorizationGrantType(AUTHORIZATION_CODE) null,
.build()); null,
null);
return !isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider)
? Optional.of(
ClientRegistrations.fromIssuerLocation(oauth.getIssuer())
.registrationId(name)
.clientId(oidcProvider.getClientId())
.clientSecret(oidcProvider.getClientSecret())
.scope(oidcProvider.getScopes())
.userNameAttributeName(oidcProvider.getUseAsUsername().getName())
.clientName(clientName)
.redirectUri(REDIRECT_URI_PATH + name)
.authorizationGrantType(AUTHORIZATION_CODE)
.build())
: Optional.empty();
} }
private boolean isOAuth2Enabled(OAUTH2 oAuth2) { private boolean isOAuth2Enabled(OAUTH2 oAuth2) {
@ -213,8 +221,7 @@ public class OAuth2Configuration {
@Bean @Bean
@ConditionalOnProperty( @ConditionalOnProperty(
value = "security.oauth2.enabled", value = "security.oauth2.enabled",
havingValue = "true", havingValue = "true")
matchIfMissing = false)
GrantedAuthoritiesMapper userAuthoritiesMapper() { GrantedAuthoritiesMapper userAuthoritiesMapper() {
return (authorities) -> { return (authorities) -> {
Set<GrantedAuthority> mappedAuthorities = new HashSet<>(); Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

View File

@ -140,23 +140,19 @@ public class AccountWebController {
case "userAlreadyExistsWeb" -> errorOAuth = "userAlreadyExistsWebMessage"; case "userAlreadyExistsWeb" -> errorOAuth = "userAlreadyExistsWebMessage";
case "oAuth2AuthenticationErrorWeb" -> errorOAuth = "login.oauth2InvalidUserType"; case "oAuth2AuthenticationErrorWeb" -> errorOAuth = "login.oauth2InvalidUserType";
case "invalid_token_response" -> errorOAuth = "login.oauth2InvalidTokenResponse"; case "invalid_token_response" -> errorOAuth = "login.oauth2InvalidTokenResponse";
case "authorization_request_not_found" -> case "authorization_request_not_found" -> errorOAuth = "login.oauth2RequestNotFound";
errorOAuth = "login.oauth2RequestNotFound";
case "access_denied" -> errorOAuth = "login.oauth2AccessDenied"; case "access_denied" -> errorOAuth = "login.oauth2AccessDenied";
case "invalid_user_info_response" -> case "invalid_user_info_response" -> errorOAuth = "login.oauth2InvalidUserInfoResponse";
errorOAuth = "login.oauth2InvalidUserInfoResponse";
case "invalid_request" -> errorOAuth = "login.oauth2invalidRequest"; case "invalid_request" -> errorOAuth = "login.oauth2invalidRequest";
case "invalid_id_token" -> errorOAuth = "login.oauth2InvalidIdToken"; case "invalid_id_token" -> errorOAuth = "login.oauth2InvalidIdToken";
case "oAuth2AdminBlockedUser" -> errorOAuth = "login.oAuth2AdminBlockedUser"; case "oAuth2AdminBlockedUser" -> errorOAuth = "login.oAuth2AdminBlockedUser";
case "userIsDisabled" -> errorOAuth = "login.userIsDisabled"; case "userIsDisabled" -> errorOAuth = "login.userIsDisabled";
case "invalid_destination" -> errorOAuth = "login.invalid_destination"; case "invalid_destination" -> errorOAuth = "login.invalid_destination";
case "relying_party_registration_not_found" -> case "relying_party_registration_not_found" -> errorOAuth = "login.relyingPartyRegistrationNotFound";
errorOAuth = "login.relyingPartyRegistrationNotFound";
// Valid InResponseTo was not available from the validation context, unable to // Valid InResponseTo was not available from the validation context, unable to
// evaluate // evaluate
case "invalid_in_response_to" -> errorOAuth = "login.invalid_in_response_to"; case "invalid_in_response_to" -> errorOAuth = "login.invalid_in_response_to";
case "not_authentication_provider_found" -> case "not_authentication_provider_found" -> errorOAuth = "login.not_authentication_provider_found";
errorOAuth = "login.not_authentication_provider_found";
} }
model.addAttribute("errorOAuth", errorOAuth); model.addAttribute("errorOAuth", errorOAuth);

View File

@ -0,0 +1,21 @@
package stirling.software.SPDF.model;
import lombok.Getter;
@Getter
public enum UsernameAttribute {
NAME("name"),
EMAIL("email"),
GIVEN_NAME("given_name"),
PREFERRED_NAME("preferred_name"),
PREFERRED_USERNAME("preferred_username"),
LOGIN("login"),
FAMILY_NAME("family_name"),
NICKNAME("nickname");
private final String name;
UsernameAttribute(final String name) {
this.name = name;
}
}

View File

@ -0,0 +1,7 @@
package stirling.software.SPDF.model.exception;
public class UnsupportedUsernameAttribute extends RuntimeException {
public UnsupportedUsernameAttribute(String message) {
super(message);
}
}

View File

@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import stirling.software.SPDF.model.UsernameAttribute;
@NoArgsConstructor @NoArgsConstructor
public class GitHubProvider extends Provider { public class GitHubProvider extends Provider {
@ -15,7 +16,10 @@ public class GitHubProvider extends Provider {
private static final String USER_INFO_URI = "https://api.github.com/user"; private static final String USER_INFO_URI = "https://api.github.com/user";
public GitHubProvider( public GitHubProvider(
String clientId, String clientSecret, Collection<String> scopes, String useAsUsername) { String clientId,
String clientSecret,
Collection<String> scopes,
UsernameAttribute useAsUsername) {
super( super(
null, null,
NAME, NAME,
@ -23,7 +27,7 @@ public class GitHubProvider extends Provider {
clientId, clientId,
clientSecret, clientSecret,
scopes, scopes,
useAsUsername != null ? useAsUsername : "login", useAsUsername != null ? useAsUsername : UsernameAttribute.LOGIN,
AUTHORIZATION_URI, AUTHORIZATION_URI,
TOKEN_URI, TOKEN_URI,
USER_INFO_URI); USER_INFO_URI);

View File

@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import stirling.software.SPDF.model.UsernameAttribute;
@NoArgsConstructor @NoArgsConstructor
public class GoogleProvider extends Provider { public class GoogleProvider extends Provider {
@ -16,7 +17,10 @@ public class GoogleProvider extends Provider {
"https://www.googleapis.com/oauth2/v3/userinfo?alt=json"; "https://www.googleapis.com/oauth2/v3/userinfo?alt=json";
public GoogleProvider( public GoogleProvider(
String clientId, String clientSecret, Collection<String> scopes, String useAsUsername) { String clientId,
String clientSecret,
Collection<String> scopes,
UsernameAttribute useAsUsername) {
super( super(
null, null,
NAME, NAME,

View File

@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import stirling.software.SPDF.model.UsernameAttribute;
@NoArgsConstructor @NoArgsConstructor
public class KeycloakProvider extends Provider { public class KeycloakProvider extends Provider {
@ -16,7 +17,7 @@ public class KeycloakProvider extends Provider {
String clientId, String clientId,
String clientSecret, String clientSecret,
Collection<String> scopes, Collection<String> scopes,
String useAsUsername) { UsernameAttribute useAsUsername) {
super( super(
issuer, issuer,
NAME, NAME,

View File

@ -1,6 +1,6 @@
package stirling.software.SPDF.model.provider; package stirling.software.SPDF.model.provider;
import static stirling.software.SPDF.utils.validation.Validator.isStringEmpty; import static stirling.software.SPDF.model.UsernameAttribute.EMAIL;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
@ -9,6 +9,8 @@ import java.util.stream.Collectors;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import stirling.software.SPDF.model.UsernameAttribute;
import stirling.software.SPDF.model.exception.UnsupportedUsernameAttribute;
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@ -20,7 +22,7 @@ public class Provider {
private String clientId; private String clientId;
private String clientSecret; private String clientSecret;
private Collection<String> scopes; private Collection<String> scopes;
private String useAsUsername; private UsernameAttribute useAsUsername;
private String authorizationUri; private String authorizationUri;
private String tokenUri; private String tokenUri;
private String userInfoUri; private String userInfoUri;
@ -32,7 +34,7 @@ public class Provider {
String clientId, String clientId,
String clientSecret, String clientSecret,
Collection<String> scopes, Collection<String> scopes,
String useAsUsername, UsernameAttribute useAsUsername,
String authorizationUri, String authorizationUri,
String tokenUri, String tokenUri,
String userInfoUri) { String userInfoUri) {
@ -42,7 +44,8 @@ public class Provider {
this.clientId = clientId; this.clientId = clientId;
this.clientSecret = clientSecret; this.clientSecret = clientSecret;
this.scopes = scopes == null ? new ArrayList<>() : scopes; this.scopes = scopes == null ? new ArrayList<>() : scopes;
this.useAsUsername = isStringEmpty(useAsUsername) ? "email" : useAsUsername; this.useAsUsername =
useAsUsername != null ? validateUsernameAttribute(useAsUsername) : EMAIL;
this.authorizationUri = authorizationUri; this.authorizationUri = authorizationUri;
this.tokenUri = tokenUri; this.tokenUri = tokenUri;
this.userInfoUri = userInfoUri; this.userInfoUri = userInfoUri;
@ -55,6 +58,69 @@ public class Provider {
} }
} }
private UsernameAttribute validateUsernameAttribute(UsernameAttribute usernameAttribute) {
switch (name) {
case "google" -> {
return validateGoogleUsernameAttribute(usernameAttribute);
}
case "github" -> {
return validateGitHubUsernameAttribute(usernameAttribute);
}
case "keycloak" -> {
return validateKeycloakUsernameAttribute(usernameAttribute);
}
default -> {
return usernameAttribute;
}
}
}
private UsernameAttribute validateKeycloakUsernameAttribute(
UsernameAttribute usernameAttribute) {
switch (usernameAttribute) {
case EMAIL, PREFERRED_NAME -> {
return usernameAttribute;
}
default ->
throw new UnsupportedUsernameAttribute(
"The attribute "
+ usernameAttribute
+ "is not supported for "
+ clientName
+ ".");
}
}
private UsernameAttribute validateGoogleUsernameAttribute(UsernameAttribute usernameAttribute) {
switch (usernameAttribute) {
case EMAIL, NAME, GIVEN_NAME, PREFERRED_NAME -> {
return usernameAttribute;
}
default ->
throw new UnsupportedUsernameAttribute(
"The attribute "
+ usernameAttribute
+ "is not supported for "
+ clientName
+ ".");
}
}
private UsernameAttribute validateGitHubUsernameAttribute(UsernameAttribute usernameAttribute) {
switch (usernameAttribute) {
case EMAIL, NAME, LOGIN -> {
return usernameAttribute;
}
default ->
throw new UnsupportedUsernameAttribute(
"The attribute "
+ usernameAttribute
+ "is not supported for "
+ clientName
+ ".");
}
}
@Override @Override
public String toString() { public String toString() {
return "Provider [name=" return "Provider [name="

View File

@ -23,10 +23,6 @@ public class Validator {
return false; return false;
} }
if (isStringEmpty(provider.getUseAsUsername())) {
return false;
}
return true; return true;
} }

View File

@ -6,6 +6,7 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.SPDF.model.UsernameAttribute;
import stirling.software.SPDF.model.provider.GitHubProvider; import stirling.software.SPDF.model.provider.GitHubProvider;
import stirling.software.SPDF.model.provider.GoogleProvider; import stirling.software.SPDF.model.provider.GoogleProvider;
import stirling.software.SPDF.model.provider.KeycloakProvider; import stirling.software.SPDF.model.provider.KeycloakProvider;
@ -28,7 +29,7 @@ class ValidatorTest {
when(provider.getClientId()).thenReturn("clientId"); when(provider.getClientId()).thenReturn("clientId");
when(provider.getClientSecret()).thenReturn("clientSecret"); when(provider.getClientSecret()).thenReturn("clientSecret");
when(provider.getScopes()).thenReturn(List.of("read:user")); when(provider.getScopes()).thenReturn(List.of("read:user"));
when(provider.getUseAsUsername()).thenReturn("email"); when(provider.getUseAsUsername()).thenReturn(UsernameAttribute.EMAIL);
assertTrue(Validator.validateProvider(provider)); assertTrue(Validator.validateProvider(provider));
} }
@ -41,9 +42,9 @@ class ValidatorTest {
public static Stream<Arguments> providerParams() { public static Stream<Arguments> providerParams() {
Provider generic = null; Provider generic = null;
var google = new GoogleProvider(null, "clientSecret", List.of("scope"), "email"); var google = new GoogleProvider(null, "clientSecret", List.of("scope"), UsernameAttribute.EMAIL);
var github = new GitHubProvider("clientId", "", List.of("scope"), "login"); var github = new GitHubProvider("clientId", "", List.of("scope"), UsernameAttribute.LOGIN);
var keycloak = new KeycloakProvider("issuer", "clientId", "clientSecret", List.of("scope"), "email"); var keycloak = new KeycloakProvider("issuer", "clientId", "clientSecret", List.of("scope"), UsernameAttribute.EMAIL);
keycloak.setUseAsUsername(null); keycloak.setUseAsUsername(null);