Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cascade delete #64

Merged
merged 21 commits into from
Sep 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0a79dd6
fix(delete): add cascade delete for collections
landonreed Aug 24, 2020
c94a52b
test(e2e): add api user flow test to simulate 3rd party apps
landonreed Aug 24, 2020
3fbc850
refactor(Fixed merge conflict): Merged dev and fixed conflicts
Aug 25, 2020
24acb0b
refactor(ApiUserFlowTest): Api and otp related tests using Auth0 auth…
Aug 27, 2020
3d70aa2
refactor(Merge conflict): Fixed merge conflict with dev branch
Aug 27, 2020
993a7ba
refactor(PR updates): Addressed PR feedback
Aug 28, 2020
1fa79e7
refactor(ApiUserController): Corrected issue introduced fixing merge …
Aug 28, 2020
ab88847
refactor(Auth0): do not check valid user if creating self
landonreed Sep 1, 2020
6ff1fd1
refactor: update tests and simplify mock otp
landonreed Sep 1, 2020
6e6e3a0
refactor: remove unused imports
landonreed Sep 1, 2020
39bfee4
refactor(OtpUserController): handle missing API user on OtpUser#create
landonreed Sep 1, 2020
84608b7
refactor: minor tweaks, add more setup docs
landonreed Sep 1, 2020
942c804
refactor(ApiController): change delete return type
landonreed Sep 1, 2020
4ae3945
refactor(user#delete): address PR comments
landonreed Sep 1, 2020
869713d
refactor(Auth0): fix isCreatingSelf check; handle null throwable in b…
landonreed Sep 2, 2020
e4cdb9a
Merge branch 'dev' into cascade-delete
landonreed Sep 2, 2020
d4ecd7a
refactor(DateTimeUtils): use currentTimeMillis util
landonreed Sep 2, 2020
167af9b
test(trip-history): clean up otpuser after each test
landonreed Sep 2, 2020
32f0340
Merge branch 'dev' into cascade-delete
landonreed Sep 3, 2020
69c562f
test: evaluate authDisabled after config is loaded
landonreed Sep 3, 2020
4f43685
Merge branch 'dev' into cascade-delete
landonreed Sep 3, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,26 @@ mvn package
java -jar target/otp-middleware.jar configurations/default/env.yml
```

## OTP Server Proxy Setup
## Configuration

### Auth0

TODO: Add Auth0 setup instructions.

### OTP Server Proxy Setup
The follow parameters are used to interact with an OTP server.

| Parameter | Description | Example |
| --- | --- | --- |
| OTP_API_ROOT | This is the address of the OTP server, including the root path to the OTP API, to which all OTP related requests will be sent to. | http://otp-server.example.com/otp |
| OTP_PLAN_ENDPOINT | This defines the plan endpoint part of the requesting URL. If a request is made to this, the assumption is that a plan request has been made and that the response should be processed accordingly. | /plan |

## Bugsnag Configuration Parameters
### Bugsnag

Bugsnag is used to report error events that occur within the otp-middleware application or
OpenTripPlanner components that it is monitoring.

#### Bugsnag Configuration Parameters

These values can used as defined here (were applicable), or commented out so the default values are used. Parameters
that don't have default values (N/A) can be obtained my following the steps in the next section.
Expand All @@ -61,8 +72,8 @@ that don't have default values (N/A) can be obtained my following the steps in t
| BUGSNAG_REPORTING_WINDOW_IN_DAYS | 14 | The number of days in the past to start retrieving event information. |


## Bugsnag Setup
Where default parameters can not be used, these steps describe how to obtain each compulsory parameter.
#### Bugsnag Setup
Where default parameters cannot be used, these steps describe how to obtain each compulsory parameter.

##### BUGSNAG_API_KEY
A bugsnag API key is a key that is unique to an individual Bugsnag user. This key can be obtained by logging into
Expand All @@ -79,3 +90,26 @@ From here, click on the organization name and then copy the name from the pop-up
A Bugsnag project identifier key is unique to a Bugsnag project and allows errors to be saved against it. This key can
be obtained by logging into Bugsnag (https://app.bugsnag.com), clicking on Projects (left side menu) and selecting the
required project. Once selected, the notifier API key is presented.

## Testing

### End-to-end (E2E)

In order to run E2E tests, specific configuration and environment variables must be used.

#### Auth0
The standard Auth0 configuration can be used for the server settings. However, some additional settings must be applied
in order for the server to get an oath token from Auth0 for creating authenticated requests. A private application
should be created for use in Auth0 following these instructions: https://auth0.com/docs/flows/call-your-api-using-resource-owner-password-flow

One special note is that the default directory must be set at the Account/tenant level. Otherwise, authorization will not
be successful. See this StackOverflow answer for more info: https://stackoverflow.com/a/43835563/915811

The special E2E client settings should be defined in `env.yml`:

| Parameter | Default | Description |
| --- | --- | --- |
| AUTH0_CLIENT_ID | N/A | Special E2E application client ID. |
| AUTH0_CLIENT_SECRET | N/A | Special E2E application client secret. |
Comment on lines +112 to +113
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new variables should appear in env.yml.tmp.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure that's the case if they're only intended for e2e.


**Note:** Just to reiterate, these are different from the server application settings and are only needed for E2E testing.
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
import org.opentripplanner.middleware.controllers.api.AdminUserController;
import org.opentripplanner.middleware.controllers.api.ApiUserController;
import org.opentripplanner.middleware.controllers.api.BugsnagController;
import org.opentripplanner.middleware.controllers.api.OtpUserController;
import org.opentripplanner.middleware.controllers.api.LogController;
import org.opentripplanner.middleware.controllers.api.MonitoredTripController;
import org.opentripplanner.middleware.controllers.api.OtpUserController;
import org.opentripplanner.middleware.controllers.api.TripHistoryController;
import org.opentripplanner.middleware.docs.PublicApiDocGenerator;
import org.opentripplanner.middleware.controllers.api.OtpRequestProcessor;
Expand All @@ -34,7 +34,7 @@
*/
public class OtpMiddlewareMain {
private static final Logger LOG = LoggerFactory.getLogger(OtpMiddlewareMain.class);
private static final String API_PREFIX = "/api/";
public static final String API_PREFIX = "/api/";
public static boolean inTestEnvironment = false;

public static void main(String[] args) throws IOException, InterruptedException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,24 @@
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.eclipse.jetty.http.HttpStatus;
import org.opentripplanner.middleware.models.AbstractUser;
import org.opentripplanner.middleware.models.ApiUser;
import org.opentripplanner.middleware.models.OtpUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import spark.HaltException;
import spark.Request;
import spark.Response;

import java.security.interfaces.RSAPublicKey;

import static org.opentripplanner.middleware.controllers.api.ApiUserController.API_USER_PATH;
import static org.opentripplanner.middleware.controllers.api.OtpUserController.OTP_USER_PATH;
import static org.opentripplanner.middleware.utils.ConfigUtils.getConfigPropertyAsText;
import static org.opentripplanner.middleware.utils.ConfigUtils.hasConfigProperty;
import static org.opentripplanner.middleware.utils.JsonUtils.getPOJOFromRequestBody;
import static org.opentripplanner.middleware.utils.JsonUtils.logMessageAndHalt;

/**
Expand Down Expand Up @@ -53,18 +61,59 @@ public static void checkUser(Request req) {
try {
DecodedJWT jwt = verifier.verify(token);
Auth0UserProfile profile = new Auth0UserProfile(jwt);
if (!isValidUser(profile)) {
landonreed marked this conversation as resolved.
Show resolved Hide resolved
if (isCreatingSelf(req, profile)) {
// If creating self, no user account is required (it does not exist yet!). Note: creating an
// admin user requires that the requester is an admin (checkUserIsAdmin must be passed), so this
// is not a concern for that method/controller.
LOG.info("New user is creating self. OK to proceed without existing user object for auth0UserId");
} else {
// Otherwise, if no valid user is found, halt the request.
logMessageAndHalt(req, HttpStatus.NOT_FOUND_404, "Unknown user.");
}
}
// The user attribute is used on the server side to check user permissions and does not have all of the
// fields that the raw Auth0 profile string does.
addUserToRequest(req, profile);
} catch (JWTVerificationException e) {
// Invalid signature/claims
logMessageAndHalt(req, 401, "Login failed to verify with our authorization provider.", e);
} catch (HaltException e) {
throw e;
} catch (Exception e) {
LOG.warn("Login failed to verify with our authorization provider.", e);
logMessageAndHalt(req, 401, "Could not verify user's token");
}
}

/**
* Check for POST requests that are creating an {@link AbstractUser} (a proxy for OTP/API users).
*/
private static boolean isCreatingSelf(Request req, Auth0UserProfile profile) {
String uri = req.uri();
String method = req.requestMethod();
// Check that this is a POST request.
if (method.equalsIgnoreCase("POST")) {
// Next, check that an OtpUser or ApiUser is being created (an admin must rely on another admin to create
// them).
boolean creatingOtpUser = uri.endsWith(OTP_USER_PATH);
boolean creatingApiUser = uri.endsWith(API_USER_PATH);
if (creatingApiUser || creatingOtpUser) {
// Get the correct user class depending on request path.
Class<? extends AbstractUser> userClass = creatingApiUser ? ApiUser.class : OtpUser.class;
try {
// Next, get the user object from the request body, verifying that the Auth0UserId matches between
// requester and the new user object.
AbstractUser user = getPOJOFromRequestBody(req, userClass);
return profile.auth0UserId.equals(user.auth0UserId);
} catch (JsonProcessingException e) {
LOG.warn("Could not parse user object from request.", e);
}
}
}
return false;
}

public static boolean isAuthHeaderPresent(Request req) {
final String authHeader = req.headers("Authorization");
return authHeader != null;
Expand Down Expand Up @@ -106,7 +155,7 @@ public static void addUserToRequest(Request req, Auth0UserProfile user) {
* Get user profile from Spark Request object
*/
public static Auth0UserProfile getUserFromRequest(Request req) {
return (Auth0UserProfile) req.attribute("user");
return req.attribute("user");
}

/**
Expand Down Expand Up @@ -169,40 +218,30 @@ public static boolean authDisabled() {
}

/**
* Confirm that the user exists
* Confirm that the user exists in at least one of the MongoDB user collections.
*/
private static Auth0UserProfile isValidUser(Request request) {

Auth0UserProfile profile = getUserFromRequest(request);
if (profile == null || (profile.adminUser == null && profile.otpUser == null && profile.apiUser == null)) {
logMessageAndHalt(request, HttpStatus.NOT_FOUND_404, "Unknown user.");
}

return profile;
private static boolean isValidUser(Auth0UserProfile profile) {
return profile != null && (profile.adminUser != null || profile.otpUser != null || profile.apiUser != null);
}

/**
* Confirm that the user's actions are on their items if not admin.
*/
public static void isAuthorized(String userId, Request request) {

Auth0UserProfile profile = isValidUser(request);

Auth0UserProfile profile = getUserFromRequest(request);
// let admin do anything
if (profile.adminUser != null) {
return;
}

// If userId is defined, it must be set to a value associated with the user.
if (userId != null) {
if (profile.otpUser != null && profile.otpUser.id.equals(userId)) {
return;
}

if (profile.apiUser != null && profile.apiUser.id.equals(userId)) {
return;
}
}

logMessageAndHalt(request, HttpStatus.FORBIDDEN_403, "Unauthorized access.");
}

Expand Down
64 changes: 55 additions & 9 deletions src/main/java/org/opentripplanner/middleware/auth/Auth0Users.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,41 @@
import com.auth0.json.mgmt.jobs.Job;
import com.auth0.json.mgmt.users.User;
import com.auth0.net.AuthRequest;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.apache.commons.validator.routines.EmailValidator;
import org.eclipse.jetty.http.HttpStatus;
import org.opentripplanner.middleware.bugsnag.BugsnagReporter;
import org.opentripplanner.middleware.models.AbstractUser;
import org.opentripplanner.middleware.persistence.TypedPersistence;
import org.opentripplanner.middleware.utils.HttpUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import spark.Request;

import java.net.URI;
import java.net.http.HttpResponse;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import static com.mongodb.client.model.Filters.eq;
import static org.opentripplanner.middleware.utils.ConfigUtils.getConfigPropertyAsText;
import static org.opentripplanner.middleware.utils.HttpUtils.httpRequestRawResponse;
import static org.opentripplanner.middleware.utils.JsonUtils.getSingleNodeValueFromJSON;
import static org.opentripplanner.middleware.utils.JsonUtils.logMessageAndHalt;

/**
* This class contains methods for querying Auth0 users using the Auth0 User Management API. Auth0 docs describing the
* searchable fields and query syntax are here: https://auth0.com/docs/api/management/v2/user-search
*/
public class Auth0Users {
private static final String AUTH0_DOMAIN = getConfigPropertyAsText("AUTH0_DOMAIN");
public static final String AUTH0_DOMAIN = getConfigPropertyAsText("AUTH0_DOMAIN");
landonreed marked this conversation as resolved.
Show resolved Hide resolved
// This client/secret pair is for making requests for an API access token used with the Management API.
private static final String AUTH0_API_CLIENT = getConfigPropertyAsText("AUTH0_API_CLIENT");
private static final String AUTH0_API_SECRET = getConfigPropertyAsText("AUTH0_API_SECRET");
private static final String AUTH0_CLIENT_ID = getConfigPropertyAsText("AUTH0_CLIENT_ID");
private static final String AUTH0_CLIENT_SECRET = getConfigPropertyAsText("AUTH0_CLIENT_SECRET");
private static final String DEFAULT_CONNECTION_TYPE = "Username-Password-Authentication";
private static final String MANAGEMENT_API_VERSION = "v2";
private static final String SEARCH_API_VERSION = "v3";
Expand All @@ -44,15 +53,19 @@ public class Auth0Users {
private static final AuthAPI authAPI = new AuthAPI(AUTH0_DOMAIN, AUTH0_API_CLIENT, AUTH0_API_SECRET);

/**
* Creates a standard user for the provided email address. Defaults to a random UUID password and connection type
* of {@link #DEFAULT_CONNECTION_TYPE}.
* Creates a standard user for the provided email address. Defaults to a random UUID password and connection type of
* {@link #DEFAULT_CONNECTION_TYPE}.
*/
public static User createAuth0UserForEmail(String email) throws Auth0Exception {
return createAuth0UserForEmail(email, UUID.randomUUID().toString());
}

public static User createAuth0UserForEmail(String email, String password) throws Auth0Exception {
// Create user object and assign properties.
User user = new User();
user.setEmail(email);
// TODO set name? phone? other Auth0 properties?
user.setPassword(UUID.randomUUID().toString());
user.setPassword(password);
user.setConnection(DEFAULT_CONNECTION_TYPE);
return getManagementAPI()
.users()
Expand All @@ -64,6 +77,7 @@ public static User createAuth0UserForEmail(String email) throws Auth0Exception {
* Delete Auth0 user by Auth0 user ID using the Management API.
*/
public static void deleteAuth0User(String userId) throws Auth0Exception {
LOG.info("Deleting Auth0 user for {}", userId);
getManagementAPI()
.users()
.delete(userId)
Expand All @@ -79,8 +93,8 @@ public static TokenCache getCachedToken() {
}

/**
* Gets an Auth0 API access token for authenticating requests to the Auth0 Management API. This will either create
* a new token using the oauth token endpoint or grab a cached token that it has already created (if it has not
* Gets an Auth0 API access token for authenticating requests to the Auth0 Management API. This will either create a
* new token using the oauth token endpoint or grab a cached token that it has already created (if it has not
landonreed marked this conversation as resolved.
Show resolved Hide resolved
* expired). More information on setting this up is here: https://auth0.com/docs/api/management/v2/get-access-tokens-for-production
*/
public static String getApiToken() {
Expand Down Expand Up @@ -126,9 +140,9 @@ public static User getUserByEmail(String email, boolean createIfNotExists) {
}

/**
* Method to trigger an Auth0 job to resend a verification email. Returns an Auth0 {@link Job} which can be
* used to monitor the progress of the job (using job ID). Typically the verification email goes out pretty quickly
* so there shouldn't be too much of a need to monitor the result.
* Method to trigger an Auth0 job to resend a verification email. Returns an Auth0 {@link Job} which can be used to
* monitor the progress of the job (using job ID). Typically the verification email goes out pretty quickly so there
* shouldn't be too much of a need to monitor the result.
*/
public static Job resendVerificationEmail(String userId) {
try {
Expand Down Expand Up @@ -176,13 +190,15 @@ public static <U extends AbstractUser> User createNewAuth0User(U user, Request r
// Ensure no user with email exists in MongoDB.
U userWithEmail = userStore.getOneFiltered(eq("email", user.email));
if (userWithEmail != null) {
// TODO: Does this need to change to allow multiple applications to create otpuser's with the same email?
logMessageAndHalt(req, 400, "User with email already exists in database!");
}
// Check for pre-existing user in Auth0 and create if not exists.
User auth0UserProfile = getUserByEmail(user.email, true);
if (auth0UserProfile == null) {
logMessageAndHalt(req, HttpStatus.INTERNAL_SERVER_ERROR_500, "Error creating user for email " + user.email);
}
LOG.info("Created new Auth0 user ({}) for user {}", auth0UserProfile.getId(), user.id);
return auth0UserProfile;
}

Expand Down Expand Up @@ -224,4 +240,34 @@ private static String getAuth0Url() {
? "http://locahost:8089"
: "https://" + AUTH0_DOMAIN;
}

/**
* Get an Auth0 oauth token for use in mocking user requests by using the Auth0 'Call Your API Using Resource Owner
* Password Flow' approach. Auth0 setup can be reviewed here: https://auth0.com/docs/flows/call-your-api-using-resource-owner-password-flow.
* If the user is successfully validated by Auth0 a bearer access token is returned, which is extracted and returned
* to the caller. In all other cases, null is returned.
*/
public static String getAuth0Token(String username, String password) throws JsonProcessingException {
String body = String.format(
"grant_type=password&username=%s&password=%s&audience=%s&scope=&client_id=%s&client_secret=%s",
username,
password,
"https://otp-middleware", // must match an API identifier
AUTH0_CLIENT_ID, // Auth0 application client ID
AUTH0_CLIENT_SECRET // Auth0 application client secret
);

HttpResponse<String> response = httpRequestRawResponse(
URI.create(String.format("https://%s/oauth/token", AUTH0_DOMAIN)),
1000,
HttpUtils.REQUEST_METHOD.POST,
Collections.singletonMap("content-type", "application/x-www-form-urlencoded"),
body
);
if (response == null || response.statusCode() != HttpStatus.OK_200) {
LOG.error("Cannot obtain Auth0 token for user {}. response: {} - {}", username, response.statusCode(), response.body());
return null;
}
return getSingleNodeValueFromJSON("access_token", response.body());
}
}
Loading