Skip to content

Commit

Permalink
feat(auth): Redirect to OpenShift login on empty auth request (#748)
Browse files Browse the repository at this point in the history
* Redirect to OpenShift login

* Add minor refactor

* Remove custom exception

* Set token req params dynamically

* Refactor redirect flow

* Use client id and scoped role env vars

* Rename vars and fix formatting

* Catch NPE

* Add unit tests, rename function

* Clean up interface and exception handling

Add unit tests

Cache authorization url

* Memoize authorizationUrl

* Fix names and extract constants

* Update web client
  • Loading branch information
Janelle Law authored Dec 10, 2021
1 parent 059e6de commit 2e9ccc4
Show file tree
Hide file tree
Showing 11 changed files with 534 additions and 16 deletions.
6 changes: 6 additions & 0 deletions src/main/java/io/cryostat/net/AuthManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@
*/
package io.cryostat.net;

import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.function.Function;
import java.util.function.Supplier;
Expand All @@ -49,6 +51,10 @@ public interface AuthManager {

Future<UserInfo> getUserInfo(Supplier<String> httpHeaderProvider);

Optional<String> getLoginRedirectUrl(
Supplier<String> headerProvider, Set<ResourceAction> resourceActions)
throws ExecutionException, InterruptedException;

Future<Boolean> validateToken(
Supplier<String> tokenProvider, Set<ResourceAction> resourceActions);

Expand Down
10 changes: 10 additions & 0 deletions src/main/java/io/cryostat/net/BasicAuthManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import java.nio.file.Path;
import java.util.Base64;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
Expand Down Expand Up @@ -98,6 +99,12 @@ public Future<UserInfo> getUserInfo(Supplier<String> httpHeaderProvider) {
return CompletableFuture.completedFuture(new UserInfo(user));
}

@Override
public Optional<String> getLoginRedirectUrl(
Supplier<String> headerProvider, Set<ResourceAction> resourceActions) {
return Optional.empty();
}

@Override
public Future<Boolean> validateToken(
Supplier<String> tokenProvider, Set<ResourceAction> resourceActions) {
Expand Down Expand Up @@ -159,6 +166,9 @@ public Future<Boolean> validateWebSocketSubProtocol(
}

private Pair<String, String> splitCredentials(String credentials) {
if (credentials == null) {
return null;
}
Pattern credentialsPattern = Pattern.compile("([\\S]+):([\\S]+)");
Matcher matcher = credentialsPattern.matcher(credentials);
if (!matcher.matches()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright The Cryostat Authors
*
* The Universal Permissive License (UPL), Version 1.0
*
* Subject to the condition set forth below, permission is hereby granted to any
* person obtaining a copy of this software, associated documentation and/or data
* (collectively the "Software"), free of charge and under any and all copyright
* rights in the Software, and any and all patent rights owned or freely
* licensable by each licensor hereunder covering either (i) the unmodified
* Software as contributed to or provided by such licensor, or (ii) the Larger
* Works (as defined below), to deal in both
*
* (a) the Software, and
* (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if
* one is included with the Software (each a "Larger Work" to which the Software
* is contributed by such licensors),
*
* without restriction, including without limitation the rights to copy, create
* derivative works of, display, perform, and distribute the Software and make,
* use, sell, offer for sale, import, export, have made, and have sold the
* Software and the Larger Work(s), and to sublicense the foregoing rights on
* either these or other terms.
*
* This license is subject to the following condition:
* The above copyright notice and either this complete permission notice or at
* a minimum a reference to the UPL must be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package io.cryostat.net;

public class MissingEnvironmentVariableException extends Exception {
public MissingEnvironmentVariableException(String key) {
super(String.format("Missing required environment variable: \"%s\"", key));
}
}
7 changes: 5 additions & 2 deletions src/main/java/io/cryostat/net/NetworkModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,16 @@ static BasicAuthManager provideBasicAuthManager(

@Provides
@Singleton
static OpenShiftAuthManager provideOpenShiftAuthManager(Logger logger, FileSystem fs) {
static OpenShiftAuthManager provideOpenShiftAuthManager(
Environment env, Logger logger, FileSystem fs, WebClient webClient) {
return new OpenShiftAuthManager(
env,
logger,
fs,
token ->
new DefaultOpenShiftClient(
new OpenShiftConfigBuilder().withOauthToken(token).build()));
new OpenShiftConfigBuilder().withOauthToken(token).build()),
webClient);
}

@Binds
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/io/cryostat/net/NoopAuthManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
*/
package io.cryostat.net;

import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
Expand All @@ -61,6 +62,12 @@ public Future<UserInfo> getUserInfo(Supplier<String> httpHeaderProvider) {
return CompletableFuture.completedFuture(new UserInfo(""));
}

@Override
public Optional<String> getLoginRedirectUrl(
Supplier<String> headerProvider, Set<ResourceAction> resourceActions) {
return Optional.empty();
}

@Override
public Future<Boolean> validateToken(
Supplier<String> tokenProvider, Set<ResourceAction> resourceActions) {
Expand Down
110 changes: 109 additions & 1 deletion src/main/java/io/cryostat/net/OpenShiftAuthManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@
package io.cryostat.net;

import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
Expand All @@ -55,6 +59,7 @@
import java.util.stream.Stream;

import io.cryostat.core.log.Logger;
import io.cryostat.core.sys.Environment;
import io.cryostat.core.sys.FileSystem;
import io.cryostat.net.security.ResourceAction;
import io.cryostat.net.security.ResourceType;
Expand All @@ -69,25 +74,43 @@
import io.fabric8.kubernetes.client.Config;
import io.fabric8.kubernetes.client.KubernetesClientException;
import io.fabric8.openshift.client.OpenShiftClient;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.WebClient;
import jdk.jfr.Category;
import jdk.jfr.Event;
import jdk.jfr.Label;
import jdk.jfr.Name;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.client.utils.URIBuilder;

public class OpenShiftAuthManager extends AbstractAuthManager {

private static final Set<GroupResource> PERMISSION_NOT_REQUIRED =
Set.of(GroupResource.PERMISSION_NOT_REQUIRED);
private static final String OAUTH_WELL_KNOWN_HOST = "openshift.default.svc";
private static final String WELL_KNOWN_PATH = "/.well-known/oauth-authorization-server";
private static final String OAUTH_ENDPOINT_KEY = "authorization_endpoint";
private static final String CRYOSTAT_OAUTH_CLIENT_ID = "CRYOSTAT_OAUTH_CLIENT_ID";
private static final String CRYOSTAT_OAUTH_ROLE = "CRYOSTAT_OAUTH_ROLE";

private final Environment env;
private final FileSystem fs;
private final Function<String, OpenShiftClient> clientProvider;
private final WebClient webClient;
private final ConcurrentHashMap<String, CompletableFuture<String>> authorizationUrl;

OpenShiftAuthManager(
Logger logger, FileSystem fs, Function<String, OpenShiftClient> clientProvider) {
Environment env,
Logger logger,
FileSystem fs,
Function<String, OpenShiftClient> clientProvider,
WebClient webClient) {
super(logger);
this.env = env;
this.fs = fs;
this.clientProvider = clientProvider;
this.webClient = webClient;
this.authorizationUrl = new ConcurrentHashMap<>(1);
}

@Override
Expand All @@ -113,6 +136,29 @@ public Future<UserInfo> getUserInfo(Supplier<String> httpHeaderProvider) {
}
}

@Override
public Optional<String> getLoginRedirectUrl(
Supplier<String> headerProvider, Set<ResourceAction> resourceActions)
throws ExecutionException, InterruptedException {
Boolean hasValidHeader = false;
try {
hasValidHeader = this.validateHttpHeader(headerProvider, resourceActions).get();

if (Boolean.TRUE.equals(hasValidHeader)) {
return Optional.empty();
}
return Optional.of(this.computeAuthorizationEndpoint().get());
} catch (ExecutionException ee) {
Throwable cause = ee.getCause();
if (cause instanceof PermissionDeniedException
|| cause instanceof AuthorizationErrorException
|| cause instanceof KubernetesClientException) {
return Optional.of(this.computeAuthorizationEndpoint().get());
}
throw ee;
}
}

@Override
public Future<Boolean> validateToken(
Supplier<String> tokenProvider, Set<ResourceAction> resourceActions) {
Expand Down Expand Up @@ -287,6 +333,68 @@ private Future<TokenReviewStatus> performTokenReview(String token) {
}
}

private CompletableFuture<String> computeAuthorizationEndpoint() {
return authorizationUrl.computeIfAbsent(
OAUTH_ENDPOINT_KEY,
key -> {
CompletableFuture<JsonObject> oauthMetadata = new CompletableFuture<>();
try {
String namespace = this.getNamespace();
Optional<String> clientId =
Optional.ofNullable(env.getEnv(CRYOSTAT_OAUTH_CLIENT_ID));
Optional<String> roleScope =
Optional.ofNullable(env.getEnv(CRYOSTAT_OAUTH_ROLE));

String serviceAccountAsOAuthClient =
String.format(
"system:serviceaccount:%s:%s",
namespace,
clientId.orElseThrow(
() ->
new MissingEnvironmentVariableException(
CRYOSTAT_OAUTH_CLIENT_ID)));

String scope =
String.format(
"user:check-access role:%s:%s",
roleScope.orElseThrow(
() ->
new MissingEnvironmentVariableException(
CRYOSTAT_OAUTH_ROLE)),
namespace);

webClient
.get(443, OAUTH_WELL_KNOWN_HOST, WELL_KNOWN_PATH)
.putHeader("Accept", "application/json")
.send(
ar -> {
if (ar.failed()) {
oauthMetadata.completeExceptionally(ar.cause());
return;
}
oauthMetadata.complete(ar.result().bodyAsJsonObject());
});

String authorizeEndpoint =
oauthMetadata.get().getString(OAUTH_ENDPOINT_KEY);

URIBuilder builder = new URIBuilder(authorizeEndpoint);
builder.addParameter("client_id", serviceAccountAsOAuthClient);
builder.addParameter("response_type", "token");
builder.addParameter("response_mode", "fragment");
builder.addParameter("scope", scope);

return CompletableFuture.completedFuture(builder.build().toString());
} catch (ExecutionException
| InterruptedException
| URISyntaxException
| IOException
| MissingEnvironmentVariableException e) {
throw new CompletionException(e);
}
});
}

@SuppressFBWarnings(
value = "DMI_HARDCODED_ABSOLUTE_FILENAME",
justification = "Kubernetes namespace file path is well-known and absolute")
Expand Down
52 changes: 42 additions & 10 deletions src/main/java/io/cryostat/net/web/http/api/v2/AuthPostHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,14 @@
*/
package io.cryostat.net.web.http.api.v2;

import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;

import javax.inject.Inject;

import io.cryostat.net.AuthManager;
import io.cryostat.net.UnknownUserException;
import io.cryostat.net.UserInfo;
import io.cryostat.net.security.ResourceAction;
import io.cryostat.net.web.WebServer;
Expand All @@ -61,7 +64,7 @@ protected AuthPostHandler(AuthManager auth, Gson gson) {

@Override
public boolean requiresAuthentication() {
return true;
return false;
}

@Override
Expand Down Expand Up @@ -96,14 +99,43 @@ public boolean isAsync() {

@Override
public IntermediateResponse<UserInfo> handle(RequestParameters requestParams) throws Exception {
return new IntermediateResponse<UserInfo>()
.addHeader(WebServer.AUTH_SCHEME_HEADER, auth.getScheme().toString())
.body(
auth.getUserInfo(
() ->
requestParams
.getHeaders()
.get(HttpHeaders.AUTHORIZATION))
.get());

Optional<String> redirectUrl =
auth.getLoginRedirectUrl(
() -> requestParams.getHeaders().get(HttpHeaders.AUTHORIZATION),
resourceActions());

return redirectUrl
.map(
location -> {
return new IntermediateResponse<UserInfo>()
.addHeader("X-Location", location)
.addHeader("access-control-expose-headers", "Location")
.statusCode(302);
})
.orElseGet(
() -> {
try {
return new IntermediateResponse<UserInfo>()
.addHeader(
WebServer.AUTH_SCHEME_HEADER,
auth.getScheme().toString())
.body(
auth.getUserInfo(
() ->
requestParams
.getHeaders()
.get(
HttpHeaders
.AUTHORIZATION))
.get());
} catch (ExecutionException | InterruptedException ee) {
Throwable cause = ee.getCause();
if (cause instanceof UnknownUserException) {
throw new ApiException(401, "HTTP Authorization Failure", ee);
}
throw new ApiException(500, ee);
}
});
}
}
2 changes: 1 addition & 1 deletion src/test/java/io/cryostat/net/BasicAuthManagerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ void shouldFailUnknownCredentials() throws Exception {
}

@ParameterizedTest
@ValueSource(strings = {"", "Bearer sometoken", "Basic (not_b64)"})
@ValueSource(strings = {"", "Bearer sometoken", "Basic (not_b64)", "Basic "})
@NullSource
void shouldFailBadCredentials(String s) throws Exception {
Assertions.assertFalse(mgr.validateHttpHeader(() -> s, ResourceAction.NONE).get());
Expand Down
Loading

0 comments on commit 2e9ccc4

Please sign in to comment.