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

Commit 3583ad1

Browse files
committed
Add initial implementation.
Signed-off-by: Paulo Pires <pjpires@gmail.com>
1 parent 9949e78 commit 3583ad1

10 files changed

Lines changed: 907 additions & 0 deletions

File tree

build.gradle

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
plugins {
2+
id 'java'
3+
id 'application'
4+
id 'com.github.johnrengelman.shadow' version '2.0.1'
5+
}
6+
7+
group 'com.travelaudience.nexus'
8+
version '1.0.0-SNAPSHOT'
9+
10+
mainClassName = 'io.vertx.core.Launcher'
11+
sourceCompatibility = 1.8
12+
targetCompatibility = 1.8
13+
14+
dependencies {
15+
compile 'com.github.ben-manes.caffeine:caffeine:2.5.2'
16+
compile 'com.google.apis:google-api-services-cloudresourcemanager:v1beta1-rev446-1.22.0'
17+
compile 'com.google.apis:google-api-services-oauth2:v1-rev127-1.22.0'
18+
compile 'io.vertx:vertx-auth-jwt:3.4.2'
19+
compile 'io.vertx:vertx-unit:3.4.2'
20+
compile 'io.vertx:vertx-web:3.4.2'
21+
testCompile 'org.powermock:powermock-api-mockito2:1.7.0'
22+
testCompile 'org.powermock:powermock-module-junit4:1.7.0'
23+
}
24+
25+
repositories {
26+
mavenCentral()
27+
}
28+
29+
test {
30+
// the following are not needed until e2e tests are made available
31+
systemProperty "ORGANIZATION_ID", "ORGANIZATION_ID"
32+
systemProperty "CLIENT_ID", "CLIENT_ID"
33+
systemProperty "CLIENT_SECRET", "CLIENT_SECRET"
34+
}
35+
36+
shadowJar {
37+
classifier = null
38+
39+
manifest {
40+
attributes 'Main-Verticle': 'com.travelaudience.devops.nexus.proxy.NexusProxyVerticle'
41+
}
42+
mergeServiceFiles {
43+
include 'META-INF/services/io.vertx.core.spi.VerticleFactory'
44+
}
45+
46+
version = null
47+
}
48+
49+
task wrapper(type: Wrapper) {
50+
distributionUrl = "https://services.gradle.org/distributions/gradle-$gradleVersion-all.zip"
51+
gradleVersion = '4.0.1'
52+
}

