Skip to content
This repository has been archived by the owner on Jul 26, 2022. It is now read-only.

Commit

Permalink
auth: allow bypassing consent in cli
Browse files Browse the repository at this point in the history
Signed-off-by: Paulo Pires <p.pires@travelaudience.com>
  • Loading branch information
pires committed Dec 5, 2018
1 parent 9b64ed1 commit 4ebcb8d
Show file tree
Hide file tree
Showing 4 changed files with 107 additions and 24 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ ENV BIND_PORT "8080"
ENV CLIENT_ID "REPLACE_ME"
ENV CLIENT_SECRET "REPLACE_ME"
ENV CLOUD_IAM_AUTH_ENABLED "false"
ENV JWT_REQUIRES_MEMBERSHIP_VERIFICATION "true"
ENV KEYSTORE_PATH "keystore.jceks"
ENV KEYSTORE_PASS "safe#passw0rd!"
ENV NEXUS_DOCKER_HOST "containers.example.com"
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ one year **and** for as long as the user is a member of the GCP organization.
one has configured `nexus-proxy` or any load-balancers in front of it to serve
HTTPS on host `NEXUS_HTTP_HOST` and port `443` with a valid TLS certificate.

**ATTENTION:**: Setting the `JWT_REQUIRES_MEMBERSHIP_VERIFICATION` environment variable to `false` inherently makes `nexus-proxy` less secure.
In this scenario, a user containing a valid JWT token will be able to make requests using CLI tools like Maven or Docker without having to go through the OAuth2 consent screen.
For example, if a user leaves the organization while keeping a valid JWT token, and this environment variable is set to `false`, they will still be able to make requests to Nexus.

## Introduction

