diff --git a/build.gradle b/build.gradle index b9de6ad..d54ab8f 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,7 @@ buildscript { } repositories { + maven { url "https://kamax.io/maven/releases/" } mavenCentral() } @@ -45,6 +46,9 @@ dependencies { // Spring Boot - standalone app compile 'org.springframework.boot:spring-boot-starter-web:1.5.3.RELEASE' + // Matrix Java SDK + compile 'io.kamax:matrix-java-sdk:0.0.1' + // ed25519 handling compile 'net.i2p.crypto:eddsa:0.1.0' @@ -63,6 +67,9 @@ dependencies { // Phone numbers validation compile 'com.googlecode.libphonenumber:libphonenumber:8.7.1' + // Google Firebase Authentication backend + compile 'com.google.firebase:firebase-admin:5.3.0' + testCompile 'junit:junit:4.12' } diff --git a/src/main/groovy/io/kamax/mxisd/auth/AuthManager.java b/src/main/groovy/io/kamax/mxisd/auth/AuthManager.java new file mode 100644 index 0000000..bb6e367 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/auth/AuthManager.java @@ -0,0 +1,35 @@ +package io.kamax.mxisd.auth; + +import io.kamax.mxisd.auth.provider.AuthenticatorProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class AuthManager { + + private Logger log = LoggerFactory.getLogger(AuthManager.class); + + @Autowired + private List providers = new ArrayList<>(); + + public UserAuthResult authenticate(String id, String password) { + for (AuthenticatorProvider provider : providers) { + if (!provider.isEnabled()) { + continue; + } + + UserAuthResult result = provider.authenticate(id, password); + if (result.isSuccess()) { + return result; + } + } + + return new UserAuthResult().failure(); + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/auth/UserAuthResult.java b/src/main/groovy/io/kamax/mxisd/auth/UserAuthResult.java new file mode 100644 index 0000000..61100e4 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/auth/UserAuthResult.java @@ -0,0 +1,47 @@ +package io.kamax.mxisd.auth; + +public class UserAuthResult { + + private boolean success; + private String mxid; + private String displayName; + + public UserAuthResult failure() { + success = false; + mxid = null; + displayName = null; + + return this; + } + + public void success(String mxid, String displayName) { + setSuccess(true); + setMxid(mxid); + setDisplayName(displayName); + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getMxid() { + return mxid; + } + + public void setMxid(String mxid) { + this.mxid = mxid; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/auth/provider/AuthenticatorProvider.java b/src/main/groovy/io/kamax/mxisd/auth/provider/AuthenticatorProvider.java new file mode 100644 index 0000000..bdcf72a --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/auth/provider/AuthenticatorProvider.java @@ -0,0 +1,11 @@ +package io.kamax.mxisd.auth.provider; + +import io.kamax.mxisd.auth.UserAuthResult; + +public interface AuthenticatorProvider { + + boolean isEnabled(); + + UserAuthResult authenticate(String id, String password); + +} diff --git a/src/main/groovy/io/kamax/mxisd/auth/provider/GoogleFirebaseAuthenticator.java b/src/main/groovy/io/kamax/mxisd/auth/provider/GoogleFirebaseAuthenticator.java new file mode 100644 index 0000000..56d60b3 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/auth/provider/GoogleFirebaseAuthenticator.java @@ -0,0 +1,103 @@ +package io.kamax.mxisd.auth.provider; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseCredential; +import com.google.firebase.auth.FirebaseCredentials; +import io.kamax.matrix.MatrixID; +import io.kamax.matrix._MatrixID; +import io.kamax.mxisd.auth.UserAuthResult; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FileInputStream; +import java.io.IOException; + +public class GoogleFirebaseAuthenticator implements AuthenticatorProvider { + + private Logger log = LoggerFactory.getLogger(GoogleFirebaseAuthenticator.class); + + private boolean isEnabled; + private FirebaseApp fbApp; + private FirebaseAuth fbAuth; + + public GoogleFirebaseAuthenticator(boolean isEnabled) { + this.isEnabled = isEnabled; + } + + public GoogleFirebaseAuthenticator(String credsPath, String db) { + this(true); + try { + fbApp = FirebaseApp.initializeApp(getOpts(credsPath, db)); + fbAuth = FirebaseAuth.getInstance(fbApp); + + log.info("Google Firebase Authentication is ready"); + } catch (IOException e) { + throw new RuntimeException("Error when initializing Firebase", e); + } + } + + private FirebaseCredential getCreds(String credsPath) throws IOException { + if (StringUtils.isNotBlank(credsPath)) { + return FirebaseCredentials.fromCertificate(new FileInputStream(credsPath)); + } else { + return FirebaseCredentials.applicationDefault(); + } + } + + private FirebaseOptions getOpts(String credsPath, String db) throws IOException { + if (StringUtils.isBlank(db)) { + throw new IllegalArgumentException("Firebase database is not configured"); + } + + return new FirebaseOptions.Builder() + .setCredential(getCreds(credsPath)) + .setDatabaseUrl(db) + .build(); + } + + @Override + public boolean isEnabled() { + return isEnabled; + } + + @Override + public UserAuthResult authenticate(String id, String password) { + if (!isEnabled()) { + throw new IllegalStateException(); + } + + final UserAuthResult result = new UserAuthResult(); + + try { + log.info("Trying to authenticate {}", id); + _MatrixID mxId = new MatrixID(id); + fbAuth.verifyIdToken(password).addOnSuccessListener(token -> { + if (!StringUtils.equals(mxId.getLocalPart(), token.getUid())) { + log.info("Failture to authenticate {}: Matrix ID localpart '{}' does not match Firebase UID '{}'", id, mxId.getLocalPart(), token.getUid()); + result.failure(); + } + + log.info("{} was successfully authenticated", id); + result.success(id, token.getName()); + }).addOnFailureListener(e -> { + if (e instanceof IllegalArgumentException) { + log.info("Failure to authenticate {}: invalid firebase token", id); + } else { + log.info("Failure to authenticate {}", id, e.getMessage()); + log.debug("Exception", e); + } + + result.failure(); + }); + } catch (IllegalArgumentException e) { + log.warn("Could not validate {} as a Matrix ID: {}", id, e.getMessage()); + result.failure(); + } + + return result; + } + +} diff --git a/src/main/groovy/io/kamax/mxisd/config/FirebaseConfig.java b/src/main/groovy/io/kamax/mxisd/config/FirebaseConfig.java new file mode 100644 index 0000000..22ad824 --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/config/FirebaseConfig.java @@ -0,0 +1,65 @@ +package io.kamax.mxisd.config; + +import io.kamax.mxisd.auth.provider.AuthenticatorProvider; +import io.kamax.mxisd.auth.provider.GoogleFirebaseAuthenticator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.PostConstruct; + +@Configuration +@ConfigurationProperties("firebase") +public class FirebaseConfig { + + private Logger log = LoggerFactory.getLogger(FirebaseConfig.class); + + private boolean enabled; + private String credentials; + private String database; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getCredentials() { + return credentials; + } + + public void setCredentials(String credentials) { + this.credentials = credentials; + } + + public String getDatabase() { + return database; + } + + public void setDatabase(String database) { + this.database = database; + } + + @PostConstruct + private void postConstruct() { + log.info("--- Firebase configuration ---"); + log.info("Enabled: {}", isEnabled()); + if (isEnabled()) { + log.info("Credentials: {}", getCredentials()); + log.info("Database: {}", getDatabase()); + } + } + + @Bean + public AuthenticatorProvider getProvider() { + if (!enabled) { + return new GoogleFirebaseAuthenticator(false); + } + + return new GoogleFirebaseAuthenticator(credentials, database); + } +} diff --git a/src/main/groovy/io/kamax/mxisd/controller/v1/AuthController.java b/src/main/groovy/io/kamax/mxisd/controller/v1/AuthController.java new file mode 100644 index 0000000..70dd1bb --- /dev/null +++ b/src/main/groovy/io/kamax/mxisd/controller/v1/AuthController.java @@ -0,0 +1,89 @@ +/* + * mxisd - Matrix Identity Server Daemon + * Copyright (C) 2017 Maxime Dor + * + * https://max.kamax.io/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.kamax.mxisd.controller.v1; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.kamax.mxisd.auth.AuthManager; +import io.kamax.mxisd.auth.UserAuthResult; +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@RestController +@CrossOrigin +@RequestMapping(produces = MediaType.APPLICATION_JSON_UTF8_VALUE) +public class AuthController { + + private Logger log = LoggerFactory.getLogger(AuthController.class); + + private Gson gson = new Gson(); + + @Autowired + private AuthManager mgr; + + @RequestMapping(value = "/_matrix-internal/identity/v1/check_credentials", method = RequestMethod.POST) + public String checkCredentials(HttpServletRequest req) { + try { + JsonElement el = new JsonParser().parse(IOUtils.toString(req.getInputStream(), StandardCharsets.UTF_8)); + if (!el.isJsonObject() || !el.getAsJsonObject().has("user")) { + throw new IllegalArgumentException("Missing user key"); + } + + JsonObject authData = el.getAsJsonObject().get("user").getAsJsonObject(); + if (!authData.has("id") || !authData.has("password")) { + throw new IllegalArgumentException("Missing id or password keys"); + } + + String id = authData.get("id").getAsString(); + log.info("Requested to check credentials for {}", id); + String password = authData.get("password").getAsString(); + + UserAuthResult result = mgr.authenticate(id, password); + + JsonObject authObj = new JsonObject(); + authObj.addProperty("success", result.isSuccess()); + if (result.isSuccess()) { + authObj.addProperty("mxid", result.getMxid()); + authObj.addProperty("display_name", result.getDisplayName()); + } + JsonObject obj = new JsonObject(); + + obj.add("authentication", authObj); + return gson.toJson(obj); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + +}