diff --git a/application.example.yaml b/application.example.yaml index cacbe8b..112749a 100644 --- a/application.example.yaml +++ b/application.example.yaml @@ -99,41 +99,85 @@ lookup: ldap: - enabled: false - tls: false - host: 'localhost' - port: 389 - bindDn: 'CN=Matrix Identity Server,CN=Users,DC=example,DC=org' - bindPassword: 'password' - baseDn: 'CN=Users,DC=example,DC=org' - # How should we resolve the Matrix ID in case of a match using the attribute. + # Global enable/disable switch + enabled: true + + # Connection configuration to the LDAP server + connection: + + # If the connection should be secure + tls: false + + # Host to connect to + host: 'localhost' + + # Port to connect to + port: 389 + + # Bind DN to use when performing lookups + bindDn: 'CN=Matrix Identity Server,CN=Users,DC=example,DC=org' + + # Bind password to use + bindPassword: 'password' + + # Base DN used in all queries + baseDn: 'CN=Users,DC=example,DC=org' + + # How to map Matrix attributes with LDAP attributes when performing lookup/auth + attributes: + + # The username/login that will be looked up or used to build Matrix IDs + uid: + + # How should we resolve the Matrix ID in case of a match using the attribute. + # + # The following type are supported: + # - uid : the attribute only contains the UID part of the Matrix ID. e.g. 'john.doe' in @john.doe:example.org + # - mxid : the attribute contains the full Matrix ID - e.g. '@john.doe:example.org' + type: 'uid' + + # The attribute containing the binding itself. This value will be used differently depending on the type. + # + # /!\ This should match the synapse LDAP Authenticator 'uid' configuration /!\ + # + # Typical values: + # - For type 'uid': 'userPrincipalName' or 'uid' or 'saMAccountName' + # - For type 'mxid', regardless of the directory type, we recommend using 'pager' as it is a standard attribute and + # is typically not used. + value: 'userPrincipalName' + + # The display name of the user + name: 'displayName' + + # Configuration section relating the authentication of users performed via LDAP. # - # The following type are supported: - # - uid : the attribute only contains the UID part of the Matrix ID. e.g. 'john.doe' in @john.doe:example.org - # - mxid : the attribute contains the full Matrix ID - e.g. '@john.doe:example.org' - type: 'uid' + # This can be done using the REST Auth module for synapse and pointing it to the identity server. + # See https://github.com/maxidor/matrix-synapse-rest-auth + auth: - # The attribute containing the binding itself. This value will be used differently depending on the type. - # - # /!\ This should match the synapse LDAP Authenticator 'uid' configuration /!\ - # - # Typical values: - # - For type 'uid': 'userPrincipalName' or 'uid' or 'saMAccountName' - # - For type 'mxid', regardless of the directory type, we recommend using 'pager' as it is a standard attribute and - # is typically not used. - attribute: 'userPrincipalName' - - # Configure each 3PID type with a dedicated query. - mappings: - email: "(|(mailPrimaryAddress=%3pid)(mail=%3pid)(otherMailbox=%3pid))" - - # Phone numbers query. + # What to filter potential users by, typically by using a dedicated group. + # If this value is not set, login check will be performed for all entities within the LDAP # - # Phone numbers use the MSISDN format: https://en.wikipedia.org/wiki/MSISDN - # This format does not include international prefix (+ or 00) and therefore has to be put in the query. - # Adapt this to your needs for each attribute. - msisdn: "(|(telephoneNumber=+%3pid)(mobile=+%3pid)(homePhone=+%3pid)(otherTelephone=+%3pid)(otherMobile=+%3pid)(otherHomePhone=+%3pid))" + # Example: (memberOf=CN=Matrix Users,CN=Users,DC=example,DC=org) + # + # /!\ Currently NOT supported due to a possible bug in LDAP library /!\ + filter: '' + + # Configuration section relating to identity lookups + identity: + + # Configure each 3PID type with a dedicated query. + medium: + # E-mail query + email: "(|(mailPrimaryAddress=%3pid)(mail=%3pid)(otherMailbox=%3pid))" + + # Phone numbers query + # + # Phone numbers use the MSISDN format: https://en.wikipedia.org/wiki/MSISDN + # This format does not include international prefix (+ or 00) and therefore has to be put in the query. + # Adapt this to your needs for each attribute. + msisdn: "(|(telephoneNumber=+%3pid)(mobile=+%3pid)(homePhone=+%3pid)(otherTelephone=+%3pid)(otherMobile=+%3pid)(otherHomePhone=+%3pid))" diff --git a/build.gradle b/build.gradle index 7e785b7..df0d430 100644 --- a/build.gradle +++ b/build.gradle @@ -62,6 +62,7 @@ buildscript { } repositories { + maven { url "https://kamax.io/maven/releases/" } mavenCentral() } @@ -75,6 +76,9 @@ dependencies { // Spring Boot - standalone app compile 'org.springframework.boot:spring-boot-starter-web:1.5.3.RELEASE' + // Matrix Java SDK + compile 'io.kamax:matrix-java-sdk:0.0.2' + // ed25519 handling compile 'net.i2p.crypto:eddsa:0.1.0' @@ -93,6 +97,9 @@ dependencies { // Phone numbers validation compile 'com.googlecode.libphonenumber:libphonenumber:8.7.1' + // Google Firebase Authentication backend + compile 'com.google.firebase:firebase-admin:5.3.0' + testCompile 'junit:junit:4.12' } diff --git a/src/main/groovy/io/kamax/mxisd/GlobalProvider.java b/src/main/groovy/io/kamax/mxisd/GlobalProvider.java new file mode 100644 index 0000000..9e68b5d --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/GlobalProvider.java @@ -0,0 +1,7 @@ +package io.kamax.mxisd; + +import io.kamax.mxisd.auth.provider.AuthenticatorProvider; +import io.kamax.mxisd.lookup.provider.IThreePidProvider; + +public interface GlobalProvider extends AuthenticatorProvider, IThreePidProvider { +} diff --git a/src/main/groovy/io/kamax/mxisd/auth/AuthManager.java b/src/main/groovy/io/kamax/mxisd/auth/AuthManager.java new file mode 100644 index 0000000..bb6e367 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/auth/AuthManager.java @@ -0,0 +1,35 @@ +package io.kamax.mxisd.auth; + +import io.kamax.mxisd.auth.provider.AuthenticatorProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class AuthManager { + + private Logger log = LoggerFactory.getLogger(AuthManager.class); + + @Autowired + private List providers = new ArrayList<>(); + + public UserAuthResult authenticate(String id, String password) { + for (AuthenticatorProvider provider : providers) { + if (!provider.isEnabled()) { + continue; + } + + UserAuthResult result = provider.authenticate(id, password); + if (result.isSuccess()) { + return result; + } + } + + return new UserAuthResult().failure(); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/auth/UserAuthResult.java b/src/main/groovy/io/kamax/mxisd/auth/UserAuthResult.java new file mode 100644 index 0000000..16e1cf6 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/auth/UserAuthResult.java @@ -0,0 +1,49 @@ +package io.kamax.mxisd.auth; + +public class UserAuthResult { + + private boolean success; + private String mxid; + private String displayName; + + public UserAuthResult failure() { + success = false; + mxid = null; + displayName = null; + + return this; + } + + public UserAuthResult success(String mxid, String displayName) { + setSuccess(true); + setMxid(mxid); + setDisplayName(displayName); + + return this; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getMxid() { + return mxid; + } + + public void setMxid(String mxid) { + this.mxid = mxid; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/auth/provider/AuthenticatorProvider.java b/src/main/groovy/io/kamax/mxisd/auth/provider/AuthenticatorProvider.java new file mode 100644 index 0000000..bdcf72a --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/auth/provider/AuthenticatorProvider.java @@ -0,0 +1,11 @@ +package io.kamax.mxisd.auth.provider; + +import io.kamax.mxisd.auth.UserAuthResult; + +public interface AuthenticatorProvider { + + boolean isEnabled(); + + UserAuthResult authenticate(String id, String password); + +} diff --git a/src/main/groovy/io/kamax/mxisd/auth/provider/GoogleFirebaseAuthenticator.groovy b/src/main/groovy/io/kamax/mxisd/auth/provider/GoogleFirebaseAuthenticator.groovy new file mode 100644 index 0000000..0d00c08 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/auth/provider/GoogleFirebaseAuthenticator.groovy @@ -0,0 +1,227 @@ +package io.kamax.mxisd.auth.provider + +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.* +import com.google.firebase.internal.NonNull +import com.google.firebase.tasks.OnFailureListener +import com.google.firebase.tasks.OnSuccessListener +import io.kamax.matrix.ThreePidMedium +import io.kamax.mxisd.GlobalProvider +import io.kamax.mxisd.auth.UserAuthResult +import io.kamax.mxisd.lookup.SingleLookupRequest +import io.kamax.mxisd.lookup.ThreePidMapping +import org.apache.commons.lang.StringUtils +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.function.Consumer +import java.util.regex.Matcher +import java.util.regex.Pattern + +public class GoogleFirebaseAuthenticator implements GlobalProvider { + + private Logger log = LoggerFactory.getLogger(GoogleFirebaseAuthenticator.class); + + private static final Pattern matrixIdLaxPattern = Pattern.compile("@(.*):(.+)"); + + private boolean isEnabled; + private String domain; + private FirebaseApp fbApp; + private FirebaseAuth fbAuth; + + public GoogleFirebaseAuthenticator(boolean isEnabled) { + this.isEnabled = isEnabled; + } + + public GoogleFirebaseAuthenticator(String credsPath, String db, String domain) { + this(true); + this.domain = domain; + try { + fbApp = FirebaseApp.initializeApp(getOpts(credsPath, db)); + fbAuth = FirebaseAuth.getInstance(fbApp); + + log.info("Google Firebase Authentication is ready"); + } catch (IOException e) { + throw new RuntimeException("Error when initializing Firebase", e); + } + } + + private FirebaseCredential getCreds(String credsPath) throws IOException { + if (StringUtils.isNotBlank(credsPath)) { + return FirebaseCredentials.fromCertificate(new FileInputStream(credsPath)); + } else { + return FirebaseCredentials.applicationDefault(); + } + } + + private FirebaseOptions getOpts(String credsPath, String db) throws IOException { + if (StringUtils.isBlank(db)) { + throw new IllegalArgumentException("Firebase database is not configured"); + } + + return new FirebaseOptions.Builder() + .setCredential(getCreds(credsPath)) + .setDatabaseUrl(db) + .build(); + } + + @Override + public boolean isEnabled() { + return isEnabled; + } + + @Override + public boolean isLocal() { + return true; + } + + @Override + public int getPriority() { + return 25; + } + + private void waitOnLatch(CountDownLatch l) { + try { + l.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + log.warn("Interrupted while waiting for Firebase auth check"); + } + } + + private Optional findInternal(String medium, String address) { + UserRecord r; + CountDownLatch l = new CountDownLatch(1); + + OnSuccessListener success = new OnSuccessListener() { + @Override + void onSuccess(UserRecord result) { + log.info("Found 3PID match for {}:{} - UID is {}", medium, address, result.getUid()) + r = result; + l.countDown() + } + }; + + OnFailureListener failure = new OnFailureListener() { + @Override + void onFailure(@NonNull Exception e) { + log.info("No 3PID match for {}:{} - {}", medium, address, e.getMessage()) + r = null; + l.countDown() + } + }; + + if (ThreePidMedium.Email.is(medium)) { + log.info("Performing E-mail 3PID lookup for {}", address) + fbAuth.getUserByEmail(address) + .addOnSuccessListener(success) + .addOnFailureListener(failure); + waitOnLatch(l); + } else if (ThreePidMedium.PhoneNumber.is(medium)) { + log.info("Performing msisdn 3PID lookup for {}", address) + fbAuth.getUserByPhoneNumber(address) + .addOnSuccessListener(success) + .addOnFailureListener(failure); + waitOnLatch(l); + } else { + log.info("{} is not a supported 3PID medium", medium); + r = null; + } + + return Optional.ofNullable(r); + } + + @Override + public Optional find(SingleLookupRequest request) { + Optional urOpt = findInternal(request.getType(), request.getThreePid()) + if (urOpt.isPresent()) { + return [ + address : request.getThreePid(), + medium : request.getType(), + mxid : "@${urOpt.get().getUid()}:${domain}", + not_before: 0, + not_after : 9223372036854775807, + ts : 0 + ] + } else { + return Optional.empty(); + } + } + + @Override + public List populate(List mappings) { + List results = new ArrayList<>(); + mappings.parallelStream().forEach(new Consumer() { + @Override + void accept(ThreePidMapping o) { + Optional urOpt = findInternal(o.getMedium(), o.getValue()); + if (urOpt.isPresent()) { + ThreePidMapping result = new ThreePidMapping(); + result.setMedium(o.getMedium()) + result.setValue(o.getValue()) + result.setMxid("@${urOpt.get().getUid()}:${domain}") + results.add(result) + } + } + }); + return results; + } + + @Override + public UserAuthResult authenticate(String id, String password) { + if (!isEnabled()) { + throw new IllegalStateException(); + } + + final UserAuthResult result = new UserAuthResult(); + + log.info("Trying to authenticate {}", id); + Matcher m = matrixIdLaxPattern.matcher(id); + if (!m.matches()) { + log.warn("Could not validate {} as a Matrix ID", id); + result.failure(); + } + + String localpart = m.group(1); + + CountDownLatch l = new CountDownLatch(1); + fbAuth.verifyIdToken(password).addOnSuccessListener(new OnSuccessListener() { + @Override + void onSuccess(FirebaseToken token) { + if (!StringUtils.equals(localpart, token.getUid())) { + log.info("Failture to authenticate {}: Matrix ID localpart '{}' does not match Firebase UID '{}'", id, localpart, token.getUid()); + result.failure(); + } + + log.info("{} was successfully authenticated", id); + result.success(id, token.getName()); + l.countDown() + } + }).addOnFailureListener(new OnFailureListener() { + @Override + void onFailure(@NonNull Exception e) { + if (e instanceof IllegalArgumentException) { + log.info("Failure to authenticate {}: invalid firebase token", id); + } else { + log.info("Failure to authenticate {}: {}", id, e.getMessage(), e); + log.info("Exception", e); + } + + result.failure(); + l.countDown() + } + }); + + try { + l.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + log.warn("Interrupted while waiting for Firebase auth check"); + result.failure(); + } + + return result; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/auth/provider/LdapAuthProvider.java b/src/main/groovy/io/kamax/mxisd/auth/provider/LdapAuthProvider.java new file mode 100644 index 0000000..ff2ddc4 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/auth/provider/LdapAuthProvider.java @@ -0,0 +1,115 @@ +package io.kamax.mxisd.auth.provider; + +import io.kamax.matrix.MatrixID; +import io.kamax.mxisd.auth.UserAuthResult; +import io.kamax.mxisd.config.ldap.LdapConfig; +import io.kamax.mxisd.lookup.provider.LdapProvider; +import org.apache.commons.lang.StringUtils; +import org.apache.directory.api.ldap.model.cursor.CursorException; +import org.apache.directory.api.ldap.model.cursor.CursorLdapReferralException; +import org.apache.directory.api.ldap.model.cursor.EntryCursor; +import org.apache.directory.api.ldap.model.entry.Attribute; +import org.apache.directory.api.ldap.model.entry.Entry; +import org.apache.directory.api.ldap.model.exception.LdapException; +import org.apache.directory.api.ldap.model.message.SearchScope; +import org.apache.directory.ldap.client.api.LdapConnection; +import org.apache.directory.ldap.client.api.LdapNetworkConnection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class LdapAuthProvider implements AuthenticatorProvider { + + private Logger log = LoggerFactory.getLogger(LdapAuthProvider.class); + + @Autowired + private LdapConfig ldapCfg; + + private LdapConnection getConn() { + return new LdapNetworkConnection(ldapCfg.getConn().getHost(), ldapCfg.getConn().getPort(), ldapCfg.getConn().isTls()); + } + + private void bind(LdapConnection conn) throws LdapException { + conn.bind(ldapCfg.getConn().getBindDn(), ldapCfg.getConn().getBindPassword()); + } + + private String getUidAttribute() { + return ldapCfg.getAttribute().getUid().getValue(); + } + + @Override + public boolean isEnabled() { + return ldapCfg.isEnabled(); + } + + @Override + public UserAuthResult authenticate(String id, String password) { + log.info("Performing auth for {}", id); + + LdapConnection conn = getConn(); + try { + bind(conn); + + String uidType = ldapCfg.getAttribute().getUid().getType(); + MatrixID mxIdExt = new MatrixID(id); + String userFilterValue = StringUtils.equals(LdapProvider.UID, uidType) ? mxIdExt.getLocalPart() : mxIdExt.getId(); + String userFilter = "(" + ldapCfg.getAttribute().getUid().getValue() + "=" + userFilterValue + ")"; + EntryCursor cursor = conn.search(ldapCfg.getConn().getBaseDn(), userFilter, SearchScope.SUBTREE, getUidAttribute(), ldapCfg.getAttribute().getName()); + try { + while (cursor.next()) { + Entry entry = cursor.get(); + String dn = entry.getDn().getName(); + log.info("Checking possible match, DN: {}", dn); + + Attribute attribute = entry.get(getUidAttribute()); + if (attribute == null) { + log.info("DN {}: no attribute {}, skpping", dn, getUidAttribute()); + continue; + } + + String data = attribute.get().toString(); + if (data.length() < 1) { + log.info("DN {}: empty attribute {}, skipping", getUidAttribute()); + continue; + } + + log.info("Attempting authentication on LDAP for {}", dn); + try { + conn.bind(entry.getDn(), password); + } catch (LdapException e) { + log.info("Unable to bind using {} because {}", entry.getDn().getName(), e.getMessage()); + return new UserAuthResult().failure(); + } + + Attribute nameAttribute = entry.get(ldapCfg.getAttribute().getName()); + String name = nameAttribute != null ? nameAttribute.get().toString() : null; + + log.info("Authentication successful for {}", entry.getDn().getName()); + log.info("DN {} is a valid match", dn); + + return new UserAuthResult().success(mxIdExt.getId(), name); + } + } catch (CursorLdapReferralException e) { + log.warn("Entity for {} is only available via referral, skipping", mxIdExt); + } finally { + cursor.close(); + } + + log.info("No match were found for {}", id); + return new UserAuthResult().failure(); + } catch (LdapException | IOException | CursorException e) { + throw new RuntimeException(e); + } finally { + try { + conn.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/config/FirebaseConfig.java b/src/main/groovy/io/kamax/mxisd/config/FirebaseConfig.java new file mode 100644 index 0000000..6ad4791 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/FirebaseConfig.java @@ -0,0 +1,71 @@ +package io.kamax.mxisd.config; + +import io.kamax.mxisd.GlobalProvider; +import io.kamax.mxisd.auth.provider.GoogleFirebaseAuthenticator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.PostConstruct; + +@Configuration +@ConfigurationProperties("firebase") +public class FirebaseConfig { + + private Logger log = LoggerFactory.getLogger(FirebaseConfig.class); + + @Autowired + private ServerConfig srvCfg; + + private boolean enabled; + private String credentials; + private String database; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getCredentials() { + return credentials; + } + + public void setCredentials(String credentials) { + this.credentials = credentials; + } + + public String getDatabase() { + return database; + } + + public void setDatabase(String database) { + this.database = database; + } + + @PostConstruct + private void postConstruct() { + log.info("--- Firebase configuration ---"); + log.info("Enabled: {}", isEnabled()); + if (isEnabled()) { + log.info("Credentials: {}", getCredentials()); + log.info("Database: {}", getDatabase()); + } + + + } + + @Bean + public GlobalProvider getProvider() { + if (!enabled) { + return new GoogleFirebaseAuthenticator(false); + } else { + return new GoogleFirebaseAuthenticator(credentials, database, srvCfg.getName()); + } + } +} diff --git a/src/main/groovy/io/kamax/mxisd/config/LdapConfig.groovy b/src/main/groovy/io/kamax/mxisd/config/LdapConfig.groovy deleted file mode 100644 index e90046e..0000000 --- a/src/main/groovy/io/kamax/mxisd/config/LdapConfig.groovy +++ /dev/null @@ -1,156 +0,0 @@ -/* - * mxisd - Matrix Identity Server Daemon - * Copyright (C) 2017 Maxime Dor - * - * https://max.kamax.io/ - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package io.kamax.mxisd.config - -import org.apache.commons.lang.StringUtils -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.InitializingBean -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.context.annotation.Configuration - -@Configuration -@ConfigurationProperties(prefix = "ldap") -class LdapConfig implements InitializingBean { - - private Logger log = LoggerFactory.getLogger(LdapConfig.class) - - private boolean enabled - private boolean tls - private String host - private int port - private String baseDn - private String type - private String attribute - private String bindDn - private String bindPassword - private Map mappings - - boolean getEnabled() { - return enabled - } - - void setEnabled(boolean enabled) { - this.enabled = enabled - } - - boolean getTls() { - return tls - } - - void setTls(boolean tls) { - this.tls = tls - } - - String getHost() { - return host - } - - void setHost(String host) { - this.host = host - } - - int getPort() { - return port - } - - void setPort(int port) { - this.port = port - } - - String getBaseDn() { - return baseDn - } - - void setBaseDn(String baseDn) { - this.baseDn = baseDn - } - - String getType() { - return type - } - - void setType(String type) { - this.type = type - } - - String getAttribute() { - return attribute - } - - void setAttribute(String attribute) { - this.attribute = attribute - } - - String getBindDn() { - return bindDn - } - - void setBindDn(String bindDn) { - this.bindDn = bindDn - } - - String getBindPassword() { - return bindPassword - } - - void setBindPassword(String bindPassword) { - this.bindPassword = bindPassword - } - - Map getMappings() { - return mappings - } - - void setMappings(Map mappings) { - this.mappings = mappings - } - - Optional getMapping(String type) { - if (mappings == null) { - return Optional.empty() - } - - return Optional.ofNullable(mappings.get(type)) - } - - @Override - void afterPropertiesSet() throws Exception { - log.info("LDAP enabled: {}", getEnabled()) - - if (!getEnabled()) { - return - } - - log.info("Matrix ID type: {}", getType()) - log.info("LDAP Host: {}", getHost()) - log.info("LDAP Bind DN: {}", getBindDn()) - log.info("LDAP Attribute: {}", getAttribute()) - - if (StringUtils.isBlank(getHost())) { - throw new IllegalStateException("LDAP Host must be configured!") - } - if (StringUtils.isBlank(getAttribute())) { - throw new IllegalStateException("LDAP attribute must be configured!") - } - } - -} diff --git a/src/main/groovy/io/kamax/mxisd/config/ldap/LdapAttributeConfig.java b/src/main/groovy/io/kamax/mxisd/config/ldap/LdapAttributeConfig.java new file mode 100644 index 0000000..f8139ee --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/ldap/LdapAttributeConfig.java @@ -0,0 +1,29 @@ +package io.kamax.mxisd.config.ldap; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "ldap.attribute") +public class LdapAttributeConfig { + + private LdapAttributeUidConfig uid; + private String name; + + public LdapAttributeUidConfig getUid() { + return uid; + } + + public void setUid(LdapAttributeUidConfig uid) { + this.uid = uid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/config/ldap/LdapAttributeUidConfig.java b/src/main/groovy/io/kamax/mxisd/config/ldap/LdapAttributeUidConfig.java new file mode 100644 index 0000000..a56044a --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/ldap/LdapAttributeUidConfig.java @@ -0,0 +1,40 @@ +package io.kamax.mxisd.config.ldap; + +import io.kamax.mxisd.lookup.provider.LdapProvider; +import org.apache.commons.lang.StringUtils; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.PostConstruct; + +@Configuration +@ConfigurationProperties(prefix = "ldap.attribute.uid") +public class LdapAttributeUidConfig { + + private String type; + private String value; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @PostConstruct + public void postConstruct() { + if (!StringUtils.equals(LdapProvider.UID, getType()) && !StringUtils.equals(LdapProvider.MATRIX_ID, getType())) { + throw new IllegalArgumentException("Unsupported LDAP UID type: " + getType()); + } + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/config/ldap/LdapAuthConfig.java b/src/main/groovy/io/kamax/mxisd/config/ldap/LdapAuthConfig.java new file mode 100644 index 0000000..011500e --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/ldap/LdapAuthConfig.java @@ -0,0 +1,20 @@ +package io.kamax.mxisd.config.ldap; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "ldap.auth") +public class LdapAuthConfig { + + private String filter; + + public String getFilter() { + return filter; + } + + public void setFilter(String filter) { + this.filter = filter; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/config/ldap/LdapConfig.groovy b/src/main/groovy/io/kamax/mxisd/config/ldap/LdapConfig.groovy new file mode 100644 index 0000000..55fd3d7 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/ldap/LdapConfig.groovy @@ -0,0 +1,125 @@ +/* + * mxisd - Matrix Identity Server Daemon + * Copyright (C) 2017 Maxime Dor + * + * https://max.kamax.io/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.kamax.mxisd.config.ldap + +import groovy.json.JsonOutput +import org.apache.commons.lang.StringUtils +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration + +import javax.annotation.PostConstruct + +@Configuration +@ConfigurationProperties(prefix = "ldap") +class LdapConfig { + + private Logger log = LoggerFactory.getLogger(LdapConfig.class) + + private boolean enabled + + @Autowired + private LdapConnectionConfig conn + private LdapAttributeConfig attribute + private LdapAuthConfig auth + private LdapIdentityConfig identity + + boolean isEnabled() { + return enabled + } + + void setEnabled(boolean enabled) { + this.enabled = enabled + } + + LdapConnectionConfig getConn() { + return conn + } + + void setConn(LdapConnectionConfig conn) { + this.conn = conn + } + + LdapAttributeConfig getAttribute() { + return attribute + } + + void setAttribute(LdapAttributeConfig attribute) { + this.attribute = attribute + } + + LdapAuthConfig getAuth() { + return auth + } + + void setAuth(LdapAuthConfig auth) { + this.auth = auth + } + + LdapIdentityConfig getIdentity() { + return identity + } + + void setIdentity(LdapIdentityConfig identity) { + this.identity = identity + } + + @PostConstruct + void afterPropertiesSet() { + log.info("--- LDAP Config ---") + log.info("Enabled: {}", isEnabled()) + + if (!isEnabled()) { + return + } + + if (StringUtils.isBlank(conn.getHost())) { + throw new IllegalStateException("LDAP Host must be configured!") + } + + if (1 > conn.getPort() || 65535 < conn.getPort()) { + throw new IllegalStateException("LDAP port is not valid") + } + + if (StringUtils.isBlank(attribute.getUid().getType())) { + throw new IllegalStateException("Attribute UID Type cannot be empty") + } + + + if (StringUtils.isBlank(attribute.getUid().getValue())) { + throw new IllegalStateException("Attribute UID value cannot be empty") + } + + + log.info("Conn: {}", JsonOutput.toJson(conn)) + log.info("Host: {}", conn.getHost()) + log.info("Port: {}", conn.getPort()) + log.info("Bind DN: {}", conn.getBindDn()) + log.info("Base DN: {}", conn.getBaseDn()) + + log.info("Attribute: {}", JsonOutput.toJson(attribute)) + log.info("Auth: {}", JsonOutput.toJson(auth)) + log.info("Identity: {}", JsonOutput.toJson(identity)) + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/config/ldap/LdapConnectionConfig.java b/src/main/groovy/io/kamax/mxisd/config/ldap/LdapConnectionConfig.java new file mode 100644 index 0000000..7f85e5d --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/ldap/LdapConnectionConfig.java @@ -0,0 +1,65 @@ +package io.kamax.mxisd.config.ldap; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "ldap.connection") +public class LdapConnectionConfig { + + private boolean tls; + private String host; + private int port; + private String bindDn; + private String bindPassword; + private String baseDn; + + public boolean isTls() { + return tls; + } + + public void setTls(boolean tls) { + this.tls = tls; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public String getBindDn() { + return bindDn; + } + + public void setBindDn(String bindDn) { + this.bindDn = bindDn; + } + + public String getBindPassword() { + return bindPassword; + } + + public void setBindPassword(String bindPassword) { + this.bindPassword = bindPassword; + } + + public String getBaseDn() { + return baseDn; + } + + public void setBaseDn(String baseDn) { + this.baseDn = baseDn; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/config/ldap/LdapIdentityConfig.java b/src/main/groovy/io/kamax/mxisd/config/ldap/LdapIdentityConfig.java new file mode 100644 index 0000000..c6839e4 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/ldap/LdapIdentityConfig.java @@ -0,0 +1,28 @@ +package io.kamax.mxisd.config.ldap; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Configuration +@ConfigurationProperties(prefix = "ldap.identity") +public class LdapIdentityConfig { + + private Map medium = new HashMap<>(); + + public Map getMedium() { + return medium; + } + + public Optional getQuery(String key) { + return Optional.ofNullable(medium.get(key)); + } + + public void setMedium(Map medium) { + this.medium = medium; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/AuthController.java b/src/main/groovy/io/kamax/mxisd/controller/v1/AuthController.java new file mode 100644 index 0000000..70dd1bb --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/AuthController.java @@ -0,0 +1,89 @@ +/* + * mxisd - Matrix Identity Server Daemon + * Copyright (C) 2017 Maxime Dor + * + * https://max.kamax.io/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.kamax.mxisd.controller.v1; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.kamax.mxisd.auth.AuthManager; +import io.kamax.mxisd.auth.UserAuthResult; +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@RestController +@CrossOrigin +@RequestMapping(produces = MediaType.APPLICATION_JSON_UTF8_VALUE) +public class AuthController { + + private Logger log = LoggerFactory.getLogger(AuthController.class); + + private Gson gson = new Gson(); + + @Autowired + private AuthManager mgr; + + @RequestMapping(value = "/_matrix-internal/identity/v1/check_credentials", method = RequestMethod.POST) + public String checkCredentials(HttpServletRequest req) { + try { + JsonElement el = new JsonParser().parse(IOUtils.toString(req.getInputStream(), StandardCharsets.UTF_8)); + if (!el.isJsonObject() || !el.getAsJsonObject().has("user")) { + throw new IllegalArgumentException("Missing user key"); + } + + JsonObject authData = el.getAsJsonObject().get("user").getAsJsonObject(); + if (!authData.has("id") || !authData.has("password")) { + throw new IllegalArgumentException("Missing id or password keys"); + } + + String id = authData.get("id").getAsString(); + log.info("Requested to check credentials for {}", id); + String password = authData.get("password").getAsString(); + + UserAuthResult result = mgr.authenticate(id, password); + + JsonObject authObj = new JsonObject(); + authObj.addProperty("success", result.isSuccess()); + if (result.isSuccess()) { + authObj.addProperty("mxid", result.getMxid()); + authObj.addProperty("display_name", result.getDisplayName()); + } + JsonObject obj = new JsonObject(); + + obj.add("authentication", authObj); + return gson.toJson(obj); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/StatusController.java b/src/main/groovy/io/kamax/mxisd/controller/v1/StatusController.java new file mode 100644 index 0000000..516ff03 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/StatusController.java @@ -0,0 +1,19 @@ +package io.kamax.mxisd.controller.v1; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@CrossOrigin +@RequestMapping(produces = MediaType.APPLICATION_JSON_UTF8_VALUE) +public class StatusController { + + @RequestMapping(value = "/_matrix/identity/status") + public String getStatus() { + // TODO link to backend + return "{\"status\":{\"health\":\"OK\"}}"; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/lookup/provider/LdapProvider.groovy b/src/main/groovy/io/kamax/mxisd/lookup/provider/LdapProvider.groovy index 281fb82..1984f32 100644 --- a/src/main/groovy/io/kamax/mxisd/lookup/provider/LdapProvider.groovy +++ b/src/main/groovy/io/kamax/mxisd/lookup/provider/LdapProvider.groovy @@ -20,8 +20,8 @@ package io.kamax.mxisd.lookup.provider -import io.kamax.mxisd.config.LdapConfig import io.kamax.mxisd.config.ServerConfig +import io.kamax.mxisd.config.ldap.LdapConfig import io.kamax.mxisd.lookup.SingleLookupRequest import io.kamax.mxisd.lookup.ThreePidMapping import org.apache.commons.lang.StringUtils @@ -53,7 +53,19 @@ class LdapProvider implements IThreePidProvider { @Override boolean isEnabled() { - return ldapCfg.getEnabled() + return ldapCfg.isEnabled() + } + + private LdapConnection getConn() { + return new LdapNetworkConnection(ldapCfg.getConn().getHost(), ldapCfg.getConn().getPort(), ldapCfg.getConn().isTls()) + } + + private void bind(LdapConnection conn) { + conn.bind(ldapCfg.getConn().getBindDn(), ldapCfg.getConn().getBindPassword()) + } + + private String getUidAttribute() { + return ldapCfg.getAttribute().getUid().getValue(); } @Override @@ -67,20 +79,22 @@ class LdapProvider implements IThreePidProvider { } Optional lookup(LdapConnection conn, String medium, String value) { - Optional queryOpt = ldapCfg.getMapping(medium) + String uidAttribute = getUidAttribute() + + Optional queryOpt = ldapCfg.getIdentity().getQuery(medium) if (!queryOpt.isPresent()) { log.warn("{} is not a configured 3PID type for LDAP lookup", medium) return Optional.empty() } String searchQuery = queryOpt.get().replaceAll("%3pid", value) - EntryCursor cursor = conn.search(ldapCfg.getBaseDn(), searchQuery, SearchScope.SUBTREE, ldapCfg.getAttribute()) + EntryCursor cursor = conn.search(ldapCfg.getConn().getBaseDn(), searchQuery, SearchScope.SUBTREE, uidAttribute) try { while (cursor.next()) { Entry entry = cursor.get() log.info("Found possible match, DN: {}", entry.getDn().getName()) - Attribute attribute = entry.get(ldapCfg.getAttribute()) + Attribute attribute = entry.get(uidAttribute) if (attribute == null) { log.info("DN {}: no attribute {}, skpping", entry.getDn(), ldapCfg.getAttribute()) continue @@ -94,12 +108,13 @@ class LdapProvider implements IThreePidProvider { StringBuilder matrixId = new StringBuilder() // TODO Should we turn this block into a map of functions? - if (StringUtils.equals(UID, ldapCfg.getType())) { + String uidType = ldapCfg.getAttribute().getUid().getType() + if (StringUtils.equals(UID, uidType)) { matrixId.append("@").append(data).append(":").append(srvCfg.getName()) - } else if (StringUtils.equals(MATRIX_ID, ldapCfg.getType())) { + } else if (StringUtils.equals(MATRIX_ID, uidType)) { matrixId.append(data) } else { - log.warn("Bind was found but type {} is not supported", ldapCfg.getType()) + log.warn("Bind was found but type {} is not supported", uidType) continue } @@ -119,9 +134,9 @@ class LdapProvider implements IThreePidProvider { Optional find(SingleLookupRequest request) { log.info("Performing LDAP lookup ${request.getThreePid()} of type ${request.getType()}") - LdapConnection conn = new LdapNetworkConnection(ldapCfg.getHost(), ldapCfg.getPort(), ldapCfg.getTls()) + LdapConnection conn = getConn() try { - conn.bind(ldapCfg.getBindDn(), ldapCfg.getBindPassword()) + bind(conn) Optional mxid = lookup(conn, request.getType(), request.getThreePid()) if (mxid.isPresent()) { @@ -147,9 +162,9 @@ class LdapProvider implements IThreePidProvider { log.info("Looking up {} mappings", mappings.size()) List mappingsFound = new ArrayList<>() - LdapConnection conn = new LdapNetworkConnection(ldapCfg.getHost(), ldapCfg.getPort()) + LdapConnection conn = getConn() try { - conn.bind(ldapCfg.getBindDn(), ldapCfg.getBindPassword()) + bind(conn) for (ThreePidMapping mapping : mappings) { try {