keycloak-d4science-spi-parent/avatar-importer/src/main/java/org/gcube/keycloak/oidc/avatar/importer/AvatarImporter.java

272 lines
12 KiB
Java
Executable File

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.lang.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 <a href="mailto:mauro.mugnaini@nubisware.com">Mauro Mugnaini</a>
*/
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<ProviderConfigProperty> configProperties;
private static final Set<IdentityProviderSyncMode> 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 Libravater 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<ProviderConfigProperty> 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 ince the avatar storage provider is null");
}
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<JSONObject> 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.";
}
}