├── settings.gradle
├── .gitignore
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── src
├── test
│ ├── resources
│ │ └── keystore.jceks
│ └── java
│ │ └── com
│ │ └── travelaudience
│ │ └── nexus
│ │ └── proxy
│ │ ├── UnauthenticatedNexusProxyVerticleTests.java
│ │ └── CloudIamAuthNexusProxyVerticleTests.java
└── main
│ ├── resources
│ ├── templates
│ │ ├── http-disabled.hbs
│ │ └── invalid-host.hbs
│ └── logback.xml
│ └── java
│ └── com
│ └── travelaudience
│ └── nexus
│ └── proxy
│ ├── Paths.java
│ ├── SessionKeys.java
│ ├── UnauthenticatedNexusProxyVerticle.java
│ ├── ContextKeys.java
│ ├── Main.java
│ ├── JwtAuth.java
│ ├── NexusHttpProxy.java
│ ├── BaseNexusProxyVerticle.java
│ ├── CachingGoogleAuthCodeFlow.java
│ └── CloudIamAuthNexusProxyVerticle.java
├── docs
├── img
│ ├── nexus-proxy-external-flow-auth-disabled.png
│ ├── nexus-proxy-internal-flow-auth-disabled.png
│ ├── nexus-proxy-internal-flow-auth-enabled.png
│ ├── nexus-proxy-external-flow-jwt-auth-enabled.png
│ └── nexus-proxy-external-flow-ui-auth-enabled.png
└── design.md
├── .travis.yml
├── Dockerfile
├── gradlew.bat
├── gradlew
├── README.md
├── LICENSE
└── design.md
/settings.gradle:
--------------------------------------------------------------------------------
1 | rootProject.name = 'nexus-proxy'
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle/
3 | .idea/
4 | .secrets/
5 | .vertx/
6 | build/
7 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/travelaudience/nexus-proxy/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/src/test/resources/keystore.jceks:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/travelaudience/nexus-proxy/HEAD/src/test/resources/keystore.jceks
--------------------------------------------------------------------------------
/docs/img/nexus-proxy-external-flow-auth-disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/travelaudience/nexus-proxy/HEAD/docs/img/nexus-proxy-external-flow-auth-disabled.png
--------------------------------------------------------------------------------
/docs/img/nexus-proxy-internal-flow-auth-disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/travelaudience/nexus-proxy/HEAD/docs/img/nexus-proxy-internal-flow-auth-disabled.png
--------------------------------------------------------------------------------
/docs/img/nexus-proxy-internal-flow-auth-enabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/travelaudience/nexus-proxy/HEAD/docs/img/nexus-proxy-internal-flow-auth-enabled.png
--------------------------------------------------------------------------------
/docs/img/nexus-proxy-external-flow-jwt-auth-enabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/travelaudience/nexus-proxy/HEAD/docs/img/nexus-proxy-external-flow-jwt-auth-enabled.png
--------------------------------------------------------------------------------
/docs/img/nexus-proxy-external-flow-ui-auth-enabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/travelaudience/nexus-proxy/HEAD/docs/img/nexus-proxy-external-flow-ui-auth-enabled.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.9-all.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/src/main/resources/templates/http-disabled.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | HTTP Disabled
5 |
6 |
7 | HTTP access is disabled. Click here to browse Nexus securely: https://{{nexus_http_host}}/.
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/main/resources/templates/invalid-host.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Invalid Host
5 |
6 |
7 | Invalid host. To browse Nexus, click here/. To use the Docker registry, point your client at {{nexus_docker_host}}.
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/main/resources/logback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | %date{ISO8601} [%-5level] [%thread] [%logger] %msg%n
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/main/java/com/travelaudience/nexus/proxy/Paths.java:
--------------------------------------------------------------------------------
1 | package com.travelaudience.nexus.proxy;
2 |
3 | public final class Paths {
4 | private Paths() {}
5 |
6 | /**
7 | * The path that corresponds to all possible paths within the proxy and Nexus.
8 | */
9 | public static final String ALL_PATHS = "/*";
10 | /**
11 | * The path that corresponds to the application's root.
12 | */
13 | public static final String ROOT_PATH = "/";
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/java/com/travelaudience/nexus/proxy/SessionKeys.java:
--------------------------------------------------------------------------------
1 | package com.travelaudience.nexus.proxy;
2 |
3 | import io.vertx.ext.web.RoutingContext;
4 |
5 | /**
6 | * Holds strings corresponding to keys frequently set on {@link RoutingContext#session()}.
7 | */
8 | public final class SessionKeys {
9 | /**
10 | * The key that holds the currently authenticated user's ID.
11 | */
12 | public static final String USER_ID = "user-id";
13 |
14 | private SessionKeys() {
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/main/java/com/travelaudience/nexus/proxy/UnauthenticatedNexusProxyVerticle.java:
--------------------------------------------------------------------------------
1 | package com.travelaudience.nexus.proxy;
2 |
3 | import io.vertx.ext.web.Router;
4 | import io.vertx.ext.web.RoutingContext;
5 |
6 | public class UnauthenticatedNexusProxyVerticle extends BaseNexusProxyVerticle {
7 | @Override
8 | protected void preconfigureRouting(final Router router) {
9 | // Do nothing.
10 | }
11 |
12 | @Override
13 | protected void configureRouting(final Router router) {
14 | // Do nothing.
15 | }
16 |
17 | @Override
18 | protected String getUserId(final RoutingContext ctx) {
19 | return null;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/main/java/com/travelaudience/nexus/proxy/ContextKeys.java:
--------------------------------------------------------------------------------
1 | package com.travelaudience.nexus.proxy;
2 |
3 | import io.vertx.ext.web.RoutingContext;
4 |
5 | /**
6 | * Holds strings corresponding to keys frequently set on {@link RoutingContext#data()}.
7 | */
8 | public final class ContextKeys {
9 | /**
10 | * The key that holds the fact that there's an {@code Authorization} header on the current request.
11 | */
12 | public static final String HAS_AUTHORIZATION_HEADER = "has-authorization-header";
13 | /**
14 | * The key that holds the instance of {@link NexusHttpProxy} to use when serving the current request.
15 | */
16 | public static final String PROXY = "proxy";
17 |
18 | private ContextKeys() {
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: required
2 |
3 | services:
4 | - docker
5 |
6 | before_install:
7 | - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
8 | - sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
9 | - sudo apt-get update
10 | - sudo apt-get -y install docker-ce
11 |
12 | script:
13 | - docker build -t "quay.io/travelaudience/docker-nexus-proxy:${TRAVIS_TAG:-latest}" .
14 |
15 | after_success:
16 | - if ([[ "${TRAVIS_BRANCH}" == "master" ]] && [[ "${TRAVIS_PULL_REQUEST}" == "false" ]]) || [[ ! -z "${TRAVIS_TAG}" ]];
17 | then
18 | docker login -u "${DOCKER_USERNAME}" -p "${DOCKER_PASSWORD}" quay.io;
19 | docker push "quay.io/travelaudience/docker-nexus-proxy:${TRAVIS_TAG:-latest}";
20 | fi
21 |
--------------------------------------------------------------------------------
/src/main/java/com/travelaudience/nexus/proxy/Main.java:
--------------------------------------------------------------------------------
1 | package com.travelaudience.nexus.proxy;
2 |
3 | import io.vertx.core.Vertx;
4 | import io.vertx.core.logging.SLF4JLogDelegateFactory;
5 |
6 | import static io.vertx.core.logging.LoggerFactory.LOGGER_DELEGATE_FACTORY_CLASS_NAME;
7 |
8 | public class Main {
9 | private static final boolean CLOUD_IAM_AUTH_ENABLED = Boolean.valueOf(System.getenv("CLOUD_IAM_AUTH_ENABLED"));
10 |
11 | static {
12 | // Make Vert.x use SLF4J.
13 | System.setProperty(LOGGER_DELEGATE_FACTORY_CLASS_NAME, SLF4JLogDelegateFactory.class.getName());
14 | }
15 |
16 | public static void main(String[] args) {
17 | Vertx.vertx().deployVerticle(
18 | CLOUD_IAM_AUTH_ENABLED ? new CloudIamAuthNexusProxyVerticle() : new UnauthenticatedNexusProxyVerticle()
19 | );
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # -- build
2 | FROM openjdk:8-jdk-slim AS builder
3 | COPY ./ /src/
4 | WORKDIR /src/
5 | RUN ./gradlew --info --no-daemon build
6 |
7 | # -- run
8 | FROM alpine:3.10
9 |
10 | # Install java runtime
11 | RUN apk add --no-cache --update openjdk8-jre && \
12 | rm -rf /tmp/* /var/cache/apk/*
13 |
14 | ENV JAVA_HOME=/usr/lib/jvm/default-jvm/jre
15 | ENV JAVA_TOOL_OPTIONS ""
16 | ENV ALLOWED_USER_AGENTS_ON_ROOT_REGEX "GoogleHC"
17 | ENV AUTH_CACHE_TTL "300"
18 | ENV BIND_PORT "8080"
19 | ENV CLIENT_ID "REPLACE_ME"
20 | ENV CLIENT_SECRET "REPLACE_ME"
21 | ENV CLOUD_IAM_AUTH_ENABLED "false"
22 | ENV JWT_REQUIRES_MEMBERSHIP_VERIFICATION "true"
23 | ENV KEYSTORE_PATH "keystore.jceks"
24 | ENV KEYSTORE_PASS "safe#passw0rd!"
25 | ENV NEXUS_DOCKER_HOST "containers.example.com"
26 | ENV NEXUS_HTTP_HOST "nexus.example.com"
27 | ENV NEXUS_RUT_HEADER "X-Forwarded-User"
28 | ENV ORGANIZATION_ID "REPLACE_ME"
29 | ENV REDIRECT_URL "https://nexus.example.com/oauth/callback"
30 | ENV SESSION_TTL "1440000"
31 | ENV TLS_CERT_PK12_PATH "cert.pk12"
32 | ENV TLS_CERT_PK12_PASS "safe#passw0rd!"
33 | ENV TLS_ENABLED "false"
34 | ENV UPSTREAM_DOCKER_PORT "5003"
35 | ENV UPSTREAM_HOST "localhost"
36 | ENV UPSTREAM_HTTP_PORT "8081"
37 |
38 | COPY --from=builder /src/build/libs/nexus-proxy-2.3.0.jar /nexus-proxy.jar
39 |
40 | EXPOSE 8080
41 | EXPOSE 8443
42 |
43 | CMD ["-jar", "/nexus-proxy.jar"]
44 |
45 | ENTRYPOINT ["java"]
46 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/src/test/java/com/travelaudience/nexus/proxy/UnauthenticatedNexusProxyVerticleTests.java:
--------------------------------------------------------------------------------
1 | package com.travelaudience.nexus.proxy;
2 |
3 | import static org.junit.Assert.assertEquals;
4 |
5 | import io.vertx.core.Vertx;
6 | import io.vertx.core.http.HttpHeaders;
7 | import io.vertx.ext.unit.Async;
8 | import io.vertx.ext.unit.TestContext;
9 | import io.vertx.ext.unit.junit.VertxUnitRunner;
10 | import org.junit.After;
11 | import org.junit.Before;
12 | import org.junit.Test;
13 | import org.junit.runner.RunWith;
14 | import org.powermock.api.mockito.PowerMockito;
15 | import org.powermock.core.classloader.annotations.PrepareForTest;
16 | import org.powermock.modules.junit4.PowerMockRunner;
17 | import org.powermock.modules.junit4.PowerMockRunnerDelegate;
18 |
19 | import java.io.IOException;
20 | import java.io.UncheckedIOException;
21 | import java.net.InetAddress;
22 | import java.net.ServerSocket;
23 | import java.util.HashMap;
24 | import java.util.Map;
25 |
26 | @RunWith(PowerMockRunner.class)
27 | @PowerMockRunnerDelegate(VertxUnitRunner.class)
28 | @PrepareForTest({ NexusHttpProxy.class, UnauthenticatedNexusProxyVerticle.class })
29 | public class UnauthenticatedNexusProxyVerticleTests {
30 | private static final String HOST = "localhost";
31 | private static final int PORT = findRandomUnusedPort();
32 |
33 | private static final Map VARS = new HashMap() {
34 | {
35 | put("ALLOWED_USER_AGENTS_ON_ROOT_REGEX", "GoogleHC");
36 | put("BIND_PORT", String.valueOf(PORT));
37 | put("CLOUD_IAM_AUTH_ENABLED", "false");
38 | put("NEXUS_DOCKER_HOST", "containers.example.com");
39 | put("NEXUS_HTTP_HOST", "nexus.example.com");
40 | put("NEXUS_RUT_HEADER", "X-Forwarded-User");
41 | put("TLS_CERT_PK12_PATH", "cert.pk12");
42 | put("TLS_CERT_PK12_PASS", "safe#passw0rd!");
43 | put("TLS_ENABLED", "false");
44 | put("UPSTREAM_DOCKER_PORT", "5003");
45 | put("UPSTREAM_HOST", "localhost");
46 | put("UPSTREAM_HTTP_PORT", "8081");
47 | }
48 | };
49 |
50 | private NexusHttpProxy proxy;
51 | private Vertx vertx;
52 |
53 | @Before
54 | public void setUp(final TestContext context) throws Exception {
55 | PowerMockito.mockStatic(System.class);
56 | VARS.entrySet().stream().forEach(e -> PowerMockito.when(System.getenv(e.getKey())).thenReturn(e.getValue()));
57 |
58 | this.vertx = Vertx.vertx();
59 | this.vertx.deployVerticle(UnauthenticatedNexusProxyVerticle.class.getName(), context.asyncAssertSuccess());
60 | }
61 |
62 | @After
63 | public void tearDown(TestContext context) {
64 | vertx.close(context.asyncAssertSuccess());
65 | }
66 |
67 | @Test
68 | public void root_responds_with_200_to_allowed_user_agents(final TestContext ctx) {
69 | final Async async = ctx.async();
70 |
71 | vertx.createHttpClient().get(PORT, HOST, "/", res -> {
72 | assertEquals(200, res.statusCode());
73 | assertEquals("0", res.headers().get(HttpHeaders.CONTENT_LENGTH));
74 | async.complete();
75 | }).putHeader(HttpHeaders.USER_AGENT, "GoogleHC/1.0").end();
76 | }
77 |
78 | private static final int findRandomUnusedPort() {
79 | try (final ServerSocket socket = new ServerSocket(0, 50, InetAddress.getLocalHost())) {
80 | return socket.getLocalPort();
81 | } catch (final IOException ex) {
82 | throw new UncheckedIOException(ex);
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/main/java/com/travelaudience/nexus/proxy/JwtAuth.java:
--------------------------------------------------------------------------------
1 | package com.travelaudience.nexus.proxy;
2 |
3 | import static java.time.temporal.ChronoUnit.DAYS;
4 |
5 | import io.vertx.core.Vertx;
6 | import io.vertx.core.json.JsonArray;
7 | import io.vertx.core.json.JsonObject;
8 | import io.vertx.ext.auth.jwt.JWTAuth;
9 | import io.vertx.ext.auth.jwt.JWTOptions;
10 |
11 | import java.time.Duration;
12 | import java.util.List;
13 | import java.util.function.Consumer;
14 |
15 | /**
16 | * Provides utility methods for dealing with JWT-based authentication.
17 | */
18 | public final class JwtAuth {
19 | private static final String UID_KEY = "uid";
20 |
21 | private final List audience;
22 | private final JWTAuth jwtAuth;
23 | private final JWTOptions jwtOptions;
24 |
25 | private JwtAuth(final Vertx vertx,
26 | final String keystorePath,
27 | final String keystorePass,
28 | final List audience) {
29 | this.jwtAuth = JWTAuth.create(vertx, new JsonObject().put("keyStore", new JsonObject()
30 | .put("path", keystorePath)
31 | .put("type", "jceks")
32 | .put("password", keystorePass)));
33 | this.jwtOptions = new JWTOptions()
34 | .setAudience(audience)
35 | .setAlgorithm("RS256")
36 | .setExpiresInSeconds(Duration.of(365, DAYS).getSeconds());
37 | this.audience = audience;
38 | }
39 |
40 | /**
41 | * Creates a new instance of {@link JwtAuth}.
42 | *
43 | * @param vertx the base {@link Vertx} instance.
44 | * @param keystorePath the path to the keystore containing the signing key.
45 | * @param keystorePass the password to the keystore containing the signing key.
46 | * @param audience the intended audience of the generated tokens.
47 | * @return a new instance of {@link JwtAuth}.
48 | */
49 | public static final JwtAuth create(final Vertx vertx,
50 | final String keystorePath,
51 | final String keystorePass,
52 | final List audience) {
53 | return new JwtAuth(vertx, keystorePath, keystorePass, audience);
54 | }
55 |
56 | /**
57 | * Returns a new JWT for the specified user.
58 | *
59 | * @param userId the authenticated user.
60 | * @return a new JWT for the specified user.
61 | */
62 | public final String generate(final String userId) {
63 | return this.jwtAuth.generateToken(new JsonObject().put(UID_KEY, userId), this.jwtOptions);
64 | }
65 |
66 | /**
67 | * Validates whether the specified {@code jwtToken} is valid, returning the user's ID if validation is successful or
68 | * null otherwise.
69 | *
70 | * @param jwtToken the JWT token with which the user is authenticating.
71 | * @param handler the result handler.
72 | */
73 | public final void validate(final String jwtToken,
74 | final Consumer handler) {
75 | final JsonObject authData = new JsonObject()
76 | .put("jwt", jwtToken)
77 | .put("options", new JsonObject()
78 | .put("audience", new JsonArray(audience)));
79 |
80 | this.jwtAuth.authenticate(authData, res -> {
81 | if (res.succeeded()) {
82 | handler.accept(res.result().principal().getString(UID_KEY));
83 | } else {
84 | handler.accept(null);
85 | }
86 | });
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/main/java/com/travelaudience/nexus/proxy/NexusHttpProxy.java:
--------------------------------------------------------------------------------
1 | package com.travelaudience.nexus.proxy;
2 |
3 | import io.vertx.core.Handler;
4 | import io.vertx.core.Vertx;
5 | import io.vertx.core.http.HttpClient;
6 | import io.vertx.core.http.HttpClientRequest;
7 | import io.vertx.core.http.HttpClientResponse;
8 | import io.vertx.core.http.HttpHeaders;
9 | import io.vertx.core.http.HttpMethod;
10 | import io.vertx.core.http.HttpServerRequest;
11 | import io.vertx.core.http.HttpServerResponse;
12 | import io.vertx.ext.web.RoutingContext;
13 |
14 | /**
15 | * A basic class which proxies user requests to a Nexus instance, conveying authentication information.
16 | *
17 | * @see Authentication via Remote User Token
18 | */
19 | public final class NexusHttpProxy {
20 | private static final CharSequence X_FORWARDED_PROTO = HttpHeaders.createOptimized("X-Forwarded-Proto");
21 | private static final CharSequence X_FORWARDED_FOR = HttpHeaders.createOptimized("X-Forwarded-For");
22 |
23 | private final String host;
24 | private final HttpClient httpClient;
25 | private final String nexusRutHeader;
26 | private final int port;
27 |
28 | private NexusHttpProxy(final Vertx vertx,
29 | final String host,
30 | final int port,
31 | final String nexusRutHeader) {
32 | this.host = host;
33 | this.httpClient = vertx.createHttpClient();
34 | this.nexusRutHeader = nexusRutHeader;
35 | this.port = port;
36 | }
37 |
38 | /**
39 | * Creates a new instance of {@link NexusHttpProxy}.
40 | *
41 | * @param vertx the base {@link Vertx} instance.
42 | * @param host the host we will be proxying to.
43 | * @param port the port we will be proxying to.
44 | * @param nexusRutHeader the name of the RUT authentication header as configured in Nexus.
45 | * @return a new instance of {@link NexusHttpProxy}.
46 | */
47 | public static final NexusHttpProxy create(final Vertx vertx,
48 | final String host,
49 | final int port,
50 | final String nexusRutHeader) {
51 | return new NexusHttpProxy(vertx, host, port, nexusRutHeader);
52 | }
53 |
54 | /**
55 | * Proxies the specified HTTP request, enriching its headers with authentication information.
56 | *
57 | * @param userId the ID of the user making the request.
58 | * @param origReq the original request (i.e., {@link RoutingContext#request()}.
59 | * @param origRes the original response (i.e., {@link RoutingContext#request()}.
60 | */
61 | public void proxyUserRequest(final String userId,
62 | final HttpServerRequest origReq,
63 | final HttpServerResponse origRes) {
64 | final Handler proxiedResHandler = proxiedRes -> {
65 | origRes.setChunked(true);
66 | origRes.setStatusCode(proxiedRes.statusCode());
67 | origRes.headers().setAll(proxiedRes.headers());
68 | proxiedRes.handler(origRes::write);
69 | proxiedRes.endHandler(v -> origRes.end());
70 | };
71 |
72 | final HttpClientRequest proxiedReq;
73 | proxiedReq = httpClient.request(origReq.method(), port, host, origReq.uri(), proxiedResHandler);
74 | if(origReq.method() == HttpMethod.OTHER) {
75 | proxiedReq.setRawMethod(origReq.rawMethod());
76 | }
77 | proxiedReq.setChunked(true);
78 | proxiedReq.headers().add(X_FORWARDED_PROTO, getHeader(origReq, X_FORWARDED_PROTO, origReq.scheme()));
79 | proxiedReq.headers().add(X_FORWARDED_FOR, getHeader(origReq, X_FORWARDED_FOR, origReq.remoteAddress().host()));
80 | proxiedReq.headers().addAll(origReq.headers());
81 | injectRutHeader(proxiedReq, userId);
82 | origReq.handler(proxiedReq::write);
83 | origReq.endHandler(v -> proxiedReq.end());
84 | }
85 |
86 | private final void injectRutHeader(final HttpClientRequest req,
87 | final String userId) {
88 | if (nexusRutHeader != null && nexusRutHeader.length() > 0 && userId != null && userId.length() > 0) {
89 | req.headers().add(nexusRutHeader, userId);
90 | }
91 | }
92 |
93 | private static final String getHeader(final HttpServerRequest req,
94 | final CharSequence name,
95 | final String defaultValue) {
96 | final String originalHeader = req.headers().get(name);
97 |
98 | if (originalHeader == null) {
99 | return defaultValue;
100 | } else {
101 | return originalHeader;
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/src/test/java/com/travelaudience/nexus/proxy/CloudIamAuthNexusProxyVerticleTests.java:
--------------------------------------------------------------------------------
1 | package com.travelaudience.nexus.proxy;
2 |
3 | import io.vertx.core.Vertx;
4 | import io.vertx.core.http.HttpHeaders;
5 | import io.vertx.ext.unit.Async;
6 | import io.vertx.ext.unit.TestContext;
7 | import io.vertx.ext.unit.junit.VertxUnitRunner;
8 | import org.junit.After;
9 | import org.junit.Before;
10 | import org.junit.Test;
11 | import org.junit.runner.RunWith;
12 | import org.powermock.api.mockito.PowerMockito;
13 | import org.powermock.core.classloader.annotations.PrepareForTest;
14 | import org.powermock.modules.junit4.PowerMockRunner;
15 | import org.powermock.modules.junit4.PowerMockRunnerDelegate;
16 |
17 | import java.io.IOException;
18 | import java.io.UncheckedIOException;
19 | import java.io.UnsupportedEncodingException;
20 | import java.net.*;
21 | import java.util.AbstractMap;
22 | import java.util.Arrays;
23 | import java.util.HashMap;
24 | import java.util.Map;
25 | import java.util.regex.Pattern;
26 |
27 | import static java.util.stream.Collectors.toMap;
28 | import static org.junit.Assert.assertEquals;
29 |
30 | @RunWith(PowerMockRunner.class)
31 | @PowerMockRunnerDelegate(VertxUnitRunner.class)
32 | @PrepareForTest(CloudIamAuthNexusProxyVerticle.class)
33 | public class CloudIamAuthNexusProxyVerticleTests {
34 | private static final String HOST = "localhost";
35 | private static final int PORT = findRandomUnusedPort();
36 |
37 | private static final Map VARS = new HashMap() {
38 | {
39 | /* GCP Organization stuff, not needed at this point */
40 | put("ORGANIZATION_ID", System.getProperty("ORGANIZATION_ID"));
41 | put("CLIENT_ID", System.getProperty("CLIENT_ID"));
42 | put("CLIENT_SECRET", System.getProperty("CLIENT_SECRET"));
43 |
44 | put("ALLOWED_USER_AGENTS_ON_ROOT_REGEX", "GoogleHC");
45 | put("AUTH_CACHE_TTL", "60");
46 | put("BIND_PORT", String.valueOf(PORT));
47 | put("CLOUD_IAM_AUTH_ENABLED", "true");
48 | put("KEYSTORE_PATH", "keystore.jceks");
49 | put("KEYSTORE_PASS", "safe#passw0rd!");
50 | put("NEXUS_DOCKER_HOST", "containers.example.com");
51 | put("NEXUS_HTTP_HOST", "nexus.example.com");
52 | put("NEXUS_RUT_HEADER", "X-Forwarded-User");
53 | put("REDIRECT_URL", "https://nexus.example.com/oauth/callback");
54 | put("SESSION_TTL", "1440000");
55 | put("TLS_CERT_PK12_PATH", "cert.pk12");
56 | put("TLS_CERT_PK12_PASS", "safe#passw0rd!");
57 | put("TLS_ENABLED", "false");
58 | put("UPSTREAM_DOCKER_PORT", "5003");
59 | put("UPSTREAM_HOST", "localhost");
60 | put("UPSTREAM_HTTP_PORT", "8081");
61 | }
62 | };
63 |
64 | private Vertx vertx;
65 |
66 | @Before
67 | public void setUp(final TestContext context) {
68 | PowerMockito.mockStatic(System.class);
69 | VARS.entrySet().stream().forEach(e -> PowerMockito.when(System.getenv(e.getKey())).thenReturn(e.getValue()));
70 |
71 | this.vertx = Vertx.vertx();
72 | this.vertx.deployVerticle(CloudIamAuthNexusProxyVerticle.class.getName(), context.asyncAssertSuccess());
73 | }
74 |
75 | @After
76 | public void tearDown(TestContext context) {
77 | vertx.close(context.asyncAssertSuccess());
78 | }
79 |
80 | @Test
81 | public void root_responds_with_302(final TestContext ctx) {
82 | final Async async = ctx.async();
83 |
84 | vertx.createHttpClient().get(PORT, HOST, "/", res -> {
85 | assertEquals(302, res.statusCode());
86 | assertEquals("/oauth/callback", res.headers().get(HttpHeaders.LOCATION));
87 | async.complete();
88 | }).putHeader(HttpHeaders.USER_AGENT, "SomeUserAgent/1.0").end();
89 | }
90 |
91 | @Test
92 | public void root_responds_with_200_to_allowed_user_agents(final TestContext ctx) {
93 | final Async async = ctx.async();
94 |
95 | vertx.createHttpClient().get(PORT, HOST, "/", res -> {
96 | assertEquals(200, res.statusCode());
97 | assertEquals("0", res.headers().get(HttpHeaders.CONTENT_LENGTH));
98 | async.complete();
99 | }).putHeader(HttpHeaders.USER_AGENT, "GoogleHC/1.0").end();
100 | }
101 |
102 | @Test
103 | public void maven_repository_root_path_responds_with_401_when_no_authentication_is_present(final TestContext ctx) {
104 | final Async async = ctx.async();
105 |
106 | vertx.createHttpClient().get(PORT, HOST, "/repository/maven-public/", res -> {
107 | assertEquals(401, res.statusCode());
108 | assertEquals("Basic Realm=\"nexus-proxy\"", res.headers().get("WWW-Authenticate"));
109 | async.complete();
110 | }).putHeader(HttpHeaders.HOST, VARS.get("NEXUS_HTTP_HOST")).end();
111 | }
112 |
113 | @Test
114 | public void docker_repository_root_path_responds_with_401_when_no_authentication_is_present(final TestContext ctx) {
115 | final Async async = ctx.async();
116 |
117 | vertx.createHttpClient().get(PORT, HOST, "/v2/", res -> {
118 | assertEquals(401, res.statusCode());
119 | assertEquals("Basic Realm=\"nexus-proxy\"", res.headers().get("WWW-Authenticate"));
120 | assertEquals("registry/2.0", res.headers().get("Docker-Distribution-Api-Version"));
121 | async.complete();
122 | }).putHeader(HttpHeaders.HOST, VARS.get("NEXUS_DOCKER_HOST")).end();
123 | }
124 |
125 | @Test
126 | public void callback_path_responds_with_302_when_no_auth_code_param_is_present(final TestContext ctx) {
127 | final Async async = ctx.async();
128 |
129 | vertx.createHttpClient().get(PORT, HOST, "/oauth/callback", res -> {
130 | assertEquals(302, res.statusCode());
131 | final URL redirectUrl = buildUrl(res.headers().get(HttpHeaders.LOCATION));
132 | assertEquals("accounts.google.com", redirectUrl.getHost());
133 | final Map params = parseQuery(redirectUrl);
134 | assertEquals("offline", params.get("access_type"));
135 | assertEquals("force", params.get("approval_prompt"));
136 | assertEquals(System.getenv("CLIENT_ID"), params.get("client_id"));
137 | assertEquals(System.getenv("REDIRECT_URL"), params.get("redirect_uri"));
138 | assertEquals("code", params.get("response_type"));
139 | async.complete();
140 | }).putHeader(HttpHeaders.USER_AGENT, "SomeUserAgent/1.0").end();
141 | }
142 |
143 | private static final URL buildUrl(final String url) {
144 | try {
145 | return new URL(url);
146 | } catch (final MalformedURLException ex) {
147 | throw new UncheckedIOException(ex);
148 | }
149 | }
150 |
151 | private static final int findRandomUnusedPort() {
152 | try (final ServerSocket socket = new ServerSocket(0, 50, InetAddress.getLocalHost())) {
153 | return socket.getLocalPort();
154 | } catch (final IOException ex) {
155 | throw new UncheckedIOException(ex);
156 | }
157 | }
158 |
159 | private static final Map parseQuery(final URL url) {
160 | return Pattern.compile("&").splitAsStream(url.getQuery())
161 | .map(s -> Arrays.copyOf(s.split("="), 2))
162 | .map(o -> new AbstractMap.SimpleEntry<>(urlDecode(o[0]), urlDecode(o[1])))
163 | .collect(toMap(e -> e.getKey(), e -> e.getValue()));
164 | }
165 |
166 | private static final String urlDecode(final String encoded) {
167 | try {
168 | return encoded == null ? null : URLDecoder.decode(encoded, "UTF-8");
169 | } catch (final UnsupportedEncodingException ex) {
170 | throw new UncheckedIOException(ex);
171 | }
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/docs/design.md:
--------------------------------------------------------------------------------
1 | # Design of `nexus-proxy`
2 |
3 | ## Table of Contents
4 |
5 | * [Introduction](#introduction)
6 | * [JSON Web Token (JWT)](#jwt)
7 | * [JWT Usage in `nexus-proxy`](#json-web-tokens-jwt-jwt-usage-in-nexus-proxy)
8 | * [External Flow](#external-flow)
9 | * [Authentication Disabled](#external-flow-authentication-disabled)
10 | * [Authentication Enabled](#external-flow-authentication-enabled)
11 | * [Browsing the Nexus UI](#external-flow-authentication-enabled-browsing-the-nexus-ui)
12 | * [Requesting Credentials for CLI Tools](#external-flow-authentication-enabled-requesting-credentials-for-cli-tools)
13 | * [Internal Flow](#internal-flow)
14 | * [Authentication Disabled](#internal-flow-authentication-disabled)
15 | * [Authentication Enabled](#internal-flow-authentication-enabled)
16 |
17 |
18 |
19 | ## Introduction
20 |
21 | When designing `nexus-proxy` we knew we had to support both browser-based and
22 | CLI-based flows (e.g., browsing the Nexus UI and using tools such as Maven or
23 | Docker to upload/download artifacts). We also knew beforehand that we would need
24 | to authenticate Nexus against
25 | [Google Cloud Identity & Access Management](https://cloud.google.com/iam/), but
26 | we wanted to make this authentication optional so that `nexus-proxy` could be
27 | used in simpler scenarios.
28 |
29 | This document starts by describing briefly a specific type of token
30 | that `nexus-proxy` uses for authentication, and proceeds into describing both
31 | the external (i.e., from the perspective of the user) and internal flows of
32 | `nexus-proxy`, detailing what happens in each flow when authentication is
33 | disabled or enabled. For simplicity we assume that
34 | `nexus-proxy` is reachable at https://nexus.example.com.
35 |
36 |
37 |
38 | ## JSON Web Tokens (JWT)
39 |
40 | [JSON Web Token](https://jwt.io), commonly referred to as JWT, ia a type of
41 | token used to convey arbitrary information, also known as _claims_ in a secure
42 | way between two distinct entities (e.g., a client and a server). These claims
43 | are described in JSON and encoded using an URL-safe variant of Base64.Then, a
44 | cryptographic signature of the resulting information is computed and appended to
45 | the the encoded claims, forming a _token_. This token is then sent to a
46 | destination which verifies its authenticity and, in case it is authentic,
47 | decodes the claims and uses them as trusted, verified information.
48 |
49 | **NOTE:** The way the signature is computed and verified depends on the
50 | [algorithm](https://auth0.com/blog/json-web-token-signing-algorithms-overview/)
51 | being used.
52 |
53 |
54 |
55 | ### JWT Usage in `nexus-proxy`
56 |
57 | `nexus-proxy` uses RS256, which means that the signature is computed using an
58 | RSA private key and the SHA-256 algorithm, and verified using the corresponding
59 | RSA public key. The tokens generated by `nexus-proxy` carry the following
60 | claims:
61 |
62 | * `uid` — The email address of the authenticated user.
63 | * `iat` — The timestamp at which the token was issued.
64 | * `exp` — The timestamp at which the token will expire.
65 | * `aud` — The token's intended audience, i.e., the domain names being in use.
66 |
67 | In practice this means that a token generated by `nexus-proxy` will look like
68 |
69 | ```
70 | eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1aWQiOiJqb2huLmRvZUBleGFtcGxlLmNv
71 | bSIsImlhdCI6MTUwMzA1NTI3NiwiZXhwIjoxNTM0NTkxMjc2LCJhdWQiOlsiY29udGFpbmVyc
72 | y5leGFtcGxlLmNvbSIsIm5leHVzLmV4YW1wbGUuY29tIl19.14dRJRxkwAp0iwm-XcxuVNV5S
73 | Ixc78ipcvg_kJAjeEseeKlvpsIQxv2TEOInLGkxUhppnwpV5ZYuNrSmKP_bdHifubCdIglvP2
74 | iK41RvVgSNmvHy8SATzeHhtPOK3EyCc9SyLPxYTfbsjqXxClqPuvzkn0QMdJRn36sJIfjAzmc
75 | ```
76 |
77 | The JWT tokens generated by `nexus-proxy` are valid for one year. Further
78 | details can be found
79 | [below](#external-flow-authentication-enabled-requesting-credentials-for-cli-tools).
80 |
81 |
82 |
83 | ## External Flow
84 |
85 |
86 |
87 | ### Authentication Disabled
88 |
89 | When authentication is disabled, `nexus-proxy` is transparent to the user. The
90 | following flowchart details a user's experience when browsing
91 | https://nexus.example.com:
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | ### Authentication Enabled
100 |
101 | When authentication is enabled, `nexus-proxy` requires that one authenticates
102 | themselves against Google Cloud IAM.
103 |
104 |
105 |
106 | #### Browsing the Nexus UI
107 |
108 | The following flowchart details a user's experience when browsing
109 | https://nexus.example.com:
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | #### Requesting Credentials for CLI Tools
118 |
119 | In order to use tools such as Maven or Docker, one must obtain a specific set of
120 | credentials. This is necessary so that one doesn't have to use their Google
121 | organization credentials or a manually-obtained Google authentication token in
122 | their configuration files. As mentioned above, the credentials generated by
123 | `nexus-proxy` are JWT tokens and are valid for one year (unless organization
124 | membership is revoked before this period).
125 |
126 | The following flowchart details a user's experience when visiting https://nexus.example.com/cli/credentials:
127 |
128 |
129 |
130 |
131 |
132 | One must then configure their tools to use these credentials. Detailed steps can
133 | be found
134 | [here](https://github.com/travelaudience/kubernetes-nexus/tree/master/docs/usage).
135 |
136 | **ATTENTION:** These credentials are not renewed automatically. Whenever one's JWT
137 | expires one must obtain a new one by visiting
138 | https://nexus.example.com/cli/credentials again.
139 |
140 | **ATTENTION:** Every visit to https://nexus.example.com/cli/credentials will return a
141 | different JWT, as the creation and expiration dates are encoded in the token
142 | itself. One may use different JWTs in different tools but must be aware that
143 | they will expire at different moments.
144 |
145 |
146 |
147 | ## Internal Flow
148 |
149 |
150 |
151 | ### Authentication Disabled
152 |
153 | When authentication is disabled, `nexus-proxy` proxies requests to Nexus and
154 | responds to health-checks on its behalf. An health-check is any HTTP request
155 | made to `/` by user-agents matching a configurable regular expression. These
156 | must usually be responded with `200 OK`, and `nexus-proxy` ensures that.
157 |
158 | The following flowchart details the flow of an HTTP request made to
159 | `nexus-proxy` when authentication is disabled:
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 | ### Authentication Enabled
168 |
169 | When authentication is enabled, `nexus-proxy` performs the following high-level
170 | tasks:
171 |
172 | * Responds to health-checks as described
173 | [above](#internal-flow-authentication-disabled).
174 | * Performs an OAuth2 "_authorization code_" flow in order to establish a
175 | session.
176 | * Ensure that only authenticated users can access protected resources in both
177 | in-browser and CLI flows.
178 | * Partially implements the authentication flows of Maven and Docker
179 | repositories.
180 |
181 | This last item is necessary because Maven and Docker expect HTTP Basic
182 | authentication (as opposed to the Bearer Token/JWT authentication which we are
183 | using). Also, Docker expects specific response headers at a specific endpoint,
184 | according to the
185 | [Docker Registry HTTP API V2](https://docs.docker.com/registry/spec/api/). As
186 | such, and since `nexus-proxy` stands between Maven/Docker and Nexus, this part
187 | of the authentication flow had to be implemented in the proxy.
188 |
189 | The following flowchart details the flow of an HTTP request made to
190 | `nexus-proxy` when authentication is enabled:
191 |
192 |
193 |
194 |
195 |
--------------------------------------------------------------------------------
/src/main/java/com/travelaudience/nexus/proxy/BaseNexusProxyVerticle.java:
--------------------------------------------------------------------------------
1 | package com.travelaudience.nexus.proxy;
2 |
3 | import static com.travelaudience.nexus.proxy.ContextKeys.PROXY;
4 | import static com.travelaudience.nexus.proxy.Paths.ALL_PATHS;
5 | import static com.travelaudience.nexus.proxy.Paths.ROOT_PATH;
6 |
7 | import com.google.common.base.Objects;
8 | import com.google.common.primitives.Ints;
9 | import io.vertx.core.AbstractVerticle;
10 | import io.vertx.core.http.HttpHeaders;
11 | import io.vertx.core.http.HttpServerOptions;
12 | import io.vertx.core.net.PfxOptions;
13 | import io.vertx.ext.web.Router;
14 | import io.vertx.ext.web.RoutingContext;
15 | import io.vertx.ext.web.handler.VirtualHostHandler;
16 | import io.vertx.ext.web.templ.HandlebarsTemplateEngine;
17 | import org.slf4j.Logger;
18 | import org.slf4j.LoggerFactory;
19 |
20 | import java.net.URI;
21 | import java.net.URISyntaxException;
22 | import java.util.regex.Pattern;
23 |
24 | public abstract class BaseNexusProxyVerticle extends AbstractVerticle {
25 | private static final Logger LOGGER = LoggerFactory.getLogger(BaseNexusProxyVerticle.class);
26 |
27 | private static final String ALLOWED_USER_AGENTS_ON_ROOT_REGEX = System.getenv("ALLOWED_USER_AGENTS_ON_ROOT_REGEX");
28 | private static final String BIND_HOST = Objects.firstNonNull(System.getenv("BIND_HOST"), "0.0.0.0");
29 | private static final Integer BIND_PORT = Ints.tryParse(System.getenv("BIND_PORT"));
30 | private static final Boolean ENFORCE_HTTPS = Boolean.parseBoolean(System.getenv("ENFORCE_HTTPS"));
31 | private static final String NEXUS_RUT_HEADER = System.getenv("NEXUS_RUT_HEADER");
32 | private static final String TLS_CERT_PK12_PATH = System.getenv("TLS_CERT_PK12_PATH");
33 | private static final String TLS_CERT_PK12_PASS = System.getenv("TLS_CERT_PK12_PASS");
34 | private static final Boolean TLS_ENABLED = Boolean.parseBoolean(System.getenv("TLS_ENABLED"));
35 | private static final Integer UPSTREAM_DOCKER_PORT = Ints.tryParse(System.getenv("UPSTREAM_DOCKER_PORT"));
36 | private static final String UPSTREAM_HOST = System.getenv("UPSTREAM_HOST");
37 | private static final Integer UPSTREAM_HTTP_PORT = Ints.tryParse(System.getenv("UPSTREAM_HTTP_PORT"));
38 |
39 | private static final CharSequence X_FORWARDED_PROTO = HttpHeaders.createOptimized("X-Forwarded-Proto");
40 |
41 | protected final String nexusDockerHost = System.getenv("NEXUS_DOCKER_HOST");
42 | protected final String nexusHttpHost = System.getenv("NEXUS_HTTP_HOST");
43 |
44 | protected final HandlebarsTemplateEngine handlebars = HandlebarsTemplateEngine.create();
45 |
46 | /**
47 | * The pattern against which to match 'User-Agent' headers.
48 | */
49 | private static final Pattern ALLOWED_USER_AGENTS_ON_ROOT_PATTERN = Pattern.compile(
50 | ALLOWED_USER_AGENTS_ON_ROOT_REGEX,
51 | Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE
52 | );
53 |
54 | @Override
55 | public final void start() throws Exception {
56 | final NexusHttpProxy dockerProxy = NexusHttpProxy.create(
57 | vertx,
58 | UPSTREAM_HOST,
59 | UPSTREAM_DOCKER_PORT,
60 | NEXUS_RUT_HEADER
61 | );
62 | final NexusHttpProxy httpProxy = NexusHttpProxy.create(
63 | vertx,
64 | UPSTREAM_HOST,
65 | UPSTREAM_HTTP_PORT,
66 | NEXUS_RUT_HEADER
67 | );
68 | final Router router = Router.router(
69 | vertx
70 | );
71 |
72 | preconfigureRouting(router);
73 |
74 | router.route(ROOT_PATH).handler(ctx -> {
75 | final String agent = ctx.request().headers().get(HttpHeaders.USER_AGENT);
76 |
77 | if (agent != null && ALLOWED_USER_AGENTS_ON_ROOT_PATTERN.matcher(agent).find()) {
78 | ctx.response().setStatusCode(200).end();
79 | } else {
80 | ctx.next();
81 | }
82 | });
83 |
84 | router.route(ALL_PATHS).handler(VirtualHostHandler.create(nexusDockerHost, ctx -> {
85 | ctx.data().put(PROXY, dockerProxy);
86 | ctx.next();
87 | }));
88 |
89 | router.route(ALL_PATHS).handler(VirtualHostHandler.create(nexusHttpHost, ctx -> {
90 | ctx.data().put(PROXY, httpProxy);
91 | ctx.next();
92 | }));
93 |
94 | router.route(ALL_PATHS).handler(VirtualHostHandler.create(nexusHttpHost, ctx -> {
95 | final String protocol = ctx.request().headers().get(X_FORWARDED_PROTO);
96 |
97 | if (!ENFORCE_HTTPS || "https".equals(protocol)) {
98 | ctx.next();
99 | return;
100 | }
101 |
102 | final URI oldUri;
103 |
104 | try {
105 | oldUri = new URI(ctx.request().absoluteURI());
106 | } catch (final URISyntaxException ex) {
107 | throw new RuntimeException(ex);
108 | }
109 |
110 | if ("https".equals(oldUri.getScheme())) {
111 | ctx.next();
112 | return;
113 | }
114 |
115 | ctx.put("nexus_http_host", nexusHttpHost);
116 |
117 | handlebars.render(ctx, "templates", "/http-disabled.hbs", res -> { // The '/' is somehow necessary.
118 | if (res.succeeded()) {
119 | ctx.response().setStatusCode(400).end(res.result());
120 | } else {
121 | ctx.response().setStatusCode(500).end("Internal Server Error");
122 | }
123 | });
124 | }));
125 |
126 | configureRouting(router);
127 |
128 | router.route(ALL_PATHS).handler(ctx -> {
129 | String expectHeader = ctx.request().getHeader("Expect");
130 | if (expectHeader != null &&
131 | expectHeader.contains("100-continue")) {
132 | ctx.response().writeContinue();
133 | }
134 |
135 | final NexusHttpProxy proxy = ((NexusHttpProxy) ctx.data().get(PROXY));
136 |
137 | if (proxy != null) {
138 | proxy.proxyUserRequest(getUserId(ctx), ctx.request(), ctx.response());
139 | return;
140 | }
141 |
142 | // The only way proxy can be null is if the Host header of the request doesn't match any of the known
143 | // hosts (NEXUS_DOCKER_HOST or NEXUS_HTTP_HOST). In that scenario we should fail gracefully and indicate
144 | // how to access Nexus properly.
145 | ctx.put("nexus_http_host", nexusHttpHost);
146 | ctx.put("nexus_docker_host", nexusDockerHost);
147 | handlebars.render(ctx, "templates", "/invalid-host.hbs", res -> { // The '/' is somehow necessary.
148 | if (res.succeeded()) {
149 | ctx.response().setStatusCode(400).end(res.result());
150 | } else {
151 | ctx.response().setStatusCode(500).end("Internal Server Error");
152 | }
153 | });
154 | });
155 |
156 | final PfxOptions pfxOptions = new PfxOptions().setPath(TLS_CERT_PK12_PATH).setPassword(TLS_CERT_PK12_PASS);
157 |
158 | vertx.createHttpServer(
159 | new HttpServerOptions().setSsl(TLS_ENABLED).setPfxKeyCertOptions(pfxOptions)
160 | ).requestHandler(
161 | router::accept
162 | ).listen(BIND_PORT, BIND_HOST, res -> {
163 | if (res.succeeded()) {
164 | LOGGER.info("Listening at {}:{}.", BIND_HOST, BIND_PORT);
165 | } else {
166 | LOGGER.error("Couldn't bind to {}:{}.", BIND_HOST, BIND_PORT, res.cause());
167 | }
168 | });
169 | }
170 |
171 | /**
172 | * Configures the main routes. This will be called after {@link BaseNexusProxyVerticle#preconfigureRouting(Router)},
173 | * after user-agent checking on root and after the setup of virtual hosts handlers, but before the actual proxying.
174 | * @param router the {@link Router} which to configure.
175 | */
176 | protected abstract void configureRouting(final Router router);
177 |
178 | /**
179 | * Returns the currently authenticated user, or {@code null} if no valid authentication info is present.
180 | * @param ctx the current routing context.
181 | * @return the currently authenticated user, or {@code null} if no valid authentication info is present.
182 | */
183 | protected abstract String getUserId(final RoutingContext ctx);
184 |
185 | /**
186 | * Configures prerouting routes. This will be called right after the creation of {@code router}.
187 | * @param router the {@link Router} which to configure.
188 | */
189 | protected abstract void preconfigureRouting(final Router router);
190 | }
191 |
--------------------------------------------------------------------------------
/src/main/java/com/travelaudience/nexus/proxy/CachingGoogleAuthCodeFlow.java:
--------------------------------------------------------------------------------
1 | package com.travelaudience.nexus.proxy;
2 |
3 | import static com.google.api.services.cloudresourcemanager.CloudResourceManagerScopes.CLOUD_PLATFORM_READ_ONLY;
4 | import static com.google.api.services.oauth2.Oauth2Scopes.USERINFO_EMAIL;
5 | import static java.util.concurrent.TimeUnit.MILLISECONDS;
6 |
7 | import com.github.benmanes.caffeine.cache.Cache;
8 | import com.github.benmanes.caffeine.cache.Caffeine;
9 | import com.google.api.client.auth.oauth2.Credential;
10 | import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
11 | import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse;
12 | import com.google.api.client.http.HttpTransport;
13 | import com.google.api.client.http.javanet.NetHttpTransport;
14 | import com.google.api.client.json.JsonFactory;
15 | import com.google.api.client.json.jackson2.JacksonFactory;
16 | import com.google.api.client.util.store.DataStoreFactory;
17 | import com.google.api.client.util.store.MemoryDataStoreFactory;
18 | import com.google.api.services.cloudresourcemanager.CloudResourceManager;
19 | import com.google.api.services.cloudresourcemanager.model.Organization;
20 | import com.google.common.collect.ImmutableSet;
21 | import org.slf4j.Logger;
22 | import org.slf4j.LoggerFactory;
23 |
24 | import java.io.IOException;
25 | import java.io.UncheckedIOException;
26 | import java.util.List;
27 | import java.util.Set;
28 |
29 | /**
30 | * Wraps {@link GoogleAuthorizationCodeFlow} caching authorization results and providing unchecked methods.
31 | */
32 | public class CachingGoogleAuthCodeFlow {
33 | private static final Logger LOGGER = LoggerFactory.getLogger(CachingGoogleAuthCodeFlow.class);
34 |
35 | private static final DataStoreFactory DATA_STORE_FACTORY = new MemoryDataStoreFactory();
36 | private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
37 | private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
38 | private static final Set SCOPES = ImmutableSet.of(CLOUD_PLATFORM_READ_ONLY, USERINFO_EMAIL);
39 |
40 | private final Cache authCache;
41 | private final GoogleAuthorizationCodeFlow authFlow;
42 | private final String organizationId;
43 | private final String redirectUri;
44 |
45 | private CachingGoogleAuthCodeFlow(final int authCacheTtl,
46 | final String clientId,
47 | final String clientSecret,
48 | final String organizationId,
49 | final String redirectUri) throws IOException {
50 | this.authCache = Caffeine.newBuilder()
51 | .maximumSize(4096)
52 | .expireAfterWrite(authCacheTtl, MILLISECONDS)
53 | .build();
54 | this.authFlow = new GoogleAuthorizationCodeFlow.Builder(
55 | HTTP_TRANSPORT,
56 | JSON_FACTORY,
57 | clientId,
58 | clientSecret,
59 | SCOPES
60 | ).setDataStoreFactory(
61 | DATA_STORE_FACTORY
62 | ).setAccessType(
63 | "offline"
64 | ).setApprovalPrompt(
65 | "force"
66 | ).build();
67 | this.organizationId = organizationId;
68 | this.redirectUri = redirectUri;
69 | }
70 |
71 | /**
72 | * Creates a new instance of {@link CachingGoogleAuthCodeFlow}.
73 | *
74 | * @param authCacheTtl the amount of time (in ms) during which to cache the fact that a user is authorized.
75 | * @param clientId the application's client ID.
76 | * @param clientSecret the application's client secret.
77 | * @param organizationId the organization ID.
78 | * @param redirectUri the URL which to redirect users to in the end of the authentication flow.
79 | * @return a new instance of {@link CachingGoogleAuthCodeFlow}.
80 | */
81 | public static final CachingGoogleAuthCodeFlow create(final int authCacheTtl,
82 | final String clientId,
83 | final String clientSecret,
84 | final String organizationId,
85 | final String redirectUri) {
86 | try {
87 | return new CachingGoogleAuthCodeFlow(authCacheTtl, clientId, clientSecret, organizationId, redirectUri);
88 | } catch (final IOException ex) {
89 | throw new UncheckedIOException(ex);
90 | }
91 | }
92 |
93 | /**
94 | * Returns the full authorization code request URL (complete with the redirect URL).
95 | *
96 | * @return the full authorization code request URL (complete with the redirect URL).
97 | */
98 | public final String buildAuthorizationUri() {
99 | return this.authFlow.newAuthorizationUrl().setRedirectUri(redirectUri).build();
100 | }
101 |
102 | /**
103 | * Returns the principal authenticated by {@code token}.
104 | *
105 | * @param token an instance of {@link GoogleTokenResponse}.
106 | * @return the principal authenticated by {@code token}.
107 | */
108 | public final String getPrincipal(final GoogleTokenResponse token) {
109 | try {
110 | return token.parseIdToken().getPayload().getEmail();
111 | } catch (final IOException ex) {
112 | throw new UncheckedIOException(ex);
113 | }
114 | }
115 |
116 | /**
117 | * Returns whether a given user is a member of the organization.
118 | *
119 | * @param userId the user's ID (typically his organization email address).
120 | * @return whether a given user is a member of the organization.
121 | */
122 | public final Boolean isOrganizationMember(final String userId) {
123 | // Try to grab membership information from the cache.
124 | Boolean isMember = this.authCache.getIfPresent(userId);
125 |
126 | // If we have previously validated this user as a member of the organization, return.
127 | if (isMember != null && isMember) {
128 | LOGGER.debug("{} is an organization member (cache hit).", userId);
129 | return true;
130 | }
131 |
132 | LOGGER.debug("No entry in cache for {}. Hitting the Resource Manager API.", userId);
133 |
134 | // 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.
135 | // Hence we perform the validation process afresh by getting the list of organizations for which the user is a member.
136 |
137 | final Credential credential = this.loadCredential(userId);
138 |
139 | if (credential == null) {
140 | return false;
141 | }
142 |
143 | final CloudResourceManager crm = new CloudResourceManager.Builder(HTTP_TRANSPORT, JSON_FACTORY, credential)
144 | .setApplicationName(this.authFlow.getClientId())
145 | .build();
146 |
147 | final List organizations;
148 |
149 | try {
150 | organizations = crm.organizations().list().execute().getOrganizations();
151 | } catch (final IOException ex) {
152 | throw new UncheckedIOException(ex);
153 | }
154 |
155 | // Check whether the current organization is in the list of the user's organizations.
156 | isMember = organizations != null
157 | && organizations.stream().anyMatch(org -> this.organizationId.equals(org.getOrganizationId()));
158 |
159 | // If we've successfully validated this user as a member of the organization, put this information in the cache.
160 | if (isMember) {
161 | LOGGER.debug("{} has been verified as an organization member. Caching.", userId);
162 | this.authCache.put(userId, true);
163 | } else {
164 | LOGGER.debug("{} couldn't be verified as an organization member.");
165 | }
166 | return isMember;
167 | }
168 |
169 | /**
170 | * Loads the credential for the given user ID from the credential store.
171 | *
172 | * @param userId the user's ID.
173 | * @return the credential found in the credential store for the given user ID or {@code null} if none is found.
174 | */
175 | public final Credential loadCredential(final String userId) {
176 | try {
177 | return this.authFlow.loadCredential(userId);
178 | } catch (final IOException ex) {
179 | throw new UncheckedIOException(ex);
180 | }
181 | }
182 |
183 | /**
184 | * Returns a {@link GoogleTokenResponse} corresponding to an authorization code token request based on the given
185 | * authorization code.
186 | *
187 | * @param authorizationCode the authorization code to use.
188 | * @return a {@link GoogleTokenResponse} corresponding to an auth code token request based on the given auth code.
189 | */
190 | public final GoogleTokenResponse requestToken(final String authorizationCode) {
191 | try {
192 | return this.authFlow.newTokenRequest(authorizationCode).setRedirectUri(this.redirectUri).execute();
193 | } catch (final IOException ex) {
194 | throw new UncheckedIOException(ex);
195 | }
196 | }
197 |
198 | /**
199 | * Stores the credential corresponding to the specified {@link GoogleTokenResponse}.
200 | *
201 | * @param token an instance of {@link GoogleTokenResponse}.
202 | * @return the {@link Credential} corresponding to the specified {@link GoogleTokenResponse}.
203 | */
204 | public final Credential storeCredential(final GoogleTokenResponse token) {
205 | try {
206 | return this.authFlow.createAndStoreCredential(token, this.getPrincipal(token));
207 | } catch (final IOException ex) {
208 | throw new UncheckedIOException(ex);
209 | }
210 | }
211 | }
212 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # nexus-proxy
2 |
3 | [](https://travis-ci.org/travelaudience/nexus-proxy)
4 | [](https://quay.io/repository/travelaudience/docker-nexus-proxy)
5 |
6 | A proxy for Nexus Repository Manager that allows for optional authentication against external identity providers.
7 |
8 | Read [the design document](docs/design.md) for a more detailed explanation of why and how.
9 |
10 | ## Before proceeding
11 |
12 | **ATTENTION**: This software does not manage or enforce authorization. It's
13 | therefore required that users, roles and permissions are to be configured
14 | through Nexus administrative UI before start using Nexus.
15 |
16 | **ATTENTION**: If GCP IAM authentication is enabled, every user account
17 | **must be created** with their organization email address as the username.
18 | A password needs to be set but it will only be important if GCP IAM
19 | authentication is disabled. **Also** it is necessary to grant the
20 | "_Organization Viewer_" role [**at organization-level**](https://cloud.google.com/iam/docs/resource-hierarchy-access-control)
21 | (i.e., in the "_IAM & Admin_" section of the organization in the GCP UI) to
22 | every user.
23 |
24 | **ATTENTION:**: If GCP IAM authentication is enabled, it is necessary to
25 | [enable the Nexus "_Rut Auth_" capability](https://help.sonatype.com/display/NXRM3/Security#Security-AuthenticationviaRemoteUserToken).
26 | Otherwise, authentication succeeds but Nexus can't initiate user sessions.
27 |
28 | **ATTENTION**: The Nexus-specific credentials mentioned above are valid for
29 | one year **and** for as long as the user is a member of the GCP organization.
30 |
31 | **ATTENTION**: If the `ENFORCE_HTTPS` flag is set to `true` it is assumed that
32 | one has configured `nexus-proxy` or any load-balancers in front of it to serve
33 | HTTPS on host `NEXUS_HTTP_HOST` and port `443` with a valid TLS certificate.
34 |
35 | **ATTENTION:**: Setting the `JWT_REQUIRES_MEMBERSHIP_VERIFICATION` environment variable to `false` inherently makes `nexus-proxy` less secure.
36 | 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.
37 | 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.
38 |
39 | ## Introduction
40 |
41 | While deploying Nexus Repository Manager on GKE, we identified a couple issues:
42 |
43 | 1. GCLB backend health-checks weren't working when reaching Nexus directly.
44 | 1. Couldn't expose Docker private registry with the same set-up used to
45 | expose the other artifact repositories.
46 |
47 | We also knew beforehand that we would need to authenticate Nexus against
48 | [Google Cloud Identity & Access Management](https://cloud.google.com/iam/).
49 |
50 | While the aforementioned issues were easily fixed with [Nginx](https://nginx.org/en/),
51 | the authentication part proved much more complicated. For all of those reasons,
52 | we decided to implement our own proxy software that would deliver everything we
53 | needed.
54 |
55 | Also, authentication is disabled by default so it can be used in simpler scenarios.
56 |
57 | **When GCP IAM authentication is enabled**, every user attempting to access Nexus
58 | is asked to authenticate against GCP with their GCP organization credentials.
59 | If authentication succeeds, an encrypted token will be generated by the proxy
60 | and sent to the client ,e.g. browser, so it knows how to authenticate itself.
61 | After being logged-in, and only when authentication is enabled, the user must
62 | request Nexus-specific credentials for using with tools like Maven,
63 | Gradle, sbt, Python (pip) and Docker.
64 |
65 | ## Pre-requisites
66 |
67 | For building the project:
68 |
69 | * JDK 8.
70 |
71 | For basic proxying:
72 |
73 | * A domain name configured with an `A` and a `CNAME` records pointing to the proxy.
74 | * For local testing one may create two entries on `/etc/hosts` pointing to `127.0.0.1`.
75 | * A running and properly configured instance of Nexus.
76 | * One may use the default `8081` port for the HTTP connector and `5003` for the Docker registry, for example.
77 |
78 | For opt-in authentication against Google Cloud IAM:
79 |
80 | * All of the above.
81 | * A GCP organization.
82 | * A GCP project with the _Cloud Resources Manager_ API enabled.
83 | * A set of credentials of type _OAuth Client ID_ obtained from _GCP > API Manager > Credentials_.
84 | * Proper configuration of the resulting client's "_Redirect URL_".
85 |
86 | ## Generating the Keystore
87 |
88 | A Java keystore is needed in order for the proxy to sign user tokens (JWT).
89 | Here's how to generate the keystore:
90 |
91 | ```bash
92 | $ keytool -genkey \
93 | -keystore keystore.jceks \
94 | -storetype jceks \
95 | -keyalg RSA \
96 | -keysize 2048 \
97 | -alias RS256 \
98 | -sigalg SHA256withRSA \
99 | -dname "CN=,OU=,O=,L=,ST=,C=" \
100 | -validity 3651
101 | ```
102 |
103 | One will be prompted for two passwords. One must make sure the passwords match.
104 |
105 | Also, one is free to change the value of the `dname`, `keystore` and `validity` parameters.
106 |
107 | ## Building the code
108 |
109 | The following command will build the project and generate a runnable jar:
110 |
111 | ```bash
112 | $ ./gradlew build
113 | ```
114 |
115 | ## Running the proxy
116 |
117 | The following command will run the proxy on port `8080` with no authentication
118 | and pointing to a local Nexus instance:
119 |
120 | ```bash
121 | $ ALLOWED_USER_AGENTS_ON_ROOT_REGEX="GoogleHC" \
122 | BIND_PORT="8080" \
123 | NEXUS_DOCKER_HOST="containers.example.com" \
124 | NEXUS_HTTP_HOST="nexus.example.com" \
125 | NEXUS_RUT_HEADER="X-Forwarded-User" \
126 | TLS_ENABLED="false" \
127 | UPSTREAM_DOCKER_PORT="5000" \
128 | UPSTREAM_HTTP_PORT="8081" \
129 | UPSTREAM_HOST="localhost" \
130 | java -jar ./build/libs/nexus-proxy-2.3.0.jar
131 | ```
132 |
133 | ## Running the proxy with GCP IAM authentication enabled
134 |
135 | The following command will run the proxy on port `8080` with GCP IAM
136 | authentication enabled and pointing to a local Nexus instance:
137 |
138 | ```bash
139 | $ ALLOWED_USER_AGENTS_ON_ROOT_REGEX="GoogleHC" \
140 | AUTH_CACHE_TTL="60000" \
141 | BIND_PORT="8080" \
142 | CLOUD_IAM_AUTH_ENABLED="true" \
143 | CLIENT_ID="my-client-id" \
144 | CLIENT_SECRET="my-client-secret" \
145 | KEYSTORE_PATH="./.secrets/keystore.jceks" \
146 | KEYSTORE_PASS="my-keystore-password" \
147 | NEXUS_DOCKER_HOST="containers.example.com" \
148 | NEXUS_HTTP_HOST="nexus.example.com" \
149 | NEXUS_RUT_HEADER="X-Forwarded-User" \
150 | ORGANIZATION_ID="123412341234" \
151 | REDIRECT_URL="https://nexus.example.com/oauth/callback" \
152 | SESSION_TTL="1440000" \
153 | TLS_ENABLED="false" \
154 | UPSTREAM_DOCKER_PORT="5000" \
155 | UPSTREAM_HTTP_PORT="8081" \
156 | UPSTREAM_HOST="localhost" \
157 | java -jar ./build/libs/nexus-proxy-2.3.0.jar
158 | ```
159 |
160 | ## Environment Variables
161 |
162 | | Name | Description |
163 | |-------------------------------------|-------------|
164 | | `ALLOWED_USER_AGENTS_ON_ROOT_REGEX` | A regex against which to match the `User-Agent` of requests to `GET /` so that they can be answered with `200 OK`. |
165 | | `AUTH_CACHE_TTL` | The amount of time (in _milliseconds_) during which to cache the fact that a given user is authorized to make requests. |
166 | | `BIND_HOST` | The interface on which to listen for incoming requests. Defaults to `0.0.0.0`. |
167 | | `BIND_PORT` | The port on which to listen for incoming requests. |
168 | | `CLIENT_ID` | The application's client ID in _GCP / API Manager / Credentials_. |
169 | | `CLIENT_SECRET` | The abovementioned application's client secret. |
170 | | `CLOUD_IAM_AUTH_ENABLED` | Whether to enable authentication against Google Cloud IAM. |
171 | | `ENFORCE_HTTPS` | Whether to enforce access by HTTPS only. If set to `true` Nexus will only be accessible via HTTPS. |
172 | | `JAVA_TOOL_OPTIONS` | JVM options to provide, for example `-XX:MaxDirectMemorySize=1024M`. |
173 | | `JWT_REQUIRES_MEMBERSHIP_VERIFICATION` | Whether users presenting valid JWT tokens must still be verified for membership within the organization. |
174 | | `KEYSTORE_PATH` | The path to the keystore containing the key with which to sign JWTs. |
175 | | `KEYSTORE_PASS` | The password of the abovementioned keystore. |
176 | | `LOG_LEVEL` | The desired log level (i.e., `trace`, `debug`, `info`, `warn` or `error`). Defaults to `info`. |
177 | | `NEXUS_DOCKER_HOST` | The host used to access the Nexus Docker registry. |
178 | | `NEXUS_HTTP_HOST` | The host used to access the Nexus UI and Maven repositories. |
179 | | `NEXUS_RUT_HEADER` | The name of the header which will convey auth info to Nexus. |
180 | | `ORGANIZATION_ID` | The ID of the organization against which to validate users' membership. |
181 | | `REDIRECT_URL` | The URL where to redirect users after the OAuth2 consent screen. |
182 | | `SESSION_TTL` | The TTL (in _milliseconds_) of a user's session. |
183 | | `TLS_CERT_PK12_PATH` | The path to the PK12 file to use when enabling TLS. |
184 | | `TLS_CERT_PK12_PASS` | The password of the PK12 file to use when enabling TLS. |
185 | | `TLS_ENABLED` | Whether to enable TLS. |
186 | | `UPSTREAM_DOCKER_PORT` | The port where the proxied Nexus Docker registry listens. |
187 | | `UPSTREAM_HTTP_PORT` | The port where the proxied Nexus instance listens. |
188 | | `UPSTREAM_HOST` | The host where the proxied Nexus instance listens. |
189 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright {yyyy} {name of copyright owner}
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/design.md:
--------------------------------------------------------------------------------
1 | # Integrating Nexus authentication with Google Cloud IAM
2 |
3 | **Attention**: all references to previous Nexus setup are linked to:
4 | * https://github.com/travelaudience/docker-nexus
5 | * https://github.com/travelaudience/docker-nexus-backup
6 | * https://github.com/travelaudience/kubernetes-nexus
7 |
8 | ## Abstract
9 |
10 | Sonatype Nexus OSS has no built-in support for SSO. Since GKE and other GCP resources in
11 | use rely on one GCP organization users to authorize access to the same resources,
12 | Cloud IAM is a good authentication backend against which to authenticate. As such, it was
13 | decided that a SSO solution based on Cloud IAM should be developed.
14 |
15 | This document focuses on the challenges of developing such as solution and integrating it
16 | with build and deployment tools like Maven, Gradle and Docker. We will start by gathering
17 | the requirements, then dive into identified problems and proposed solutions, and
18 | ultimately conclude on how we may meet these requirements.
19 |
20 | ## Requirements
21 |
22 | 1. Block unauthenticated usage of Nexus repositories.
23 | 1. No user from outside a GCP organization shall be able to access Nexus or
24 | otherwise download or upload any artifacts or container images.
25 | 1. Members of the GCP organization must be able to authenticate themselves to
26 | Nexus in an in-browser environment using their GCP account credentials.
27 | 1. Members of the GCP organization must be able to authenticate themselves to
28 | Nexus in a CLI environment — e.g. when using Maven, Gradle or Docker — and download or
29 | upload artifacts.
30 | 1. At no moment in time shall Nexus, Maven, Gradle, Docker or any other tool deal with or
31 | have knowledge of the developer's raw credentials — which should remain a secret known
32 | only to the user.
33 | 1. Permissions for every member or group of members (role) of the GCP organization
34 | are configured within Nexus by an administrator and may be different for
35 | different users.
36 |
37 | A couple of important notes must be made here:
38 |
39 | * The last requirement deals with authorization rather than with authentication.
40 | * Authorization must be made within Nexus and as such is out of the scope of this report.
41 |
42 | ## Potential Problems
43 |
44 | ### Organization Membership
45 |
46 | Evaluating whether a user is a member of the GCP organization requires an API
47 | call to the [Cloud Resource Manager API](https://goo.gl/9e3thP), from now on referred to
48 | as CRM API, on behalf of the user. An OAuth2 authentication flow must thus be
49 | established as part of the solution, and the user must grant the following scopes:
50 |
51 | ```
52 | https://www.googleapis.com/auth/cloud-platform.read-only
53 | https://www.googleapis.com/auth/userinfo.email
54 | ```
55 |
56 | This means that the user must be presented an OAuth2 consent screen when accessing Nexus,
57 | and only then organization membership can be evaluated. On the other hand there's a quota
58 | of 100 read requests per 400 seconds on the CRM API. As such, it's not
59 | hard to conclude that organization membership cannot be evaluated on every request. In
60 | order to workaround this limitation, responses could be cached. The proposed caching
61 | mechanism could work on a per-user basis and most probably a reasonable TTL would be
62 | involved.
63 |
64 | ### Authenticating with CLI Tools
65 |
66 | Maven, Gradle and Docker authenticate within Nexus in different ways. For instance, Maven
67 | and Gradle use HTTP Basic Auth, but by default do so only when uploading artifacts. This
68 | happens mainly because these tools assume that most downloaded artifacts come from public
69 | repositories (Maven Central, JCenter and friends). When downloading artifacts from
70 | private repositories these tools expect to be presented with an HTTP Basic Auth
71 | challenge. Only then will they authenticate themselves. Hence, in order to meet the
72 | requirements, one may have to configure _preemptive authentication_ so that every request
73 | (`GET`, `HEAD`, and `PUT`) is authenticated. On the other hand, Docker also uses HTTP
74 | Basic Auth but expects Nexus to always present a challenge. Thus, our solution needs to
75 | be able to present Docker with such a challenge on behalf of Nexus in order to prevent
76 | unauthorized access.
77 |
78 | ## Potential Solutions
79 |
80 | ### Cloud Identity-Aware Proxy
81 |
82 | Google is currently introducing
83 | [Cloud Identity-Aware Proxy](https://cloud.google.com/iap/docs/). This service sits in
84 | front of an HTTPS-enabled GCLB and controlls access to upstream by verifying a user’s
85 | identity and assigned permissions. The in-browser experience provided by Cloud IAP is
86 | simple: an authenticated user with all the necessary permissions to access an application
87 | will be allowed to browse the application, while unauthenticated users or users without
88 | adequate permissions will be shown a big red exclamation mark and the message "You don't
89 | have access".
90 |
91 | The [authentication flow](https://cloud.google.com/iap/docs/authentication-howto) for CLI
92 | apps is a bit more complex — the
93 | user must obtain an _access token_ which is valid for one hour (despite being possible to
94 | refresh it an infinite number of times) and use that _access token_ as a bearer token. It
95 | is impossible as far as we know to use Bearer tokens with Maven and Gradle, which renders
96 | Cloud IAP unusable in our scenario.
97 |
98 | #### Pros
99 |
100 | * Simple to setup and manage.
101 | * Seamless in-browser experience.
102 | * Seamless integration with GKE.
103 |
104 | #### Cons
105 |
106 | * Forces the use of bearer tokens for the authentication of CLI apps.
107 | * Maven and Gradle do not support this kind of authentication (although we could
108 | implement plug-ins for each one of these tools).
109 | * Even if they did, tokens would have to be refreshed manually on an hourly basis.
110 | * `kube-lego`, the service many Kubernetes/GKE adopters use to configure HTTPS
111 | for the Nexus public load-balancer, cannot pass through Cloud IAP.
112 | * TLS certificates cannot be generated and installed without manual intervention.
113 | * Management of TLS certificates would become painful.
114 |
115 | ### GCP IAM-Aware Proxy for Nexus
116 |
117 | We currently use an Nginx instance as a proxy to Nexus mostly to be able to serve Nexus'
118 | Docker registry while keeping GCE's health checks happy by answering with `200 OK` on the
119 | `GET /` endpoint. Our proposal is to replace Nginx with a custom HTTP proxy, and leverage
120 | on Google's OAuth2 authorization flow and the Nexus RUT authentication realm to establish
121 | and securely convey identity information.
122 |
123 | In order to meet the aforementioned requirements and mitigate the potential problems that
124 | we have identified, the proxy's workflow with respect to identity would be the following:
125 |
126 | 1. Alice opens https://nexus.example.com on her browser and has not active session.
127 | Alice is redirected towards Google's OAuth2 consent page.
128 | 1. After agreeing to the terms of the consent page, Alice is redirected back to the Nexus
129 | landing page by Google. Behind the scenes the proxy is given an authorization code. If
130 | Alice does not agree to the terms, either an error message is presented or the
131 | flow restarts.
132 | 1. The proxy exchanges this authorization code by both an _access_ and a _refresh token_.
133 | The former can be used to make authenticated requests to CRM API on
134 | behalf of Alice and expires in one hour, while the latter can be used to obtain access
135 | tokens _ad infinitum_.
136 | 1. The proxy stores the refresh token and uses the access token to obtain the list of all
137 | organizations Alice is a member of. If Alice is a member of the GCP organization, this info
138 | is cached for a given period of time and a session. If Alice is not a member of the
139 | GCP organization, access is denied and an error message is presented.
140 | 1. After (3) and (4) happen (in background), Alice is presented with the Nexus landing page.
141 | Alice's identity is now established at the proxy and conveyed to Nexus via the [RUT header](https://books.sonatype.com/nexus-book/reference/rutauth.html).
142 | 1. Everytime an HTTP request is made by Alice, the proxy queries the cache for membership
143 | information. If there's a hit the HTTP request/response cycle proceeds immediately. If
144 | there's a miss the HTTP request is put on hold while the proxy queries the CRM API for
145 | membership information. The result is then handled and a decision is made as described
146 | in (4).
147 |
148 | As for CLI tools, the proposed flow is the following:
149 |
150 | 1. Alice visits https://nexus.example.com/cli/credentials. A JWT carrying identity
151 | information — namely the Alice's GCP organization email address — is generated and presented
152 | so that Alice can use in in tools like Maven, Gradle or Docker.
153 | 1. Alice instructs CLI tools to always use HTTP Basic Auth when making HTTP requests,
154 | a technique known to Maven and Gradle users as _preemptive authentication_. Every HTTP
155 | request made by the tools will now carry an `Authorization` header, containing Alice's
156 | email address as the username and the generated JWT token as the password.
157 | 1. Upon receiving an HTTP request with the `Authorization` header, the proxy will attempt
158 | to establish identity by validating the JWT. If validation fails, an error response is
159 | sent and the flow is interrupted If validation succeeds, the abovementioned membership
160 | cache is queried once again and the flow proceeds as detailed in (4) above.
161 |
162 | A few remarks should be made here:
163 |
164 | * No one except Google has knowledge of Alice's credentials.
165 | * The generated JWT is in no way related and can in no way be used to call Google's APIs.
166 | It also doesn't contain any sensitive information, and is signed using RS256 (RSA 2048+
167 | with SHA-512 signature). It cannot be forged or tampered with in any way without access
168 | to the private key.
169 | * As we cache membership information, latency is expected to be very low.
170 |
171 | Finally, applications authenticating within the proxy using Google service accounts (e.g.
172 | Jenkins) can provide their generated email addresses as the username and their private
173 | key as the password as described in (2) above, since this private key is, to some extent,
174 | analogous to a user's refresh token. Identity can then be established as described above.
175 |
176 | #### Pros
177 |
178 | * Easily integrates with the planned deployment — the proxy's basically a replacement for
179 | Nginx and will run as a sidecar container on the same pod as Nexus just as Nginx would.
180 | * We end up with our own in-house implementation of IAP which we can tailor to every need
181 | shall APIs change or the need to support a new tool arises.
182 | * We have full control about what checks are made on the users. If, for instance, we need
183 | to change the authentication criteria from Organization membership to something else we
184 | have the means and knowledge to do so.
185 | * Simple to setup and manage — if it is not simple then we're not making it right.
186 | * Seamless in-browser experience like in Cloud IAP.
187 | * Out-of-the-box support for Maven, Gradle and Docker flows — we are coding against their
188 | requirements and behaviour.
189 |
190 | #### Cons
191 |
192 | * We're making our own security here, and it's no secret that _security is hard_. We must
193 | develop a comprehensive test suite covering as many scenarios as possible, and have the
194 | utmost concern at protecting the private key used to sign JWTs.
195 |
196 | ## Conclusion
197 |
198 | As mentioned above, Cloud IAP is not compatible with the set of CLI tools used. It's easy
199 | to conclude that it is not usable as a solution to our problem.
200 |
201 | On the other hand and despite the _cons_ we have identified (which may even not be _cons_
202 | at all, just things to keep in mind) the proxy solution seems to be a clever one — we are
203 | replacing an external part with a part over which we have total control, and depending on
204 | the chosen technologies a lot of work may already have been done for us.
205 |
206 | If we end up going ahead with the in-house proxy we recommend that it is developed in the
207 | Java language using the [Vert.x](http://vertx.io/) framework. We choose Java because
208 | Google's SDKs for Java are very stable, mature and well-tested, and deal with the process
209 | of obtaining, storing and refreshing credentials automatically. Vert.x is advised because
210 | of its performance and ease of development: the proxying of HTTP requests is almost built
211 | into Vert.x, as is JWT generation and validation.
212 |
--------------------------------------------------------------------------------
/src/main/java/com/travelaudience/nexus/proxy/CloudIamAuthNexusProxyVerticle.java:
--------------------------------------------------------------------------------
1 | package com.travelaudience.nexus.proxy;
2 |
3 | import static com.travelaudience.nexus.proxy.ContextKeys.HAS_AUTHORIZATION_HEADER;
4 | import static com.travelaudience.nexus.proxy.Paths.ALL_PATHS;
5 | import static com.travelaudience.nexus.proxy.Paths.ROOT_PATH;
6 |
7 | import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse;
8 | import com.google.api.client.util.Base64;
9 | import com.google.common.base.Charsets;
10 | import com.google.common.collect.ImmutableList;
11 | import com.google.common.net.MediaType;
12 | import com.google.common.primitives.Ints;
13 | import io.vertx.core.Context;
14 | import io.vertx.core.Vertx;
15 | import io.vertx.core.http.HttpHeaders;
16 | import io.vertx.core.json.JsonObject;
17 | import io.vertx.ext.web.Router;
18 | import io.vertx.ext.web.RoutingContext;
19 | import io.vertx.ext.web.handler.CookieHandler;
20 | import io.vertx.ext.web.handler.SessionHandler;
21 | import io.vertx.ext.web.handler.VirtualHostHandler;
22 | import io.vertx.ext.web.sstore.LocalSessionStore;
23 | import org.slf4j.Logger;
24 | import org.slf4j.LoggerFactory;
25 |
26 | import java.io.UncheckedIOException;
27 | import java.util.Optional;
28 |
29 | /**
30 | * A verticle which implements a simple proxy for authenticating Nexus users against Google Cloud IAM.
31 | */
32 | public class CloudIamAuthNexusProxyVerticle extends BaseNexusProxyVerticle {
33 | private static final Logger LOGGER = LoggerFactory.getLogger(CloudIamAuthNexusProxyVerticle.class);
34 |
35 | private static final Integer AUTH_CACHE_TTL = Ints.tryParse(System.getenv("AUTH_CACHE_TTL"));
36 | private static final String CLIENT_ID = System.getenv("CLIENT_ID");
37 | private static final String CLIENT_SECRET = System.getenv("CLIENT_SECRET");
38 | private static final String KEYSTORE_PATH = System.getenv("KEYSTORE_PATH");
39 | private static final String KEYSTORE_PASS = System.getenv("KEYSTORE_PASS");
40 | // JWT_REQUIRES_MEMBERSHIP_VERIFICATION indicates whether a user presenting a valid JWT token must still be verified for membership within the organization.
41 | private static final Boolean JWT_REQUIRES_MEMBERSHIP_VERIFICATION = Boolean.parseBoolean(System.getenv("JWT_REQUIRES_MEMBERSHIP_VERIFICATION"));
42 | private static final String ORGANIZATION_ID = System.getenv("ORGANIZATION_ID");
43 | private static final String REDIRECT_URL = System.getenv("REDIRECT_URL");
44 | private static final Integer SESSION_TTL = Ints.tryParse(System.getenv("SESSION_TTL"));
45 |
46 | /**
47 | * The path that corresponds to the callback URL to be called by Google.
48 | */
49 | private static final String CALLBACK_PATH = "/oauth/callback";
50 | /**
51 | * The path that corresponds to the URL where users may get their CLI credentials from.
52 | */
53 | private static final String CLI_CREDENTIALS_PATH = "/cli/credentials";
54 | /**
55 | * The path that corresponds to all possible paths within the Nexus Docker registry.
56 | */
57 | private static final String DOCKER_V2_API_PATHS = "/v2/*";
58 | /**
59 | * The path that corresponds to all possible paths within the Nexus Maven repositories.
60 | */
61 | private static final String NEXUS_REPOSITORY_PATHS = "/repository/*";
62 |
63 | /**
64 | * The name of the parameters conveying the authorization code when {@code CALLBACK_PATH} is called.
65 | */
66 | private static final String AUTH_CODE_PARAM_NAME = "code";
67 |
68 | /**
69 | * The name of the response header conveying information about the Docker registry's version.
70 | */
71 | private static final CharSequence DOCKER_DISTRIBUTION_API_VERSION_NAME =
72 | HttpHeaders.createOptimized("Docker-Distribution-Api-Version");
73 | /**
74 | * The value of the response header conveying information about the Docker registry's version.
75 | */
76 | private static final CharSequence DOCKER_DISTRIBUTION_API_VERSION_VALUE =
77 | HttpHeaders.createOptimized("registry/2.0");
78 | /**
79 | * The name of the 'WWW-Authenticate' header.
80 | */
81 | private static final CharSequence WWW_AUTHENTICATE_HEADER_NAME =
82 | HttpHeaders.createOptimized("WWW-Authenticate");
83 | /**
84 | * The value of the 'WWW-Authenticate' header.
85 | */
86 | private static final CharSequence WWW_AUTHENTICATE_HEADER_VALUE =
87 | HttpHeaders.createOptimized("Basic Realm=\"nexus-proxy\"");
88 |
89 |
90 | private CachingGoogleAuthCodeFlow flow;
91 | private JwtAuth jwtAuth;
92 |
93 | @Override
94 | public void init(final Vertx vertx,
95 | final Context context) {
96 | super.init(vertx, context);
97 |
98 | this.flow = CachingGoogleAuthCodeFlow.create(
99 | AUTH_CACHE_TTL,
100 | CLIENT_ID,
101 | CLIENT_SECRET,
102 | ORGANIZATION_ID,
103 | REDIRECT_URL
104 | );
105 |
106 | this.jwtAuth = JwtAuth.create(
107 | vertx,
108 | KEYSTORE_PATH,
109 | KEYSTORE_PASS,
110 | ImmutableList.of(nexusDockerHost, nexusHttpHost)
111 | );
112 | }
113 |
114 | @Override
115 | protected void preconfigureRouting(final Router router) {
116 | router.route().handler(CookieHandler.create());
117 | router.route().handler(SessionHandler.create(LocalSessionStore.create(vertx)).setSessionTimeout(SESSION_TTL));
118 | }
119 |
120 | @Override
121 | protected void configureRouting(Router router) {
122 | // Enforce authentication for the Docker API.
123 | router.route(DOCKER_V2_API_PATHS).handler(VirtualHostHandler.create(nexusDockerHost, ctx -> {
124 | if (ctx.request().headers().get(HttpHeaders.AUTHORIZATION) == null) {
125 | LOGGER.debug("No authorization header found. Denying.");
126 | ctx.response().putHeader(WWW_AUTHENTICATE_HEADER_NAME, WWW_AUTHENTICATE_HEADER_VALUE);
127 | ctx.response().putHeader(DOCKER_DISTRIBUTION_API_VERSION_NAME, DOCKER_DISTRIBUTION_API_VERSION_VALUE);
128 | ctx.fail(401);
129 | } else {
130 | LOGGER.debug("Authorization header found.");
131 | ctx.data().put(HAS_AUTHORIZATION_HEADER, true);
132 | ctx.next();
133 | }
134 | }));
135 |
136 | // Enforce authentication for the Nexus UI and API.
137 | router.route(NEXUS_REPOSITORY_PATHS).handler(VirtualHostHandler.create(nexusHttpHost, ctx -> {
138 | if (ctx.request().headers().get(HttpHeaders.AUTHORIZATION) == null) {
139 | LOGGER.debug("No authorization header found. Denying.");
140 | ctx.response().putHeader(WWW_AUTHENTICATE_HEADER_NAME, WWW_AUTHENTICATE_HEADER_VALUE);
141 | ctx.fail(401);
142 | } else {
143 | LOGGER.debug("Authorization header found.");
144 | ctx.data().put(HAS_AUTHORIZATION_HEADER, true);
145 | ctx.next();
146 | }
147 | }));
148 |
149 | // Configure the callback used by the OAuth2 consent screen.
150 | router.route(CALLBACK_PATH).handler(ctx -> {
151 | final String authorizationUri = flow.buildAuthorizationUri();
152 |
153 | // Check if the request contains an authentication code.
154 | // If it doesn't, redirect to the OAuth2 consent screen.
155 | if (!ctx.request().params().contains(AUTH_CODE_PARAM_NAME)) {
156 | LOGGER.debug("No authentication code found. Redirecting to consent screen.");
157 | ctx.response().setStatusCode(302).putHeader(HttpHeaders.LOCATION, authorizationUri).end();
158 | return;
159 | }
160 |
161 | // The request contains an authentication code.
162 | // We must now use it to request an access token for the user and know their identity.
163 | final GoogleTokenResponse token;
164 | final String principal;
165 |
166 | try {
167 | LOGGER.debug("Requesting access token from Google.");
168 | token = flow.requestToken(ctx.request().params().get(AUTH_CODE_PARAM_NAME));
169 | flow.storeCredential(token);
170 | principal = flow.getPrincipal(token);
171 | LOGGER.debug("Got access token for principal {}.", principal);
172 | } catch (final UncheckedIOException ex) {
173 | // We've failed to request the access token.
174 | // Our best bet is to redirect the user back to the consent screen so the process can be retried.
175 | LOGGER.error("Couldn't request access token from Google. Redirecting to consent screen.", ex);
176 | ctx.response().setStatusCode(302).putHeader(HttpHeaders.LOCATION, authorizationUri).end();
177 | return;
178 | }
179 |
180 | // We've got the required access token, so we redirect the user to the root.
181 | LOGGER.debug("Redirecting principal {} to {}.", principal, ROOT_PATH);
182 | ctx.session().put(SessionKeys.USER_ID, principal);
183 | ctx.response().setStatusCode(302).putHeader(HttpHeaders.LOCATION, ROOT_PATH).end();
184 | });
185 |
186 | // Configure token-based authentication for all paths in order to support authentication for CLI tools such as Maven and Docker.
187 | router.route(ALL_PATHS).handler(ctx -> {
188 | // Check for the presence of an authorization header so we can validate it.
189 | // If an authorization header is present, this must be a request from a CLI tool.
190 | final String authHeader = ctx.request().headers().get(HttpHeaders.AUTHORIZATION);
191 |
192 | // Skip this step if no authorization header has been found.
193 | if (authHeader == null) {
194 | ctx.next();
195 | return;
196 | }
197 |
198 | // The request carries an authorization header.
199 | // 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".
200 | // 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.
201 | final String[] parts = authHeader.split("\\s+");
202 |
203 | if (parts.length != 2) {
204 | ctx.next();
205 | return;
206 | }
207 | if (!"Basic".equals(parts[0])) {
208 | ctx.next();
209 | return;
210 | }
211 |
212 | LOGGER.debug("Request carries HTTP Basic authentication. Validating JWT token.");
213 |
214 | final String credentials = new String(Base64.decodeBase64(parts[1]), Charsets.UTF_8);
215 | final int colonIdx = credentials.indexOf(":");
216 |
217 | final String password;
218 |
219 | if (colonIdx != -1) {
220 | password = credentials.substring(colonIdx + 1);
221 | } else {
222 | password = credentials;
223 | }
224 |
225 | // Validate the password as a JWT token.
226 | jwtAuth.validate(password, userId -> {
227 | if (userId == null) {
228 | LOGGER.debug("Got invalid JWT token. Denying.");
229 | ctx.response().setStatusCode(403).end();
230 | } else {
231 | LOGGER.debug("Got valid JWT token for principal {}.", userId);
232 | ctx.data().put(SessionKeys.USER_ID, userId);
233 | ctx.next();
234 | }
235 | });
236 | });
237 |
238 | // Configure routing for all paths.
239 | router.route(ALL_PATHS).handler(ctx -> {
240 | // Check whether the user has already been identified.
241 | // This happens either at the handler for CALLBACK_PATH or at the handler for JWT tokens.
242 | final String userId = getUserId(ctx);
243 |
244 | // If the user has NOT been identified yet, and the request does not carry an authorization header, redirect the user to the callback.
245 | if (userId == null) {
246 | LOGGER.debug("Got no authorization info. Redirecting to {}.", CALLBACK_PATH);
247 | ctx.response().setStatusCode(302).putHeader(HttpHeaders.LOCATION, CALLBACK_PATH).end();
248 | return;
249 | }
250 |
251 | // At this point we've got a valid principal.
252 | // We should, however, still check whether they are (still) a member of the organization (unless this check is explicitly disabled).
253 | // This is done mostly to prevent long-lived JWT tokens from being used after a user leaves the organization.
254 |
255 | final Boolean hasAuthorizationHeader = ((Boolean) ctx.data().getOrDefault(HAS_AUTHORIZATION_HEADER, false));
256 |
257 | // If there is an authorization header but membership verification is not required, skip the remaining of this handler.
258 | if (hasAuthorizationHeader && !JWT_REQUIRES_MEMBERSHIP_VERIFICATION) {
259 | LOGGER.debug("{} has a valid auth token but is not an organization member. Allowing since membership verification is not required.", userId);
260 | ctx.next();
261 | return;
262 | }
263 |
264 | // Check if the user is still a member of the organization.
265 | boolean isOrganizationMember = false;
266 |
267 | try {
268 | LOGGER.debug("Checking organization membership for principal {}.", userId);
269 | isOrganizationMember = flow.isOrganizationMember(userId);
270 | LOGGER.debug("Principal is organization member: {}.", isOrganizationMember);
271 | } catch (final UncheckedIOException ex) {
272 | // Destroy the user's session in case of an error while validating membership.
273 | ctx.session().destroy();
274 | LOGGER.error("Couldn't check membership for {}. Their session has been destroyed.", userId, ex);
275 | }
276 |
277 | // Make a decision based on whether the user is an organization member.
278 | // If they aren't, decide based on the presence of the authorization header (indicating either a CLI flow or a UI flow).
279 | if (isOrganizationMember) {
280 | // The user is an organization member. Allow the request.
281 | LOGGER.debug("{} is organization member. Allowing.", userId);
282 | ctx.next();
283 | } else if (hasAuthorizationHeader) {
284 | // The user is not an organization member (or membership couldn't be verified) AND is most probably using a CLI tool. Deny the request.
285 | LOGGER.debug("{} is not an organization member. Denying.", userId);
286 | ctx.response().setStatusCode(403).end();
287 | } else {
288 | // The user is not an organization member AND is most probably browsing Nexus UI. Redirect to the callback.
289 | LOGGER.debug("{} does not have an auth token and is not an organization member. Redirecting to {}.", userId, CALLBACK_PATH);
290 | ctx.response().setStatusCode(302).putHeader(HttpHeaders.LOCATION, CALLBACK_PATH).end();
291 | }
292 | });
293 |
294 | // Configure the path from where a JWT token can be obtained.
295 | router.get(CLI_CREDENTIALS_PATH).produces(MediaType.JSON_UTF_8.toString()).handler(ctx -> {
296 | final String userId = ctx.session().get(SessionKeys.USER_ID);
297 |
298 | LOGGER.debug("Generating JWT token for principal {}.", userId);
299 |
300 | final JsonObject body = new JsonObject()
301 | .put("username", userId)
302 | .put("password", jwtAuth.generate(userId));
303 |
304 | ctx.response().putHeader(HttpHeaders.CONTENT_TYPE, MediaType.JSON_UTF_8.toString()).end(body.encode());
305 | });
306 | }
307 |
308 | @Override
309 | protected String getUserId(final RoutingContext ctx) {
310 | return Optional.ofNullable(
311 | (String) ctx.data().get(SessionKeys.USER_ID)
312 | ).orElse(
313 | ctx.session().get(SessionKeys.USER_ID)
314 | );
315 | }
316 | }
317 |
--------------------------------------------------------------------------------