diff --git a/build.gradle b/build.gradle index 59e6b9d..053837f 100644 --- a/build.gradle +++ b/build.gradle @@ -79,6 +79,9 @@ dependencies { // Spring Boot - standalone app compile 'org.springframework.boot:spring-boot-starter-web:1.5.3.RELEASE' + // Thymeleaf for HTML templates + compile "org.springframework.boot:spring-boot-starter-thymeleaf:1.5.3.RELEASE" + // Matrix Java SDK compile 'io.kamax:matrix-java-sdk:0.0.2' diff --git a/docs/sessions/3pid.md b/docs/sessions/3pid.md new file mode 100644 index 0000000..0f3a0ba --- /dev/null +++ b/docs/sessions/3pid.md @@ -0,0 +1,335 @@ +# 3PID Sessions +- [Overview](#overview) +- [Purpose](#purpose) +- [Federation](#federation) + - [3PID scope](#3pid-scope) + - [Session scope](#session-scope) +- [Notifications](#notifications) + - [Email](#email) +- [Usage](#usage) + - [Configuration](#configuration) + - [Scenarios](#scenarios) + - [Default](#default) + - [Local sessions only](#local-sessions-only) + - [Remote sessions only](#remote-sessions-only) + - [Sessions disabled](#sessions-disabled) + +## Overview +When adding an email, a phone number or any other kind of 3PID (Third-Party Identifier), +the identity server is called to validate the 3PID. + +Once this 3PID is validated, the Homeserver will publish the user Matrix ID on the Identity Server and +add this 3PID to the Matrix account which initiated the request. + +## Purpose +This serves two purposes: +- Add the 3PID as an administrative/login info for the Homeserver directly +- Publish, or *Bind*, the 3PID so it can be queried from Homeservers and clients when inviting someone in a room +by a 3PID, allowing it to be resolved to a Matrix ID. + +## Federation +Federation is based on the principle that one can get a domain name and serve services and information within that +domain namespace in a way which can be discovered following a specific protocol or specification. + +In the Matrix eco-system, some 3PID can be federated (e.g. emails) while some others cannot (phone numbers). +Also, Matrix users might add 3PIDs that would not point to the Identity server that actually holds the 3PID binding. + +Example: a user from Homeserver `example.org` adds an email `john@gmail.com`. +If a federated lookup was performed, Identity servers would try to find the 3PID bind at the `gmail.com` server, and +not `example.org`. + +To allow global publishing of 3PID bindings to be found anywhere within the current protocol specification, one would +perform a *Remote session* and *Remote bind*, effectively starting a new 3PID session with another Identity server on +behalf of the user. +To ensure lookup works consistency within the current Matrix network, the central Matrix.org Identity Server should be +used to store *remote* sessions and binds. + +On the flip side, at the time of writing, the Matrix specification and the central Matrix.org servers do not allow to +remote a 3PID bind. This means that once a 3PID is published (email, phone number, etc.), it cannot be easily remove +and would require contacting the Matrix.org administrators for each bind individually. +This poses a privacy, control and security concern, especially for groups/corporations that want to keep a tight control +on where such identifiers can be made publicly visible. + +To ensure full control, validation management rely on two concepts: +- The scope of 3PID being validated +- The scope of 3PID sessions that should be possible/offered + +### 3PID scope +3PID can either be scoped as local or remote. + +Local means that they can looked up using federation and that such federation call would end up on the local +Identity Server. +Remote means that they cannot be lookup using federation or that a federation call would not end up on the local +Identity Server. + +Email addresses can either be local or remote 3PID, depending on the domain. If the address is one from the configured +domain in the Identity server, it will be scoped as local. If it is from another domain, it will be as remote. + +Phone number can only be scoped as remote, since there is currently no way to perform DNS queries that would lead back +to the Identity server who validated the phone number. + +### Session scope +Sessions can be scoped as: +- Local only - validate 3PIDs directly, do not allow the creation of 3PID sessions on a remote Identity server. +- Local and Remote - validate 3PIDs directly, offer users to option to also validate and bind 3PID on another server. +- Remote only - validate and bind 3PIDs on another server, no validation or bind done locally. + +--- + +**IMPORTANT NOTE:** mxisd does not store bindings directly. While a user can see its email, phone number or any other +3PID in its settings/profile, it does **NOT** mean it is published anywhere and can be used to invite/search the user. +Identity backends (LDAP, REST, SQL) are the ones holding such data. +If you still want added arbitrary 3PIDs to be discoverable on your local server, you will need to link mxisd to your +synapse DB to make it an Identity backend. + +See the [Scenarios](#scenarios) for more info on how and why. + +## Notifications +3PIDs are validated by sending a pre-formatted message containing a token to that 3PID address, which must be given to the +Identity server that received the request. This is usually done by means of a URL to visit for email or a short number +received by SMS for phone numbers. + +mxisd use two components for this: +- Generator which produces the message to be sent with the necessary information the user needs to validate their session. +- Connector which actually send the notification (e.g. SMTP for email). + +Built-in generators and connectors for supported 3PID types: + +### Email +Generators: +- Template + +Connectors: +- SMTP + + +## Usage +### Configuration +The following example of configuration (incomplete extract) shows which items are relevant for 3PID sessions. + +**IMPORTANT:** Most configuration items shown have default values and should not be included in your own configuration +file unless you want to specifically overwrite them. +Please refer to the full example config file to see which keys are mandatory and to be included in your configuration. +``` +matrix: + identity: + servers: + root: # Not to be included in config! Already present in default config! + - 'https://matrix.org' + + +threepid: + medium: + email: + connector: 'smtp' + generator: 'template' + connectors: + smtp: + host: '' + port: 587 + tls: 1 + login: '' + password: '' + generators: + template: # Not to be included in config! Already present in default config! + invite: 'classpath:email/invite-template.eml' + session: + validation: + local: 'classpath:email/validate-local-template.eml' + remote: 'classpath:email/validate-remote-template.eml' + +session: + policy: + validation: + enabled: true + forLocal: + enabled: true + toLocal: true + toRemote: + enabled: true + server: 'configExample' # Not to be included in config! Already present in default config! + forRemote: + enabled: true + toLocal: false + toRemote: + enabled: true + server: 'configExample' # Not to be included in config! Already present in default config! +``` + +`matrix.identity.servers` is the namespace to configure arbitrary list of Identity servers with a label as parent key. +In the above example, the list with label `configExample` contains a single server entry pointing to `https://matrix.org`. + +**NOTE:** The server list is set to `root` by default and should typically NOT be included in your config. + +Identity server entry can be of two format: +- URL, bypassing any kind of domain and port discovery +- Domain name as `string`, allowing federated discovery to take place. + +The label can be used in other places of the configuration, allowing you to only declare Identity servers once. + +--- + +`threepid.medium.<3PID>` is the namespace to configure 3PID specific items, not directly tied to any other component of +mxisd. +In the above example, only `email` is defined as 3PID type. + +Each 3PID namespace comes with 4 configuration key allowing you to configure generators and connectors for notifications: +- `connectors` is a configuration namespace to be used for any connector configuration. Child keys represent the unique +ID for each connector. +- `generators` is a configuration namespace to be used for any generator configuration. Child keys represent the unique +ID for each generator. +- `connector` is given the ID of the connector to be used at runtime. +- `generator` is given the ID of the generator to be used at runtime. + +In the above example, emails notifications are generated by the `template` module and sent with the `smtp` module. + +mxisd comes with the following IDs built-in: +**Connectors** +- `smtp` for a basic SMTP connector, attempting STARTLS by default. + +**Generators** +- `template`, loading content from template files, using built-in mxisd templates by default. + +--- + +`session.policy.validation` is the core configuration to control what users configured to use your Identity server +are allowed to do in terms of 3PID sessions. + +The policy is divided contains a global on/off switch for 3PID sessions using `.enabled` +It is also divided into two sections: `forLocal` and `forRemote` which refers to the 3PID scopes. + +Each scope is divided into three parts: +- global on/off switch for 3PID sessions using `.enabled` +- `toLocal` allowing or not local 3PID session validations +- `toRemote` allowing or not remote 3PID session validations and to which server such sessions should be sent. +`.server` takes a Matrix Identity server list label. Only the first server in the list is currently used. + +If both `toLocal` and `toRemote` are enabled, the user will be offered to initiate a remote session once their 3PID +locally validated. + +### Scenarios +It is important to keep in mind that mxisd does not create bindings, irrelevant if a user added a 3PID to their profile. +Instead, when queried for bindings, mxisd will query Identity backends which are responsible to store this kind of information. + +This has the side effect that any 3PID added to a user profile which is NOT within a configured and enabled Identity backend +will simply not be usable for search or invites, **even on the same Homeserver!** +mxisd does not store binds on purpose, as one of its primary goal is to ensure maximum compatibility with federation +and the rest of the Matrix ecosystem is preserved. + +Nonetheless, because mxisd also aims at offering support for tight control over identity data, it is possible to have +such 3PID bindings available for search and invite queries on the local Homeserver by using the `SQL` backend and +configuring it to use the synapse database. Support for `SQLite` and `PostgreSQL` is available. + +See the [Local sessions only](#local-sessions-only) use case for more information on how to configure. + +#### Default +By default, mxisd allows the following: + +| | Local Session | Remote Session | +|----------------|-------|--------| +| **Local 3PID** | Yes | Yes, offered | +| **Remote 3PID** | No, Remote forced | Yes | + +This is usually what people expect and will feel natural to users and does not involve further integration. + +This allows to stay in control for e-mail addresses which domain matches your Matrix environment, still making them +discoverable with federation but not recorded in a 3rd party Identity server which is not under your control. +Users still get the possibility to publish globally their address if needed. + +Other e-mail addresses and phone number will be redirected to remote sessions to ensure full compatibility with the Matrix +ecosystem and other federated servers. + +#### Local sessions only +**NOTE:** This does not affect 3PID lookups (queries to find Matrix IDs) which will remain public due to limitation +in the Matrix protocol. + +This configuration ensures maximum confidentiality and privacy. +Typical use cases: +- Private Homeserver, not federated +- Internal Homeserver without direct Internet access +- Custom product based on Matrix which does not federate + +No 3PID will be sent to a remote Identity server and all validation will be performed locally. +On the flip side, people with *Remote* 3PID scopes will not be found from other servers. + +Use the following values: +``` +session: + policy: + validation: + enabled: true + forLocal: + enabled: true + toLocal: true + toRemote: + enabled: false + forRemote: + enabled: true + toLocal: true + toRemote: + enabled: false +``` + +**IMPORTANT**: When using local-only mode, you will also need to link mxisd to synapse if you want user searches and invites to work. +To do so, add/edit the following configuration keys: +``` +sql: + enabled: true + type: 'postgresql' + connection: '' +``` +- `sql.enabled` set to `true` to activate the SQL backend. +- `sql.type` can be set to `sqlite` or `postgresql`, depending on your synapse setup. +- `sql.connection` use a JDBC format which is appened after the `jdbc:type:` connection URI. +Example values for each type: + - `sqlite`: `/path/to/homeserver.db` + - `postgresql`: `//localhost/database?user=synapse&password=synapse` + +#### Remote sessions only +This configuration ensures all 3PID are made public for maximum compatibility and reach within the Matrix ecosystem, at +the cost of confidentiality and privacy. + +Typical use cases: +- Public Homeserver +- Homeserver with registration enabled + +Use the following values: +``` +session: + policy: + validation: + enabled: true + forLocal: + enabled: true + toLocal: false + toRemote: + enabled: true + forRemote: + enabled: true + toLocal: false + toRemote: + enabled: true +``` + +#### Sessions disabled +This configuration would disable 3PID session altogether, preventing users from adding emails and/or phone numbers to +their profiles. +This would be used if mxisd is also performing authentication for the Homeserver, typically with synapse and the +[REST Auth module](https://github.com/kamax-io/matrix-synapse-rest-auth). + +While this feature is not yet ready in the REST auth module, you would use this configuration mode to auto-populate 3PID +at user login and prevent any further add. + +**This mode comes with several important restrictions:** +- This does not prevent users from removing 3PID from their profile. They would be unable to add them back! +- This prevents users from initiating remote session to make their 3PID binds globally visible + +It is therefore recommended to not fully disable sessions but instead restrict specific set of 3PID and Session scopes. + +Use the following values to enable this mode: +``` +session: + policy: + validation: + enabled: false +``` diff --git a/src/main/groovy/io/kamax/mxisd/ThreePid.java b/src/main/groovy/io/kamax/mxisd/ThreePid.java index d51ecd6..3a0b6bf 100644 --- a/src/main/groovy/io/kamax/mxisd/ThreePid.java +++ b/src/main/groovy/io/kamax/mxisd/ThreePid.java @@ -26,6 +26,10 @@ public class ThreePid { private String medium; private String address; + public ThreePid(ThreePid tpid) { + this(tpid.getMedium(), tpid.getAddress()); + } + public ThreePid(String medium, String address) { this.medium = medium; this.address = address; 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 new file mode 100644 index 0000000..fab29ae --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/SessionConfig.java @@ -0,0 +1,173 @@ +/* + * 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 com.google.gson.Gson; +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("session") +public class SessionConfig { + + private static Logger log = LoggerFactory.getLogger(SessionConfig.class); + + public static class Policy { + + public static class PolicyTemplate { + + 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 toLocal; + private PolicySourceRemote toRemote = new PolicySourceRemote(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean toLocal() { + return toLocal; + } + + public void setToLocal(boolean toLocal) { + this.toLocal = toLocal; + } + + public boolean toRemote() { + return toRemote.isEnabled(); + } + + public PolicySourceRemote getToRemote() { + return toRemote; + } + + public void setToRemote(PolicySourceRemote toRemote) { + this.toRemote = toRemote; + } + + } + + private boolean enabled; + private PolicySource forLocal = new PolicySource(); + private PolicySource forRemote = new PolicySource(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public PolicySource getForLocal() { + return forLocal; + } + + public PolicySource forLocal() { + return forLocal; + } + + public PolicySource getForRemote() { + return forRemote; + } + + public PolicySource forRemote() { + return forRemote; + } + + public PolicySource forIf(boolean isLocal) { + return isLocal ? forLocal : forRemote; + } + + } + + private PolicyTemplate validation = new PolicyTemplate(); + + public PolicyTemplate getValidation() { + return validation; + } + + public void setValidation(PolicyTemplate validation) { + this.validation = validation; + } + + } + + private MatrixConfig mxCfg; + private Policy policy = new Policy(); + + @Autowired + public SessionConfig(MatrixConfig mxCfg) { + this.mxCfg = mxCfg; + } + + public MatrixConfig getMatrixCfg() { + return mxCfg; + } + + public Policy getPolicy() { + return policy; + } + + public void setPolicy(Policy policy) { + this.policy = policy; + } + + @PostConstruct + public void build() { + log.info("--- Session config ---"); + log.info("Global Policy: {}", new Gson().toJson(policy)); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/config/ThymeleafConfig.java b/src/main/groovy/io/kamax/mxisd/config/ThymeleafConfig.java new file mode 100644 index 0000000..ecb967b --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/ThymeleafConfig.java @@ -0,0 +1,42 @@ +/* + * 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.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.thymeleaf.resourceresolver.FileResourceResolver; +import org.thymeleaf.templateresolver.TemplateResolver; + +@Configuration +public class ThymeleafConfig { + + @Bean + public TemplateResolver getFileSystemResolver() { + TemplateResolver resolver = new TemplateResolver(); + resolver.setPrefix(""); + resolver.setSuffix(""); + resolver.setCacheable(false); + resolver.setOrder(1); + resolver.setResourceResolver(new FileResourceResolver()); + return resolver; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/config/ViewConfig.java b/src/main/groovy/io/kamax/mxisd/config/ViewConfig.java new file mode 100644 index 0000000..2a6fa7c --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/ViewConfig.java @@ -0,0 +1,144 @@ +/* + * 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 com.google.gson.Gson; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.PostConstruct; + +@Configuration +@ConfigurationProperties("view") +public class ViewConfig { + + private Logger log = LoggerFactory.getLogger(ViewConfig.class); + + public static class Session { + + public static class Paths { + + private String failure; + private String success; + + public String getFailure() { + return failure; + } + + public void setFailure(String failure) { + this.failure = failure; + } + + public String getSuccess() { + return success; + } + + public void setSuccess(String success) { + this.success = success; + } + + } + + public static class Local { + + private Paths onTokenSubmit = new Paths(); + + public Paths getOnTokenSubmit() { + return onTokenSubmit; + } + + public void setOnTokenSubmit(Paths onTokenSubmit) { + this.onTokenSubmit = onTokenSubmit; + } + + } + + public static class Remote { + + private Paths onRequest = new Paths(); + private Paths onCheck = new Paths(); + + public Paths getOnRequest() { + return onRequest; + } + + public void setOnRequest(Paths onRequest) { + this.onRequest = onRequest; + } + + public Paths getOnCheck() { + return onCheck; + } + + public void setOnCheck(Paths onCheck) { + this.onCheck = onCheck; + } + + } + + private Local local = new Local(); + private Local localRemote = new Local(); + private Remote remote = new Remote(); + + public Local getLocal() { + return local; + } + + public void setLocal(Local local) { + this.local = local; + } + + public Local getLocalRemote() { + return localRemote; + } + + public void setLocalRemote(Local localRemote) { + this.localRemote = localRemote; + } + + public Remote getRemote() { + return remote; + } + + public void setRemote(Remote remote) { + this.remote = remote; + } + } + + private Session session = new Session(); + + public Session getSession() { + return session; + } + + public void setSession(Session session) { + this.session = session; + } + + @PostConstruct + public void build() { + log.info("--- View config ---"); + log.info("Session: {}", new Gson().toJson(session)); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/config/invite/sender/EmailSenderConfig.java b/src/main/groovy/io/kamax/mxisd/config/threepid/connector/EmailSmtpConfig.java similarity index 56% rename from src/main/groovy/io/kamax/mxisd/config/invite/sender/EmailSenderConfig.java rename to src/main/groovy/io/kamax/mxisd/config/threepid/connector/EmailSmtpConfig.java index 8a0fa67..49848b8 100644 --- a/src/main/groovy/io/kamax/mxisd/config/invite/sender/EmailSenderConfig.java +++ b/src/main/groovy/io/kamax/mxisd/config/threepid/connector/EmailSmtpConfig.java @@ -18,7 +18,7 @@ * along with this program. If not, see . */ -package io.kamax.mxisd.config.invite.sender; +package io.kamax.mxisd.config.threepid.connector; import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; @@ -27,22 +27,18 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; import javax.annotation.PostConstruct; -import java.io.File; @Configuration -@ConfigurationProperties(prefix = "invite.sender.email") -public class EmailSenderConfig { +@ConfigurationProperties(prefix = "threepid.medium.email.connectors.smtp") +public class EmailSmtpConfig { - private Logger log = LoggerFactory.getLogger(EmailSenderConfig.class); + private Logger log = LoggerFactory.getLogger(EmailSmtpConfig.class); private String host; private int port; private int tls; private String login; private String password; - private String email; - private String name; - private String template; public String getHost() { return host; @@ -84,52 +80,14 @@ public class EmailSenderConfig { this.password = password; } - public String getEmail() { - return email; - } - - public void setEmail(String email) { - this.email = email; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getTemplate() { - return template; - } - - public void setTemplate(String template) { - this.template = template; - } - @PostConstruct - private void postConstruct() { - log.info("--- E-mail Invite Sender config ---"); + public void build() { + log.info("--- E-mail SMTP Connector config ---"); log.info("Host: {}", getHost()); log.info("Port: {}", getPort()); log.info("TLS Mode: {}", getTls()); log.info("Login: {}", getLogin()); - log.info("Has password: {}", !StringUtils.isBlank(getPassword())); - log.info("E-mail: {}", getEmail()); - if (!StringUtils.startsWith(getTemplate(), "classpath:")) { - if (StringUtils.isBlank(getTemplate())) { - log.warn("invite.sender.template is empty! Will not send invites"); - } else { - File cp = new File(getTemplate()).getAbsoluteFile(); - log.info("Template: {}", cp.getAbsolutePath()); - if (!cp.exists() || !cp.isFile() || !cp.canRead()) { - log.warn(getTemplate() + " does not exist, is not a file or cannot be read"); - } - } - } else { - log.info("Template: Built-in"); - } + log.info("Has password: {}", StringUtils.isNotBlank(getPassword())); } } diff --git a/src/main/groovy/io/kamax/mxisd/config/threepid/medium/EmailConfig.java b/src/main/groovy/io/kamax/mxisd/config/threepid/medium/EmailConfig.java new file mode 100644 index 0000000..29ac7c0 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/threepid/medium/EmailConfig.java @@ -0,0 +1,116 @@ +/* + * 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.threepid.medium; + +import io.kamax.mxisd.config.MatrixConfig; +import io.kamax.mxisd.exception.ConfigurationException; +import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang.WordUtils; +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("threepid.medium.email") +public class EmailConfig { + + public static class Identity { + private String from; + private String name; + + public String getFrom() { + return from; + } + + public void setFrom(String from) { + this.from = from; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + } + + private String generator; + private String connector; + + private Logger log = LoggerFactory.getLogger(EmailConfig.class); + + private MatrixConfig mxCfg; + private Identity identity = new Identity(); + + @Autowired + public EmailConfig(MatrixConfig mxCfg) { + this.mxCfg = mxCfg; + } + + public Identity getIdentity() { + return identity; + } + + public String getGenerator() { + return generator; + } + + public void setGenerator(String generator) { + this.generator = generator; + } + + public String getConnector() { + return connector; + } + + public void setConnector(String connector) { + this.connector = connector; + } + + @PostConstruct + public void build() { + log.info("--- E-mail config ---"); + + if (StringUtils.isBlank(getGenerator())) { + throw new ConfigurationException("generator"); + } + + if (StringUtils.isBlank(getConnector())) { + throw new ConfigurationException("connector"); + } + + log.info("From: {}", identity.getFrom()); + + if (StringUtils.isBlank(identity.getName())) { + identity.setName(WordUtils.capitalize(mxCfg.getDomain()) + " Identity Server"); + } + log.info("Name: {}", identity.getName()); + log.info("Generator: {}", getGenerator()); + log.info("Connector: {}", getConnector()); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/config/threepid/medium/EmailTemplateConfig.java b/src/main/groovy/io/kamax/mxisd/config/threepid/medium/EmailTemplateConfig.java new file mode 100644 index 0000000..a426dc4 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/threepid/medium/EmailTemplateConfig.java @@ -0,0 +1,107 @@ +/* + * 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.threepid.medium; + +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.PostConstruct; + +@Configuration +@ConfigurationProperties("threepid.medium.email.generators.template") +public class EmailTemplateConfig { + + private static Logger log = LoggerFactory.getLogger(EmailTemplateConfig.class); + private static final String classpathPrefix = "classpath:"; + + private static String getName(String path) { + if (StringUtils.startsWith(path, classpathPrefix)) { + return "Built-in (" + path.substring(classpathPrefix.length()) + ")"; + } + + return path; + } + + public static class Session { + + public static class SessionValidation { + + private String local; + private String remote; + + public String getLocal() { + return local; + } + + public void setLocal(String local) { + this.local = local; + } + + public String getRemote() { + return remote; + } + + public void setRemote(String remote) { + this.remote = remote; + } + + } + + private SessionValidation validation; + + public SessionValidation getValidation() { + return validation; + } + + public void setValidation(SessionValidation validation) { + this.validation = validation; + } + + } + + private String invite; + private Session session = new Session(); + + public String getInvite() { + return invite; + } + + public void setInvite(String invite) { + this.invite = invite; + } + + public Session getSession() { + return session; + } + + @PostConstruct + public void build() { + log.info("--- E-mail Generator templates config ---"); + log.info("Invite: {}", getName(getInvite())); + log.info("Session validation:"); + log.info("\tLocal: {}", getName(getSession().getValidation().getLocal())); + log.info("\tRemote: {}", getName(getSession().getValidation().getRemote())); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/DefaultExceptionHandler.java b/src/main/groovy/io/kamax/mxisd/controller/v1/DefaultExceptionHandler.java index 1fb0f86..8d03874 100644 --- a/src/main/groovy/io/kamax/mxisd/controller/v1/DefaultExceptionHandler.java +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/DefaultExceptionHandler.java @@ -23,7 +23,9 @@ package io.kamax.mxisd.controller.v1; import com.google.gson.Gson; import com.google.gson.JsonObject; import io.kamax.mxisd.exception.BadRequestException; +import io.kamax.mxisd.exception.InternalServerError; import io.kamax.mxisd.exception.MappingAlreadyExistsException; +import io.kamax.mxisd.exception.MatrixException; import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,6 +35,8 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.time.Instant; @ControllerAdvice @ResponseBody @@ -50,6 +54,23 @@ public class DefaultExceptionHandler { return gson.toJson(obj); } + @ExceptionHandler(InternalServerError.class) + public String handle(InternalServerError e, HttpServletResponse response) { + if (StringUtils.isNotBlank(e.getInternalReason())) { + log.error("Reference #{} - {}", e.getReference(), e.getInternalReason()); + } else { + log.error("Reference #{}", e); + } + + return handleGeneric(e, response); + } + + @ExceptionHandler(MatrixException.class) + public String handleGeneric(MatrixException e, HttpServletResponse response) { + response.setStatus(e.getStatus()); + return handle(e.getErrorCode(), e.getError()); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(MissingServletRequestParameterException.class) public String handle(MissingServletRequestParameterException e) { @@ -72,7 +93,14 @@ public class DefaultExceptionHandler { @ExceptionHandler(RuntimeException.class) public String handle(HttpServletRequest req, RuntimeException e) { log.error("Unknown error when handling {}", req.getRequestURL(), e); - return handle("M_UNKNOWN", StringUtils.defaultIfBlank(e.getMessage(), "An uknown error occured. Contact the server administrator if this persists.")); + return handle( + "M_UNKNOWN", + StringUtils.defaultIfBlank( + e.getMessage(), + "An internal server error occured. If this error persists, please contact support with reference #" + + Instant.now().toEpochMilli() + ) + ); } } diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/SessionController.groovy b/src/main/groovy/io/kamax/mxisd/controller/v1/SessionController.groovy index 2df1de0..718d671 100644 --- a/src/main/groovy/io/kamax/mxisd/controller/v1/SessionController.groovy +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/SessionController.groovy @@ -20,142 +20,62 @@ package io.kamax.mxisd.controller.v1 -import com.google.gson.Gson -import com.google.gson.JsonObject -import io.kamax.mxisd.controller.v1.io.SessionEmailTokenRequestJson -import io.kamax.mxisd.controller.v1.io.SessionPhoneTokenRequestJson -import io.kamax.mxisd.exception.BadRequestException -import io.kamax.mxisd.invitation.InvitationManager -import io.kamax.mxisd.lookup.ThreePidValidation -import io.kamax.mxisd.mapping.MappingManager -import org.apache.commons.io.IOUtils -import org.apache.commons.lang.StringUtils -import org.apache.http.HttpStatus +import io.kamax.mxisd.config.ServerConfig +import io.kamax.mxisd.config.ViewConfig +import io.kamax.mxisd.controller.v1.remote.RemoteIdentityAPIv1 +import io.kamax.mxisd.session.SessionMananger +import io.kamax.mxisd.session.ValidationResult 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.* +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse -import java.nio.charset.StandardCharsets -@RestController -@CrossOrigin -@RequestMapping(path = IdentityAPIv1.BASE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) +@Controller +@RequestMapping(path = IdentityAPIv1.BASE) class SessionController { - @Autowired - private MappingManager mgr - - @Autowired - private InvitationManager invMgr; - - private Gson gson = new Gson() - private Logger log = LoggerFactory.getLogger(SessionController.class) - private T fromJson(HttpServletRequest req, Class obj) { - gson.fromJson(new InputStreamReader(req.getInputStream(), StandardCharsets.UTF_8), obj) - } + @Autowired + private ServerConfig srvCfg; - @RequestMapping(value = "/validate/{medium}/requestToken") - String init(HttpServletRequest request, HttpServletResponse response, @PathVariable String medium) { - log.info("Requested: {}", request.getRequestURL(), request.getQueryString()) + @Autowired + private SessionMananger mgr - if (StringUtils.equals("email", medium)) { - SessionEmailTokenRequestJson req = fromJson(request, SessionEmailTokenRequestJson.class) - return gson.toJson(new Sid(mgr.create(req))) - } - - if (StringUtils.equals("msisdn", medium)) { - SessionPhoneTokenRequestJson req = fromJson(request, SessionPhoneTokenRequestJson.class) - return gson.toJson(new Sid(mgr.create(req))) - } - - JsonObject obj = new JsonObject(); - obj.addProperty("errcode", "M_INVALID_3PID_TYPE") - obj.addProperty("error", medium + " is not supported as a 3PID type") - response.setStatus(HttpStatus.SC_BAD_REQUEST) - return gson.toJson(obj) - } + @Autowired + private ViewConfig viewCfg; @RequestMapping(value = "/validate/{medium}/submitToken") - String validate(HttpServletRequest request, - @RequestParam String sid, - @RequestParam("client_secret") String secret, @RequestParam String token) { + String validate( + HttpServletRequest request, + HttpServletResponse response, + @RequestParam String sid, + @RequestParam("client_secret") String secret, + @RequestParam String token, + Model model + ) { log.info("Requested: {}?{}", request.getRequestURL(), request.getQueryString()) - mgr.validate(sid, secret, token) - - return "{}" - } - - @RequestMapping(value = "/3pid/getValidated3pid") - String check(HttpServletRequest request, HttpServletResponse response, - @RequestParam String sid, @RequestParam("client_secret") String secret) { - log.info("Requested: {}?{}", request.getRequestURL(), request.getQueryString()) - - Optional result = mgr.getValidated(sid, secret) - if (result.isPresent()) { - log.info("requested session was validated") - ThreePidValidation pid = result.get() - - JsonObject obj = new JsonObject() - obj.addProperty("medium", pid.getMedium()) - obj.addProperty("address", pid.getAddress()) - obj.addProperty("validated_at", pid.getValidation().toEpochMilli()) - - return gson.toJson(obj); + ValidationResult r = mgr.validate(sid, secret, token) + log.info("Session {} was validated", sid) + if (r.getNextUrl().isPresent()) { + String url = srvCfg.getPublicUrl() + r.getNextUrl().get() + log.info("Session {} validation: next URL is present, redirecting to {}", sid, url) + response.sendRedirect(url) } else { - log.info("requested session was not validated") - - JsonObject obj = new JsonObject() - obj.addProperty("errcode", "M_SESSION_NOT_VALIDATED") - obj.addProperty("error", "sid, secret or session not valid") - response.setStatus(HttpStatus.SC_BAD_REQUEST) - return gson.toJson(obj) - } - } - - @RequestMapping(value = "/3pid/bind") - String bind(HttpServletRequest request, HttpServletResponse response, - @RequestParam String sid, @RequestParam("client_secret") String secret, @RequestParam String mxid) { - String data = IOUtils.toString(request.getReader()) - log.info("Requested: {}", request.getRequestURL(), request.getQueryString()) - try { - mgr.bind(sid, secret, mxid) - return "{}" - } catch (BadRequestException e) { - log.info("requested session was not validated") - - JsonObject obj = new JsonObject() - obj.addProperty("errcode", "M_SESSION_NOT_VALIDATED") - obj.addProperty("error", e.getMessage()) - response.setStatus(HttpStatus.SC_BAD_REQUEST) - return gson.toJson(obj) - } finally { - // If a user registers, there is no standard login event. Instead, this is the only way to trigger - // resolution at an appropriate time. Meh at synapse/Riot! - invMgr.lookupMappingsForInvites() - } - } - - private class Sid { - - private String sid; - - public Sid(String sid) { - setSid(sid); - } - - String getSid() { - return sid - } - - void setSid(String sid) { - this.sid = sid + if (r.isCanRemote()) { + String url = srvCfg.getPublicUrl() + RemoteIdentityAPIv1.getRequestToken(r.getSession().getId(), r.getSession().getSecret()); + model.addAttribute("remoteSessionLink", url) + return viewCfg.getSession().getLocalRemote().getOnTokenSubmit().getSuccess() + } else { + return viewCfg.getSession().getLocal().getOnTokenSubmit().getSuccess() + } } } diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/SessionRestController.java b/src/main/groovy/io/kamax/mxisd/controller/v1/SessionRestController.java new file mode 100644 index 0000000..aa7061c --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/SessionRestController.java @@ -0,0 +1,159 @@ +/* + * 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.JsonObject; +import io.kamax.matrix.ThreePidMedium; +import io.kamax.mxisd.ThreePid; +import io.kamax.mxisd.config.ServerConfig; +import io.kamax.mxisd.config.ViewConfig; +import io.kamax.mxisd.controller.v1.io.SessionEmailTokenRequestJson; +import io.kamax.mxisd.controller.v1.io.SessionPhoneTokenRequestJson; +import io.kamax.mxisd.exception.BadRequestException; +import io.kamax.mxisd.exception.SessionNotValidatedException; +import io.kamax.mxisd.invitation.InvitationManager; +import io.kamax.mxisd.lookup.ThreePidValidation; +import io.kamax.mxisd.session.SessionMananger; +import io.kamax.mxisd.util.GsonParser; +import org.apache.http.HttpStatus; +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.*; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@RestController +@CrossOrigin +@RequestMapping(path = IdentityAPIv1.BASE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) +public class SessionRestController { + + private Logger log = LoggerFactory.getLogger(SessionRestController.class); + + private class Sid { // FIXME replace with RequestTokenResponse + + private String sid; + + public Sid(String sid) { + setSid(sid); + } + + String getSid() { + return sid; + } + + void setSid(String sid) { + this.sid = sid; + } + } + + @Autowired + private ServerConfig srvCfg; + + @Autowired + private SessionMananger mgr; + + @Autowired + private InvitationManager invMgr; + + @Autowired + private ViewConfig viewCfg; + + private Gson gson = new Gson(); + private GsonParser parser = new GsonParser(gson); + + @RequestMapping(value = "/validate/{medium}/requestToken") + String init(HttpServletRequest request, HttpServletResponse response, @PathVariable String medium) throws IOException { + log.info("Request {}: {}", request.getMethod(), request.getRequestURL(), request.getQueryString()); + if (ThreePidMedium.Email.is(medium)) { + SessionEmailTokenRequestJson req = parser.parse(request, SessionEmailTokenRequestJson.class); + return gson.toJson(new Sid(mgr.create( + request.getRemoteHost(), + new ThreePid(req.getMedium(), req.getValue()), + req.getSecret(), + req.getAttempt(), + req.getNextLink()))); + } + + if (ThreePidMedium.PhoneNumber.is(medium)) { + SessionPhoneTokenRequestJson req = parser.parse(request, SessionPhoneTokenRequestJson.class); + return gson.toJson(new Sid(mgr.create( + request.getRemoteHost(), + new ThreePid(req.getMedium(), req.getValue()), + req.getSecret(), + req.getAttempt(), + req.getNextLink()))); + } + + JsonObject obj = new JsonObject(); + obj.addProperty("errcode", "M_INVALID_3PID_TYPE"); + obj.addProperty("error", medium + " is not supported as a 3PID type"); + response.setStatus(HttpStatus.SC_BAD_REQUEST); + return gson.toJson(obj); + } + + @RequestMapping(value = "/3pid/getValidated3pid") + String check(HttpServletRequest request, HttpServletResponse response, + @RequestParam String sid, @RequestParam("client_secret") String secret) { + log.info("Requested: {}", request.getRequestURL(), request.getQueryString()); + + try { + ThreePidValidation pid = mgr.getValidated(sid, secret); + + JsonObject obj = new JsonObject(); + obj.addProperty("medium", pid.getMedium()); + obj.addProperty("address", pid.getAddress()); + obj.addProperty("validated_at", pid.getValidation().toEpochMilli()); + + return gson.toJson(obj); + } catch (SessionNotValidatedException e) { + log.info("Session {} was requested but has not yet been validated", sid); + throw e; + } + } + + @RequestMapping(value = "/3pid/bind") + String bind(HttpServletRequest request, HttpServletResponse response, + @RequestParam String sid, @RequestParam("client_secret") String secret, @RequestParam String mxid) { + log.info("Requested: {}", request.getRequestURL(), request.getQueryString()); + try { + mgr.bind(sid, secret, mxid); + return "{}"; + } catch (BadRequestException e) { + log.info("requested session was not validated"); + + JsonObject obj = new JsonObject(); + obj.addProperty("errcode", "M_SESSION_NOT_VALIDATED"); + obj.addProperty("error", e.getMessage()); + response.setStatus(HttpStatus.SC_BAD_REQUEST); + return gson.toJson(obj); + } finally { + // If a user registers, there is no standard login event. Instead, this is the only way to trigger + // resolution at an appropriate time. Meh at synapse/Riot! + invMgr.lookupMappingsForInvites(); + } + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/io/GenericTokenRequestJson.java b/src/main/groovy/io/kamax/mxisd/controller/v1/io/GenericTokenRequestJson.java index f7d1b35..0812505 100644 --- a/src/main/groovy/io/kamax/mxisd/controller/v1/io/GenericTokenRequestJson.java +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/io/GenericTokenRequestJson.java @@ -20,13 +20,11 @@ package io.kamax.mxisd.controller.v1.io; -import io.kamax.mxisd.mapping.MappingSession; - -public abstract class GenericTokenRequestJson implements MappingSession { +public abstract class GenericTokenRequestJson { private String client_secret; private int send_attempt; - private String id_server; + private String next_link; public String getSecret() { return client_secret; @@ -36,8 +34,8 @@ public abstract class GenericTokenRequestJson implements MappingSession { return send_attempt; } - public String getServer() { - return id_server; + public String getNextLink() { + return next_link; } } diff --git a/src/main/groovy/io/kamax/mxisd/invitation/sender/IInviteSender.java b/src/main/groovy/io/kamax/mxisd/controller/v1/io/RequestTokenResponse.java similarity index 79% rename from src/main/groovy/io/kamax/mxisd/invitation/sender/IInviteSender.java rename to src/main/groovy/io/kamax/mxisd/controller/v1/io/RequestTokenResponse.java index 14af7fd..1946cd0 100644 --- a/src/main/groovy/io/kamax/mxisd/invitation/sender/IInviteSender.java +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/io/RequestTokenResponse.java @@ -18,14 +18,14 @@ * along with this program. If not, see . */ -package io.kamax.mxisd.invitation.sender; +package io.kamax.mxisd.controller.v1.io; -import io.kamax.mxisd.invitation.IThreePidInviteReply; +public class RequestTokenResponse { -public interface IInviteSender { + private String sid; - String getMedium(); - - void send(IThreePidInviteReply invite); + public String getSid() { + return sid; + } } diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/io/SessionEmailTokenRequestJson.java b/src/main/groovy/io/kamax/mxisd/controller/v1/io/SessionEmailTokenRequestJson.java index 3901045..527f1e7 100644 --- a/src/main/groovy/io/kamax/mxisd/controller/v1/io/SessionEmailTokenRequestJson.java +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/io/SessionEmailTokenRequestJson.java @@ -24,12 +24,10 @@ public class SessionEmailTokenRequestJson extends GenericTokenRequestJson { private String email; - @Override public String getMedium() { return "email"; } - @Override public String getValue() { return email; } diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/io/SessionPhoneTokenRequestJson.java b/src/main/groovy/io/kamax/mxisd/controller/v1/io/SessionPhoneTokenRequestJson.java index b66e881..e2e82c2 100644 --- a/src/main/groovy/io/kamax/mxisd/controller/v1/io/SessionPhoneTokenRequestJson.java +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/io/SessionPhoneTokenRequestJson.java @@ -31,12 +31,10 @@ public class SessionPhoneTokenRequestJson extends GenericTokenRequestJson { private String country; private String phone_number; - @Override public String getMedium() { return "msisdn"; } - @Override public String getValue() { try { Phonenumber.PhoneNumber num = phoneUtil.parse(phone_number, country); diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/remote/RemoteIdentityAPIv1.java b/src/main/groovy/io/kamax/mxisd/controller/v1/remote/RemoteIdentityAPIv1.java new file mode 100644 index 0000000..11d5192 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/remote/RemoteIdentityAPIv1.java @@ -0,0 +1,37 @@ +/* + * 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.remote; + +public class RemoteIdentityAPIv1 { + + public static final String BASE = "/_matrix/identity/remote/api/v1"; + public static final String SESSION_REQUEST_TOKEN = BASE + "/validate/requestToken"; + public static final String SESSION_CHECK = BASE + "/validate/check"; + + public static String getRequestToken(String id, String secret) { + return SESSION_REQUEST_TOKEN + "?sid=" + id + "&client_secret=" + secret; + } + + public static String getSessionCheck(String id, String secret) { + return SESSION_CHECK + "?sid=" + id + "&client_secret=" + secret; + } + +} 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..25c7517 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/remote/RemoteSessionController.java @@ -0,0 +1,59 @@ +package io.kamax.mxisd.controller.v1.remote; + +import io.kamax.mxisd.config.ViewConfig; +import io.kamax.mxisd.exception.SessionNotValidatedException; +import io.kamax.mxisd.session.SessionMananger; +import io.kamax.mxisd.threepid.session.IThreePidSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.servlet.http.HttpServletRequest; + +import static io.kamax.mxisd.controller.v1.remote.RemoteIdentityAPIv1.SESSION_CHECK; +import static io.kamax.mxisd.controller.v1.remote.RemoteIdentityAPIv1.SESSION_REQUEST_TOKEN; + +@Controller +public class RemoteSessionController { + + private Logger log = LoggerFactory.getLogger(RemoteSessionController.class); + + @Autowired + private ViewConfig viewCfg; + + @Autowired + private SessionMananger mgr; + + @RequestMapping(path = SESSION_REQUEST_TOKEN) + public String requestToken( + HttpServletRequest request, + @RequestParam String sid, + @RequestParam("client_secret") String secret, + Model model + ) { + log.info("Request {}: {}", request.getMethod(), request.getRequestURL()); + IThreePidSession session = mgr.createRemote(sid, secret); + model.addAttribute("checkLink", RemoteIdentityAPIv1.getSessionCheck(session.getId(), session.getSecret())); + return viewCfg.getSession().getRemote().getOnRequest().getSuccess(); + } + + @RequestMapping(path = SESSION_CHECK) + public String check( + HttpServletRequest request, + @RequestParam String sid, + @RequestParam("client_secret") String secret) { + log.info("Request {}: {}", request.getMethod(), request.getRequestURL()); + + try { + mgr.validateRemote(sid, secret); + return viewCfg.getSession().getRemote().getOnCheck().getSuccess(); + } catch (SessionNotValidatedException e) { + return viewCfg.getSession().getRemote().getOnCheck().getFailure(); + } + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/exception/InternalServerError.java b/src/main/groovy/io/kamax/mxisd/exception/InternalServerError.java new file mode 100644 index 0000000..5680075 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/exception/InternalServerError.java @@ -0,0 +1,54 @@ +/* + * 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; + +import java.time.Instant; + +public class InternalServerError extends MatrixException { + + private String reference = Long.toString(Instant.now().toEpochMilli()); + private String internalReason; + + public InternalServerError() { + super( + HttpStatus.SC_INTERNAL_SERVER_ERROR, + "M_UNKNOWN", + "An internal server error occured. If this error persists, please contact support with reference #" + + Instant.now().toEpochMilli() + ); + } + + public InternalServerError(String internalReason) { + this(); + this.internalReason = internalReason; + } + + public String getReference() { + return reference; + } + + public String getInternalReason() { + return internalReason; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/exception/InvalidCredentialsException.java b/src/main/groovy/io/kamax/mxisd/exception/InvalidCredentialsException.java new file mode 100644 index 0000000..bbd66df --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/exception/InvalidCredentialsException.java @@ -0,0 +1,33 @@ +/* + * 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.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.FORBIDDEN) +public class InvalidCredentialsException extends RuntimeException { + + public InvalidCredentialsException() { + super("Supplied credentials are invalid"); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/exception/MatrixException.java b/src/main/groovy/io/kamax/mxisd/exception/MatrixException.java new file mode 100644 index 0000000..7565b15 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/exception/MatrixException.java @@ -0,0 +1,47 @@ +/* + * 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; + +public abstract class MatrixException extends MxisdException { + + private int status; + private String errorCode; + private String error; + + public MatrixException(int status, String errorCode, String error) { + this.status = status; + this.errorCode = errorCode; + this.error = error; + } + + public int getStatus() { + return status; + } + + public String getErrorCode() { + return errorCode; + } + + public String getError() { + return error; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/exception/MxisdException.java b/src/main/groovy/io/kamax/mxisd/exception/MxisdException.java new file mode 100644 index 0000000..e6088f3 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/exception/MxisdException.java @@ -0,0 +1,24 @@ +/* + * 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; + +public class MxisdException extends RuntimeException { +} diff --git a/src/main/groovy/io/kamax/mxisd/exception/NotAllowedException.java b/src/main/groovy/io/kamax/mxisd/exception/NotAllowedException.java new file mode 100644 index 0000000..068127d --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/exception/NotAllowedException.java @@ -0,0 +1,32 @@ +/* + * 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 NotAllowedException extends MatrixException { + + public NotAllowedException(String s) { + super(HttpStatus.SC_FORBIDDEN, "M_FORBIDDEN", s); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/exception/NotImplementedException.groovy b/src/main/groovy/io/kamax/mxisd/exception/NotImplementedException.groovy index 57ab376..5912136 100644 --- a/src/main/groovy/io/kamax/mxisd/exception/NotImplementedException.groovy +++ b/src/main/groovy/io/kamax/mxisd/exception/NotImplementedException.groovy @@ -24,5 +24,10 @@ import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.ResponseStatus @ResponseStatus(value = HttpStatus.NOT_IMPLEMENTED) -class NotImplementedException extends RuntimeException { +public class NotImplementedException extends RuntimeException { + + public NotImplementedException(String s) { + super(s); + } + } diff --git a/src/main/groovy/io/kamax/mxisd/exception/ObjectNotFoundException.java b/src/main/groovy/io/kamax/mxisd/exception/ObjectNotFoundException.java new file mode 100644 index 0000000..acfb0c3 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/exception/ObjectNotFoundException.java @@ -0,0 +1,33 @@ +/* + * 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.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.NOT_FOUND) +public class ObjectNotFoundException extends RuntimeException { + + public ObjectNotFoundException(String type, String id) { + super(type + " with ID " + id + " does not exist"); + } + +} 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/exception/SessionNotValidatedException.java b/src/main/groovy/io/kamax/mxisd/exception/SessionNotValidatedException.java new file mode 100644 index 0000000..6524ba6 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/exception/SessionNotValidatedException.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 SessionNotValidatedException extends MatrixException { + + public SessionNotValidatedException() { + super(HttpStatus.SC_OK, "M_SESSION_NOT_VALIDATED", "This validation session has not yet been completed"); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/exception/SessionUnknownException.java b/src/main/groovy/io/kamax/mxisd/exception/SessionUnknownException.java new file mode 100644 index 0000000..14cd5fa --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/exception/SessionUnknownException.java @@ -0,0 +1,33 @@ +/* + * 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; + +public class SessionUnknownException extends MatrixException { + + public SessionUnknownException() { + this("No valid session was found matching that sid and client secret"); + } + + public SessionUnknownException(String error) { + super(200, "M_NO_VALID_SESSION", error); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/invitation/InvitationManager.java b/src/main/groovy/io/kamax/mxisd/invitation/InvitationManager.java index f9bab89..06a0d39 100644 --- a/src/main/groovy/io/kamax/mxisd/invitation/InvitationManager.java +++ b/src/main/groovy/io/kamax/mxisd/invitation/InvitationManager.java @@ -26,10 +26,10 @@ import io.kamax.mxisd.config.DnsOverwrite; import io.kamax.mxisd.config.DnsOverwriteEntry; import io.kamax.mxisd.exception.BadRequestException; import io.kamax.mxisd.exception.MappingAlreadyExistsException; -import io.kamax.mxisd.invitation.sender.IInviteSender; import io.kamax.mxisd.lookup.SingleLookupReply; import io.kamax.mxisd.lookup.ThreePidMapping; import io.kamax.mxisd.lookup.strategy.LookupStrategy; +import io.kamax.mxisd.notification.NotificationManager; import io.kamax.mxisd.signature.SignatureManager; import io.kamax.mxisd.storage.IStorage; import io.kamax.mxisd.storage.ormlite.ThreePidInviteIO; @@ -83,14 +83,15 @@ public class InvitationManager { @Autowired private DnsOverwrite dns; - private Map senders; + private NotificationManager notifMgr; private CloseableHttpClient client; private Gson gson; private Timer refreshTimer; - private String getId(IThreePidInvite invite) { - return invite.getSender().getDomain().toLowerCase() + invite.getMedium().toLowerCase() + invite.getAddress().toLowerCase(); + @Autowired + public InvitationManager(NotificationManager notifMgr) { + this.notifMgr = notifMgr; } @PostConstruct @@ -140,9 +141,14 @@ public class InvitationManager { @PreDestroy private void preDestroy() { + refreshTimer.cancel(); ForkJoinPool.commonPool().awaitQuiescence(1, TimeUnit.MINUTES); } + private String getId(IThreePidInvite invite) { + return invite.getSender().getDomain().toLowerCase() + invite.getMedium().toLowerCase() + invite.getAddress().toLowerCase(); + } + private String getIdForLog(IThreePidInviteReply reply) { return reply.getInvite().getSender().getId() + ":" + reply.getInvite().getRoomId() + ":" + reply.getInvite().getMedium() + ":" + reply.getInvite().getAddress(); } @@ -193,21 +199,14 @@ public class InvitationManager { return "https://" + domain + ":8448"; } - @Autowired - public InvitationManager(List senderList) { - senders = new HashMap<>(); - senderList.forEach(sender -> senders.put(sender.getMedium(), sender)); - } - public synchronized IThreePidInviteReply storeInvite(IThreePidInvite invitation) { // TODO better sync - IInviteSender sender = senders.get(invitation.getMedium()); - if (sender == null) { + if (!notifMgr.isMediumSupported(invitation.getMedium())) { throw new BadRequestException("Medium type " + invitation.getMedium() + " is not supported"); } String invId = getId(invitation); log.info("Handling invite for {}:{} from {} in room {}", invitation.getMedium(), invitation.getAddress(), invitation.getSender(), invitation.getRoomId()); - if (invitations.containsKey(invId)) { // FIXME we need to lookup using the HS domain too!! + if (invitations.containsKey(invId)) { log.info("Invite is already pending for {}:{}, returning data", invitation.getMedium(), invitation.getAddress()); return invitations.get(invId); } @@ -224,7 +223,7 @@ public class InvitationManager { IThreePidInviteReply reply = new ThreePidInviteReply(invId, invitation, token, displayName); log.info("Performing invite to {}:{}", invitation.getMedium(), invitation.getAddress()); - sender.send(reply); + notifMgr.sendForInvite(reply); log.info("Storing invite under ID {}", invId); storage.insertInvite(reply); diff --git a/src/main/groovy/io/kamax/mxisd/invitation/sender/EmailInviteSender.java b/src/main/groovy/io/kamax/mxisd/invitation/sender/EmailInviteSender.java deleted file mode 100644 index 8a67ac2..0000000 --- a/src/main/groovy/io/kamax/mxisd/invitation/sender/EmailInviteSender.java +++ /dev/null @@ -1,138 +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.invitation.sender; - -import com.sun.mail.smtp.SMTPTransport; -import io.kamax.matrix.ThreePidMedium; -import io.kamax.mxisd.config.MatrixConfig; -import io.kamax.mxisd.config.invite.sender.EmailSenderConfig; -import io.kamax.mxisd.exception.ConfigurationException; -import io.kamax.mxisd.invitation.IThreePidInviteReply; -import org.apache.commons.io.IOUtils; -import org.apache.commons.lang.StringUtils; -import org.apache.commons.lang.WordUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationContext; -import org.springframework.stereotype.Component; - -import javax.annotation.PostConstruct; -import javax.mail.Message; -import javax.mail.MessagingException; -import javax.mail.Session; -import javax.mail.internet.InternetAddress; -import javax.mail.internet.MimeMessage; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.nio.charset.StandardCharsets; -import java.util.Date; - -@Component -public class EmailInviteSender implements IInviteSender { - - private Logger log = LoggerFactory.getLogger(EmailInviteSender.class); - - @Autowired - private EmailSenderConfig cfg; - - @Autowired - private MatrixConfig mxCfg; - - @Autowired - private ApplicationContext app; - - private Session session; - private InternetAddress sender; - - @PostConstruct - private void postConstruct() { - try { - session = Session.getInstance(System.getProperties()); - sender = new InternetAddress(cfg.getEmail(), cfg.getName()); - } catch (UnsupportedEncodingException e) { - // What are we supposed to do with this?! - throw new ConfigurationException(e); - } - } - - @Override - public String getMedium() { - return ThreePidMedium.Email.getId(); - } - - @Override - public void send(IThreePidInviteReply invite) { - if (!ThreePidMedium.Email.is(invite.getInvite().getMedium())) { - throw new IllegalArgumentException(invite.getInvite().getMedium() + " is not a supported 3PID type"); - } - - try { - String domainPretty = WordUtils.capitalizeFully(mxCfg.getDomain()); - String senderName = invite.getInvite().getProperties().getOrDefault("sender_display_name", ""); - String senderNameOrId = StringUtils.defaultIfBlank(senderName, invite.getInvite().getSender().getId()); - String roomName = invite.getInvite().getProperties().getOrDefault("room_name", ""); - String roomNameOrId = StringUtils.defaultIfBlank(roomName, invite.getInvite().getRoomId()); - - String templateBody = IOUtils.toString( - StringUtils.startsWith(cfg.getTemplate(), "classpath:") ? - app.getResource(cfg.getTemplate()).getInputStream() : new FileInputStream(cfg.getTemplate()), - StandardCharsets.UTF_8); - templateBody = templateBody.replace("%DOMAIN%", mxCfg.getDomain()); - templateBody = templateBody.replace("%DOMAIN_PRETTY%", domainPretty); - templateBody = templateBody.replace("%FROM_EMAIL%", cfg.getEmail()); - templateBody = templateBody.replace("%FROM_NAME%", cfg.getName()); - templateBody = templateBody.replace("%SENDER_ID%", invite.getInvite().getSender().getId()); - templateBody = templateBody.replace("%SENDER_NAME%", senderName); - templateBody = templateBody.replace("%SENDER_NAME_OR_ID%", senderNameOrId); - templateBody = templateBody.replace("%INVITE_MEDIUM%", invite.getInvite().getMedium()); - templateBody = templateBody.replace("%INVITE_ADDRESS%", invite.getInvite().getAddress()); - templateBody = templateBody.replace("%ROOM_ID%", invite.getInvite().getRoomId()); - templateBody = templateBody.replace("%ROOM_NAME%", roomName); - templateBody = templateBody.replace("%ROOM_NAME_OR_ID%", roomNameOrId); - - MimeMessage msg = new MimeMessage(session, IOUtils.toInputStream(templateBody, StandardCharsets.UTF_8)); - msg.setHeader("X-Mailer", "mxisd"); // TODO set version - msg.setSentDate(new Date()); - msg.setFrom(sender); - msg.setRecipients(Message.RecipientType.TO, invite.getInvite().getAddress()); - msg.saveChanges(); - - log.info("Sending invite to {} via SMTP using {}:{}", invite.getInvite().getAddress(), cfg.getHost(), cfg.getPort()); - SMTPTransport transport = (SMTPTransport) session.getTransport("smtp"); - transport.setStartTLS(cfg.getTls() > 0); - transport.setRequireStartTLS(cfg.getTls() > 1); - - log.info("Connecting to {}:{}", cfg.getHost(), cfg.getPort()); - transport.connect(cfg.getHost(), cfg.getPort(), cfg.getLogin(), cfg.getPassword()); - try { - transport.sendMessage(msg, InternetAddress.parse(invite.getInvite().getAddress())); - log.info("Invite to {} was sent", invite.getInvite().getAddress()); - } finally { - transport.close(); - } - } catch (IOException | MessagingException e) { - throw new RuntimeException("Unable to send e-mail invite to " + invite.getInvite().getAddress(), e); - } - } - -} diff --git a/src/main/groovy/io/kamax/mxisd/lookup/ThreePidValidation.java b/src/main/groovy/io/kamax/mxisd/lookup/ThreePidValidation.java index 8d3b4a0..206d9d4 100644 --- a/src/main/groovy/io/kamax/mxisd/lookup/ThreePidValidation.java +++ b/src/main/groovy/io/kamax/mxisd/lookup/ThreePidValidation.java @@ -28,8 +28,8 @@ public class ThreePidValidation extends ThreePid { private Instant validation; - public ThreePidValidation(String medium, String address, Instant validation) { - super(medium, address); + public ThreePidValidation(ThreePid tpid, Instant validation) { + super(tpid); this.validation = validation; } 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/lookup/strategy/LookupStrategy.groovy b/src/main/groovy/io/kamax/mxisd/lookup/strategy/LookupStrategy.groovy index 37e4327..44b67f0 100644 --- a/src/main/groovy/io/kamax/mxisd/lookup/strategy/LookupStrategy.groovy +++ b/src/main/groovy/io/kamax/mxisd/lookup/strategy/LookupStrategy.groovy @@ -32,6 +32,10 @@ interface LookupStrategy { Optional find(String medium, String address, boolean recursive) + Optional findLocal(String medium, String address); + + Optional findRemote(String medium, String address); + Optional find(SingleLookupRequest request) Optional findRecursive(SingleLookupRequest request) diff --git a/src/main/groovy/io/kamax/mxisd/lookup/strategy/RecursivePriorityLookupStrategy.groovy b/src/main/groovy/io/kamax/mxisd/lookup/strategy/RecursivePriorityLookupStrategy.groovy index e4ef776..048dd21 100644 --- a/src/main/groovy/io/kamax/mxisd/lookup/strategy/RecursivePriorityLookupStrategy.groovy +++ b/src/main/groovy/io/kamax/mxisd/lookup/strategy/RecursivePriorityLookupStrategy.groovy @@ -118,17 +118,44 @@ class RecursivePriorityLookupStrategy implements LookupStrategy, InitializingBea }).collect(Collectors.toList()) } - @Override - Optional find(String medium, String address, boolean recursive) { + List getRemoteProviders() { + return providers.stream().filter(new Predicate() { + @Override + boolean test(IThreePidProvider iThreePidProvider) { + return iThreePidProvider.isEnabled() && !iThreePidProvider.isLocal() + } + }).collect(Collectors.toList()) + } + + private static SingleLookupRequest build(String medium, String address) { SingleLookupRequest req = new SingleLookupRequest(); req.setType(medium) req.setThreePid(address) req.setRequester("Internal") - return find(req, recursive) + return req; + } + + @Override + Optional find(String medium, String address, boolean recursive) { + return find(build(medium, address), recursive) + } + + @Override + Optional findLocal(String medium, String address) { + return find(build(medium, address), getLocalProviders()) + } + + @Override + Optional findRemote(String medium, String address) { + return find(build(medium, address), getRemoteProviders()) } Optional find(SingleLookupRequest request, boolean forceRecursive) { - for (IThreePidProvider provider : listUsableProviders(request, forceRecursive)) { + return find(request, listUsableProviders(request, forceRecursive)); + } + + Optional find(SingleLookupRequest request, List providers) { + for (IThreePidProvider provider : providers) { Optional lookupDataOpt = provider.find(request) if (lookupDataOpt.isPresent()) { return lookupDataOpt diff --git a/src/main/groovy/io/kamax/mxisd/mapping/MappingManager.java b/src/main/groovy/io/kamax/mxisd/mapping/MappingManager.java deleted file mode 100644 index 1f8c3ec..0000000 --- a/src/main/groovy/io/kamax/mxisd/mapping/MappingManager.java +++ /dev/null @@ -1,175 +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.mapping; - -import io.kamax.mxisd.exception.BadRequestException; -import io.kamax.mxisd.lookup.ThreePidValidation; -import org.apache.commons.lang.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.*; - -@Component -public class MappingManager { - - private Logger log = LoggerFactory.getLogger(MappingManager.class); - - private Map sessions = new HashMap<>(); - private Timer cleaner; - - MappingManager() { - cleaner = new Timer(); - cleaner.schedule(new TimerTask() { - @Override - public void run() { - List sList = new ArrayList<>(sessions.values()); - for (Session s : sList) { - if (s.timestamp.plus(24, ChronoUnit.HOURS).isBefore(Instant.now())) { // TODO config timeout - log.info("Session {} is obsolete, removing", s.sid); - - sessions.remove(s.sid); - } - } - } - }, 0, 10 * 1000); // TODO config delay - } - - public String create(MappingSession data) { - String sid; - do { - sid = Long.toString(System.currentTimeMillis()); - } while (sessions.containsKey(sid)); - - String threePidHash = data.getMedium() + data.getValue(); - // TODO think how to handle different requests for the same e-mail - Session session = new Session(sid, threePidHash, data); - sessions.put(sid, session); - - log.info("Created new session {} to validate {} {}", sid, session.medium, session.address); - return sid; - } - - public void validate(String sid, String secret, String token) { - Session s = sessions.get(sid); - if (s == null || !StringUtils.equals(s.secret, secret)) { - throw new BadRequestException("sid or secret are not valid"); - } - - // TODO actually check token - - s.isValidated = true; - s.validationTimestamp = Instant.now(); - } - - public Optional getValidated(String sid, String secret) { - Session s = sessions.get(sid); - if (s != null && StringUtils.equals(s.secret, secret)) { - return Optional.of(new ThreePidValidation(s.medium, s.address, s.validationTimestamp)); - } - - return Optional.empty(); - } - - public void bind(String sid, String secret, String mxid) { - Session s = sessions.get(sid); - if (s == null || !StringUtils.equals(s.secret, secret)) { - throw new BadRequestException("sid or secret are not valid"); - } - - log.info("Performed bind for mxid {}", mxid); - // TODO perform bind, whatever it is - } - - private class Session { - - private String sid; - private String hash; - private Instant timestamp; - private Instant validationTimestamp; - private boolean isValidated; - private String secret; - private String medium; - private String address; - - public Session(String sid, String hash, MappingSession data) { - this.sid = sid; - this.hash = hash; - timestamp = Instant.now(); - validationTimestamp = Instant.now(); - secret = data.getSecret(); - medium = data.getMedium(); - address = data.getValue(); - } - - public Instant getTimestamp() { - return timestamp; - } - - public void setTimestamp(Instant timestamp) { - this.timestamp = timestamp; - } - - public Instant getValidationTimestamp() { - return validationTimestamp; - } - - public void setValidationTimestamp(Instant validationTimestamp) { - this.validationTimestamp = validationTimestamp; - } - - public boolean isValidated() { - return isValidated; - } - - public void setValidated(boolean validated) { - isValidated = validated; - } - - public String getSecret() { - return secret; - } - - public void setSecret(String secret) { - this.secret = secret; - } - - public String getMedium() { - return medium; - } - - public void setMedium(String medium) { - this.medium = medium; - } - - public String getAddress() { - return address; - } - - public void setAddress(String address) { - this.address = address; - } - } - -} 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/notification/INotificationHandler.java b/src/main/groovy/io/kamax/mxisd/notification/INotificationHandler.java new file mode 100644 index 0000000..e42735f --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/notification/INotificationHandler.java @@ -0,0 +1,36 @@ +/* + * 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.notification; + +import io.kamax.mxisd.invitation.IThreePidInviteReply; +import io.kamax.mxisd.threepid.session.IThreePidSession; + +public interface INotificationHandler { + + String getMedium(); + + void sendForInvite(IThreePidInviteReply invite); + + void sendForValidation(IThreePidSession session); + + void sendForRemoteValidation(IThreePidSession session); + +} diff --git a/src/main/groovy/io/kamax/mxisd/notification/NotificationManager.java b/src/main/groovy/io/kamax/mxisd/notification/NotificationManager.java new file mode 100644 index 0000000..78a38a2 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/notification/NotificationManager.java @@ -0,0 +1,69 @@ +/* + * 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.notification; + +import io.kamax.mxisd.exception.NotImplementedException; +import io.kamax.mxisd.invitation.IThreePidInviteReply; +import io.kamax.mxisd.threepid.session.IThreePidSession; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class NotificationManager { + + private Map handlers; + + @Autowired + public NotificationManager(List handlers) { + this.handlers = new HashMap<>(); + handlers.forEach(h -> this.handlers.put(h.getMedium(), h)); + } + + private INotificationHandler ensureMedium(String medium) { + INotificationHandler handler = handlers.get(medium); + if (handler == null) { + throw new NotImplementedException(medium + " is not a supported 3PID medium type"); + } + + return handler; + } + + public boolean isMediumSupported(String medium) { + return handlers.containsKey(medium); + } + + public void sendForInvite(IThreePidInviteReply invite) { + ensureMedium(invite.getInvite().getMedium()).sendForInvite(invite); + } + + public void sendForValidation(IThreePidSession session) { + ensureMedium(session.getThreePid().getMedium()).sendForValidation(session); + } + + public void sendforRemoteValidation(IThreePidSession session) { + ensureMedium(session.getThreePid().getMedium()).sendForRemoteValidation(session); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/session/SessionMananger.java b/src/main/groovy/io/kamax/mxisd/session/SessionMananger.java new file mode 100644 index 0000000..1f41029 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/session/SessionMananger.java @@ -0,0 +1,339 @@ +/* + * 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.session; + +import com.google.gson.JsonObject; +import io.kamax.matrix.MatrixID; +import io.kamax.matrix.ThreePidMedium; +import io.kamax.matrix._MatrixID; +import io.kamax.mxisd.ThreePid; +import io.kamax.mxisd.config.MatrixConfig; +import io.kamax.mxisd.config.SessionConfig; +import io.kamax.mxisd.controller.v1.io.RequestTokenResponse; +import io.kamax.mxisd.controller.v1.remote.RemoteIdentityAPIv1; +import io.kamax.mxisd.exception.*; +import io.kamax.mxisd.lookup.ThreePidValidation; +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.IThreePidSession; +import io.kamax.mxisd.threepid.session.ThreePidSession; +import io.kamax.mxisd.util.GsonParser; +import io.kamax.mxisd.util.RestClientUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.RandomStringUtils; +import org.apache.commons.lang.StringUtils; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.message.BasicNameValuePair; +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.nio.charset.StandardCharsets; +import java.util.Arrays; +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 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, NotificationManager notifMgr) { + this.cfg = cfg; + this.mxCfg = mxCfg; + this.storage = storage; + this.notifMgr = notifMgr; + } + + private boolean isLocal(ThreePid tpid) { + if (!ThreePidMedium.Email.is(tpid.getMedium())) { // We can only handle E-mails for now + return false; + } + + String domain = tpid.getAddress().split("@")[1]; + return StringUtils.equalsIgnoreCase(cfg.getMatrixCfg().getDomain(), domain); + } + + private ThreePidSession getSession(String sid, String secret) { + Optional dao = storage.getThreePidSession(sid); + if (!dao.isPresent() || !StringUtils.equals(dao.get().getSecret(), secret)) { + throw new SessionUnknownException(); + } + + return new ThreePidSession(dao.get()); + } + + private ThreePidSession getSessionIfValidated(String sid, String secret) { + ThreePidSession session = getSession(sid, secret); + if (!session.isValidated()) { + throw new SessionNotValidatedException(); + } + return session; + } + + public String create(String server, ThreePid tpid, String secret, int attempt, String nextLink) { + PolicyTemplate policy = cfg.getPolicy().getValidation(); + if (!policy.isEnabled()) { + throw new NotAllowedException("Validating 3PID is disabled globally"); + } + + synchronized (this) { + log.info("Server {} is asking to create session for {} (Attempt #{}) - Next link: {}", server, tpid, attempt, nextLink); + Optional dao = storage.findThreePidSession(tpid, secret); + if (dao.isPresent()) { + ThreePidSession session = new ThreePidSession(dao.get()); + log.info("We already have a session for {}: {}", tpid, session.getId()); + if (session.getAttempt() < attempt) { + log.info("Received attempt {} is greater than stored attempt {}, sending validation communication", attempt, session.getAttempt()); + notifMgr.sendForValidation(session); + log.info("Sent validation notification to {}", tpid); + session.increaseAttempt(); + storage.updateThreePidSession(session.getDao()); + } + + return session.getId(); + } else { + log.info("No existing session for {}", tpid); + + boolean isLocal = isLocal(tpid); + log.info("Is 3PID bound to local domain? {}", isLocal); + + // This might need a configuration by medium type? + 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"); + } + + String sessionId; + do { + sessionId = Long.toString(System.currentTimeMillis()); + } while (storage.getThreePidSession(sessionId).isPresent()); + + String token = RandomStringUtils.randomNumeric(6); + ThreePidSession session = new ThreePidSession(sessionId, server, tpid, secret, attempt, nextLink, token); + log.info("Generated new session {} to validate {} from server {}", sessionId, tpid, server); + + // This might need a configuration by medium type? + if (policySource.toLocal()) { + log.info("Session {} for {}: sending local validation notification", sessionId, tpid); + notifMgr.sendForValidation(session); + } else { + log.info("Session {} for {}: sending remote-only validation notification", sessionId, tpid); + notifMgr.sendforRemoteValidation(session); + } + + storage.insertThreePidSession(session.getDao()); + log.info("Stored session {}", sessionId, tpid, server); + + return sessionId; + } + } + } + + public ValidationResult validate(String sid, String secret, String token) { + ThreePidSession session = getSession(sid, secret); + log.info("Attempting validation for session {} from {}", session.getId(), session.getServer()); + + boolean isLocal = isLocal(session.getThreePid()); + PolicySource policy = cfg.getPolicy().getValidation().forIf(isLocal); + if (!policy.isEnabled()) { + throw new NotAllowedException("Validating " + (isLocal ? "local" : "remote") + " 3PID is not allowed"); + } + + session.validate(token); + storage.updateThreePidSession(session.getDao()); + log.info("Session {} has been validated", session.getId()); + + // FIXME definitely doable in a nicer way + ValidationResult r = new ValidationResult(session, policy.toRemote()); + if (!policy.toLocal()) { + r.setNextUrl(RemoteIdentityAPIv1.getRequestToken(sid, secret)); + } else { + session.getNextLink().ifPresent(r::setNextUrl); + } + return r; + } + + public ThreePidValidation getValidated(String sid, String secret) { + ThreePidSession session = getSessionIfValidated(sid, secret); + return new ThreePidValidation(session.getThreePid(), session.getValidationTime()); + } + + public void bind(String sid, String secret, String mxidRaw) { + _MatrixID mxid = new MatrixID(mxidRaw); + ThreePidSession session = getSessionIfValidated(sid, secret); + + if (!session.isRemote()) { + log.info("Session {} for {}: MXID {} was bound locally", sid, session.getThreePid(), mxid); + return; + } + + log.info("Session {} for {}: MXID {} bind is remote", sid, session.getThreePid(), mxid); + if (!session.isRemoteValidated()) { + log.error("Session {} for {}: Not validated remotely", sid, session.getThreePid()); + throw new SessionNotValidatedException(); + } + + log.info("Session {} for {}: Performing remote bind", sid, session.getThreePid()); + + UrlEncodedFormEntity entity = new UrlEncodedFormEntity( + Arrays.asList( + new BasicNameValuePair("sid", session.getRemoteId()), + new BasicNameValuePair("client_secret", session.getRemoteSecret()), + new BasicNameValuePair("mxid", mxid.getId()) + ), StandardCharsets.UTF_8); + HttpPost bindReq = new HttpPost(session.getRemoteServer() + "/_matrix/identity/api/v1/3pid/bind"); + bindReq.setEntity(entity); + + try (CloseableHttpResponse response = client.execute(bindReq)) { + int status = response.getStatusLine().getStatusCode(); + if (status < 200 || status >= 300) { + String body = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); + log.error("Session {} for {}: Remote IS {} failed when trying to bind {} for remote session {}\n{}", + sid, session.getThreePid(), session.getRemoteServer(), mxid, session.getRemoteId(), body); + throw new RemoteIdentityServerException(body); + } + + log.error("Session {} for {}: MXID {} was bound remotely", sid, session.getThreePid(), mxid); + } catch (IOException e) { + log.error("Session {} for {}: I/O Error when trying to bind mxid {}", sid, session.getThreePid(), mxid); + throw new RemoteIdentityServerException(e.getMessage()); + } + } + + public IThreePidSession createRemote(String sid, String secret) { + ThreePidSession session = getSessionIfValidated(sid, secret); + log.info("Creating remote 3PID session for {} with local session [{}] to {}", session.getThreePid(), sid); + + 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"); + } + log.info("Remote 3PID is allowed by policy"); + + List servers = mxCfg.getIdentity().getServers(policy.getToRemote().getServer()); + if (servers.isEmpty()) { + throw new InternalServerError(); + } + String url = IdentityServerUtils.findIsUrlForDomain(servers.get(0)).orElseThrow(InternalServerError::new); + log.info("Will use IS endpoint {}", url); + + String remoteSecret = session.isRemote() ? session.getRemoteSecret() : RandomStringUtils.randomAlphanumeric(16); + + JsonObject body = new JsonObject(); + body.addProperty("client_secret", remoteSecret); + body.addProperty(session.getThreePid().getMedium(), session.getThreePid().getAddress()); + body.addProperty("send_attempt", session.increaseAndGetRemoteAttempt()); + + log.info("Requesting remote session with attempt {}", session.getRemoteAttempt()); + 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); + } + + RequestTokenResponse data = new GsonParser().parse(response, RequestTokenResponse.class); + log.info("Remote Session ID: {}", data.getSid()); + + session.setRemoteData(url, data.getSid(), remoteSecret, 1); + storage.updateThreePidSession(session.getDao()); + log.info("Updated Session {} with remote data", sid); + + return session; + } catch (IOException e) { + log.warn("Failed to create remote session with {} for {}: {}", url, session.getThreePid(), e.getMessage()); + throw new RemoteIdentityServerException(e.getMessage()); + } + } + + public void validateRemote(String sid, String secret) { + ThreePidSession session = getSessionIfValidated(sid, secret); + if (!session.isRemote()) { + throw new NotAllowedException("Cannot remotely validate a local session"); + } + + log.info("Session {} for {}: Validating remote 3PID session {} on {}", sid, session.getThreePid(), session.getRemoteId(), session.getRemoteServer()); + if (session.isRemoteValidated()) { + log.info("Session {} for {}: Already remotely validated", sid, session.getThreePid()); + return; + } + + HttpGet validateReq = new HttpGet(session.getRemoteServer() + "/_matrix/identity/api/v1/3pid/getValidated3pid?sid=" + session.getRemoteId() + "&client_secret=" + session.getRemoteSecret()); + try (CloseableHttpResponse response = client.execute(validateReq)) { + int status = response.getStatusLine().getStatusCode(); + if (status < 200 || status >= 300) { + throw new RemoteIdentityServerException("Remote identity server returned with status " + status); + } + + JsonObject o = new GsonParser().parse(response.getEntity().getContent()); + if (o.has("errcode")) { + String errcode = o.get("errcode").getAsString(); + if (StringUtils.equals("M_SESSION_NOT_VALIDATED", errcode)) { + throw new SessionNotValidatedException(); + } else if (StringUtils.equals("M_NO_VALID_SESSION", errcode)) { + throw new SessionUnknownException(); + } else { + throw new RemoteIdentityServerException("Unknown error while validating Remote 3PID session: " + errcode + " - " + o.get("error").getAsString()); + } + } + + if (o.has("validated_at")) { + ThreePid remoteThreePid = new ThreePid(o.get("medium").getAsString(), o.get("address").getAsString()); + if (session.getThreePid().equals(remoteThreePid)) { // sanity check + throw new InternalServerError("Local 3PID " + session.getThreePid() + " and remote 3PID " + remoteThreePid + " do not match for session " + session.getId()); + } + + log.info("Session {} for {}: Remotely validated successfully", sid, session.getThreePid()); + session.validateRemote(); + storage.updateThreePidSession(session.getDao()); + log.info("Session {} was updated in storage", sid); + } + } catch (IOException e) { + log.warn("Session {} for {}: Failed to validated remotely on {}: {}", sid, session.getThreePid(), session.getRemoteServer(), e.getMessage()); + throw new RemoteIdentityServerException(e.getMessage()); + } + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/session/ValidationResult.java b/src/main/groovy/io/kamax/mxisd/session/ValidationResult.java new file mode 100644 index 0000000..72c987f --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/session/ValidationResult.java @@ -0,0 +1,54 @@ +/* + * 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.session; + +import io.kamax.mxisd.threepid.session.IThreePidSession; + +import java.util.Optional; + +public class ValidationResult { + + private IThreePidSession session; + private boolean canRemote; + private String nextUrl; + + public ValidationResult(IThreePidSession session, boolean canRemote) { + this.session = session; + this.canRemote = canRemote; + } + + public IThreePidSession getSession() { + return session; + } + + public boolean isCanRemote() { + return canRemote; + } + + public Optional getNextUrl() { + return Optional.ofNullable(nextUrl); + } + + public void setNextUrl(String nextUrl) { + this.nextUrl = nextUrl; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/storage/IStorage.java b/src/main/groovy/io/kamax/mxisd/storage/IStorage.java index b77c50b..329dfd0 100644 --- a/src/main/groovy/io/kamax/mxisd/storage/IStorage.java +++ b/src/main/groovy/io/kamax/mxisd/storage/IStorage.java @@ -20,10 +20,13 @@ package io.kamax.mxisd.storage; +import io.kamax.mxisd.ThreePid; import io.kamax.mxisd.invitation.IThreePidInviteReply; +import io.kamax.mxisd.storage.dao.IThreePidSessionDao; import io.kamax.mxisd.storage.ormlite.ThreePidInviteIO; import java.util.Collection; +import java.util.Optional; public interface IStorage { @@ -33,4 +36,12 @@ public interface IStorage { void deleteInvite(String id); + Optional getThreePidSession(String sid); + + Optional findThreePidSession(ThreePid tpid, String secret); + + void insertThreePidSession(IThreePidSessionDao session); + + void updateThreePidSession(IThreePidSessionDao session); + } diff --git a/src/main/groovy/io/kamax/mxisd/storage/dao/IThreePidSessionDao.java b/src/main/groovy/io/kamax/mxisd/storage/dao/IThreePidSessionDao.java new file mode 100644 index 0000000..a907e59 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/storage/dao/IThreePidSessionDao.java @@ -0,0 +1,59 @@ +/* + * 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.storage.dao; + +public interface IThreePidSessionDao { + + String getId(); + + long getCreationTime(); + + String getServer(); + + String getMedium(); + + String getAddress(); + + String getSecret(); + + int getAttempt(); + + String getNextLink(); + + String getToken(); + + boolean getValidated(); + + long getValidationTime(); + + boolean isRemote(); + + String getRemoteServer(); + + String getRemoteId(); + + String getRemoteSecret(); + + int getRemoteAttempt(); + + boolean isRemoteValidated(); + +} diff --git a/src/main/groovy/io/kamax/mxisd/storage/ormlite/OrmLiteSqliteStorage.java b/src/main/groovy/io/kamax/mxisd/storage/ormlite/OrmLiteSqliteStorage.java index e76596a..0aac625 100644 --- a/src/main/groovy/io/kamax/mxisd/storage/ormlite/OrmLiteSqliteStorage.java +++ b/src/main/groovy/io/kamax/mxisd/storage/ormlite/OrmLiteSqliteStorage.java @@ -26,8 +26,14 @@ import com.j256.ormlite.dao.DaoManager; import com.j256.ormlite.jdbc.JdbcConnectionSource; import com.j256.ormlite.support.ConnectionSource; import com.j256.ormlite.table.TableUtils; +import io.kamax.mxisd.ThreePid; +import io.kamax.mxisd.exception.InternalServerError; import io.kamax.mxisd.invitation.IThreePidInviteReply; import io.kamax.mxisd.storage.IStorage; +import io.kamax.mxisd.storage.dao.IThreePidSessionDao; +import io.kamax.mxisd.storage.ormlite.dao.ThreePidSessionDao; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; @@ -35,59 +41,141 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Optional; public class OrmLiteSqliteStorage implements IStorage { + private Logger log = LoggerFactory.getLogger(OrmLiteSqliteStorage.class); + + @FunctionalInterface + private interface Getter { + + T get() throws SQLException, IOException; + + } + + @FunctionalInterface + private interface Doer { + + void run() throws SQLException, IOException; + + } + private Dao invDao; + private Dao sessionDao; OrmLiteSqliteStorage(String path) { - try { + withCatcher(() -> { File parent = new File(path).getParentFile(); if (!parent.mkdirs() && !parent.isDirectory()) { throw new RuntimeException("Unable to create DB parent directory: " + parent); } ConnectionSource connPool = new JdbcConnectionSource("jdbc:sqlite:" + path); - invDao = DaoManager.createDao(connPool, ThreePidInviteIO.class); - TableUtils.createTableIfNotExists(connPool, ThreePidInviteIO.class); - } catch (SQLException e) { + invDao = createDaoAndTable(connPool, ThreePidInviteIO.class); + sessionDao = createDaoAndTable(connPool, ThreePidSessionDao.class); + }); + } + + private Dao createDaoAndTable(ConnectionSource connPool, Class c) throws SQLException { + Dao dao = DaoManager.createDao(connPool, c); + TableUtils.createTableIfNotExists(connPool, c); + return dao; + } + + private T withCatcher(Getter g) { + try { + return g.get(); + } catch (SQLException | IOException e) { throw new RuntimeException(e); // FIXME do better } } + private void withCatcher(Doer d) { + try { + d.run(); + } catch (SQLException | IOException e) { + throw new RuntimeException(e); // FIXME do better + } + } + + private List forIterable(CloseableWrappedIterable t) { + return withCatcher(() -> { + try { + List ioList = new ArrayList<>(); + t.forEach(ioList::add); + return ioList; + } finally { + t.close(); + } + }); + } + @Override public Collection getInvites() { - try (CloseableWrappedIterable t = invDao.getWrappedIterable()) { - List ioList = new ArrayList<>(); - t.forEach(ioList::add); - return ioList; - } catch (IOException e) { - throw new RuntimeException(e); // FIXME do better - } + return forIterable(invDao.getWrappedIterable()); } @Override public void insertInvite(IThreePidInviteReply data) { - try { + withCatcher(() -> { int updated = invDao.create(new ThreePidInviteIO(data)); if (updated != 1) { throw new RuntimeException("Unexpected row count after DB action: " + updated); } - } catch (SQLException e) { - throw new RuntimeException(e); // FIXME do better - } + }); } @Override public void deleteInvite(String id) { - try { + withCatcher(() -> { int updated = invDao.deleteById(id); if (updated != 1) { throw new RuntimeException("Unexpected row count after DB action: " + updated); } - } catch (SQLException e) { - throw new RuntimeException(e); // FIXME do better - } + }); + } + + @Override + public Optional getThreePidSession(String sid) { + return withCatcher(() -> Optional.ofNullable(sessionDao.queryForId(sid))); + } + + @Override + public Optional findThreePidSession(ThreePid tpid, String secret) { + return withCatcher(() -> { + List daoList = sessionDao.queryForMatchingArgs(new ThreePidSessionDao(tpid, secret)); + if (daoList.size() > 1) { + log.error("Lookup for 3PID Session {}:{} returned more than one result"); + throw new InternalServerError(); + } + + if (daoList.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(daoList.get(0)); + }); + } + + @Override + public void insertThreePidSession(IThreePidSessionDao session) { + withCatcher(() -> { + int updated = sessionDao.create(new ThreePidSessionDao(session)); + if (updated != 1) { + throw new RuntimeException("Unexpected row count after DB action: " + updated); + } + }); + } + + @Override + public void updateThreePidSession(IThreePidSessionDao session) { + withCatcher(() -> { + int updated = sessionDao.update(new ThreePidSessionDao(session)); + if (updated != 1) { + throw new RuntimeException("Unexpected row count after DB action: " + updated); + } + }); } } diff --git a/src/main/groovy/io/kamax/mxisd/storage/ormlite/dao/ThreePidSessionDao.java b/src/main/groovy/io/kamax/mxisd/storage/ormlite/dao/ThreePidSessionDao.java new file mode 100644 index 0000000..fc74bf6 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/storage/ormlite/dao/ThreePidSessionDao.java @@ -0,0 +1,264 @@ +/* + * 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.storage.ormlite.dao; + +import com.j256.ormlite.field.DatabaseField; +import com.j256.ormlite.table.DatabaseTable; +import io.kamax.mxisd.ThreePid; +import io.kamax.mxisd.storage.dao.IThreePidSessionDao; + +@DatabaseTable(tableName = "session_3pid") +public class ThreePidSessionDao implements IThreePidSessionDao { + + @DatabaseField(id = true) + private String id; + + @DatabaseField(canBeNull = false) + private long creationTime; + + @DatabaseField(canBeNull = false) + private String server; + + @DatabaseField(canBeNull = false) + private String medium; + + @DatabaseField(canBeNull = false) + private String address; + + @DatabaseField(canBeNull = false) + private String secret; + + @DatabaseField(canBeNull = false) + private int attempt; + + @DatabaseField + private String nextLink; + + @DatabaseField(canBeNull = false) + private String token; + + @DatabaseField + private boolean validated; + + @DatabaseField + private long validationTime; + + @DatabaseField(canBeNull = false) + private boolean isRemote; + + @DatabaseField + private String remoteServer; + + @DatabaseField + private String remoteId; + + @DatabaseField + private String remoteSecret; + + @DatabaseField + private Integer remoteAttempt; + + @DatabaseField(canBeNull = false) + private boolean isRemoteValidated; + + public ThreePidSessionDao() { + // stub for ORMLite + } + + public ThreePidSessionDao(IThreePidSessionDao session) { + setId(session.getId()); + setCreationTime(session.getCreationTime()); + setServer(session.getServer()); + setMedium(session.getMedium()); + setAddress(session.getAddress()); + setSecret(session.getSecret()); + setAttempt(session.getAttempt()); + setNextLink(session.getNextLink()); + setToken(session.getToken()); + setValidated(session.getValidated()); + setValidationTime(session.getValidationTime()); + setRemote(session.isRemote()); + setRemoteServer(session.getRemoteServer()); + setRemoteId(session.getRemoteId()); + setRemoteSecret(session.getRemoteSecret()); + setRemoteAttempt(session.getRemoteAttempt()); + setRemoteValidated(session.isRemoteValidated()); + } + + public ThreePidSessionDao(ThreePid tpid, String secret) { + setMedium(tpid.getMedium()); + setAddress(tpid.getAddress()); + setSecret(secret); + } + + @Override + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @Override + public long getCreationTime() { + return creationTime; + } + + public void setCreationTime(long creationTime) { + this.creationTime = creationTime; + } + + @Override + public String getServer() { + return server; + } + + public void setServer(String server) { + this.server = server; + } + + @Override + public String getMedium() { + return medium; + } + + public void setMedium(String medium) { + this.medium = medium; + } + + @Override + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + @Override + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + @Override + public int getAttempt() { + return attempt; + } + + public void setAttempt(int attempt) { + this.attempt = attempt; + } + + @Override + public String getNextLink() { + return nextLink; + } + + public void setNextLink(String nextLink) { + this.nextLink = nextLink; + } + + @Override + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public boolean getValidated() { + return validated; + } + + public void setValidated(boolean validated) { + this.validated = validated; + } + + @Override + public long getValidationTime() { + return validationTime; + } + + @Override + public boolean isRemote() { + return isRemote; + } + + public void setRemote(boolean remote) { + isRemote = remote; + } + + @Override + public String getRemoteServer() { + return remoteServer; + } + + public void setRemoteServer(String remoteServer) { + this.remoteServer = remoteServer; + } + + @Override + public String getRemoteId() { + return remoteId; + } + + public void setRemoteId(String remoteId) { + this.remoteId = remoteId; + } + + @Override + public String getRemoteSecret() { + return remoteSecret; + } + + public void setRemoteSecret(String remoteSecret) { + this.remoteSecret = remoteSecret; + } + + @Override + public int getRemoteAttempt() { + return remoteAttempt; + } + + @Override + public boolean isRemoteValidated() { + return isRemoteValidated; + } + + public void setRemoteValidated(boolean remoteValidated) { + isRemoteValidated = remoteValidated; + } + + public void setRemoteAttempt(int remoteAttempt) { + this.remoteAttempt = remoteAttempt; + } + + public void setValidationTime(long validationTime) { + this.validationTime = validationTime; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/mapping/MappingSession.java b/src/main/groovy/io/kamax/mxisd/threepid/connector/IThreePidConnector.java similarity index 83% rename from src/main/groovy/io/kamax/mxisd/mapping/MappingSession.java rename to src/main/groovy/io/kamax/mxisd/threepid/connector/IThreePidConnector.java index 7d647d3..c8fc23f 100644 --- a/src/main/groovy/io/kamax/mxisd/mapping/MappingSession.java +++ b/src/main/groovy/io/kamax/mxisd/threepid/connector/IThreePidConnector.java @@ -18,18 +18,12 @@ * along with this program. If not, see . */ -package io.kamax.mxisd.mapping; +package io.kamax.mxisd.threepid.connector; -public interface MappingSession { +public interface IThreePidConnector { - String getServer(); - - String getSecret(); - - int getAttempt(); + String getId(); String getMedium(); - String getValue(); - } diff --git a/src/main/groovy/io/kamax/mxisd/threepid/connector/email/EmailSmtpConnector.java b/src/main/groovy/io/kamax/mxisd/threepid/connector/email/EmailSmtpConnector.java new file mode 100644 index 0000000..18e0fae --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/threepid/connector/email/EmailSmtpConnector.java @@ -0,0 +1,100 @@ +/* + * 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.threepid.connector.email; + +import com.sun.mail.smtp.SMTPTransport; +import io.kamax.matrix.ThreePidMedium; +import io.kamax.mxisd.config.threepid.connector.EmailSmtpConfig; +import io.kamax.mxisd.exception.InternalServerError; +import org.apache.commons.io.IOUtils; +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 javax.mail.Message; +import javax.mail.MessagingException; +import javax.mail.Session; +import javax.mail.internet.InternetAddress; +import javax.mail.internet.MimeMessage; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class EmailSmtpConnector implements IEmailConnector { + + private Logger log = LoggerFactory.getLogger(EmailSmtpConnector.class); + + private EmailSmtpConfig cfg; + private Session session; + + @Autowired + public EmailSmtpConnector(EmailSmtpConfig cfg) { + this.cfg = cfg; + session = Session.getInstance(System.getProperties()); + } + + @Override + public String getId() { + return "smtp"; + } + + @Override + public String getMedium() { + return ThreePidMedium.Email.getId(); + } + + @Override + public void send(String senderAddress, String senderName, String recipient, String content) { + if (StringUtils.isBlank(content)) { + throw new InternalServerError("Notification content is empty"); + } + + try { + InternetAddress sender = new InternetAddress(senderAddress, senderName); + MimeMessage msg = new MimeMessage(session, IOUtils.toInputStream(content, StandardCharsets.UTF_8)); + msg.setHeader("X-Mailer", "mxisd"); // FIXME set version + msg.setSentDate(new Date()); + msg.setFrom(sender); + msg.setRecipients(Message.RecipientType.TO, recipient); + msg.saveChanges(); + + log.info("Sending invite to {} via SMTP using {}:{}", recipient, cfg.getHost(), cfg.getPort()); + SMTPTransport transport = (SMTPTransport) session.getTransport("smtp"); + transport.setStartTLS(cfg.getTls() > 0); + transport.setRequireStartTLS(cfg.getTls() > 1); + + log.info("Connecting to {}:{}", cfg.getHost(), cfg.getPort()); + transport.connect(cfg.getHost(), cfg.getPort(), cfg.getLogin(), cfg.getPassword()); + try { + transport.sendMessage(msg, InternetAddress.parse(recipient)); + log.info("Invite to {} was sent", recipient); + } finally { + transport.close(); + } + } catch (UnsupportedEncodingException | MessagingException e) { + throw new RuntimeException("Unable to send e-mail invite to " + recipient, e); + } + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/threepid/connector/email/IEmailConnector.java b/src/main/groovy/io/kamax/mxisd/threepid/connector/email/IEmailConnector.java new file mode 100644 index 0000000..e2b7bd1 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/threepid/connector/email/IEmailConnector.java @@ -0,0 +1,35 @@ +/* + * 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.threepid.connector.email; + +import io.kamax.matrix.ThreePidMedium; +import io.kamax.mxisd.threepid.connector.IThreePidConnector; + +public interface IEmailConnector extends IThreePidConnector { + + @Override + default String getMedium() { + return ThreePidMedium.Email.getId(); + } + + void send(String senderAddress, String senderName, String recipient, String content); + +} diff --git a/src/main/groovy/io/kamax/mxisd/threepid/notification/INotificationGenerator.java b/src/main/groovy/io/kamax/mxisd/threepid/notification/INotificationGenerator.java new file mode 100644 index 0000000..ad429ed --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/threepid/notification/INotificationGenerator.java @@ -0,0 +1,38 @@ +/* + * 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.threepid.notification; + +import io.kamax.mxisd.invitation.IThreePidInviteReply; +import io.kamax.mxisd.threepid.session.IThreePidSession; + +public interface INotificationGenerator { + + String getId(); + + String getMedium(); + + String getForInvite(IThreePidInviteReply invite); + + String getForValidation(IThreePidSession session); + + String getForRemoteValidation(IThreePidSession session); + +} diff --git a/src/main/groovy/io/kamax/mxisd/threepid/notification/email/EmailNotificationGenerator.java b/src/main/groovy/io/kamax/mxisd/threepid/notification/email/EmailNotificationGenerator.java new file mode 100644 index 0000000..85739db --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/threepid/notification/email/EmailNotificationGenerator.java @@ -0,0 +1,154 @@ +/* + * 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.threepid.notification.email; + +import io.kamax.mxisd.ThreePid; +import io.kamax.mxisd.config.MatrixConfig; +import io.kamax.mxisd.config.ServerConfig; +import io.kamax.mxisd.config.threepid.medium.EmailConfig; +import io.kamax.mxisd.config.threepid.medium.EmailTemplateConfig; +import io.kamax.mxisd.controller.v1.IdentityAPIv1; +import io.kamax.mxisd.exception.InternalServerError; +import io.kamax.mxisd.invitation.IThreePidInviteReply; +import io.kamax.mxisd.threepid.session.IThreePidSession; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringUtils; +import org.apache.commons.lang.WordUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +@Component +public class EmailNotificationGenerator implements IEmailNotificationGenerator { + + private Logger log = LoggerFactory.getLogger(EmailNotificationGenerator.class); + + private EmailConfig cfg; + private EmailTemplateConfig templateCfg; + private MatrixConfig mxCfg; + private ServerConfig srvCfg; + + @Autowired + private ApplicationContext app; + + @Autowired + public EmailNotificationGenerator(EmailTemplateConfig templateCfg, EmailConfig cfg, MatrixConfig mxCfg, ServerConfig srvCfg) { + this.cfg = cfg; + this.templateCfg = templateCfg; + this.mxCfg = mxCfg; + this.srvCfg = srvCfg; + } + + @Override + public String getId() { + return "template"; + } + + private String getTemplateContent(String location) { + try { + InputStream is = StringUtils.startsWith(location, "classpath:") ? + app.getResource(location).getInputStream() : new FileInputStream(location); + return IOUtils.toString(is, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new InternalServerError("Unable to read template content at " + location + ": " + e.getMessage()); + } + } + + private String populateCommon(String content, ThreePid recipient) { + String domainPretty = WordUtils.capitalizeFully(mxCfg.getDomain()); + + content = content.replace("%DOMAIN%", mxCfg.getDomain()); + content = content.replace("%DOMAIN_PRETTY%", domainPretty); + content = content.replace("%FROM_EMAIL%", cfg.getIdentity().getFrom()); + content = content.replace("%FROM_NAME%", cfg.getIdentity().getName()); + content = content.replace("%RECIPIENT_MEDIUM%", recipient.getMedium()); + content = content.replace("%RECIPIENT_ADDRESS%", recipient.getAddress()); + return content; + } + + private String getTemplateAndPopulate(String location, ThreePid recipient) { + return populateCommon(getTemplateContent(location), recipient); + } + + @Override + public String getForInvite(IThreePidInviteReply invite) { + ThreePid tpid = new ThreePid(invite.getInvite().getMedium(), invite.getInvite().getAddress()); + String templateBody = getTemplateAndPopulate(templateCfg.getInvite(), tpid); + + String senderName = invite.getInvite().getProperties().getOrDefault("sender_display_name", ""); + String senderNameOrId = StringUtils.defaultIfBlank(senderName, invite.getInvite().getSender().getId()); + String roomName = invite.getInvite().getProperties().getOrDefault("room_name", ""); + String roomNameOrId = StringUtils.defaultIfBlank(roomName, invite.getInvite().getRoomId()); + + templateBody = templateBody.replace("%SENDER_ID%", invite.getInvite().getSender().getId()); + templateBody = templateBody.replace("%SENDER_NAME%", senderName); + templateBody = templateBody.replace("%SENDER_NAME_OR_ID%", senderNameOrId); + templateBody = templateBody.replace("%INVITE_MEDIUM%", tpid.getMedium()); + templateBody = templateBody.replace("%INVITE_ADDRESS%", tpid.getAddress()); + templateBody = templateBody.replace("%ROOM_ID%", invite.getInvite().getRoomId()); + templateBody = templateBody.replace("%ROOM_NAME%", roomName); + templateBody = templateBody.replace("%ROOM_NAME_OR_ID%", roomNameOrId); + + return templateBody; + } + + @Override + public String getForValidation(IThreePidSession session) { + log.info("Generating notification content for 3PID Session validation"); + String templateBody = getTemplateAndPopulate(templateCfg.getSession().getValidation().getLocal(), session.getThreePid()); + + // FIXME should have a global link builder, most likely in the SDK? + String validationLink = srvCfg.getPublicUrl() + IdentityAPIv1.BASE + + "/validate/" + session.getThreePid().getMedium() + + "/submitToken?sid=" + session.getId() + "&client_secret=" + session.getSecret() + + "&token=" + session.getToken(); + + templateBody = templateBody.replace("%VALIDATION_LINK%", validationLink); + templateBody = templateBody.replace("%VALIDATION_TOKEN%", session.getToken()); + + return templateBody; + } + + @Override + public String getForRemoteValidation(IThreePidSession session) { + log.info("Generating notification content for remote-only 3PID session"); + String templateBody = getTemplateAndPopulate(templateCfg.getSession().getValidation().getRemote(), session.getThreePid()); + + // FIXME should have a global link builder, most likely in the SDK? + String validationLink = srvCfg.getPublicUrl() + IdentityAPIv1.BASE + + "/validate/" + session.getThreePid().getMedium() + + "/submitToken?sid=" + session.getId() + "&client_secret=" + session.getSecret() + + "&token=" + session.getToken(); + + templateBody = templateBody.replace("%VALIDATION_LINK%", validationLink); + templateBody = templateBody.replace("%VALIDATION_TOKEN%", session.getToken()); + + return templateBody; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/threepid/notification/email/EmailNotificationHandler.java b/src/main/groovy/io/kamax/mxisd/threepid/notification/email/EmailNotificationHandler.java new file mode 100644 index 0000000..46c4b2e --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/threepid/notification/email/EmailNotificationHandler.java @@ -0,0 +1,87 @@ +/* + * 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.threepid.notification.email; + +import io.kamax.matrix.ThreePidMedium; +import io.kamax.mxisd.config.threepid.medium.EmailConfig; +import io.kamax.mxisd.exception.ConfigurationException; +import io.kamax.mxisd.invitation.IThreePidInviteReply; +import io.kamax.mxisd.notification.INotificationHandler; +import io.kamax.mxisd.threepid.connector.email.IEmailConnector; +import io.kamax.mxisd.threepid.session.IThreePidSession; +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class EmailNotificationHandler implements INotificationHandler { + + private EmailConfig cfg; + private IEmailNotificationGenerator generator; + private IEmailConnector connector; + + @Autowired + public EmailNotificationHandler(EmailConfig cfg, List generators, List connectors) { + this.cfg = cfg; + + generator = generators.stream() + .filter(o -> StringUtils.equals(cfg.getGenerator(), o.getId())) + .findFirst() + .orElseThrow(() -> new ConfigurationException("Email notification generator [" + cfg.getGenerator() + "] could not be found")); + + connector = connectors.stream() + .filter(o -> StringUtils.equals(cfg.getConnector(), o.getId())) + .findFirst() + .orElseThrow(() -> new ConfigurationException("Email sender connector [" + cfg.getConnector() + "] could not be found")); + } + + @Override + public String getMedium() { + return ThreePidMedium.Email.getId(); + } + + private void send(String recipient, String content) { + connector.send( + cfg.getIdentity().getFrom(), + cfg.getIdentity().getName(), + recipient, + content + ); + } + + @Override + public void sendForInvite(IThreePidInviteReply invite) { + send(invite.getInvite().getAddress(), generator.getForInvite(invite)); + } + + @Override + public void sendForValidation(IThreePidSession session) { + send(session.getThreePid().getAddress(), generator.getForValidation(session)); + } + + @Override + public void sendForRemoteValidation(IThreePidSession session) { + send(session.getThreePid().getAddress(), generator.getForRemoteValidation(session)); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/threepid/notification/email/IEmailNotificationGenerator.java b/src/main/groovy/io/kamax/mxisd/threepid/notification/email/IEmailNotificationGenerator.java new file mode 100644 index 0000000..a7401ab --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/threepid/notification/email/IEmailNotificationGenerator.java @@ -0,0 +1,33 @@ +/* + * 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.threepid.notification.email; + +import io.kamax.matrix.ThreePidMedium; +import io.kamax.mxisd.threepid.notification.INotificationGenerator; + +public interface IEmailNotificationGenerator extends INotificationGenerator { + + @Override + default String getMedium() { + return ThreePidMedium.Email.getId(); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/threepid/session/IThreePidSession.java b/src/main/groovy/io/kamax/mxisd/threepid/session/IThreePidSession.java new file mode 100644 index 0000000..8611855 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/threepid/session/IThreePidSession.java @@ -0,0 +1,66 @@ +/* + * 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.threepid.session; + +import io.kamax.mxisd.ThreePid; + +import java.time.Instant; +import java.util.Optional; + +public interface IThreePidSession { + + String getId(); + + Instant getCreationTime(); + + String getServer(); + + ThreePid getThreePid(); + + String getSecret(); + + int getAttempt(); + + void increaseAttempt(); + + Optional getNextLink(); + + String getToken(); + + void validate(String token); + + boolean isValidated(); + + Instant getValidationTime(); + + boolean isRemote(); + + String getRemoteServer(); + + String getRemoteId(); + + String getRemoteSecret(); + + int getRemoteAttempt(); + + void setRemoteData(String server, String id, String secret, int attempt); + +} diff --git a/src/main/groovy/io/kamax/mxisd/threepid/session/ThreePidSession.java b/src/main/groovy/io/kamax/mxisd/threepid/session/ThreePidSession.java new file mode 100644 index 0000000..035a60a --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/threepid/session/ThreePidSession.java @@ -0,0 +1,304 @@ +/* + * 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.threepid.session; + +import io.kamax.mxisd.ThreePid; +import io.kamax.mxisd.exception.BadRequestException; +import io.kamax.mxisd.exception.InvalidCredentialsException; +import io.kamax.mxisd.storage.dao.IThreePidSessionDao; +import org.apache.commons.lang.StringUtils; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +public class ThreePidSession implements IThreePidSession { + + private String id; + private Instant timestamp; + private String server; + private ThreePid tPid; + private String secret; + private String nextLink; + private String token; + private int attempt; + private Instant validationTimestamp; + private boolean isValidated; + private boolean isRemote; + private String remoteServer; + private String remoteId; + private String remoteSecret; + private int remoteAttempt; + private boolean isRemoteValidated; + + public ThreePidSession(IThreePidSessionDao dao) { + this( + dao.getId(), + dao.getServer(), + new ThreePid(dao.getMedium(), dao.getAddress()), + dao.getSecret(), + dao.getAttempt(), + dao.getNextLink(), + dao.getToken() + ); + timestamp = Instant.ofEpochMilli(dao.getCreationTime()); + isValidated = dao.getValidated(); + if (isValidated) { + validationTimestamp = Instant.ofEpochMilli(dao.getValidationTime()); + } + + isRemote = dao.isRemote(); + remoteServer = dao.getRemoteServer(); + remoteId = dao.getRemoteId(); + remoteSecret = dao.getRemoteSecret(); + remoteAttempt = dao.getRemoteAttempt(); + isRemoteValidated = dao.isRemoteValidated(); + } + + public ThreePidSession(String id, String server, ThreePid tPid, String secret, int attempt, String nextLink, String token) { + this.id = id; + this.server = server; + this.tPid = new ThreePid(tPid); + this.secret = secret; + this.attempt = attempt; + this.nextLink = nextLink; + this.token = token; + + this.timestamp = Instant.now(); + } + + @Override + public String getId() { + return id; + } + + @Override + public Instant getCreationTime() { + return timestamp; + } + + @Override + public String getServer() { + return server; + } + + @Override + public ThreePid getThreePid() { + return tPid; + } + + public String getSecret() { + return secret; + } + + @Override + public int getAttempt() { + return attempt; + } + + @Override + public void increaseAttempt() { + attempt++; + } + + @Override + public Optional getNextLink() { + return Optional.ofNullable(nextLink); + } + + @Override + public String getToken() { + return token; + } + + public synchronized void setAttempt(int attempt) { + if (isValidated()) { + throw new IllegalStateException(); + } + + this.attempt = attempt; + } + + @Override + public Instant getValidationTime() { + return validationTimestamp; + } + + @Override + public boolean isRemote() { + return isRemote; + } + + @Override + public String getRemoteServer() { + return remoteServer; + } + + @Override + public String getRemoteId() { + return remoteId; + } + + @Override + public String getRemoteSecret() { + return remoteSecret; + } + + @Override + public int getRemoteAttempt() { + return remoteAttempt; + } + + public int increaseAndGetRemoteAttempt() { + return ++remoteAttempt; + } + + @Override + public void setRemoteData(String server, String id, String secret, int attempt) { + this.remoteServer = server; + this.remoteId = id; + this.remoteSecret = secret; + this.attempt = attempt; + this.isRemote = true; + } + + @Override + public boolean isValidated() { + return isValidated; + } + + public synchronized void validate(String token) { + if (Instant.now().minus(24, ChronoUnit.HOURS).isAfter(getCreationTime())) { + throw new BadRequestException("Session " + getId() + " has expired"); + } + + if (!StringUtils.equals(this.token, token)) { + throw new InvalidCredentialsException(); + } + + if (isValidated()) { + return; + } + + validationTimestamp = Instant.now(); + isValidated = true; + } + + public boolean isRemoteValidated() { + return isRemoteValidated; + } + + public void validateRemote() { + this.isRemoteValidated = true; + } + + public IThreePidSessionDao getDao() { + return new IThreePidSessionDao() { + + @Override + public String getId() { + return id; + } + + @Override + public long getCreationTime() { + return timestamp.toEpochMilli(); + } + + @Override + public String getServer() { + return server; + } + + @Override + public String getMedium() { + return tPid.getMedium(); + } + + @Override + public String getAddress() { + return tPid.getAddress(); + } + + @Override + public String getSecret() { + return secret; + } + + @Override + public int getAttempt() { + return attempt; + } + + @Override + public String getNextLink() { + return nextLink; + } + + @Override + public String getToken() { + return token; + } + + @Override + public boolean getValidated() { + return isValidated; + } + + @Override + public long getValidationTime() { + return isValidated ? validationTimestamp.toEpochMilli() : 0; + } + + @Override + public boolean isRemote() { + return isRemote; + } + + @Override + public String getRemoteServer() { + return remoteServer; + } + + @Override + public String getRemoteId() { + return remoteId; + } + + @Override + public String getRemoteSecret() { + return remoteSecret; + } + + @Override + public int getRemoteAttempt() { + return remoteAttempt; + } + + @Override + public boolean isRemoteValidated() { + return isRemoteValidated; + } + + }; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/util/GsonParser.java b/src/main/groovy/io/kamax/mxisd/util/GsonParser.java index ec311c8..a83ebaf 100644 --- a/src/main/groovy/io/kamax/mxisd/util/GsonParser.java +++ b/src/main/groovy/io/kamax/mxisd/util/GsonParser.java @@ -26,6 +26,7 @@ import io.kamax.mxisd.exception.JsonMemberNotFoundException; import org.apache.commons.io.IOUtils; import org.apache.http.HttpResponse; +import javax.servlet.http.HttpServletRequest; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -53,6 +54,10 @@ public class GsonParser { return el.getAsJsonObject(); } + public T parse(HttpServletRequest req, Class type) throws IOException { + return gson.fromJson(parse(req.getInputStream()), type); + } + public T parse(HttpResponse res, Class type) throws IOException { return gson.fromJson(parse(res.getEntity().getContent()), type); } 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 87d88f1..b5ebaa6 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 @@ -55,19 +61,77 @@ firebase: enabled: false sql: + enabled: false type: 'sqlite' + connection: '' + auth: + enabled: false + identity: + type: 'mxid' + query: "SELECT user_id AS uid FROM user_threepids WHERE medium = ? AND address = ?" forward: servers: - "https://matrix.org" - "https://vector.im" -invite: - sender: +threepid: + medium: email: - tls: 1 - name: "mxisd Identity Server" - template: "classpath:email/invite-template.eml" + identity: + from: '' + name: '' + connector: 'smtp' + generator: 'template' + connectors: + smtp: + host: '' + port: 587 + tls: 1 + login: '' + password: '' + generators: + template: + invite: 'classpath:email/invite-template.eml' + session: + validation: + local: 'classpath:email/validate-local-template.eml' + remote: 'classpath:email/validate-remote-template.eml' + +session: + policy: + validation: + enabled: true + forLocal: + enabled: true + toLocal: true + toRemote: + enabled: true + server: 'root' + forRemote: + enabled: true + toLocal: false + toRemote: + enabled: true + server: 'root' + +view: + session: + local: + onTokenSubmit: + success: 'session/local/tokenSubmitSuccess' + failure: 'session/local/tokenSubmitFailure' + localRemote: + onTokenSubmit: + success: 'session/localRemote/tokenSubmitSuccess' + failure: 'session/local/tokenSubmitFailure' + remote: + onRequest: + success: 'session/remote/requestSuccess' + failure: 'session/remote/requestFailure' + onCheck: + success: 'session/remote/checkSuccess' + failure: 'session/remote/checkFailure' storage: backend: 'sqlite' diff --git a/src/main/resources/email/validate-local-template.eml b/src/main/resources/email/validate-local-template.eml new file mode 100644 index 0000000..78df56e --- /dev/null +++ b/src/main/resources/email/validate-local-template.eml @@ -0,0 +1,87 @@ +Subject: Your Matrix Validation Token +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="7REaIwWQCioQ6NaBlAQlg8ztbUQj6PKJ" + +--7REaIwWQCioQ6NaBlAQlg8ztbUQj6PKJ +Content-Type: text/plain; charset=UTF-8 +Content-Disposition: inline + +Hello there! + +We have received a request to link this email address with your Matrix account. + +If it was really you who made this request, you can click on the following link to +complete the verification of your email address: + + %VALIDATION_LINK% + +If you didn't make this request, you can safely disregard this email. + +%DOMAIN_PRETTY% Admins + +--7REaIwWQCioQ6NaBlAQlg8ztbUQj6PKJ +Content-Type: multipart/related; + boundary="M3yzHl5YZehm9v4bAM8sKEdcOoVnRnKR"; + type="text/html" + +--M3yzHl5YZehm9v4bAM8sKEdcOoVnRnKR +Content-Type: text/html; charset=UTF-8 +Content-Disposition: inline + + + + + + + + + + + + + +
+

Hello there!

+ +

We have received a request to link this email address with your Matrix account.

+ +

If it was really you who made this request, you can click on the following link to + complete the verification of your email address:

+ +

Complete email verification

+ +

If you didn't make this request, you can safely disregard this email.

+ +

%DOMAIN_PRETTY% Admins

+
+ + +--M3yzHl5YZehm9v4bAM8sKEdcOoVnRnKR-- + +--7REaIwWQCioQ6NaBlAQlg8ztbUQj6PKJ-- diff --git a/src/main/resources/email/validate-remote-template.eml b/src/main/resources/email/validate-remote-template.eml new file mode 100644 index 0000000..b4888c6 --- /dev/null +++ b/src/main/resources/email/validate-remote-template.eml @@ -0,0 +1,102 @@ +Subject: Linking your Email address to your Matrix account +MIME-Version: 1.0 +Content-Type: multipart/alternative; + boundary="7REaIwWQCioQ6NaBlAQlg8ztbUQj6PKJ" + +--7REaIwWQCioQ6NaBlAQlg8ztbUQj6PKJ +Content-Type: text/plain; charset=UTF-8 +Content-Disposition: inline + +Hello there! + +We have received a request to link this email address with your Matrix account. + +Due to the security policy in place, this email address can only be stored in the central Matrix Identity Server. +If you continue, your e-mail address and Matrix ID association will be made public without any current mean to be removed. + +If you would still like to continue, you will need to: +1. Go to your private Public registration process page: + + %VALIDATION_LINK% + +2. Follow the registration process of the central Identity Server, usually another email with similar content +3. Once your email address validated with the central Identity Server, click on "Continue" on page of step #1 +4. If your public association is found by our Identity server, the next step will be given to you. + + +If you didn't make this request, or do not want to make your address public, you can safely disregard this email. + +%DOMAIN_PRETTY% Admins + +--7REaIwWQCioQ6NaBlAQlg8ztbUQj6PKJ +Content-Type: multipart/related; + boundary="M3yzHl5YZehm9v4bAM8sKEdcOoVnRnKR"; + type="text/html" + +--M3yzHl5YZehm9v4bAM8sKEdcOoVnRnKR +Content-Type: text/html; charset=UTF-8 +Content-Disposition: inline + + + + + + + + + + + + + +
+

Hello there!

+ +

We have received a request to link this email address with your Matrix account.

+ +

Due to the security policy in place, this email address can only be stored in the central Matrix Identity Server. + If you continue, your e-mail address and Matrix ID association will be made public without any current mean to be removed.

+ +

If you would still like to continue, you will need to: +

    +
  1. Go to your private Public registration process page
  2. +
  3. Follow the registration process of the central Identity Server, usually another email with similar content
  4. +
  5. Once your email address validated with the central Identity Server, click on "Continue" on page of step #1
  6. +
  7. If your public association is found by our Identity server, the next step will be given to you.
  8. +
+

+ +

If you didn't make this request, or do not want to make your address public, you can safely disregard this email.

+ +

%DOMAIN_PRETTY% Admins

+
+ + +--M3yzHl5YZehm9v4bAM8sKEdcOoVnRnKR-- + +--7REaIwWQCioQ6NaBlAQlg8ztbUQj6PKJ-- diff --git a/src/main/resources/templates/session/local/tokenSubmitFailure.html b/src/main/resources/templates/session/local/tokenSubmitFailure.html new file mode 100644 index 0000000..bdeed88 --- /dev/null +++ b/src/main/resources/templates/session/local/tokenSubmitFailure.html @@ -0,0 +1,40 @@ + + + + + Matrix Token Verification + + + +
+

Verification failed: you may need to request another verification email

+
+ + diff --git a/src/main/resources/templates/session/local/tokenSubmitSuccess.html b/src/main/resources/templates/session/local/tokenSubmitSuccess.html new file mode 100644 index 0000000..2bcd7c6 --- /dev/null +++ b/src/main/resources/templates/session/local/tokenSubmitSuccess.html @@ -0,0 +1,40 @@ + + + + + Matrix Token Verification + + + +
+

Verification successful: return to your Matrix client to complete the process.

+
+ + diff --git a/src/main/resources/templates/session/localRemote/tokenSubmitSuccess.html b/src/main/resources/templates/session/localRemote/tokenSubmitSuccess.html new file mode 100644 index 0000000..2dc81a1 --- /dev/null +++ b/src/main/resources/templates/session/localRemote/tokenSubmitSuccess.html @@ -0,0 +1,47 @@ + + + + + Matrix Token Verification + + + +
+

Verification successful!

+

Your email will remain private and you will only be discoverable with it on your own server, or any related + servers configured by your system admin.
+ If you would like to be globally discoverable, start the process here. +
If you chose to start the global publication process, wait until it is done before returning to your + client.

+

If the remote process is finished, or if you do not wish to start it at this time, you can now return to your + Matrix client to complete the process.

+
+ + diff --git a/src/main/resources/templates/session/remote/checkFailure.html b/src/main/resources/templates/session/remote/checkFailure.html new file mode 100644 index 0000000..95965fc --- /dev/null +++ b/src/main/resources/templates/session/remote/checkFailure.html @@ -0,0 +1,43 @@ + + + + + Matrix global token verification + + + +
+

You do not seem to have validated your session with the global server. Please check your messages for one similar + to the one you received initially.
+ Once this is done, click here to continue

+

If this problem persists, contact your system administrator with the following info: Reference #ABC

+
+ + diff --git a/src/main/resources/templates/session/remote/checkSuccess.html b/src/main/resources/templates/session/remote/checkSuccess.html new file mode 100644 index 0000000..1369763 --- /dev/null +++ b/src/main/resources/templates/session/remote/checkSuccess.html @@ -0,0 +1,41 @@ + + + + + Matrix global token verification + + + +
+

Verification successful!

+

Return to your Matrix client to complete the process and make yourself globally discoverable.

+
+ + diff --git a/src/main/resources/templates/session/remote/requestFailure.html b/src/main/resources/templates/session/remote/requestFailure.html new file mode 100644 index 0000000..d546307 --- /dev/null +++ b/src/main/resources/templates/session/remote/requestFailure.html @@ -0,0 +1,42 @@ + + + + + Matrix global token verification + + + +
+

The process to be globally discoverable has failed!
You can try to refresh this page in a few seconds or + minutes.

+

If this problem persists, contact your system administrator with the following info: Reference #ABC

+
+ + diff --git a/src/main/resources/templates/session/remote/requestSuccess.html b/src/main/resources/templates/session/remote/requestSuccess.html new file mode 100644 index 0000000..fbf678b --- /dev/null +++ b/src/main/resources/templates/session/remote/requestSuccess.html @@ -0,0 +1,45 @@ + + + + + Matrix global token verification + + + +
+

The process to be globally discoverable has started. A verification token has been requested on your behalf.

+

You will receive a similar communication as the first verification message.
+ Follow the instructions and come back to this page once you are told to return to your Matrix client or that the + verification was successful.

+

Once the validation was successful with the global server, please follow this link + to validate it with us.

+
+ +