diff --git a/docsite/docs/authentication.md b/docsite/docs/authentication.md new file mode 100644 index 000000000..d2bf1540b --- /dev/null +++ b/docsite/docs/authentication.md @@ -0,0 +1,24 @@ +# Authentication + +By default, Whitefox runs with authentication disabled. + +## Token authentication + +We provide a simple way to secure your application using a single static token. + +To enable token authentication you need to: + +- set `whitefox.server.authentication.enabled` to `true` +- set `whitefox.server.authentication.bearerToken` to a secret string + +After doing so, you need to provide a http header such as `Authentication: Bearer $token` in every request otherwise +you will receive a 401 error authentication error on the client. + +| property name | default value | description | +|--------------------------------------------|-----------------|-------------------------------------------------| +| whitefox.server.authentication.enabled | false | either true or false to enable authentication | +| whitefox.server.authentication.bearerToken | | the token used for authentication | + + + +In order to set configurations refer to [Quarkus documentation](https://quarkus.io/guides/config-reference). \ No newline at end of file diff --git a/server/app/build.gradle.kts b/server/app/build.gradle.kts index c85b7577c..1b564e1c7 100644 --- a/server/app/build.gradle.kts +++ b/server/app/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { // QUARKUS implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) implementation("io.quarkus:quarkus-arc") + implementation("io.quarkus:quarkus-security") implementation("io.quarkus:quarkus-container-image-docker") implementation("io.quarkus:quarkus-resteasy-reactive") implementation("io.quarkus:quarkus-resteasy-reactive-jackson") diff --git a/server/app/src/main/java/io/whitefox/api/server/auth/SimpleTokenAuthenticationMechanism.java b/server/app/src/main/java/io/whitefox/api/server/auth/SimpleTokenAuthenticationMechanism.java new file mode 100644 index 000000000..63155f56c --- /dev/null +++ b/server/app/src/main/java/io/whitefox/api/server/auth/SimpleTokenAuthenticationMechanism.java @@ -0,0 +1,76 @@ +package io.whitefox.api.server.auth; + +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AnonymousAuthenticationRequest; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import jakarta.inject.Inject; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public class SimpleTokenAuthenticationMechanism implements HttpAuthenticationMechanism { + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final QuarkusPrincipal principal = new QuarkusPrincipal("Mr. WhiteFox"); + + String token; + + @Inject + IdentityProviderManager identityProvider; + + public SimpleTokenAuthenticationMechanism(String token) { + this.token = token; + } + + private Uni anonymous() { + return identityProvider.authenticate(AnonymousAuthenticationRequest.INSTANCE); + } + + @Override + public Uni authenticate( + RoutingContext context, IdentityProviderManager identityProviderManager) { + if (context.normalizedPath().startsWith("/q/")) return anonymous(); + var optionalHeader = Optional.ofNullable(context.request().headers().get(AUTHORIZATION_HEADER)); + QuarkusSecurityIdentity identity = + new QuarkusSecurityIdentity.Builder().setPrincipal(principal).build(); + AuthenticationFailedException missingOrUnrecognizedCredentials = + new AuthenticationFailedException("Missing or unrecognized credentials"); + AuthenticationFailedException missingToken = new AuthenticationFailedException( + "Simple authentication enabled, but token is missing in the request"); + if (optionalHeader.isEmpty()) throw missingToken; + else { + if (Objects.equals("Bearer " + token, optionalHeader.get())) { + return Uni.createFrom().item(identity); + } else { + throw missingOrUnrecognizedCredentials; + } + } + } + + // Not really needed for this mechanism, does not get called; + // The response to the user is handled through a custom attemptAuthentication from + // WhitefoxHttpAuthenticator + @Override + public Uni getChallenge(RoutingContext context) { + var challenge = "token -> " + token; + ChallengeData result = new ChallengeData( + HttpResponseStatus.UNAUTHORIZED.code(), HttpHeaderNames.WWW_AUTHENTICATE, challenge); + return Uni.createFrom().item(result); + } + + // no need for this mechanism, for others + @Override + public Set> getCredentialTypes() { + return null; + } +} diff --git a/server/app/src/main/java/io/whitefox/api/server/auth/WhitefoxAuthenticationConfig.java b/server/app/src/main/java/io/whitefox/api/server/auth/WhitefoxAuthenticationConfig.java new file mode 100644 index 000000000..e7d69458d --- /dev/null +++ b/server/app/src/main/java/io/whitefox/api/server/auth/WhitefoxAuthenticationConfig.java @@ -0,0 +1,20 @@ +package io.whitefox.api.server.auth; + +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import io.smallrye.config.WithName; +import java.util.Optional; + +/** Configuration for Whitefox authentication settings. */ +@ConfigMapping(prefix = "whitefox.server.authentication") +public interface WhitefoxAuthenticationConfig { + + /** Returns {@code true} if Whitefox authentication is enabled. */ + @WithName("enabled") + @WithDefault("false") + boolean enabled(); + + /** Bearer token that should be used in requests to grant authorization. */ + @WithName("bearerToken") + Optional bearerToken(); +} diff --git a/server/app/src/main/java/io/whitefox/api/server/auth/WhitefoxHttpAuthenticator.java b/server/app/src/main/java/io/whitefox/api/server/auth/WhitefoxHttpAuthenticator.java new file mode 100644 index 000000000..cded7d6e4 --- /dev/null +++ b/server/app/src/main/java/io/whitefox/api/server/auth/WhitefoxHttpAuthenticator.java @@ -0,0 +1,75 @@ +package io.whitefox.api.server.auth; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AnonymousAuthenticationRequest; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticator; +import io.quarkus.vertx.http.runtime.security.PathMatchingHttpSecurityPolicy; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import jakarta.annotation.Priority; +import jakarta.enterprise.inject.Alternative; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +/** + * A custom {@link HttpAuthenticator}. This authenticator that performs the following main duties: + * + *
    + *
  • Authenticates requests using a token provided in the application.properties when authentication is enabled. + *
  • Completely disallows unauthenticated requests when authentication is enabled. + *
