Commit 693bad73 authored by Ubuntu's avatar Ubuntu
Browse files

initial commit

parents
/target/
# Keycloak: * Link IdP Login with User Provider
* Registration
When using an external identity provider, [Keycloak](https://keycloak.jboss.org)
will, by default, ask the user if they would like to link their IdP login
with an existing account, if one exists. When the external identity provider
is an enterprise SSO solution linked to an enterprise user directory with
which Keycloak is federated, these additional prompts are undesirable and
confusing to users. This small authentication provider can be dropped into a
flow to automatically link an IdP login with an existing user, federated or
otherwise, without prompting the user.
## Usage
1. Download a release jar or build with maven: `mvn package`.
2. Drop the jar into one of the directories defined in the `deployments` folder (/opt/jboss/keycloak/standalone/deployments).
3. Create or modify an Authentication flow to include the new `Link IDP Login`
provider in the appropriate place.
4. Modify an Identity Provider to use the above flow.
## Example
Typically, you'll want a simple flow that starts with `Create User if Unique`
and continues to `Link IDP Login`, both of which should be alternative.
![Link IdP Login Example](doc/link-idp-login-example.png)
## License
* [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0)
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<name>EOSC Keycloak Extension</name>
<version>1.0.0</version>
<modelVersion>4.0.0</modelVersion>
<groupId>eu.eosc.life.keycloak</groupId>
<artifactId>eosc-keycloak-extension</artifactId>
<packaging>jar</packaging>
<properties>
<keycloak.version>4.5.0.Final</keycloak.version>
<outputDirectory>${project.build.directory}</outputDirectory>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<scope>provided</scope>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<scope>provided</scope>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<scope>provided</scope>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-ldap-federation</artifactId>
<version>${keycloak.version}</version>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Dependencies>org.keycloak.keycloak-ldap-federation,org.keycloak.keycloak-server-spi,org.keycloak.keycloak-server-spi-private,org.keycloak.keycloak-services,org.keycloak.keycloak-core</Dependencies>
</manifestEntries>
</archive>
<!-- Allows easy changing of the output directory when debugging -->
<outputDirectory>${outputDirectory}</outputDirectory>
</configuration>
</plugin>
</plugins>
</build>
</project>
package eu.eosc.life.keycloak.authentication;
import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.authenticators.broker.util.ExistingUserInfo;
import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.messages.Messages;
import javax.ws.rs.core.Response;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.keycloak.authentication.authenticators.broker.IdpCreateUserIfUniqueAuthenticator;
//ldap stuff
import javax.naming.Context;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.BasicAttribute;
import javax.naming.directory.BasicAttributes;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.UserStorageProviderFactory;
import org.keycloak.storage.UserStorageProviderModel;
import org.keycloak.storage.ldap.LDAPStorageProviderFactory;
import org.keycloak.storage.user.ImportSynchronization;
import org.keycloak.common.util.Time;
public class EoscCreateUserIfUniqueAuthenticator extends IdpCreateUserIfUniqueAuthenticator{
private static Logger logger = Logger.getLogger(EoscCreateUserIfUniqueAuthenticator.class);
private DirContext ctx = null;
@Override
protected void actionImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
}
@Override
protected void authenticateImpl(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
KeycloakSession session = context.getSession();
RealmModel realm = context.getRealm();
if (context.getAuthenticationSession().getAuthNote(EXISTING_USER_INFO) != null) {
context.attempted();
return;
}
String username = getUsername(context, serializedCtx, brokerContext);
if (username == null) {
ServicesLogger.LOGGER.resetFlow(realm.isRegistrationEmailAsUsername() ? "Email" : "Username");
context.getAuthenticationSession().setAuthNote(ENFORCE_UPDATE_PROFILE, "true");
context.resetFlow();
return;
}
try {
this.addUserToLdap(context, username, brokerContext.getEmail(),
brokerContext.getFirstName(), brokerContext.getLastName());
List<UserStorageProviderModel> providers = realm.getUserStorageProviders();
for (final UserStorageProviderModel provider : providers) {
UserStorageProviderFactory factory =
(UserStorageProviderFactory) session.getKeycloakSessionFactory().getProviderFactory(UserStorageProvider.class, provider.getProviderId());
if (provider.isImportEnabled() && factory instanceof LDAPStorageProviderFactory) {
System.out.println("An instance of LDAP Factory");
int oldLastSync = provider.getLastSync();
((ImportSynchronization)factory).syncSince(Time.toDate(oldLastSync), session.getKeycloakSessionFactory(), realm.getId(), provider);
}
}
ExistingUserInfo duplication = checkExistingUser(context, username, serializedCtx, brokerContext);
if (duplication == null) {
logger.debugf("No duplication detected. Creating account for user '%s' and linking with identity provider '%s' .",
username, brokerContext.getIdpConfig().getAlias());
UserModel federatedUser = session.users().addUser(realm, username);
federatedUser.setEnabled(true);
federatedUser.setEmail(brokerContext.getEmail());
federatedUser.setFirstName(brokerContext.getFirstName());
federatedUser.setLastName(brokerContext.getLastName());
for (Map.Entry<String, List<String>> attr : serializedCtx.getAttributes().entrySet()) {
federatedUser.setAttribute(attr.getKey(), attr.getValue());
}
AuthenticatorConfigModel config = context.getAuthenticatorConfig();
if (config != null && Boolean.parseBoolean(config.getConfig().get(EoscCreateUserIfUniqueAuthenticatorFactory.REQUIRE_PASSWORD_UPDATE_AFTER_REGISTRATION))) {
logger.debugf("User '%s' required to update password", federatedUser.getUsername());
federatedUser.addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD);
}
userRegisteredSuccess(context, federatedUser, serializedCtx, brokerContext);
context.setUser(federatedUser);
context.getAuthenticationSession().setAuthNote(BROKER_REGISTERED_NEW_USER, "true");
context.success();
} else {
logger.debugf("Duplication detected. There is already existing user with %s '%s' .",
duplication.getDuplicateAttributeName(), duplication.getDuplicateAttributeValue());
// Set duplicated user, so next authenticators can deal with it
context.getAuthenticationSession().setAuthNote(EXISTING_USER_INFO, duplication.serialize());
Response challengeResponse = context.form()
.setError(Messages.FEDERATED_IDENTITY_EXISTS, duplication.getDuplicateAttributeName(), duplication.getDuplicateAttributeValue())
.createErrorPage(Response.Status.CONFLICT);
context.challenge(challengeResponse);
if (context.getExecution().isRequired()) {
context.getEvent()
.user(duplication.getExistingUserId())
.detail("existing_" + duplication.getDuplicateAttributeName(), duplication.getDuplicateAttributeValue())
.removeDetail(Details.AUTH_METHOD)
.removeDetail(Details.AUTH_TYPE)
.error(Errors.FEDERATED_IDENTITY_EXISTS);
}
}
}
catch(Exception e){
this.sendFailureChallenge(context, Response.Status.NOT_MODIFIED, "", e.getMessage(), AuthenticationFlowError.INTERNAL_ERROR);
}
}
/**
* This model creates CoESRA username regardless of selection to use email as username or not
*/
@Override
protected String getUsername(AuthenticationFlowContext context, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
//RealmModel realm = context.getRealm();
String email = brokerContext.getEmail();
String eoscUserName = null;
if(email != null && !email.trim().isEmpty()) {
String[] emailParts = email.split("@");
eoscUserName = emailParts[0] + "_" + emailParts[1].split("\\.")[0];
}
return eoscUserName;
}
@Override
protected ExistingUserInfo checkExistingUser(AuthenticationFlowContext context, String username, SerializedBrokeredIdentityContext serializedCtx, BrokeredIdentityContext brokerContext) {
// only check by username
UserModel existingUser = context.getSession().users().getUserByUsername(username, context.getRealm());
if (existingUser != null) {
return new ExistingUserInfo(existingUser.getId(), UserModel.USERNAME, existingUser.getUsername());
}
return null;
}
private void addUserToLdap(AuthenticationFlowContext context, String username, String email,
String firstname, String lastname) throws Exception{
AuthenticatorConfigModel config = context.getAuthenticatorConfig();
String ldapBaseDN = config.getConfig().get(EoscCreateUserIfUniqueAuthenticatorFactory.LDAP_SECURITY_BASE_DN);
int ldapDefaultGid = Integer.parseInt(config.getConfig().get(EoscCreateUserIfUniqueAuthenticatorFactory.LDAP_DEFAULT_GID));
int ldapStartingUid = Integer.parseInt(config.getConfig().get(EoscCreateUserIfUniqueAuthenticatorFactory.LDAP_UID_START));
if(this.ctx == null)
this.initLdapContext(context);
if(this.userExistInLDap(ctx, email, ldapBaseDN))
return;
Attributes attributes=new BasicAttributes();
//object class
Attribute objectClass=new BasicAttribute("objectClass");
objectClass.add("top");
objectClass.add("inetOrgPerson");
objectClass.add("posixAccount");
objectClass.add("shadowAccount");
attributes.put(objectClass);
username = username.toLowerCase();
//sn
attributes.put("sn", lastname);
//cn
attributes.put("cn", username);
//mail
attributes.put("mail", email);
//organisation
attributes.put("o", "EOSC");
//givenName
attributes.put("givenName", firstname);
//uid
attributes.put("uid", username);
//homedir
attributes.put("homeDirectory", "/home/"+username);
//shadowLastChange
attributes.put("shadowLastChange", 15140+"");
//shadowMin
attributes.put("shadowMin", 0+"");
//shadowMax
attributes.put("shadowMax", 99999+"");
//shadowWarning
attributes.put("shadowWarning", 3+"");
//gidNumber
attributes.put("gidNumber", ldapDefaultGid + "");
//loginShell
attributes.put("loginShell", "/bin/bash");
//uidNumber
attributes.put("uidNumber", this.getNextUid(ctx, ldapBaseDN, ldapStartingUid) + "");
//userPassword
//password are generated randomly
String _ramdomPassword = UUID.randomUUID().toString();
attributes.put("userPassword", this.harshPassword(_ramdomPassword));
// //businessCategory
// attributes.put("businessCategory", user.getAreaOfResearch());
String ldapPath = "cn=" + username + "," + ldapBaseDN;
ctx.createSubcontext(ldapPath,attributes);
}
/**
* whether user is in ldap
* @param user
* @return
* @throws Exception
*/
private boolean userExistInLDap(DirContext ctx, String email, String ldapUserBaseDN) throws Exception{
String searchFilder = email;
String searchFilter = String.format("mail=%s", escapeLDAPSearchFilter(searchFilder));
NamingEnumeration<SearchResult> results = ctx.search(ldapUserBaseDN, searchFilter, new SearchControls());
return results.hasMore();
}
/**
* Prevent LDAP injection
* @param filter LDAP filter string to escape
* @return escaped string
*/
private String escapeLDAPSearchFilter(String filter) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < filter.length(); i++) {
char curChar = filter.charAt(i);
switch (curChar) {
case '\\':
sb.append("\\5c");
break;
case '*':
sb.append("\\2a");
break;
case '(':
sb.append("\\28");
break;
case ')':
sb.append("\\29");
break;
case '\u0000':
sb.append("\\00");
break;
default:
sb.append(curChar);
}
}
return sb.toString();
}
/**
* create ldap context
* @param context
* @throws NamingException
*/
private void initLdapContext(AuthenticationFlowContext context) throws NamingException{
AuthenticatorConfigModel config = context.getAuthenticatorConfig();
String ldapProviderUrl = config.getConfig().get(EoscCreateUserIfUniqueAuthenticatorFactory.LDAP_SERVER_URL);
String ldapSecurityPrinciple = config.getConfig().get(EoscCreateUserIfUniqueAuthenticatorFactory.LDAP_SECURITY_PRINCIPLE);
String ldapSecurityAuthentication= config.getConfig().get(EoscCreateUserIfUniqueAuthenticatorFactory.LDAP_SECURITY_AUTHENTICATION);
String ldapSecurityCredentials= config.getConfig().get(EoscCreateUserIfUniqueAuthenticatorFactory.LDAP_SECURITY_CREDENTIALS);
Hashtable<String, String> env = new Hashtable<String, String>();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.PROVIDER_URL, ldapProviderUrl);
// Authenticate if credentials were provided
if (ldapSecurityPrinciple != null) {
env.put(Context.SECURITY_AUTHENTICATION, ldapSecurityAuthentication);
env.put(Context.SECURITY_PRINCIPAL, ldapSecurityPrinciple);
env.put(Context.SECURITY_CREDENTIALS, ldapSecurityCredentials);
}
else
throw new NamingException("Ldap Principle must not be null");
ctx = new InitialDirContext(env);
}
/**
* count the number of users in ldap
* @return
* @throws NamingException
*/
private int countNumberOfUsersInLdap(DirContext ctx, String userBaseDN) throws NamingException{
String searchFilter = "(cn=*)";
NamingEnumeration<SearchResult> results = ctx.search(userBaseDN, searchFilter, new SearchControls());
//is there any better way ?
int count = 0;
while(results.hasMore()){
results.next();
count++;
}
return count;
}
private int getNextUid(DirContext ctx, String userBaseDN, int startingUid) throws NamingException{
String searchFilter = "(cn=*)";
SearchControls searchControls = new SearchControls();
searchControls.setReturningAttributes(new String[]{"uidNumber"});
NamingEnumeration<SearchResult> results = ctx.search(userBaseDN, searchFilter, searchControls);
//is there any better way ?
int highestUid = startingUid;
while(results.hasMore()){
SearchResult result = results.next();
Attribute uidNumberAtt = result.getAttributes().get("uidNumber");
if(uidNumberAtt!=null) {
try {
int uid = Integer.parseInt((String)uidNumberAtt.get());
if(uid > highestUid)
highestUid = uid;
}
catch(NumberFormatException e) {
}
}
}
return (highestUid + 1);
}
/**
* harsh a password
* @param s
* @return
*/
private String harshPassword(String s){
String generatedPassword = null;
try {
SecureRandom sr = SecureRandom.getInstance("SHA1PRNG");
byte[] salt = new byte[16];
sr.nextBytes(salt);
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(salt);
byte[] bytes = md.digest(s.getBytes());
StringBuilder sb = new StringBuilder();
for(int i=0; i< bytes.length ;i++)
{
sb.append(Integer.toString((bytes[i] & 0xff) + 0x100, 16).substring(1));
}
generatedPassword = sb.toString();
}
catch (NoSuchAlgorithmException e)
{
//ig nore
}
return generatedPassword;
}
}
package eu.eosc.life.keycloak.authentication;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.ArrayList;
import java.util.List;
public class EoscCreateUserIfUniqueAuthenticatorFactory implements AuthenticatorFactory {
public static final String PROVIDER_ID = "eosc-idp-create-user-if-unique";
static EoscCreateUserIfUniqueAuthenticator SINGLETON = new EoscCreateUserIfUniqueAuthenticator();
public static final String REQUIRE_PASSWORD_UPDATE_AFTER_REGISTRATION = "require.password.update.after.registration";
public static final String LDAP_SERVER_URL = "eosc.ldap.server.url";
public static final String LDAP_SECURITY_AUTHENTICATION = "eosc.ldap.security.authentication";
public static final String LDAP_SECURITY_PRINCIPLE = "eosc.ldap.security.principle";
public static final String LDAP_SECURITY_CREDENTIALS = "eosc.ldap.security.credentials";
public static final String LDAP_DEFAULT_GID = "eosc.ldap.security.default.gid";
public static final String LDAP_UID_START = "eosc.ldap.security.uid.start";
public static final String LDAP_SECURITY_BASE_DN = "eosc.ldap.security.base.dn";
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getReferenceCategory() {
return "createEoscUserIfUnique";
}
@Override
public boolean isConfigurable() {
return true;
}
public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.DISABLED};
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public String getDisplayType() {
return "Create EOSC User If Unique";
}
@Override
public String getHelpText() {
return "Detect if there is existing Keycloak account with same email like identity provider. If no, create new user";
}
@Override
public boolean isUserSetupAllowed() {
return false;
}
private static final List<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();
static {
// add properties
ProviderConfigProperty requiredPasswordUpdateProperty;
requiredPasswordUpdateProperty = new ProviderConfigProperty();
requiredPasswordUpdateProperty.setName(REQUIRE_PASSWORD_UPDATE_AFTER_REGISTRATION);
requiredPasswordUpdateProperty.setLabel("Require Password Update After Registration");