Skip to content

Commit

Permalink
user management, auth token api endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
abyrd committed May 10, 2022
1 parent 89a4bc2 commit 9b88456
Show file tree
Hide file tree
Showing 4 changed files with 237 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -31,14 +32,16 @@ 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);
censusExtractor = new SeamlessCensusGridExtractor(config);
// Instantiate the HttpControllers last, when all the components except the HttpApi are already created.
List<HttpController> 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Document> users;

private LoadingCache<String, Token> 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;
}
}

}
Original file line number Diff line number Diff line change
@@ -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/[email protected]&group=local&password=testpass"
// 201 CREATED
// curl "localhost:7070/[email protected]&password=testpass"
// 200 {"token":"LHKUz6weI32mEk3SXBfGZFvPP3P9FZq8xboJdPPBIdo="}
// curl -H "authorization: [email protected] 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);
}

}

0 comments on commit 9b88456

Please sign in to comment.