From 9b884569a948806baf503746aef873d502a60aa1 Mon Sep 17 00:00:00 2001 From: Andrew Byrd Date: Sat, 30 Apr 2022 15:55:07 +0800 Subject: [PATCH] user management, auth token api endpoints --- .../components/BackendComponents.java | 2 +- .../components/LocalBackendComponents.java | 5 +- .../components/TokenAuthentication.java | 150 ++++++++++++++++++ .../controllers/AuthTokenController.java | 82 ++++++++++ 4 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/conveyal/analysis/components/TokenAuthentication.java create mode 100644 src/main/java/com/conveyal/analysis/controllers/AuthTokenController.java diff --git a/src/main/java/com/conveyal/analysis/components/BackendComponents.java b/src/main/java/com/conveyal/analysis/components/BackendComponents.java index c549f6078..706c454de 100644 --- a/src/main/java/com/conveyal/analysis/components/BackendComponents.java +++ b/src/main/java/com/conveyal/analysis/components/BackendComponents.java @@ -7,11 +7,11 @@ import com.conveyal.analysis.controllers.BrokerController; import com.conveyal.analysis.controllers.BundleController; import com.conveyal.analysis.controllers.DataSourceController; +import com.conveyal.analysis.controllers.DatabaseController; import com.conveyal.analysis.controllers.GtfsController; import com.conveyal.analysis.controllers.HttpController; import com.conveyal.analysis.controllers.OpportunityDatasetController; import com.conveyal.analysis.controllers.RegionalAnalysisController; -import com.conveyal.analysis.controllers.DatabaseController; import com.conveyal.analysis.controllers.UserActivityController; import com.conveyal.analysis.controllers.WorkerProxyController; import com.conveyal.analysis.grids.SeamlessCensusGridExtractor; diff --git a/src/main/java/com/conveyal/analysis/components/LocalBackendComponents.java b/src/main/java/com/conveyal/analysis/components/LocalBackendComponents.java index 4de8e3098..f9b044b48 100644 --- a/src/main/java/com/conveyal/analysis/components/LocalBackendComponents.java +++ b/src/main/java/com/conveyal/analysis/components/LocalBackendComponents.java @@ -4,6 +4,7 @@ import com.conveyal.analysis.components.broker.Broker; import com.conveyal.analysis.components.eventbus.ErrorLogger; import com.conveyal.analysis.components.eventbus.EventBus; +import com.conveyal.analysis.controllers.AuthTokenController; import com.conveyal.analysis.controllers.HttpController; import com.conveyal.analysis.controllers.LocalFilesController; import com.conveyal.analysis.grids.SeamlessCensusGridExtractor; @@ -31,7 +32,8 @@ public LocalBackendComponents () { // New (October 2019) DB layer, this should progressively replace the Persistence class database = new AnalysisDB(config); eventBus = new EventBus(taskScheduler); - authentication = new LocalAuthentication(); + final TokenAuthentication tokenAuthentication = new TokenAuthentication(database); + authentication = tokenAuthentication; // TODO add nested LocalWorkerComponents here, to reuse some components, and pass it into the LocalWorkerLauncher? workerLauncher = new LocalWorkerLauncher(config, fileStorage, gtfsCache, osmCache); broker = new Broker(config, fileStorage, eventBus, workerLauncher); @@ -39,6 +41,7 @@ public LocalBackendComponents () { // Instantiate the HttpControllers last, when all the components except the HttpApi are already created. List httpControllers = standardHttpControllers(); httpControllers.add(new LocalFilesController(fileStorage)); + httpControllers.add(new AuthTokenController(tokenAuthentication)); httpApi = new HttpApi(fileStorage, authentication, eventBus, config, httpControllers); // compute = new LocalCompute(); // persistence = persistence(local_Mongo) diff --git a/src/main/java/com/conveyal/analysis/components/TokenAuthentication.java b/src/main/java/com/conveyal/analysis/components/TokenAuthentication.java new file mode 100644 index 000000000..5f2366040 --- /dev/null +++ b/src/main/java/com/conveyal/analysis/components/TokenAuthentication.java @@ -0,0 +1,150 @@ +package com.conveyal.analysis.components; + +import com.conveyal.analysis.AnalysisServerException; +import com.conveyal.analysis.UserPermissions; +import com.conveyal.analysis.controllers.AuthTokenController; +import com.conveyal.analysis.persistence.AnalysisDB; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.mongodb.client.MongoCollection; +import org.bson.Document; +import org.bson.types.Binary; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import spark.Request; +import spark.Response; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.lang.invoke.MethodHandles; +import java.security.spec.KeySpec; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.Random; + +import static com.conveyal.analysis.AnalysisServerException.Type.UNAUTHORIZED; +import static com.mongodb.client.model.Filters.eq; + +/** + * Simple bearer token authentication storing hashed passwords in database. + * Allows direct management of users and permissions. + */ +public class TokenAuthentication implements Authentication { + + private static final Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private final MongoCollection users; + + private LoadingCache tokenForEmail = + Caffeine.newBuilder().expireAfterAccess(Duration.ofMinutes(10)).build(Token::forEmail); + + public TokenAuthentication (AnalysisDB database) { + // TODO verify that sharing a MongoCollection across threads is safe + this.users = database.getBsonCollection("users"); + } + + @Override + public UserPermissions authenticate(Request request) { + String authHeader = request.headers("authorization").strip(); + if ("sesame".equalsIgnoreCase(authHeader)) { + return new UserPermissions("local", true, "local"); + } + String[] authHeaderParts = authHeader.split(" "); + if (authHeaderParts.length != 2 || !authHeaderParts[0].contains("@")) { + throw new AnalysisServerException(UNAUTHORIZED, "Authorization header should be '[email] [token]", 401); + } + String email = authHeaderParts[0]; + String token = authHeaderParts[1]; + if (tokenValid(email, token)) { + return new UserPermissions(email, true, "local"); + } else { + throw new AnalysisServerException(UNAUTHORIZED, "Inalid authorization token.", 401); + } + } + + /** + * Token is just a string, but use this class to keep things more typed and produce more structured JSON responses. + * Add fields for expiration etc. if not handled by cache. + */ + public static class Token { + public final String token; + public Token() { + Random random = new Random(); + byte[] tokenBytes = new byte[32]; + random.nextBytes(tokenBytes); + token = Base64.getEncoder().encodeToString(tokenBytes); + } + public static Token forEmail (String _email) { + return new Token(); + } + } + + /** + * Ideally we could do this without the email, using a secondary map from token -> UserPermissions. + */ + public boolean tokenValid (String email, String token) { + // Here a loadingCache is not appropriate. We want to be able to check if a token is present without creating one. + // Though in practice this still works, it just generates tokens for any user that's queried and they don't match. + return token.equals(tokenForEmail.get(email).token); + } + + /** + * @return byte[] representing the supplied password hashed with the supplied salt. + */ + private byte[] hashWithSalt (String password, byte[] salt) { + try { + // Note Java char is 16-bit Unicode (not byte, which requires a specific encoding like UTF8). + // 256 bit key length is 32 bytes. + KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, 65536, 256); + SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + byte[] hash = keyFactory.generateSecret(keySpec).getEncoded(); + return hash; + // return Base64.getEncoder().encodeToString(hash); + } catch (Exception e) { + throw new RuntimeException("Exception:", e); + } + } + + /** + * Create a user with the specified password. Stores the random salt and hashed password in the database. + */ + public void createUser (String email, String group, String password) { + // TODO validate password entropy + Random random = new Random(); + byte[] salt = new byte[32]; + random.nextBytes(salt); + byte[] hash = hashWithSalt(password, salt); + // Due to Mongo's nature it may not be possible to verify whether the user already exists. + // Once the write is finalized though, this will produce E11000 duplicate key error. + // We may want to allow updating a user by simply calling this HTTP API method more than once. + users.insertOne(new Document("_id", email) + .append("group", group) + .append("salt", new Binary(salt)) + .append("hash", new Binary(hash)) + ); + } + + /** + * Create a new token, replacing any existing one for the same user (email) as long as the password is correct. + * @return a new token, or null if the supplied password is incorrect. + */ + public Token getTokenForEmail (String email, String password) { + Document userDocument = users.find(eq("_id", email)).first(); + if (userDocument == null) { + throw new IllegalArgumentException("User unknown: " + email); + } + Binary salt = (Binary) userDocument.get("salt"); + Binary hash = (Binary) userDocument.get("hash"); + byte[] hashForComparison = hashWithSalt(password, salt.getData()); + if (Arrays.equals(hash.getData(), hashForComparison)) { + // Maybe invalidation is pointless and we can continue to return the same key indefinitely. + tokenForEmail.invalidate(email); + Token token = tokenForEmail.get(email); + return token; + } else { + return null; + } + } + +} diff --git a/src/main/java/com/conveyal/analysis/controllers/AuthTokenController.java b/src/main/java/com/conveyal/analysis/controllers/AuthTokenController.java new file mode 100644 index 000000000..354d963c6 --- /dev/null +++ b/src/main/java/com/conveyal/analysis/controllers/AuthTokenController.java @@ -0,0 +1,82 @@ +package com.conveyal.analysis.controllers; + +import com.conveyal.analysis.AnalysisServerException; +import com.conveyal.analysis.UserPermissions; +import com.conveyal.analysis.components.TokenAuthentication; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import spark.Request; +import spark.Response; + +import java.lang.invoke.MethodHandles; + +import static com.conveyal.analysis.AnalysisServerException.Type.UNAUTHORIZED; +import static com.conveyal.analysis.util.JsonUtil.toJson; +import static com.conveyal.r5.analyst.cluster.AnalysisWorker.sleepSeconds; + +/** + * HTTP API Controller that handles user accounts and authentication. + * Serve up tokens for valid users. Allow admin users to create new users and set their passwords. + * TODO add rate limiting and map size limiting (limit number of concurrent users in case of attacks). + */ +public class AuthTokenController implements HttpController { + + private static final Logger LOG = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private final TokenAuthentication tokenAuthentication; + + public AuthTokenController (TokenAuthentication tokenAuthentication) { + this.tokenAuthentication = tokenAuthentication; + } + + /** + * Create a user with the specified password. Stores the random salt and hashed password in the database. + */ + private Object createUser (Request req, Response res) { + if (!UserPermissions.from(req).admin) { + throw new AnalysisServerException(UNAUTHORIZED, "Only admin users can create new users.", 401); + } + String email = req.queryParams("email"); + String group = req.queryParams("group"); + String password = req.queryParams("password"); + tokenAuthentication.createUser(email, group, password); + res.status(201); + return "CREATED"; // alternatively UPDATED or FAILED + } + + /** + * Create a new token, replacing any existing one for the same user (email). + */ + private TokenAuthentication.Token getTokenForEmail (Request req, Response res) { + String email = req.queryParams("email"); + String password = req.queryParams("password"); + // Crude rate limiting, might just lead to connections piling up in event of attack. + // sleepSeconds(2); + TokenAuthentication.Token token = tokenAuthentication.getTokenForEmail(email, password); + if (token == null) { + throw new AnalysisServerException(UNAUTHORIZED, "Incorrect email/password combination.", 401); + } else { + return token; + } + } + + // Testing with Apache bench shows some stalling + // -k keepalive connections fails immediately + + // Example usage: + // curl -H "authorization: sesame" -X POST "localhost:7070/api/user?email=abyrd@conveyal.com&group=local&password=testpass" + // 201 CREATED + // curl "localhost:7070/token?email=abyrd@conveyal.com&password=testpass" + // 200 {"token":"LHKUz6weI32mEk3SXBfGZFvPP3P9FZq8xboJdPPBIdo="} + // curl -H "authorization: abyrd@conveyal.com Jx5Re2/fl1AAISeeMzaCJOy8OCRO6MVOAJLSN7/tkSg=" "localhost:7070/api/activity" + // 200 {"systemStatusMessages":[],"taskBacklog":0,"taskProgress":[]} + + @Override + public void registerEndpoints (spark.Service sparkService) { + // Token endpoint is outside authenticated /api prefix because it's the means to get authentication tokens. + sparkService.get("/token", this::getTokenForEmail, toJson); + // User endpoint is inside the authenticated /api prefix because it is only accessible to admin users. + sparkService.post("/api/user", this::createUser); + } + +}