settings.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
rootProject.name = 'nexus-google-iam-proxy'
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package com.travelaudience.nexus.proxy;
2+
3+
import com.github.benmanes.caffeine.cache.Caffeine;
4+
import com.github.benmanes.caffeine.cache.LoadingCache;
5+
import com.google.api.client.auth.oauth2.Credential;
6+
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
7+
import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
8+
import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse;
9+
import com.google.api.client.http.HttpTransport;
10+
import com.google.api.client.http.javanet.NetHttpTransport;
11+
import com.google.api.client.json.JsonFactory;
12+
import com.google.api.client.json.jackson2.JacksonFactory;
13+
import com.google.api.client.util.store.DataStoreFactory;
14+
import com.google.api.client.util.store.MemoryDataStoreFactory;
15+
import com.google.api.services.cloudresourcemanager.CloudResourceManager;
16+
import com.google.api.services.cloudresourcemanager.model.Organization;
17+
import com.google.common.collect.ImmutableSet;
18+
19+
import java.io.IOException;
20+
import java.io.UncheckedIOException;
21+
import java.util.List;
22+
import java.util.Set;
23+
24+
import static com.google.api.services.cloudresourcemanager.CloudResourceManagerScopes.CLOUD_PLATFORM_READ_ONLY;
25+
import static com.google.api.services.oauth2.Oauth2Scopes.USERINFO_EMAIL;
26+
import static java.util.concurrent.TimeUnit.MILLISECONDS;
27+
28+
/**
29+
* Wraps {@link GoogleAuthorizationCodeFlow} caching authorization results and providing unchecked methods.
30+
*/
31+
public class CachingGoogleAuthCodeFlow {
32+
private static final DataStoreFactory DATA_STORE_FACTORY = new MemoryDataStoreFactory();
33+
private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
34+
private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
35+
private static final Set<String> SCOPES = ImmutableSet.of(CLOUD_PLATFORM_READ_ONLY, USERINFO_EMAIL);
36+
37+
private final LoadingCache<String, Boolean> authCache;
38+
private final GoogleAuthorizationCodeFlow authFlow;
39+
private final String organizationId;
40+
private final String redirectUri;
41+
42+
private CachingGoogleAuthCodeFlow(final int authCacheTtl,
43+
final String clientId,
44+
final String clientSecret,
45+
final String organizationId,
46+
final String redirectUri) throws IOException {
47+
this.authCache = Caffeine.newBuilder()
48+
.maximumSize(4096)
49+
.expireAfterWrite(authCacheTtl, MILLISECONDS)
50+
.build(k -> this.isOrganizationMember(k, true));
51+
this.authFlow = new GoogleAuthorizationCodeFlow.Builder(
52+
HTTP_TRANSPORT,
53+
JSON_FACTORY,
54+
clientId,
55+
clientSecret,
56+
SCOPES
57+
).setDataStoreFactory(
58+
DATA_STORE_FACTORY
59+
).setAccessType(
60+
"offline"
61+
).setApprovalPrompt(
62+
"force"
63+
).build();
64+
this.organizationId = organizationId;
65+
this.redirectUri = redirectUri;
66+
}
67+
68+
/**
69+
* Creates a new instance of {@link CachingGoogleAuthCodeFlow}.
70+
*
71+
* @param authCacheTtl the amount of time (in ms) during which to cache the fact that a user is authorized.
72+
* @param clientId the application's client ID.
73+
* @param clientSecret the application's client secret.
74+
* @param organizationId the organization ID.
75+
* @param redirectUri the URL which to redirect users to in the end of the authentication flow.
76+
* @return a new instance of {@link CachingGoogleAuthCodeFlow}.
77+
*/
78+
public static final CachingGoogleAuthCodeFlow create(final int authCacheTtl,
79+
final String clientId,
80+
final String clientSecret,
81+
final String organizationId,
82+
final String redirectUri) {
83+
try {
84+
return new CachingGoogleAuthCodeFlow(authCacheTtl, clientId, clientSecret, organizationId, redirectUri);
85+
} catch (final IOException ex) {
86+
throw new UncheckedIOException(ex);
87+
}
88+
}
89+
90+
/**
91+
* Returns the full authorization code request URL (complete with the redirect URL).
92+
*
93+
* @return the full authorization code request URL (complete with the redirect URL).
94+
*/
95+
public final String buildAuthorizationUri() {
96+
return this.authFlow.newAuthorizationUrl().setRedirectUri(redirectUri).build();
97+
}
98+
99+
/**
100+
* Returns the principal authenticated by {@code token}.
101+
*
102+
* @param token an instance of {@link GoogleTokenResponse}.
103+
* @return the principal authenticated by {@code token}.
104+
*/
105+
public final String getPrincipal(final GoogleTokenResponse token) {
106+
try {
107+
return token.parseIdToken().getPayload().getEmail();
108+
} catch (final IOException ex) {
109+
throw new UncheckedIOException(ex);
110+
}
111+
}
112+
113+
/**
114+
* Returns whether a given user is a member of the organization.
115+
*
116+
* @param userId the user's ID (typically his organization email address).
117+
* @return whether a given user is a member of the organization.
118+
*/
119+
public final boolean isOrganizationMember(final String userId) {
120+
return isOrganizationMember(userId, false);
121+
}
122+
123+
private final boolean isOrganizationMember(final String userId,
124+
final boolean forceCheck) {
125+
if (!forceCheck) {
126+
return this.authCache.get(userId);
127+
}
128+
129+
final Credential credential = this.loadCredential(userId);
130+
131+
if (credential == null) {
132+
return false;
133+
}
134+
135+
final GoogleCredential googleCredential = new GoogleCredential().setAccessToken(credential.getAccessToken());
136+
137+
final CloudResourceManager crm = new CloudResourceManager.Builder(
138+
HTTP_TRANSPORT,
139+
JSON_FACTORY,
140+
googleCredential).setApplicationName(this.authFlow.getClientId()).build();
141+
142+
final List<Organization> organizations;
143+
144+
try {
145+
organizations = crm.organizations().list().execute().getOrganizations();
146+
} catch (final IOException ex) {
147+
throw new UncheckedIOException(ex);
148+
}
149+
150+
return organizations != null
151+
&& organizations.stream().anyMatch(org -> this.organizationId.equals(org.getOrganizationId()));
152+
}
153+
154+
/**
155+
* Loads the credential for the given user ID from the credential store.
156+
*
157+
* @param userId the user's ID.
158+
* @return the credential found in the credential store for the given user ID or {@code null} if none is found.
159+
*/
160+
public final Credential loadCredential(final String userId) {
161+
try {
162+
return this.authFlow.loadCredential(userId);
163+
} catch (final IOException ex) {
164+
throw new UncheckedIOException(ex);
165+
}
166+
}
167+
168+
/**
169+
* Returns a {@link GoogleTokenResponse} corresponding to an authorization code token request based on the given
170+
* authorization code.
171+
*
172+
* @param authorizationCode the authorization code to use.
173+
* @return a {@link GoogleTokenResponse} corresponding to an authorization code token request based on the given
174+
* authorization code.
175+
*/
176+
public final GoogleTokenResponse requestToken(final String authorizationCode) {
177+
try {
178+
return this.authFlow.newTokenRequest(authorizationCode).setRedirectUri(this.redirectUri).execute();
179+
} catch (final IOException ex) {
180+
throw new UncheckedIOException(ex);
181+
}
182+
}
183+
184+
/**
185+
* Stores the credential corresponding to the specified {@link GoogleTokenResponse}.
186+
*
187+
* @param token an instance of {@link GoogleTokenResponse}.
188+
* @return the {@link Credential} corresponding to the specified {@link GoogleTokenResponse}.
189+
*/
190+
public final Credential storeCredential(final GoogleTokenResponse token) {
191+
try {
192+
return this.authFlow.createAndStoreCredential(token, this.getPrincipal(token));
193+
} catch (final IOException ex) {
194+
throw new UncheckedIOException(ex);
195+
}
196+
}
197+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.travelaudience.nexus.proxy;
2+
3+
import io.vertx.core.Vertx;
4+
import io.vertx.core.json.JsonArray;
5+
import io.vertx.core.json.JsonObject;
6+
import io.vertx.ext.auth.jwt.JWTAuth;
7+
import io.vertx.ext.auth.jwt.JWTOptions;
8+
9+
import java.time.Duration;
10+
import java.util.List;
11+
import java.util.function.Consumer;
12+
13+
import static java.time.temporal.ChronoUnit.DAYS;
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<String> 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<String> 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<String> 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<String> 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+
}

0 commit comments

Comments
 (0)