package org.gcube.keycloak.broker.oidc.mappers; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import org.jboss.logging.Logger; import org.keycloak.broker.oidc.KeycloakOIDCIdentityProviderFactory; import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; import org.keycloak.broker.oidc.mappers.AbstractClaimMapper; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.IdentityProviderSyncMode; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigurationBuilder; import org.keycloak.social.bitbucket.BitbucketIdentityProviderFactory; import org.keycloak.social.facebook.FacebookIdentityProviderFactory; import org.keycloak.social.github.GitHubIdentityProviderFactory; import org.keycloak.social.gitlab.GitLabIdentityProviderFactory; import org.keycloak.social.google.GoogleIdentityProviderFactory; import org.keycloak.social.instagram.InstagramIdentityProviderFactory; import org.keycloak.social.linkedin.LinkedInIdentityProviderFactory; import org.keycloak.social.microsoft.MicrosoftIdentityProviderFactory; import org.keycloak.social.openshift.OpenshiftV3IdentityProviderFactory; import org.keycloak.social.openshift.OpenshiftV4IdentityProviderFactory; import org.keycloak.social.paypal.PayPalIdentityProviderFactory; import org.keycloak.social.stackoverflow.StackoverflowIdentityProviderFactory; import org.keycloak.social.twitter.TwitterIdentityProviderFactory; /** * @author Mauro Mugnaini */ public class UsernameFromMailMapper extends AbstractClaimMapper { private static final Logger logger = Logger.getLogger(UsernameFromMailMapper.class); private static final Character PERIOD = '.'; private static final Character DASH = '-'; private static final String CYRUS = "cyrus"; private static final String POSTFIX = "postfix"; private static final String COMMA = ","; public static final String[] COMPATIBLE_PROVIDERS = { OIDCIdentityProviderFactory.PROVIDER_ID, BitbucketIdentityProviderFactory.PROVIDER_ID, FacebookIdentityProviderFactory.PROVIDER_ID, KeycloakOIDCIdentityProviderFactory.PROVIDER_ID, GitHubIdentityProviderFactory.PROVIDER_ID, GitLabIdentityProviderFactory.PROVIDER_ID, GoogleIdentityProviderFactory.PROVIDER_ID, InstagramIdentityProviderFactory.PROVIDER_ID, LinkedInIdentityProviderFactory.PROVIDER_ID, MicrosoftIdentityProviderFactory.PROVIDER_ID, OpenshiftV3IdentityProviderFactory.PROVIDER_ID, OpenshiftV4IdentityProviderFactory.PROVIDER_ID, PayPalIdentityProviderFactory.PROVIDER_ID, StackoverflowIdentityProviderFactory.PROVIDER_ID, TwitterIdentityProviderFactory.PROVIDER_ID }; private static final List configProperties; private static final Set IDENTITY_PROVIDER_SYNC_MODES = new HashSet<>( Arrays.asList(IdentityProviderSyncMode.values())); public static final String RESERVED_USERNAMES = "reserved-usernames"; public static final String AUTO_RESOLVE_CONFLICT = "auto-resolve"; static { configProperties = ProviderConfigurationBuilder.create().property().name(RESERVED_USERNAMES) .label("Reserved Usernames") .helpText("List of reserved usernames (comma separated) that cannot be accepted. " + "If found a progressive suffix number will we added.") .type(ProviderConfigProperty.STRING_TYPE).defaultValue(CYRUS + COMMA + POSTFIX) .add().property().name(AUTO_RESOLVE_CONFLICT).label("Auto resolve conflicts") .helpText("Automatically add a numeric suffix to avoid already existing username conflict.") .type(ProviderConfigProperty.BOOLEAN_TYPE).add().build(); } public static final String PROVIDER_ID = "username-from-idp-email-mapper"; @Override public boolean supportsSyncMode(IdentityProviderSyncMode syncMode) { return IDENTITY_PROVIDER_SYNC_MODES.contains(syncMode); } @Override public List getConfigProperties() { return configProperties; } @Override public String getId() { return PROVIDER_ID; } @Override public String[] getCompatibleProviders() { return COMPATIBLE_PROVIDERS; } @Override public String getDisplayCategory() { return "Preprocessor"; } @Override public String getDisplayType() { return "Username from IdP email importer"; } @Override public void updateBrokeredUserLegacy(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { } @Override public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { // preprocessFederatedIdentity gets called anyways, so we only need to set the username if necessary. // However, we don't want to set the username when the email is used as username if (!realm.isRegistrationEmailAsUsername()) { user.setUsername(context.getModelUsername()); } } @Override public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { Set reservedUsernames = getConfigValuesOrEmptySetIfNullOrEmptyString( mapperModel.getConfig().get(RESERVED_USERNAMES)); logger.debugf("Reserved usernames are: %s", reservedUsernames); boolean autoResolveConflicts = Boolean.valueOf(mapperModel.getConfig().get(AUTO_RESOLVE_CONFLICT)); logger.debugf("Auto resolve conflict setting is: %b", autoResolveConflicts); String email = context.getEmail(); logger.debugf("Email address is: %s", email); String username = email.substring(0, email.indexOf('@')).toLowerCase(); logger.debugf("Extracted raw username is: %s", username); for (Character c : username.chars().mapToObj(e -> (char) e).collect(Collectors.toSet())) { if (!isChar(c) && !isDigit(c) && (c != DASH) && (c != PERIOD)) { logger.infof("Replacing unneded char (%c) with %c", c, PERIOD); username = username.replace(c, PERIOD); } } boolean usernameAlreadyExists = usernameAlreadyExists(session, realm, username); if ((usernameAlreadyExists && autoResolveConflicts) || reservedUsernames.contains(username)) { if (usernameAlreadyExists) { logger.infof("Username already exists: %s", username); } else { logger.info("Username is one of the reserved usernames"); } for (int i = 1;; i++) { String tempUsername = username + PERIOD + i; logger.tracef("Trying with username: %s", tempUsername); if (usernameAlreadyExists(session, realm, tempUsername)) { logger.tracef("Username already exists: %s", tempUsername); } else { logger.tracef("Username is OK: %s", tempUsername); username = tempUsername; break; } } } logger.infof("Final computed username is: %s", username); context.setModelUsername(username); } private static boolean isChar(char c) { return (((c >= 'a') && (c <= 'z')) || ((c >= 'A') && (c <= 'Z'))); } private static boolean isDigit(char c) { return ((c >= '0') && (c <= '9')); } private boolean usernameAlreadyExists(KeycloakSession session, RealmModel realm, String username) { return KeycloakModelUtils.findUserByNameOrEmail(session, realm, username) != null; } @Override public String getHelpText() { return "Extract the IdP username from the e-mail address (before the '@' char)."; } protected Set getConfigValuesOrEmptySetIfNullOrEmptyString(String str) { if (str == null || "".equals(str)) { return Collections.emptySet(); } else { String[] objClasses = str.split(COMMA); Set trimmed = new HashSet<>(); for (String objectClass : objClasses) { objectClass = objectClass.trim(); if (objectClass.length() > 0) { trimmed.add(objectClass); } } return trimmed; } } }