+ */ +@Alternative // @Alternative + @Priority ensure the original HttpAuthenticator bean is not used +@Priority(1) +@Singleton +public class WhitefoxHttpAuthenticator extends HttpAuthenticator { + + private final IdentityProviderManager identityProvider; + private final boolean authEnabled; + private final WhitefoxAuthenticationConfig config; + + @Inject + public WhitefoxHttpAuthenticator( + WhitefoxAuthenticationConfig config, + IdentityProviderManager identityProviderManager, + Instance pathMatchingPolicy, + Instance httpAuthenticationMechanism, + Instance> providers) { + super(identityProviderManager, pathMatchingPolicy, httpAuthenticationMechanism, providers); + this.identityProvider = identityProviderManager; + this.config = config; + authEnabled = config.enabled(); + } + + private HttpAuthenticationMechanism selectAuthenticationMechanism( + WhitefoxAuthenticationConfig config, RoutingContext context) { + if (config.bearerToken().isPresent()) { + return new SimpleTokenAuthenticationMechanism(config.bearerToken().get()); + } else { + throw new AuthenticationFailedException( + "Other auth mechanisms not supported right now! Please add your token to application.properties"); + } + } + + @Override + public Uni attemptAuthentication(RoutingContext context) { + if (!authEnabled) { + return anonymous(); + } + // quarkus dev paths + else if (context.normalizedPath().startsWith("/q/")) { + return anonymous(); + } else { + return selectAuthenticationMechanism(config, context).authenticate(context, identityProvider); + } + } + + private Uni anonymous() { + return identityProvider.authenticate(AnonymousAuthenticationRequest.INSTANCE); + } +} diff --git a/server/app/src/main/resources/application.properties b/server/app/src/main/resources/application.properties index a464ba296..7a2c0fe6f 100644 --- a/server/app/src/main/resources/application.properties +++ b/server/app/src/main/resources/application.properties @@ -1,2 +1,4 @@ io.delta.sharing.api.server.defaultMaxResults=10 -quarkus.banner.path=banner.txt \ No newline at end of file +quarkus.banner.path=banner.txt + +whitefox.server.authentication.enabled=false \ No newline at end of file diff --git a/server/app/src/test/java/io/whitefox/api/server/auth/WhitefoxHttpAuthenticatorTest.java b/server/app/src/test/java/io/whitefox/api/server/auth/WhitefoxHttpAuthenticatorTest.java new file mode 100644 index 000000000..e73db3916 --- /dev/null +++ b/server/app/src/test/java/io/whitefox/api/server/auth/WhitefoxHttpAuthenticatorTest.java @@ -0,0 +1,82 @@ +package io.whitefox.api.server.auth; + +import static io.restassured.RestAssured.given; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.restassured.http.Header; +import io.whitefox.api.model.v1.generated.CreateMetastore; +import io.whitefox.api.model.v1.generated.MetastoreProperties; +import io.whitefox.api.model.v1.generated.SimpleAwsCredentials; +import java.util.Map; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(WhitefoxHttpAuthenticatorTest.AuthenticationProfile.class) +public class WhitefoxHttpAuthenticatorTest { + + private final CreateMetastore createMetastore = new CreateMetastore() + .name("glue_metastore_prod") + .skipValidation(false) + .type(CreateMetastore.TypeEnum.GLUE) + .properties(new MetastoreProperties() + .catalogId("123") + .credentials(new SimpleAwsCredentials() + .awsAccessKeyId("access") + .awsSecretAccessKey("secret") + .region("eu-west1"))); + + public static class AuthenticationProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "whitefox.server.authentication.enabled", + "true", + "whitefox.server.authentication.bearerToken", + "myToken"); + } + } + + public static class NoAuthenticationProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of("whitefox.server.authentication.enabled", "false"); + } + } + + // Nesting two test-profiles leads to an OOM: https://github.com/quarkusio/quarkus/issues/12498 + @Test + void expectDenied() { + given() + .when() + .get("/whitefox-api/v1/metastores/{name}", createMetastore.getName()) + .then() + .statusCode(401); + } + + @Test + void expectAcceptedWithAuth() { + given() + .when() + .header(new Header("Authorization", "Bearer myToken")) + .get("/whitefox-api/v1/metastores/{name}", createMetastore.getName()) + .then() + .statusCode(404); + } + + @Nested + @TestProfile(WhitefoxHttpAuthenticatorTest.NoAuthenticationProfile.class) + class TestNotAuthorized { + + @Test + void expectAccepted() { + given() + .when() + .get("/whitefox-api/v1/metastores/{name}", createMetastore.getName()) + .then() + .statusCode(404); + } + } +}