diff --git a/src/main/groovy/io/kamax/mxisd/config/MatrixConfig.java b/src/main/groovy/io/kamax/mxisd/config/MatrixConfig.java index f5acfb9..d3f4048 100644 --- a/src/main/groovy/io/kamax/mxisd/config/MatrixConfig.java +++ b/src/main/groovy/io/kamax/mxisd/config/MatrixConfig.java @@ -20,6 +20,7 @@ package io.kamax.mxisd.config; +import com.google.gson.Gson; import io.kamax.mxisd.exception.ConfigurationException; import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; @@ -28,14 +29,38 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; import javax.annotation.PostConstruct; +import java.util.HashMap; +import java.util.List; +import java.util.Map; @Configuration @ConfigurationProperties("matrix") public class MatrixConfig { + public static class Identity { + private Map> servers = new HashMap<>(); + + public Map> getServers() { + return servers; + } + + public void setServers(Map> servers) { + this.servers = servers; + } + + public List getServers(String label) { + if (!servers.containsKey(label)) { + throw new RuntimeException("No Identity server list with label '" + label + "'"); + } + + return servers.get(label); + } + } + private Logger log = LoggerFactory.getLogger(MatrixConfig.class); private String domain; + private Identity identity = new Identity(); public String getDomain() { return domain; @@ -45,6 +70,14 @@ public class MatrixConfig { this.domain = domain; } + public Identity getIdentity() { + return identity; + } + + public void setIdentity(Identity identity) { + this.identity = identity; + } + @PostConstruct public void build() { log.info("--- Matrix config ---"); @@ -54,6 +87,8 @@ public class MatrixConfig { } log.info("Domain: {}", getDomain()); + log.info("Identity:"); + log.info("\tServers: {}", new Gson().toJson(identity.getServers())); } } diff --git a/src/main/groovy/io/kamax/mxisd/config/SessionConfig.java b/src/main/groovy/io/kamax/mxisd/config/SessionConfig.java index e72a5fc..fab29ae 100644 --- a/src/main/groovy/io/kamax/mxisd/config/SessionConfig.java +++ b/src/main/groovy/io/kamax/mxisd/config/SessionConfig.java @@ -41,10 +41,32 @@ public class SessionConfig { public static class PolicySource { + public static class PolicySourceRemote { + + private boolean enabled; + private String server; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getServer() { + return server; + } + + public void setServer(String server) { + this.server = server; + } + + } + private boolean enabled; - private boolean alwaysValidate; private boolean toLocal; - private boolean toRemote; + private PolicySourceRemote toRemote = new PolicySourceRemote(); public boolean isEnabled() { return enabled; @@ -54,14 +76,6 @@ public class SessionConfig { this.enabled = enabled; } - public boolean isAlwaysValidate() { - return alwaysValidate; - } - - public void setAlwaysValidate(boolean alwaysValidate) { - this.alwaysValidate = alwaysValidate; - } - public boolean toLocal() { return toLocal; } @@ -71,10 +85,14 @@ public class SessionConfig { } public boolean toRemote() { + return toRemote.isEnabled(); + } + + public PolicySourceRemote getToRemote() { return toRemote; } - public void setToRemote(boolean toRemote) { + public void setToRemote(PolicySourceRemote toRemote) { this.toRemote = toRemote; } @@ -111,19 +129,11 @@ public class SessionConfig { public PolicySource forIf(boolean isLocal) { return isLocal ? forLocal : forRemote; } + } - private PolicyTemplate bind = new PolicyTemplate(); private PolicyTemplate validation = new PolicyTemplate(); - public PolicyTemplate getBind() { - return bind; - } - - public void setBind(PolicyTemplate bind) { - this.bind = bind; - } - public PolicyTemplate getValidation() { return validation; } diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/remote/RemoteSessionController.java b/src/main/groovy/io/kamax/mxisd/controller/v1/remote/RemoteSessionController.java new file mode 100644 index 0000000..ed06f49 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/remote/RemoteSessionController.java @@ -0,0 +1,37 @@ +package io.kamax.mxisd.controller.v1.remote; + +import io.kamax.mxisd.session.SessionMananger; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; + +@RestController +@CrossOrigin +@RequestMapping(path = RemoteIdentityAPIv1.BASE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) +public class RemoteSessionController { + + private Logger log = LoggerFactory.getLogger(RemoteSessionController.class); + + @Autowired + private SessionMananger mgr; + + @RequestMapping(path = "/validate/requestToken") + public String requestToken( + HttpServletRequest request, + @RequestParam String sid, + @RequestParam("client_secret") String secret, + @RequestParam String token) { + log.info("Request {}: {}", request.getMethod(), request.getRequestURL(), request.getQueryString()); + mgr.createRemote(sid, secret, token); + + return "{}"; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/exception/RemoteIdentityServerException.java b/src/main/groovy/io/kamax/mxisd/exception/RemoteIdentityServerException.java new file mode 100644 index 0000000..1e65fc4 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/exception/RemoteIdentityServerException.java @@ -0,0 +1,31 @@ +/* + * 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.exception; + +import org.apache.http.HttpStatus; + +public class RemoteIdentityServerException extends MatrixException { + + public RemoteIdentityServerException(String error) { + super(HttpStatus.SC_SERVICE_UNAVAILABLE, "M_REMOTE_IS_ERROR", error); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/lookup/provider/DnsLookupProvider.groovy b/src/main/groovy/io/kamax/mxisd/lookup/provider/DnsLookupProvider.groovy index e63e030..28ef1a8 100644 --- a/src/main/groovy/io/kamax/mxisd/lookup/provider/DnsLookupProvider.groovy +++ b/src/main/groovy/io/kamax/mxisd/lookup/provider/DnsLookupProvider.groovy @@ -25,14 +25,12 @@ import io.kamax.mxisd.lookup.SingleLookupReply import io.kamax.mxisd.lookup.SingleLookupRequest import io.kamax.mxisd.lookup.ThreePidMapping import io.kamax.mxisd.lookup.fetcher.IRemoteIdentityServerFetcher +import io.kamax.mxisd.matrix.IdentityServerUtils import org.apache.commons.lang.StringUtils import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Component -import org.xbill.DNS.Lookup -import org.xbill.DNS.SRVRecord -import org.xbill.DNS.Type import java.util.concurrent.ForkJoinPool import java.util.concurrent.RecursiveTask @@ -64,10 +62,6 @@ class DnsLookupProvider implements IThreePidProvider { return 10 } - String getSrvRecordName(String domain) { - return "_matrix-identity._tcp." + domain - } - Optional getDomain(String email) { int atIndex = email.lastIndexOf("@") if (atIndex == -1) { @@ -84,44 +78,7 @@ class DnsLookupProvider implements IThreePidProvider { return Optional.empty() } - log.info("Performing SRV lookup") - String lookupDns = getSrvRecordName(domain) - log.info("Lookup name: {}", lookupDns) - - SRVRecord[] records = (SRVRecord[]) new Lookup(lookupDns, Type.SRV).run() - if (records != null) { - Arrays.sort(records, new Comparator() { - - @Override - int compare(SRVRecord o1, SRVRecord o2) { - return Integer.compare(o1.getPriority(), o2.getPriority()) - } - - }) - - for (SRVRecord record : records) { - log.info("Found SRV record: {}", record.toString()) - String baseUrl = "https://${record.getTarget().toString(true)}:${record.getPort()}" - if (fetcher.isUsable(baseUrl)) { - log.info("Found Identity Server for domain {} at {}", domain, baseUrl) - return Optional.of(baseUrl) - } else { - log.info("{} is not a usable Identity Server", baseUrl) - } - } - } else { - log.info("No SRV record for {}", lookupDns) - } - - log.info("Performing basic lookup using domain name {}", domain) - String baseUrl = "https://" + domain - if (fetcher.isUsable(baseUrl)) { - log.info("Found Identity Server for domain {} at {}", domain, baseUrl) - return Optional.of(baseUrl) - } else { - log.info("{} is not a usable Identity Server", baseUrl) - return Optional.empty() - } + return IdentityServerUtils.findIsUrlForDomain(domain) } @Override diff --git a/src/main/groovy/io/kamax/mxisd/lookup/provider/RemoteIdentityServerFetcher.groovy b/src/main/groovy/io/kamax/mxisd/lookup/provider/RemoteIdentityServerFetcher.groovy index 7435879..5844d9c 100644 --- a/src/main/groovy/io/kamax/mxisd/lookup/provider/RemoteIdentityServerFetcher.groovy +++ b/src/main/groovy/io/kamax/mxisd/lookup/provider/RemoteIdentityServerFetcher.groovy @@ -28,6 +28,7 @@ import io.kamax.mxisd.lookup.SingleLookupReply import io.kamax.mxisd.lookup.SingleLookupRequest import io.kamax.mxisd.lookup.ThreePidMapping import io.kamax.mxisd.lookup.fetcher.IRemoteIdentityServerFetcher +import io.kamax.mxisd.matrix.IdentityServerUtils import org.apache.http.HttpEntity import org.apache.http.HttpResponse import org.apache.http.client.HttpClient @@ -46,36 +47,13 @@ import org.springframework.stereotype.Component @Lazy public class RemoteIdentityServerFetcher implements IRemoteIdentityServerFetcher { - public static final String THREEPID_TEST_MEDIUM = "email" - public static final String THREEPID_TEST_ADDRESS = "john.doe@example.org" - private Logger log = LoggerFactory.getLogger(RemoteIdentityServerFetcher.class) private JsonSlurper json = new JsonSlurper() @Override boolean isUsable(String remote) { - try { - HttpURLConnection rootSrvConn = (HttpURLConnection) new URL( - "${remote}/_matrix/identity/api/v1/lookup?medium=${THREEPID_TEST_MEDIUM}&address=${THREEPID_TEST_ADDRESS}" - ).openConnection() - // TODO turn this into a configuration property - rootSrvConn.setConnectTimeout(2000) - - if (rootSrvConn.getResponseCode() != 200) { - return false - } - - def output = json.parseText(rootSrvConn.getInputStream().getText()) - if (output['address']) { - return false - } - - return true - } catch (IOException | JsonException e) { - log.info("{} is not a usable Identity Server: {}", remote, e.getMessage()) - return false - } + return IdentityServerUtils.isUsable(remote) } @Override diff --git a/src/main/groovy/io/kamax/mxisd/matrix/IdentityServerUtils.java b/src/main/groovy/io/kamax/mxisd/matrix/IdentityServerUtils.java new file mode 100644 index 0000000..f607106 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/matrix/IdentityServerUtils.java @@ -0,0 +1,114 @@ +package io.kamax.mxisd.matrix; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xbill.DNS.Lookup; +import org.xbill.DNS.SRVRecord; +import org.xbill.DNS.TextParseException; +import org.xbill.DNS.Type; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Optional; + +// FIXME placeholder, this must go in matrix-java-sdk for 1.0 +public class IdentityServerUtils { + + public static final String THREEPID_TEST_MEDIUM = "email"; + public static final String THREEPID_TEST_ADDRESS = "mxisd-email-forever-unknown@forever-invalid.kamax.io"; + + private static Logger log = LoggerFactory.getLogger(IdentityServerUtils.class); + private static JsonParser parser = new JsonParser(); + + public static boolean isUsable(String remote) { + try { + // FIXME use Apache HTTP client + HttpURLConnection rootSrvConn = (HttpURLConnection) new URL( + remote + "/_matrix/identity/api/v1/lookup?medium=" + THREEPID_TEST_MEDIUM + "&address=" + THREEPID_TEST_ADDRESS + ).openConnection(); + // TODO turn this into a configuration property + rootSrvConn.setConnectTimeout(2000); + + if (rootSrvConn.getResponseCode() != 200) { + return false; + } + + JsonElement el = parser.parse(IOUtils.toString(rootSrvConn.getInputStream(), StandardCharsets.UTF_8)); + if (!el.isJsonObject()) { + log.debug("IS {} did not send back a JSON object for single 3PID lookup"); + return false; + } + + if (el.getAsJsonObject().has("address")) { + log.debug("IS {} did not send back a JSON object for single 3PID lookup"); + return false; + } + + return true; + } catch (IOException | JsonParseException e) { + log.info("{} is not a usable Identity Server: {}", remote, e.getMessage()); + return false; + } + } + + public static String getSrvRecordName(String domain) { + return "_matrix-identity._tcp." + domain; + } + + public static Optional findIsUrlForDomain(String domainOrUrl) { + try { + try { + domainOrUrl = new URL(domainOrUrl).getHost(); + } catch (MalformedURLException e) { + log.info("{} is not an URL, using as-is", domainOrUrl); + } + + log.info("Discovery Identity Server for {}", domainOrUrl); + log.info("Performing SRV lookup"); + String lookupDns = getSrvRecordName(domainOrUrl); + log.info("Lookup name: {}", lookupDns); + + SRVRecord[] records = (SRVRecord[]) new Lookup(lookupDns, Type.SRV).run(); + if (records != null) { + Arrays.sort(records, Comparator.comparingInt(SRVRecord::getPriority)); + + for (SRVRecord record : records) { + log.info("Found SRV record: {}", record.toString()); + String baseUrl = "https://${record.getTarget().toString(true)}:${record.getPort()}"; + if (isUsable(baseUrl)) { + log.info("Found Identity Server for domain {} at {}", domainOrUrl, baseUrl); + return Optional.of(baseUrl); + } else { + log.info("{} is not a usable Identity Server", baseUrl); + return Optional.empty(); + } + } + } else { + log.info("No SRV record for {}", lookupDns); + } + + log.info("Performing basic lookup using domain name {}", domainOrUrl); + String baseUrl = "https://" + domainOrUrl; + if (isUsable(baseUrl)) { + log.info("Found Identity Server for domain {} at {}", domainOrUrl, baseUrl); + return Optional.of(baseUrl); + } else { + log.info("{} is not a usable Identity Server", baseUrl); + return Optional.empty(); + } + } catch (TextParseException e) { + log.warn(domainOrUrl + " is not a valid domain name"); + return Optional.empty(); + } + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/session/SessionMananger.java b/src/main/groovy/io/kamax/mxisd/session/SessionMananger.java index 47c58b7..484abbf 100644 --- a/src/main/groovy/io/kamax/mxisd/session/SessionMananger.java +++ b/src/main/groovy/io/kamax/mxisd/session/SessionMananger.java @@ -20,41 +20,56 @@ package io.kamax.mxisd.session; +import com.google.gson.JsonObject; import io.kamax.matrix.ThreePidMedium; import io.kamax.mxisd.ThreePid; import io.kamax.mxisd.config.MatrixConfig; import io.kamax.mxisd.config.SessionConfig; -import io.kamax.mxisd.exception.InvalidCredentialsException; -import io.kamax.mxisd.exception.NotAllowedException; -import io.kamax.mxisd.exception.SessionNotValidatedException; +import io.kamax.mxisd.exception.*; import io.kamax.mxisd.lookup.ThreePidValidation; import io.kamax.mxisd.lookup.strategy.LookupStrategy; +import io.kamax.mxisd.matrix.IdentityServerUtils; import io.kamax.mxisd.notification.NotificationManager; import io.kamax.mxisd.storage.IStorage; import io.kamax.mxisd.storage.dao.IThreePidSessionDao; import io.kamax.mxisd.threepid.session.ThreePidSession; +import io.kamax.mxisd.util.RestClientUtils; import org.apache.commons.lang.RandomStringUtils; import org.apache.commons.lang.StringUtils; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import java.io.IOException; +import java.util.List; import java.util.Optional; +import static io.kamax.mxisd.config.SessionConfig.Policy.PolicyTemplate; +import static io.kamax.mxisd.config.SessionConfig.Policy.PolicyTemplate.PolicySource; + @Component public class SessionMananger { private Logger log = LoggerFactory.getLogger(SessionMananger.class); private SessionConfig cfg; + private MatrixConfig mxCfg; private IStorage storage; private LookupStrategy lookup; private NotificationManager notifMgr; + // FIXME export into central class, set version + private CloseableHttpClient client = HttpClients.custom().setUserAgent("mxisd").build(); + @Autowired public SessionMananger(SessionConfig cfg, MatrixConfig mxCfg, IStorage storage, LookupStrategy lookup, NotificationManager notifMgr) { this.cfg = cfg; + this.mxCfg = mxCfg; this.storage = storage; this.lookup = lookup; this.notifMgr = notifMgr; @@ -91,7 +106,7 @@ public class SessionMananger { } public String create(String server, ThreePid tpid, String secret, int attempt, String nextLink) { - SessionConfig.Policy.PolicyTemplate policy = cfg.getPolicy().getValidation(); + PolicyTemplate policy = cfg.getPolicy().getValidation(); if (!policy.isEnabled()) { throw new NotAllowedException("Validating 3PID is disabled globally"); } @@ -118,7 +133,7 @@ public class SessionMananger { log.info("Is 3PID bound to local domain? {}", isLocal); // This might need a configuration by medium type? - SessionConfig.Policy.PolicyTemplate.PolicySource policySource = policy.forIf(isLocal); + PolicySource policySource = policy.forIf(isLocal); if (!policySource.isEnabled() || (!policySource.toLocal() && !policySource.toRemote())) { log.info("Session for {}: cancelled due to policy", tpid); throw new NotAllowedException("Validating " + (isLocal ? "local" : "remote") + " 3PID is not allowed"); @@ -170,4 +185,42 @@ public class SessionMananger { // TODO perform this if request was proxied } + public void createRemote(String sid, String secret, String token) { + ThreePidSession session = getSessionIfValidated(sid, secret); + + boolean isLocal = isLocal(session.getThreePid()); + PolicySource policy = cfg.getPolicy().getValidation().forIf(isLocal); + if (!policy.isEnabled() || !policy.toRemote()) { + throw new NotAllowedException("Validating " + (isLocal ? "local" : "remote") + " 3PID is not allowed"); + } + + List servers = mxCfg.getIdentity().getServers(policy.getToRemote().getServer()); + if (servers.isEmpty()) { + throw new InternalServerError(); + } + + String url = IdentityServerUtils.findIsUrlForDomain(servers.get(0)).orElseThrow(InternalServerError::new); + + JsonObject body = new JsonObject(); + body.addProperty("client_secret", RandomStringUtils.randomAlphanumeric(16)); + body.addProperty(session.getThreePid().getMedium(), session.getThreePid().getAddress()); + body.addProperty("send_attempt", 1); + + log.info("Creating remote 3PID session for {} with local session [{}] to {}", session.getThreePid(), sid); + HttpPost tokenReq = RestClientUtils.post(url + "/_matrix/identity/api/v1/validate/" + session.getThreePid().getMedium() + "/requestToken", body); + try (CloseableHttpResponse response = client.execute(tokenReq)) { + int status = response.getStatusLine().getStatusCode(); + if (status < 200 || status >= 300) { + throw new RemoteIdentityServerException("Remote identity server returned with status " + status); + } + + // TODO finish + } catch (IOException e) { + log.warn("Failed to create remote session with {} for {}: {}", url, session.getThreePid(), e.getMessage()); + throw new RemoteIdentityServerException(e.getMessage()); + } + + + } + } diff --git a/src/main/groovy/io/kamax/mxisd/util/RestClientUtils.java b/src/main/groovy/io/kamax/mxisd/util/RestClientUtils.java index c521008..1c23a83 100644 --- a/src/main/groovy/io/kamax/mxisd/util/RestClientUtils.java +++ b/src/main/groovy/io/kamax/mxisd/util/RestClientUtils.java @@ -20,7 +20,9 @@ package io.kamax.mxisd.util; +import com.google.gson.FieldNamingPolicy; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; @@ -29,6 +31,8 @@ import java.nio.charset.StandardCharsets; public class RestClientUtils { + private static Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create(); + public static HttpPost post(String url, String body) { StringEntity entity = new StringEntity(body, StandardCharsets.UTF_8); entity.setContentType(ContentType.APPLICATION_JSON.toString()); @@ -45,4 +49,8 @@ public class RestClientUtils { return post(url, gson.toJson(o)); } + public static HttpPost post(String url, Object o) { + return post(url, gson.toJson(o)); + } + } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 18656d2..839b378 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -15,6 +15,12 @@ logging: server: port: 8090 +matrix: + identity: + servers: + root: + - 'https://matrix.org' + lookup: recursive: enabled: true @@ -90,11 +96,15 @@ session.policy.validation: forLocal: enabled: true toLocal: true # This should not be changed unless you know exactly the implications! - toRemote: true + toRemote: + enabled: true + server: 'root' forRemote: enabled: true toLocal: false - toRemote: true + toRemote: + enabled: true + server: 'root' storage: backend: 'sqlite'