While deploying Nexus Repository Manager on GKE, we identified a couple issues:
Expand Down Expand Up @@ -165,6 +169,7 @@ $ ALLOWED_USER_AGENTS_ON_ROOT_REGEX="GoogleHC" \
| `CLIENT_SECRET` | The abovementioned application's client secret. |
| `CLOUD_IAM_AUTH_ENABLED` | Whether to enable authentication against Google Cloud IAM. |
| `ENFORCE_HTTPS` | Whether to enforce access by HTTPS only. If set to `true` Nexus will only be accessible via HTTPS. |
| `JWT_REQUIRES_MEMBERSHIP_VERIFICATION` | Whether users presenting valid JWT tokens must still be verified for membership within the organization. |
| `KEYSTORE_PATH` | The path to the keystore containing the key with which to sign JWTs. |
| `KEYSTORE_PASS` | The password of the abovementioned keystore. |
| `LOG_LEVEL` | The desired log level (i.e., `trace`, `debug`, `info`, `warn` or `error`). Defaults to `info`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import static com.google.api.services.oauth2.Oauth2Scopes.USERINFO_EMAIL;
import static java.util.concurrent.TimeUnit.MILLISECONDS;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse;
Expand All @@ -18,6 +18,8 @@
import com.google.api.services.cloudresourcemanager.CloudResourceManager;
import com.google.api.services.cloudresourcemanager.model.Organization;
import com.google.common.collect.ImmutableSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.UncheckedIOException;
Expand All @@ -28,12 +30,14 @@
* Wraps {@link GoogleAuthorizationCodeFlow} caching authorization results and providing unchecked methods.
*/
public class CachingGoogleAuthCodeFlow {
private static final Logger LOGGER = LoggerFactory.getLogger(CachingGoogleAuthCodeFlow.class);

private static final DataStoreFactory DATA_STORE_FACTORY = new MemoryDataStoreFactory();
private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
private static final Set<String> SCOPES = ImmutableSet.of(CLOUD_PLATFORM_READ_ONLY, USERINFO_EMAIL);

private final LoadingCache<String, Boolean> authCache;
private final Cache<String, Boolean> authCache;
private final GoogleAuthorizationCodeFlow authFlow;
private final String organizationId;
private final String redirectUri;
Expand All @@ -46,7 +50,7 @@ private CachingGoogleAuthCodeFlow(final int authCacheTtl,
this.authCache = Caffeine.newBuilder()
.maximumSize(4096)
.expireAfterWrite(authCacheTtl, MILLISECONDS)
.build(k -> this.isOrganizationMember(k, true));
.build();
this.authFlow = new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT,
JSON_FACTORY,
Expand Down Expand Up @@ -115,16 +119,21 @@ public final String getPrincipal(final GoogleTokenResponse token) {
* @param userId the user's ID (typically his organization email address).
* @return whether a given user is a member of the organization.
*/
public final boolean isOrganizationMember(final String userId) {
return isOrganizationMember(userId, false);
}

private final boolean isOrganizationMember(final String userId,
final boolean forceCheck) {
if (!forceCheck) {
return this.authCache.get(userId);
public final Boolean isOrganizationMember(final String userId) {
// Try to grab membership information from the cache.
Boolean isMember = this.authCache.getIfPresent(userId);

// If we have previously validated this user as a member of the organization, return.
if (isMember != null && isMember) {
LOGGER.debug("{} is an organization member (cache hit).", userId);
return true;
}

LOGGER.debug("No entry in cache for {}. Hitting the Resource Manager API.", userId);

// At this point, either we've never validated this user as a member of the organization, or we've tried to but they weren't.
// Hence we perform the validation process afresh by getting the list of organizations for which the user is a member.

final Credential credential = this.loadCredential(userId);

if (credential == null) {
Expand All @@ -143,8 +152,18 @@ private final boolean isOrganizationMember(final String userId,
throw new UncheckedIOException(ex);
}

return organizations != null
// Check whether the current organization is in the list of the user's organizations.
isMember = organizations != null
&& organizations.stream().anyMatch(org -> this.organizationId.equals(org.getOrganizationId()));

// If we've successfully validated this user as a member of the organization, put this information in the cache.
if (isMember) {
LOGGER.debug("{} has been verified as an organization member. Caching.", userId);
this.authCache.put(userId, true);
} else {
LOGGER.debug("{} couldn't be verified as an organization member.");
}
return isMember;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public class CloudIamAuthNexusProxyVerticle extends BaseNexusProxyVerticle {
private static final String CLIENT_SECRET = System.getenv("CLIENT_SECRET");
private static final String KEYSTORE_PATH = System.getenv("KEYSTORE_PATH");
private static final String KEYSTORE_PASS = System.getenv("KEYSTORE_PASS");
// JWT_REQUIRES_MEMBERSHIP_VERIFICATION indicates whether a user presenting a valid JWT token must still be verified for membership within the organization.
private static final Boolean JWT_REQUIRES_MEMBERSHIP_VERIFICATION = Boolean.parseBoolean(System.getenv("JWT_REQUIRES_MEMBERSHIP_VERIFICATION"));
private static final String ORGANIZATION_ID = System.getenv("ORGANIZATION_ID");
private static final String REDIRECT_URL = System.getenv("REDIRECT_URL");
private static final Integer SESSION_TTL = Ints.tryParse(System.getenv("SESSION_TTL"));
Expand Down Expand Up @@ -117,60 +119,85 @@ protected void preconfigureRouting(final Router router) {

@Override
protected void configureRouting(Router router) {
// Enforce authentication for the Docker API.
router.route(DOCKER_V2_API_PATHS).handler(VirtualHostHandler.create(nexusDockerHost, ctx -> {
if (ctx.request().headers().get(HttpHeaders.AUTHORIZATION) == null) {
LOGGER.debug("No authorization header found. Denying.");
ctx.response().putHeader(WWW_AUTHENTICATE_HEADER_NAME, WWW_AUTHENTICATE_HEADER_VALUE);
ctx.response().putHeader(DOCKER_DISTRIBUTION_API_VERSION_NAME, DOCKER_DISTRIBUTION_API_VERSION_VALUE);
ctx.fail(401);
} else {
LOGGER.debug("Authorization header found.");
ctx.data().put(HAS_AUTHORIZATION_HEADER, true);
ctx.next();
}
}));

// Enforce authentication for the Nexus UI and API.
router.route(NEXUS_REPOSITORY_PATHS).handler(VirtualHostHandler.create(nexusHttpHost, ctx -> {
if (ctx.request().headers().get(HttpHeaders.AUTHORIZATION) == null) {
LOGGER.debug("No authorization header found. Denying.");
ctx.response().putHeader(WWW_AUTHENTICATE_HEADER_NAME, WWW_AUTHENTICATE_HEADER_VALUE);
ctx.fail(401);
} else {
LOGGER.debug("Authorization header found.");
ctx.data().put(HAS_AUTHORIZATION_HEADER, true);
ctx.next();
}
}));

// Configure the callback used by the OAuth2 consent screen.
router.route(CALLBACK_PATH).handler(ctx -> {
final String authorizationUri = flow.buildAuthorizationUri();

// Check if the request contains an authentication code.
// If it doesn't, redirect to the OAuth2 consent screen.
if (!ctx.request().params().contains(AUTH_CODE_PARAM_NAME)) {
LOGGER.debug("No authentication code found. Redirecting to consent screen.");
ctx.response().setStatusCode(302).putHeader(HttpHeaders.LOCATION, authorizationUri).end();
return;
}

// The request contains an authentication code.
// We must now use it to request an access token for the user and know their identity.
final GoogleTokenResponse token;
final String principal;

try {
LOGGER.debug("Requesting access token from Google.");
token = flow.requestToken(ctx.request().params().get(AUTH_CODE_PARAM_NAME));
flow.storeCredential(token);
principal = flow.getPrincipal(token);
LOGGER.debug("Got access token for principal {}.", principal);
} catch (final UncheckedIOException ex) {
LOGGER.error("Couldn't request access token from Google.", ex);
// We've failed to request the access token.
// Our best bet is to redirect the user back to the consent screen so the process can be retried.
LOGGER.error("Couldn't request access token from Google. Redirecting to consent screen.", ex);
ctx.response().setStatusCode(302).putHeader(HttpHeaders.LOCATION, authorizationUri).end();
return;
}

// We've got the required access token, so we redirect the user to the root.
LOGGER.debug("Redirecting principal {} to {}.", principal, ROOT_PATH);
ctx.session().put(SessionKeys.USER_ID, principal);
ctx.response().setStatusCode(302).putHeader(HttpHeaders.LOCATION, ROOT_PATH).end();
});

// Configure token-based authentication for all paths in order to support authentication for CLI tools such as Maven and Docker.
router.route(ALL_PATHS).handler(ctx -> {
// Check for the presence of an authorization header so we can validate it.
// If an authorization header is present, this must be a request from a CLI tool.
final String authHeader = ctx.request().headers().get(HttpHeaders.AUTHORIZATION);

// Skip this step if no authorization header has been found.
if (authHeader == null) {
ctx.next();
return;
}

// The request carries an authorization header.
// These headers are expected to be of the form "Basic X" where X is a base64-encoded string that corresponds to either "password" or "username:password".
// The password is then validated as a JWT token, which should have been obtained previously by the user via a call to CLI_CREDENTIALS_PATH.
final String[] parts = authHeader.split("\\s+");

if (parts.length != 2) {
Expand All @@ -182,6 +209,8 @@ protected void configureRouting(Router router) {
return;
}

LOGGER.debug("Request carries HTTP Basic authentication. Validating JWT token.");

final String credentials = new String(Base64.decodeBase64(parts[1]), Charsets.UTF_8);
final int colonIdx = credentials.indexOf(":");

Expand All @@ -193,52 +222,81 @@ protected void configureRouting(Router router) {
password = credentials;
}

// Validate the password as a JWT token.
jwtAuth.validate(password, userId -> {
ctx.data().put(SessionKeys.USER_ID, userId);
ctx.next();
if (userId == null) {
LOGGER.debug("Got invalid JWT token. Denying.");
ctx.response().setStatusCode(403).end();
} else {
LOGGER.debug("Got valid JWT token for principal {}.", userId);
ctx.data().put(SessionKeys.USER_ID, userId);
ctx.next();
}
});
});

// Configure routing for all paths.
router.route(ALL_PATHS).handler(ctx -> {
// Check whether the user has already been identified.
// This happens either at the handler for CALLBACK_PATH or at the handler for JWT tokens.
final String userId = getUserId(ctx);

if (userId == null && !((Boolean) ctx.data().getOrDefault(HAS_AUTHORIZATION_HEADER, false))) {
// If the user has NOT been identified yet, and the request does not carry an authorization header, redirect the user to the callback.
if (userId == null) {
LOGGER.debug("Got no authorization info. Redirecting to {}.", CALLBACK_PATH);
ctx.response().setStatusCode(302).putHeader(HttpHeaders.LOCATION, CALLBACK_PATH).end();
return;
}
if (userId == null) {
ctx.response().setStatusCode(403).end();

// At this point we've got a valid principal.
// We should, however, still check whether they are (still) a member of the organization (unless this check is explicitly disabled).
// This is done mostly to prevent long-lived JWT tokens from being used after a user leaves the organization.

final Boolean hasAuthorizationHeader = ((Boolean) ctx.data().getOrDefault(HAS_AUTHORIZATION_HEADER, false));

// If there is an authorization header but membership verification is not required, skip the remaining of this handler.
if (hasAuthorizationHeader && !JWT_REQUIRES_MEMBERSHIP_VERIFICATION) {
LOGGER.debug("{} has a valid auth token but is not an organization member. Allowing since membership verification is not required.", userId);
ctx.next();
return;
}

// Check if the user is still a member of the organization.
boolean isOrganizationMember = false;

try {
LOGGER.debug("Checking organization membership for principal {}.", userId);
isOrganizationMember = flow.isOrganizationMember(userId);
LOGGER.debug("Principal is organization member: {}.", isOrganizationMember);
} catch (final UncheckedIOException ex) {
// Destroy the user's session in case of an error while validating membership.
ctx.session().destroy();
LOGGER.error("Couldn't check membership for {}. Their session has been destroyed.", userId, ex);
}

// Make a decision based on whether the user is an organization member.
// If they aren't, decide based on the presence of the authorization header (indicating either a CLI flow or a UI flow).
if (isOrganizationMember) {
// The user is an organization member.
// The user is an organization member. Allow the request.
LOGGER.debug("{} is organization member. Allowing.", userId);
ctx.next();
} else if ((Boolean) ctx.data().getOrDefault(HAS_AUTHORIZATION_HEADER, false)) {
// The user is not an organization member AND is most probably using a CLI tool. --> Forbid.
LOGGER.debug("{} has an auth token but is not an organization member. Forbidding.", userId);
} else if (hasAuthorizationHeader) {
// The user is not an organization member (or membership couldn't be verified) AND is most probably using a CLI tool. Deny the request.
LOGGER.debug("{} is not an organization member. Denying.", userId);
ctx.response().setStatusCode(403).end();
} else {
// The user is not an organization member AND is most probably browsing Nexus UI. --> Redirect.
LOGGER.debug("{} does not have an auth token and is not an organization member. Redirecting.", userId);
// The user is not an organization member AND is most probably browsing Nexus UI. Redirect to the callback.
LOGGER.debug("{} does not have an auth token and is not an organization member. Redirecting to {}.", userId, CALLBACK_PATH);
ctx.response().setStatusCode(302).putHeader(HttpHeaders.LOCATION, CALLBACK_PATH).end();
}
});

// Configure the path from where a JWT token can be obtained.
router.get(CLI_CREDENTIALS_PATH).produces(MediaType.JSON_UTF_8.toString()).handler(ctx -> {
final String userId = ctx.session().get(SessionKeys.USER_ID);

LOGGER.debug("Generating JWT token for principal {}.", userId);

final JsonObject body = new JsonObject()
.put("username", userId)
.put("password", jwtAuth.generate(userId));
Expand Down

0 comments on commit 4ebcb8d

Please sign in to comment.