diff --git a/docs/features/authentication.md b/docs/features/authentication.md index a80c6a9..c5f8549 100644 --- a/docs/features/authentication.md +++ b/docs/features/authentication.md @@ -74,7 +74,15 @@ See your Identity store [documentation](../stores/README.md) on how to enable th ## Advanced -The Authentication feature allows users to login to their Homeserver by using their 3PIDs in a configured Identity store. +The Authentication feature allows users to: +- Rewrite usernames matching a pattern to be mapped to another username via a 3PID. +- login to their Homeserver by using their 3PIDs in a configured Identity store. + +This feature also allows to work around the following issues: +- Lowercase all usernames for synapse, allowing case-insensitive login +- Unable to login on synapse if username is numerical +- Any generic transformation of username prior to sending to synapse, bypassing the restriction that password providers +cannot change the localpart being authenticated. ### Overview This is performed by intercepting the Homeserver endpoint `/_matrix/client/r0/login` as depicted below: @@ -109,10 +117,10 @@ Steps of user authentication using a 3PID: 4. The response from the Homeserver is sent back to the client, believing it was the HS which directly answered. ### Requirements -- [Basic Authentication configured and working](#basic) -- Reverse proxy setup -- Homeserver - Compatible [Identity store](../stores/README.md) +- [Basic Authentication configured and working](#basic) +- Client and Homeserver using the [C2S API r0.4.x](https://matrix.org/docs/spec/client_server/r0.4.0.html) or later +- Reverse proxy setup ### Configuration #### Reverse Proxy @@ -153,3 +161,40 @@ In case the hostname is the same as your Matrix domain and `server.name` is not `matrix.domain` and will still probably have the correct value. `value` is the base internal URL of the Homeserver, without any `/_matrix/..` or trailing `/`. + +#### Username rewrite +In mxisd config: +```yaml +auth: + rewrite: + user: + rules: + - regex: + medium: 'your.custom.medium.type' +``` +`rules` takes a list of rules. Rules have two properties: +- `regexp`: The regex pattern to match. This **MUST** match the full string. See [Java regex](https://docs.oracle.com/javase/8/docs/api/java/util/regex/Pattern.html) for syntax. +- `medium`: Custom 3PID type that will be used in the 3PID lookup. This can be anything you want and needs to be supported +by your Identity store config and/or code. + +Rules are matched in listed order. + +Common regexp patterns: +- Numerical usernames: `[0-9]+` + +##### LDAP Example +If your users use their numerical employee IDs, which cannot be used with synapse, you can make it work with (relevant config only): +```yaml +auth: + rewrite: + user: + rules: + - regex: '[0-9]+' + medium: 'kmx.employee.id' + +ldap: + attribute: + threepid: + kmx.employee.id: + - 'ldapAttributeForEmployeeId' +``` diff --git a/src/main/java/io/kamax/mxisd/auth/AuthManager.java b/src/main/java/io/kamax/mxisd/auth/AuthManager.java index 78d0934..052773b 100644 --- a/src/main/java/io/kamax/mxisd/auth/AuthManager.java +++ b/src/main/java/io/kamax/mxisd/auth/AuthManager.java @@ -20,37 +20,95 @@ package io.kamax.mxisd.auth; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import com.google.i18n.phonenumbers.NumberParseException; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; import io.kamax.matrix.MatrixID; import io.kamax.matrix.ThreePid; import io.kamax.matrix._MatrixID; import io.kamax.matrix._ThreePid; +import io.kamax.matrix.json.GsonUtil; import io.kamax.mxisd.UserIdType; import io.kamax.mxisd.auth.provider.AuthenticatorProvider; import io.kamax.mxisd.auth.provider.BackendAuthResult; +import io.kamax.mxisd.config.AuthenticationConfig; import io.kamax.mxisd.config.MatrixConfig; +import io.kamax.mxisd.dns.ClientDnsOverwrite; +import io.kamax.mxisd.exception.RemoteLoginException; import io.kamax.mxisd.invitation.InvitationManager; import io.kamax.mxisd.lookup.ThreePidMapping; +import io.kamax.mxisd.lookup.strategy.LookupStrategy; +import io.kamax.mxisd.util.RestClientUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringUtils; +import org.apache.http.HttpEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.utils.URIBuilder; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.util.EntityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import java.io.IOException; +import java.net.URI; import java.util.ArrayList; import java.util.List; +import java.util.Objects; @Service public class AuthManager { - private Logger log = LoggerFactory.getLogger(AuthManager.class); + private static final String TypeKey = "type"; + private static final String UserKey = "user"; + private static final String IdentifierKey = "identifier"; + private static final String ThreepidMediumKey = "medium"; + private static final String ThreepidAddressKey = "address"; + private static final String UserIdTypeValue = "m.id.user"; + private static final String ThreepidTypeValue = "m.id.thirdparty"; - @Autowired - private List providers = new ArrayList<>(); + private final Logger log = LoggerFactory.getLogger(AuthManager.class); + private final Gson gson = GsonUtil.get(); - @Autowired + private List providers; private MatrixConfig mxCfg; + private AuthenticationConfig cfg; + private InvitationManager invMgr; + private ClientDnsOverwrite dns; + private LookupStrategy strategy; + private CloseableHttpClient client; @Autowired - private InvitationManager invMgr; + public AuthManager( + AuthenticationConfig cfg, + MatrixConfig mxCfg, + List providers, + LookupStrategy strategy, + InvitationManager invMgr, + ClientDnsOverwrite dns, + CloseableHttpClient client + ) { + this.cfg = cfg; + this.mxCfg = mxCfg; + this.providers = new ArrayList<>(providers); + this.strategy = strategy; + this.invMgr = invMgr; + this.dns = dns; + this.client = client; + } + + public String resolveProxyUrl(URI target) { + URIBuilder builder = dns.transform(target); + String urlToLogin = builder.toString(); + log.info("Proxy resolution: {} to {}", target.toString(), urlToLogin); + return urlToLogin; + } public UserAuthResult authenticate(String id, String password) { _MatrixID mxid = MatrixID.asAcceptable(id); @@ -92,4 +150,128 @@ public class AuthManager { return new UserAuthResult().failure(); } + public String proxyLogin(URI target, String body) { + JsonObject reqJsonObject = io.kamax.matrix.json.GsonUtil.parseObj(body); + + GsonUtil.findObj(reqJsonObject, IdentifierKey).ifPresent(obj -> { + GsonUtil.findString(obj, TypeKey).ifPresent(type -> { + if (StringUtils.equals(type, UserIdTypeValue)) { + log.info("Login request is User ID type"); + + if (cfg.getRewrite().getUser().getRules().isEmpty()) { + log.info("No User ID rewrite rules to apply"); + } else { + log.info("User ID rewrite rules: checking for a match"); + + String userId = GsonUtil.getStringOrThrow(obj, UserKey); + for (AuthenticationConfig.Rule m : cfg.getRewrite().getUser().getRules()) { + if (m.getPattern().matcher(userId).matches()) { + log.info("Found matching pattern, resolving to 3PID with medium {}", m.getMedium()); + + // Remove deprecated login info on the top object if exists to avoid duplication + reqJsonObject.remove(UserKey); + obj.addProperty(TypeKey, ThreepidTypeValue); + obj.addProperty(ThreepidMediumKey, m.getMedium()); + obj.addProperty(ThreepidAddressKey, userId); + + log.info("Rewrite to 3PID done"); + } + } + + log.info("User ID rewrite rules: done checking rules"); + } + } + }); + }); + + GsonUtil.findObj(reqJsonObject, IdentifierKey).ifPresent(obj -> { + GsonUtil.findString(obj, TypeKey).ifPresent(type -> { + if (StringUtils.equals(type, ThreepidTypeValue)) { + // Remove deprecated login info if exists to avoid duplication + reqJsonObject.remove(ThreepidMediumKey); + reqJsonObject.remove(ThreepidAddressKey); + + GsonUtil.findPrimitive(obj, ThreepidMediumKey).ifPresent(medium -> { + GsonUtil.findPrimitive(obj, ThreepidAddressKey).ifPresent(address -> { + log.info("Login request with medium '{}' and address '{}'", medium.getAsString(), address.getAsString()); + strategy.findLocal(medium.getAsString(), address.getAsString()).ifPresent(lookupDataOpt -> { + obj.remove(ThreepidMediumKey); + obj.remove(ThreepidAddressKey); + obj.addProperty(TypeKey, UserIdTypeValue); + obj.addProperty(UserKey, lookupDataOpt.getMxid().getLocalPart()); + }); + }); + }); + } + + if (StringUtils.equals(type, "m.id.phone")) { + // Remove deprecated login info if exists to avoid duplication + reqJsonObject.remove(ThreepidMediumKey); + reqJsonObject.remove(ThreepidAddressKey); + + GsonUtil.findPrimitive(obj, "number").ifPresent(number -> { + GsonUtil.findPrimitive(obj, "country").ifPresent(country -> { + log.info("Login request with phone '{}'-'{}'", country.getAsString(), number.getAsString()); + try { + PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance(); + Phonenumber.PhoneNumber phoneNumber = phoneUtil.parse(number.getAsString(), country.getAsString()); + String msisdn = phoneUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164).replace("+", ""); + String medium = "msisdn"; + strategy.findLocal(medium, msisdn).ifPresent(lookupDataOpt -> { + obj.remove("country"); + obj.remove("number"); + obj.addProperty(TypeKey, UserIdTypeValue); + obj.addProperty(UserKey, lookupDataOpt.getMxid().getLocalPart()); + }); + } catch (NumberParseException e) { + log.error("Not a valid phone number"); + throw new RuntimeException(e); + } + }); + }); + } + }); + }); + + // invoke 'login' on homeserver + HttpPost httpPost = RestClientUtils.post(resolveProxyUrl(target), gson, reqJsonObject); + try (CloseableHttpResponse httpResponse = client.execute(httpPost)) { + // check http status + int status = httpResponse.getStatusLine().getStatusCode(); + log.info("http status = {}", status); + if (status != 200) { + // try to get possible json error message from response + // otherwise just get returned plain error message + String errcode = String.valueOf(httpResponse.getStatusLine().getStatusCode()); + String error = EntityUtils.toString(httpResponse.getEntity()); + if (httpResponse.getEntity() != null) { + try { + JsonObject bodyJson = new JsonParser().parse(error).getAsJsonObject(); + if (bodyJson.has("errcode")) { + errcode = bodyJson.get("errcode").getAsString(); + } + if (bodyJson.has("error")) { + error = bodyJson.get("error").getAsString(); + } + throw new RemoteLoginException(status, errcode, error, bodyJson); + } catch (JsonSyntaxException e) { + log.warn("Response body is not JSON"); + } + } + throw new RemoteLoginException(status, errcode, error); + } + + // return response + HttpEntity entity = httpResponse.getEntity(); + if (Objects.isNull(entity)) { + log.warn("Expected HS to return data but got nothing"); + return ""; + } else { + return IOUtils.toString(httpResponse.getEntity().getContent(), httpResponse.getEntity().getContentType().getValue()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } diff --git a/src/main/java/io/kamax/mxisd/config/AuthenticationConfig.java b/src/main/java/io/kamax/mxisd/config/AuthenticationConfig.java new file mode 100644 index 0000000..3f0d4a6 --- /dev/null +++ b/src/main/java/io/kamax/mxisd/config/AuthenticationConfig.java @@ -0,0 +1,110 @@ +/* + * mxisd - Matrix Identity Server Daemon + * Copyright (C) 2018 Kamax Sarl + * + * https://www.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.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +@Configuration +@ConfigurationProperties(prefix = "auth") +public class AuthenticationConfig { + + public static class Rule { + + private String regex; + private transient Pattern pattern; + private String medium; + + public String getRegex() { + return regex; + } + + public void setRegex(String regex) { + this.regex = regex; + } + + public Pattern getPattern() { + return pattern; + } + + public void setPattern(Pattern pattern) { + this.pattern = pattern; + } + + public String getMedium() { + return medium; + } + + public void setMedium(String medium) { + this.medium = medium; + } + + } + + public static class User { + + private List rules = new ArrayList<>(); + + public List getRules() { + return rules; + } + + public void setRules(List mappings) { + this.rules = mappings; + } + + } + + public static class Rewrite { + + private User user = new User(); + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + } + + private Rewrite rewrite = new Rewrite(); + + public Rewrite getRewrite() { + return rewrite; + } + + public void setRewrite(Rewrite rewrite) { + this.rewrite = rewrite; + } + + @PostConstruct + public void build() { + getRewrite().getUser().getRules().forEach(mapping -> mapping.setPattern(Pattern.compile(mapping.getRegex()))); + } + +} diff --git a/src/main/java/io/kamax/mxisd/controller/auth/v1/AuthController.java b/src/main/java/io/kamax/mxisd/controller/auth/v1/AuthController.java index 25cf643..f7fb7a9 100644 --- a/src/main/java/io/kamax/mxisd/controller/auth/v1/AuthController.java +++ b/src/main/java/io/kamax/mxisd/controller/auth/v1/AuthController.java @@ -20,25 +20,18 @@ package io.kamax.mxisd.controller.auth.v1; -import com.google.gson.*; -import com.google.i18n.phonenumbers.NumberParseException; -import com.google.i18n.phonenumbers.PhoneNumberUtil; -import com.google.i18n.phonenumbers.Phonenumber; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import io.kamax.mxisd.auth.AuthManager; import io.kamax.mxisd.auth.UserAuthResult; import io.kamax.mxisd.controller.auth.v1.io.CredentialsValidationResponse; -import io.kamax.mxisd.dns.ClientDnsOverwrite; import io.kamax.mxisd.exception.JsonMemberNotFoundException; -import io.kamax.mxisd.exception.RemoteLoginException; -import io.kamax.mxisd.lookup.strategy.LookupStrategy; import io.kamax.mxisd.util.GsonParser; import io.kamax.mxisd.util.GsonUtil; -import io.kamax.mxisd.util.RestClientUtils; -import org.apache.commons.lang.StringUtils; +import org.apache.commons.io.IOUtils; 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.client.utils.URIBuilder; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.util.EntityUtils; import org.slf4j.Logger; @@ -54,10 +47,11 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; @RestController @CrossOrigin -@RequestMapping(produces = MediaType.APPLICATION_JSON_UTF8_VALUE) +@RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE) public class AuthController { // TODO export into SDK @@ -71,23 +65,9 @@ public class AuthController { @Autowired private AuthManager mgr; - @Autowired - private LookupStrategy strategy; - - @Autowired - private ClientDnsOverwrite dns; - @Autowired private CloseableHttpClient client; - private String resolveProxyUrl(HttpServletRequest req) { - URI target = URI.create(req.getRequestURL().toString()); - URIBuilder builder = dns.transform(target); - String urlToLogin = builder.toString(); - log.info("Proxy resolution: {} to {}", target.toString(), urlToLogin); - return urlToLogin; - } - @RequestMapping(value = "/_matrix-internal/identity/v1/check_credentials", method = RequestMethod.POST) public String checkCredentials(HttpServletRequest req) { try { @@ -120,7 +100,9 @@ public class AuthController { @RequestMapping(value = logV1Url, method = RequestMethod.GET) public String getLogin(HttpServletRequest req, HttpServletResponse res) { - try (CloseableHttpResponse hsResponse = client.execute(new HttpGet(resolveProxyUrl(req)))) { + URI target = URI.create(req.getRequestURL().toString()); + + try (CloseableHttpResponse hsResponse = client.execute(new HttpGet(mgr.resolveProxyUrl(target)))) { res.setStatus(hsResponse.getStatusLine().getStatusCode()); return EntityUtils.toString(hsResponse.getEntity()); } catch (IOException e) { @@ -130,98 +112,11 @@ public class AuthController { @RequestMapping(value = logV1Url, method = RequestMethod.POST) public String login(HttpServletRequest req) { + URI target = URI.create(req.getRequestURL().toString()); try { - JsonObject reqJsonObject = parser.parse(req.getInputStream()); - - // find 3PID in main object - GsonUtil.findPrimitive(reqJsonObject, "medium").ifPresent(medium -> { - GsonUtil.findPrimitive(reqJsonObject, "address").ifPresent(address -> { - log.info("Login request with medium '{}' and address '{}'", medium.getAsString(), address.getAsString()); - strategy.findLocal(medium.getAsString(), address.getAsString()).ifPresent(lookupDataOpt -> { - reqJsonObject.addProperty("user", lookupDataOpt.getMxid().getLocalPart()); - reqJsonObject.remove("medium"); - reqJsonObject.remove("address"); - }); - }); - }); - - // find 3PID in 'identifier' object - GsonUtil.findObj(reqJsonObject, "identifier").ifPresent(identifier -> { - GsonUtil.findPrimitive(identifier, "type").ifPresent(type -> { - - if (StringUtils.equals(type.getAsString(), "m.id.thirdparty")) { - GsonUtil.findPrimitive(identifier, "medium").ifPresent(medium -> { - GsonUtil.findPrimitive(identifier, "address").ifPresent(address -> { - log.info("Login request with medium '{}' and address '{}'", medium.getAsString(), address.getAsString()); - strategy.findLocal(medium.getAsString(), address.getAsString()).ifPresent(lookupDataOpt -> { - identifier.addProperty("type", "m.id.user"); - identifier.addProperty("user", lookupDataOpt.getMxid().getLocalPart()); - identifier.remove("medium"); - identifier.remove("address"); - }); - }); - }); - } - - if (StringUtils.equals(type.getAsString(), "m.id.phone")) { - GsonUtil.findPrimitive(identifier, "number").ifPresent(number -> { - GsonUtil.findPrimitive(identifier, "country").ifPresent(country -> { - log.info("Login request with phone '{}'-'{}'", country.getAsString(), number.getAsString()); - try { - PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance(); - Phonenumber.PhoneNumber phoneNumber = phoneUtil.parse(number.getAsString(), country.getAsString()); - String canon_phoneNumber = phoneUtil.format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164).replace("+", ""); - String medium = "msisdn"; - strategy.findLocal(medium, canon_phoneNumber).ifPresent(lookupDataOpt -> { - identifier.addProperty("type", "m.id.user"); - identifier.addProperty("user", lookupDataOpt.getMxid().getLocalPart()); - identifier.remove("country"); - identifier.remove("number"); - }); - } catch (NumberParseException e) { - throw new RuntimeException(e); - } - }); - }); - } - }); - }); - - // invoke 'login' on homeserver - HttpPost httpPost = RestClientUtils.post(resolveProxyUrl(req), gson, reqJsonObject); - try (CloseableHttpResponse httpResponse = client.execute(httpPost)) { - // check http status - int status = httpResponse.getStatusLine().getStatusCode(); - log.info("http status = {}", status); - if (status != 200) { - // try to get possible json error message from response - // otherwise just get returned plain error message - String errcode = String.valueOf(httpResponse.getStatusLine().getStatusCode()); - String error = EntityUtils.toString(httpResponse.getEntity()); - if (httpResponse.getEntity() != null) { - try { - JsonObject bodyJson = new JsonParser().parse(error).getAsJsonObject(); - if (bodyJson.has("errcode")) { - errcode = bodyJson.get("errcode").getAsString(); - } - if (bodyJson.has("error")) { - error = bodyJson.get("error").getAsString(); - } - throw new RemoteLoginException(status, errcode, error, bodyJson); - } catch (JsonSyntaxException e) { - log.warn("Response body is not JSON"); - } - } - throw new RemoteLoginException(status, errcode, error); - } - - /// return response - JsonObject respJsonObject = parser.parseOptional(httpResponse).get(); - return gson.toJson(respJsonObject); - } catch (IOException e) { - throw new RuntimeException(e); - } + return mgr.proxyLogin(target, IOUtils.toString(req.getInputStream(), StandardCharsets.UTF_8)); } catch (IOException e) { + log.error("Unable to read input data from client"); throw new RuntimeException(e); } }