package org.gcube.keycloak.oidc.avatar.importer; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URL; import java.util.Arrays; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; import org.apache.commons.lang3.ArrayUtils; import org.gcube.keycloak.avatar.storage.AvatarStorageProvider; import org.gcube.keycloak.oidc.avatar.importer.libravatar.Libravatar; import org.gcube.keycloak.oidc.avatar.importer.libravatar.LibravatarDefaultImage; import org.gcube.keycloak.oidc.avatar.importer.libravatar.LibravatarException; import org.gcube.keycloak.oidc.avatar.importer.libravatar.LibravatarOptions; import org.jboss.logging.Logger; import org.json.simple.JSONArray; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; import org.keycloak.broker.oidc.KeycloakOIDCIdentityProvider; 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.provider.ProviderConfigProperty; import org.keycloak.provider.ProviderConfigurationBuilder; import org.keycloak.social.facebook.FacebookIdentityProviderFactory; import org.keycloak.social.google.GoogleIdentityProviderFactory; import org.keycloak.social.linkedin.LinkedInIdentityProviderFactory; /** * @author Mauro Mugnaini */ public class AvatarImporter extends AbstractClaimMapper { private static final Logger logger = Logger.getLogger(AvatarImporter.class); public static final String[] PROVIDERS_WITH_PICTURE_CLAIM = { GoogleIdentityProviderFactory.PROVIDER_ID, OIDCIdentityProviderFactory.PROVIDER_ID, // BitbucketIdentityProviderFactory.PROVIDER_ID, KeycloakOIDCIdentityProviderFactory.PROVIDER_ID // GitHubIdentityProviderFactory.PROVIDER_ID, // GitLabIdentityProviderFactory.PROVIDER_ID, // InstagramIdentityProviderFactory.PROVIDER_ID, // MicrosoftIdentityProviderFactory.PROVIDER_ID, // OpenshiftV3IdentityProviderFactory.PROVIDER_ID, // OpenshiftV4IdentityProviderFactory.PROVIDER_ID, // PayPalIdentityProviderFactory.PROVIDER_ID, // StackoverflowIdentityProviderFactory.PROVIDER_ID, // TwitterIdentityProviderFactory.PROVIDER_ID }; public static final String[] PROVIDERS_WITH_SPECIFIC_CODE = { FacebookIdentityProviderFactory.PROVIDER_ID, LinkedInIdentityProviderFactory.PROVIDER_ID }; public static final String[] COMPATIBLE_PROVIDERS = (String[]) ArrayUtils.addAll(PROVIDERS_WITH_PICTURE_CLAIM, PROVIDERS_WITH_SPECIFIC_CODE); private static final List configProperties; private static final Set IDENTITY_PROVIDER_SYNC_MODES = new HashSet<>( Arrays.asList(IdentityProviderSyncMode.values())); public static final Integer DEFAULT_AVATAR_SIZE = 160; public static final String USE_LIBRAVATAR_PROPERTY = "use-libravatar"; public static final String FORK_IMPORT_THREAD_PROPERTY = "import-with-thread"; static { configProperties = ProviderConfigurationBuilder.create() .property().name(USE_LIBRAVATAR_PROPERTY) .label("Use Libravatar service") .helpText("If the provider not provide the image claim, the email is used to search the avatar " + "by using libravatar service.") .defaultValue(Boolean.TRUE) .type(ProviderConfigProperty.BOOLEAN_TYPE).add() .property().name(FORK_IMPORT_THREAD_PROPERTY) .label("Import avatar in a separate thread") .helpText("Import the avatar by forking a new thread and don't wait the end of import.") .defaultValue(Boolean.FALSE) .type(ProviderConfigProperty.BOOLEAN_TYPE).add() .build(); } public static final String MAPPER_ID = "avatar-importer"; @Override public boolean supportsSyncMode(IdentityProviderSyncMode syncMode) { return IDENTITY_PROVIDER_SYNC_MODES.contains(syncMode); } @Override public List getConfigProperties() { return configProperties; } @Override public String getId() { return MAPPER_ID; } @Override public String[] getCompatibleProviders() { return COMPATIBLE_PROVIDERS; } @Override public String getDisplayCategory() { return "Preprocessor"; } @Override public String getDisplayType() { return "Avatar importer"; } @Override public void updateBrokeredUserLegacy(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { logger.debug("Importing avatar for brokered user legacy"); importNewUser(session, realm, user, mapperModel, context); } @Override public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { logger.debug("Importing avatar for brokered user"); importNewUser(session, realm, user, mapperModel, context); } @Override public void importNewUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { final boolean useLibravatar = Boolean.valueOf(mapperModel.getConfig().get(USE_LIBRAVATAR_PROPERTY)); final boolean forkNewThread = Boolean.valueOf(mapperModel.getConfig().get(FORK_IMPORT_THREAD_PROPERTY)); final String identityProviderAlias = mapperModel.getIdentityProviderAlias(); Runnable importAvatarRunnable = new Runnable() { @Override public void run() { AvatarStorageProvider avatarStorageProvider = session.getProvider(AvatarStorageProvider.class); if (avatarStorageProvider == null) { logger.warn("Cannot perform avatar import since the avatar storage provider is null"); return; } InputStream avatarInputStream = null; if (ArrayUtils.contains(AvatarImporter.PROVIDERS_WITH_PICTURE_CLAIM, identityProviderAlias)) { String imageClaim = (String) getClaimValue(context, "picture"); if (imageClaim != null && !"".equals(imageClaim)) { logger.infof("Getting avatar from token claim. Value is: %s", imageClaim); try { avatarInputStream = new URL(imageClaim).openStream(); } catch (IOException e) { logger.info("Cannot load avatar image from claim: " + imageClaim, e); } } } else if (LinkedInIdentityProviderFactory.PROVIDER_ID.equals(identityProviderAlias)) { avatarInputStream = loadAvatarFromLinkedIn(context); } else if (FacebookIdentityProviderFactory.PROVIDER_ID.equals(identityProviderAlias)) { avatarInputStream = loadAvatarFromFacebook(context); } if (avatarInputStream == null) { if (useLibravatar) { logger.debugf("Trying getting avatar from libravatar service"); String email = user.getEmail(); try { avatarInputStream = Libravatar.from(email) .withOptions( new LibravatarOptions().withHttps().defaultingTo(LibravatarDefaultImage.NOT_FOUND) .withImageSize(DEFAULT_AVATAR_SIZE)) .download(); } catch (LibravatarException e) { logger.infof("Avatar not found via libravatar for email: %s", email); } } else { logger.debug("Skipped search on libravatar due to mapper configuration"); } } if (avatarInputStream != null) { logger.debug("Saving the image via avatar storage provider"); avatarStorageProvider.saveAvatarImage(realm, user, avatarInputStream); } else { logger.debugf("No avatar found for user: %s", user); } } }; if (forkNewThread) { logger.debug("Forking new thread to perform the avatar import"); new Thread(importAvatarRunnable, "oidc-avatar-import").start(); } else { logger.debug("Performing the avatar import in the same thread"); importAvatarRunnable.run(); } } private InputStream loadAvatarFromLinkedIn(BrokeredIdentityContext context) { logger.info("Getting avatar from LinkedIn profile picture prjection"); try { JSONObject displayImageProjection = (JSONObject) new JSONParser().parse(new InputStreamReader((new URL( "https://api.linkedin.com/v2/me?projection=(profilePicture(displayImage~:playableStreams))&oauth2_access_token=" + getAccessTokenString(context)).openStream()))); String imageURL = null; @SuppressWarnings("unchecked") Iterator elementsIterator = ((JSONArray) ((JSONObject) ((JSONObject) displayImageProjection .get("profilePicture")).get("displayImage~")).get("elements")).iterator(); while (elementsIterator.hasNext()) { JSONObject element = elementsIterator.next(); if ((Double) ((JSONObject) ((JSONObject) ((JSONObject) ((JSONObject) element).get("data")) .get("com.linkedin.digitalmedia.mediaartifact.StillImage")).get("displaySize")) .get("width") == 100.0) { imageURL = (String) ((JSONObject) ((JSONArray) ((JSONObject) element).get("identifiers")) .get(0)).get("identifier"); } } if (imageURL != null) { logger.infof("Opening stream connnection to %s", imageURL); return new URL(imageURL).openStream(); } } catch (IOException | ParseException e) { logger.info("Cannot load LinkedIn avatar image from projection", e); } return null; } protected InputStream loadAvatarFromFacebook(BrokeredIdentityContext context) { logger.info("Getting avatar from Facebook Graph API call"); try { return new URL(String.format("https://graph.facebook.com/%s/picture?type=normal", context.getId())) .openStream(); } catch (IOException e) { logger.info("Cannot load Facebook avatar image from Graph API", e); return null; } } protected String getAccessTokenString(BrokeredIdentityContext context) { return (String) context.getContextData().get(KeycloakOIDCIdentityProvider.FEDERATED_ACCESS_TOKEN); } @Override public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) { } @Override public String getHelpText() { return "Import the IdP avatar image or use the libravatr service to find it."; } }