272 lines
12 KiB
Java
272 lines
12 KiB
Java
|
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.";
|
||
|
}
|
||
|
|
||
|
}
|