From 86ad4102a41f846cf7431b8440a3461574bfca17 Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Wed, 23 Oct 2019 13:22:30 +1100 Subject: [PATCH] Add form based authentication Fixes #4348 --- docs/src/main/asciidoc/security-guide.adoc | 28 +++ .../ElytronDeploymentProcessor.java | 3 +- .../ElytronTrustedIdentityProvider.java | 77 +++++++ .../runtime/UnauthorizedExceptionMapper.java | 31 +-- .../deployment/HttpSecurityProcessor.java | 19 +- .../vertx/http/security/FormAuthTestCase.java | 125 ++++++++++++ .../security/TestTrustedIdentityProvider.java | 33 +++ .../vertx/http/runtime/AuthConfig.java | 4 +- .../vertx/http/runtime/FormAuthConfig.java | 48 +++++ .../vertx/http/runtime/HttpConfiguration.java | 10 + .../BasicAuthenticationMechanism.java | 5 +- .../security/FormAuthenticationMechanism.java | 189 ++++++++++++++++++ .../runtime/security/HttpAuthenticator.java | 2 +- .../security/HttpSecurityRecorder.java | 9 + .../security/PersistentLoginManager.java | 118 +++++++++++ .../TrustedAuthenticationRequest.java | 21 ++ 16 files changed, 700 insertions(+), 22 deletions(-) create mode 100644 extensions/elytron-security/runtime/src/main/java/io/quarkus/elytron/security/runtime/ElytronTrustedIdentityProvider.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/FormAuthTestCase.java create mode 100644 extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/TestTrustedIdentityProvider.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/FormAuthConfig.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/FormAuthenticationMechanism.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PersistentLoginManager.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/TrustedAuthenticationRequest.java diff --git a/docs/src/main/asciidoc/security-guide.adoc b/docs/src/main/asciidoc/security-guide.adoc index 2634076d8c82a..823be0ac33fc3 100644 --- a/docs/src/main/asciidoc/security-guide.adoc +++ b/docs/src/main/asciidoc/security-guide.adoc @@ -99,6 +99,34 @@ very much a work in progress, so this list will be expanded over the coming week Please see the linked documents above for details on how to setup the various extensions. +### Authenticating Via HTTP + +Quarkus has two built in authentication mechanisms for HTTP based FORM and BASIC auth. This mechanism is pluggable +however so extensions can add additional mechanisms (most notably OpenID Connect for Keycloak based auth). + +#### Basic Authentication + +To enable basic authentication set `quarkus.http.auth.basic=true`. You must also have at least one extension installed +that provides a username/password based `IdentityProvider`, such as link:security-jdbc-guide.html[Elytron JDBC]. + +#### Form Based Authentication + +Quarkus provides form based authentication that works in a similar manner to traditional Servlet form based auth. Unlike +traditional form authentication the authenticated user is not stored in a HTTP session, as Quarkus does not provide +clustered HTTP session support. Instead the authentication information is stored in an encrypted cookie, which can +be read by all members of the cluster (provided they all share the same encryption key). + +The encryption key can be set using the `auth.session.encryption-key` property, and it must be at least 16 characters +long. This key is hashed using SHA-256 and the resulting digest is used as a key for AES-256 encryption of the cookie +value. This cookie contains a expiry time as part of the encrypted value, so all nodes in the cluster must have their +clocks synchronised. At one minute intervals a new cookie will be generated with an updated expiry time if the session +is in use. + +The following properties can be used to configure form based auth: + +include::{generated-dir}/config/quarkus-http-auth-form.adoc[opts=optional, leveloffset=+1] + + ### Securing Web Endpoints Quarkus has an integrated plugable web security layer. If security is enabled all HTTP requests will have a permission diff --git a/extensions/elytron-security/deployment/src/main/java/io/quarkus/elytron/security/deployment/ElytronDeploymentProcessor.java b/extensions/elytron-security/deployment/src/main/java/io/quarkus/elytron/security/deployment/ElytronDeploymentProcessor.java index 6325d67b36e97..ac2c7ffced154 100644 --- a/extensions/elytron-security/deployment/src/main/java/io/quarkus/elytron/security/deployment/ElytronDeploymentProcessor.java +++ b/extensions/elytron-security/deployment/src/main/java/io/quarkus/elytron/security/deployment/ElytronDeploymentProcessor.java @@ -18,6 +18,7 @@ import io.quarkus.elytron.security.runtime.ElytronRecorder; import io.quarkus.elytron.security.runtime.ElytronSecurityDomainManager; import io.quarkus.elytron.security.runtime.ElytronTokenIdentityProvider; +import io.quarkus.elytron.security.runtime.ElytronTrustedIdentityProvider; import io.quarkus.runtime.RuntimeValue; /** @@ -56,12 +57,12 @@ void addBeans(BuildProducer beans, List token) { beans.produce(AdditionalBeanBuildItem.unremovableOf(ElytronSecurityDomainManager.class)); beans.produce(AdditionalBeanBuildItem.unremovableOf(DefaultRoleDecoder.class)); - if (!token.isEmpty()) { beans.produce(AdditionalBeanBuildItem.unremovableOf(ElytronTokenIdentityProvider.class)); } if (!pw.isEmpty()) { beans.produce(AdditionalBeanBuildItem.unremovableOf(ElytronPasswordIdentityProvider.class)); + beans.produce(AdditionalBeanBuildItem.unremovableOf(ElytronTrustedIdentityProvider.class)); } } diff --git a/extensions/elytron-security/runtime/src/main/java/io/quarkus/elytron/security/runtime/ElytronTrustedIdentityProvider.java b/extensions/elytron-security/runtime/src/main/java/io/quarkus/elytron/security/runtime/ElytronTrustedIdentityProvider.java new file mode 100644 index 0000000000000..7dcf14cf0ca40 --- /dev/null +++ b/extensions/elytron-security/runtime/src/main/java/io/quarkus/elytron/security/runtime/ElytronTrustedIdentityProvider.java @@ -0,0 +1,77 @@ +package io.quarkus.elytron.security.runtime; + +import java.util.concurrent.CompletionStage; +import java.util.function.Supplier; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +import org.jboss.logging.Logger; +import org.wildfly.security.auth.server.RealmIdentity; +import org.wildfly.security.auth.server.RealmUnavailableException; +import org.wildfly.security.auth.server.SecurityDomain; +import org.wildfly.security.auth.server.ServerAuthenticationContext; +import org.wildfly.security.credential.PasswordCredential; + +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.TrustedAuthenticationRequest; + +/** + * + * + */ +@ApplicationScoped +public class ElytronTrustedIdentityProvider implements IdentityProvider { + + private static Logger log = Logger.getLogger(ElytronTrustedIdentityProvider.class); + + @Inject + SecurityDomain domain; + + @Override + public Class getRequestType() { + return TrustedAuthenticationRequest.class; + } + + @Override + public CompletionStage authenticate(TrustedAuthenticationRequest request, + AuthenticationRequestContext context) { + return context.runBlocking(new Supplier() { + @Override + public SecurityIdentity get() { + org.wildfly.security.auth.server.SecurityIdentity result; + try { + RealmIdentity id = domain.getIdentity(request.getPrincipal()); + if (!id.exists()) { + return null; + } + PasswordCredential cred = id.getCredential(PasswordCredential.class); + ServerAuthenticationContext ac = domain.createNewAuthenticationContext(); + ac.setAuthenticationName(request.getPrincipal()); + ac.addPrivateCredential(cred); + ac.authorize(); + result = ac.getAuthorizedIdentity(); + + if (result == null) { + throw new AuthenticationFailedException(); + } + QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); + builder.setPrincipal(result.getPrincipal()); + for (String i : result.getRoles()) { + builder.addRole(i); + } + return builder.build(); + } catch (RealmUnavailableException e) { + throw new RuntimeException(e); + } catch (SecurityException e) { + log.debug("Authentication failed", e); + throw new AuthenticationFailedException(); + } + } + }); + } +} diff --git a/extensions/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/UnauthorizedExceptionMapper.java b/extensions/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/UnauthorizedExceptionMapper.java index dd7270c955d48..0a05d428651d0 100644 --- a/extensions/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/UnauthorizedExceptionMapper.java +++ b/extensions/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/UnauthorizedExceptionMapper.java @@ -65,22 +65,25 @@ public Response toResponse(UnauthorizedException exception) { } } } - HttpAuthenticator authenticator = identity.getAttribute(HttpAuthenticator.class.getName()); RoutingContext context = ResteasyContext.getContextData(RoutingContext.class); - if (authenticator != null && context != null) { - try { - ChallengeData challengeData = authenticator.getChallenge(context) - .toCompletableFuture() - .get(); - return Response.status(challengeData.status) - .header(challengeData.headerName.toString(), challengeData.headerContent) - .build(); - } catch (InterruptedException | ExecutionException e) { - log.error("Failed to read challenge data for unauthorized response", e); - return Response.status(401).entity("Not authorized").build(); + if (context != null) { + HttpAuthenticator authenticator = context.get(HttpAuthenticator.class.getName()); + if (authenticator != null) { + try { + ChallengeData challengeData = authenticator.getChallenge(context) + .toCompletableFuture() + .get(); + Response.ResponseBuilder status = Response.status(challengeData.status); + if (challengeData.headerName != null) { + status.header(challengeData.headerName.toString(), challengeData.headerContent); + } + return status.build(); + } catch (InterruptedException | ExecutionException e) { + log.error("Failed to read challenge data for unauthorized response", e); + return Response.status(401).entity("Not authorized").build(); + } } - } else { - return Response.status(401).entity("Not authorized").build(); } + return Response.status(401).entity("Not authorized").build(); } } diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java index bce104522555b..333083eec6099 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java @@ -6,6 +6,7 @@ import java.util.function.Supplier; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.arc.deployment.BeanContainerListenerBuildItem; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.annotations.BuildProducer; @@ -13,10 +14,12 @@ import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Record; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.quarkus.vertx.http.runtime.PolicyConfig; import io.quarkus.vertx.http.runtime.security.AuthenticatedHttpSecurityPolicy; import io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.DenySecurityPolicy; +import io.quarkus.vertx.http.runtime.security.FormAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.HttpAuthenticator; import io.quarkus.vertx.http.runtime.security.HttpAuthorizer; import io.quarkus.vertx.http.runtime.security.HttpSecurityPolicy; @@ -40,6 +43,18 @@ public void builtins(BuildProducer producer, HttpBu } } + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void initFormAuth( + BeanContainerBuildItem beanContainerBuildItem, + HttpSecurityRecorder recorder, + HttpBuildTimeConfig buildTimeConfig, + HttpConfiguration httpConfiguration) { + if (buildTimeConfig.auth.form.enabled) { + recorder.setupFormAuth(beanContainerBuildItem.getValue(), httpConfiguration, buildTimeConfig); + } + } + @BuildStep @Record(ExecutionTime.STATIC_INIT) void setupAuthenticationMechanisms( @@ -58,7 +73,9 @@ void setupAuthenticationMechanisms( policyMap.put(e.getName(), e.policySupplier); } - if (buildTimeConfig.auth.basic) { + if (buildTimeConfig.auth.form.enabled) { + beanProducer.produce(AdditionalBeanBuildItem.unremovableOf(FormAuthenticationMechanism.class)); + } else if (buildTimeConfig.auth.basic) { beanProducer.produce(AdditionalBeanBuildItem.unremovableOf(BasicAuthenticationMechanism.class)); } diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/FormAuthTestCase.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/FormAuthTestCase.java new file mode 100644 index 0000000000000..a44b5a3ff9f6f --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/FormAuthTestCase.java @@ -0,0 +1,125 @@ +package io.quarkus.vertx.http.security; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; + +import java.util.function.Supplier; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; +import io.restassured.filter.cookie.CookieFilter; + +public class FormAuthTestCase { + + private static final String APP_PROPS = "" + + "quarkus.http.auth.form.enabled=true\n" + + "quarkus.http.auth.form.login-page=login\n" + + "quarkus.http.auth.form.error-page=error\n" + + "quarkus.http.auth.form.landing-page=landing\n" + + "quarkus.http.auth.policy.r1.roles-allowed=admin\n" + + "quarkus.http.auth.permission.roles1.paths=/admin\n" + + "quarkus.http.auth.permission.roles1.policy=r1\n"; + + @RegisterExtension + static QuarkusUnitTest test = new QuarkusUnitTest().setArchiveProducer(new Supplier() { + @Override + public JavaArchive get() { + return ShrinkWrap.create(JavaArchive.class) + .addClasses(TestIdentityProvider.class, TestTrustedIdentityProvider.class, PathHandler.class) + .addAsResource(new StringAsset(APP_PROPS), "application.properties"); + } + }); + + @BeforeAll + public static void setup() { + TestIdentityController.resetRoles() + .add("admin", "admin", "admin"); + } + + @Test + public void testFormBasedAuthSuccess() { + CookieFilter cookies = new CookieFilter(); + RestAssured + .given() + .filter(cookies) + .redirects().follow(false) + .when() + .get("/admin") + .then() + .assertThat() + .statusCode(302) + .header("location", containsString("/login")) + .cookie("quarkus-redirect-location", containsString("/admin")); + + RestAssured + .given() + .filter(cookies) + .redirects().follow(false) + .when() + .formParam("j_username", "admin") + .formParam("j_password", "admin") + .post("/j_security_check") + .then() + .assertThat() + .statusCode(302) + .header("location", containsString("/admin")) + .cookie("quarkus-credential", notNullValue()); + + RestAssured + .given() + .filter(cookies) + .redirects().follow(false) + .when() + .get("/admin") + .then() + .assertThat() + .statusCode(200) + .body(equalTo("admin:/admin")); + + } + + @Test + public void testFormBasedAuthSuccessLandingPage() { + CookieFilter cookies = new CookieFilter(); + RestAssured + .given() + .filter(cookies) + .redirects().follow(false) + .when() + .formParam("j_username", "admin") + .formParam("j_password", "admin") + .post("/j_security_check") + .then() + .assertThat() + .statusCode(302) + .header("location", containsString("/landing")) + .cookie("quarkus-credential", notNullValue()); + + } + + @Test + public void testFormAuthFailure() { + CookieFilter cookies = new CookieFilter(); + RestAssured + .given() + .filter(cookies) + .redirects().follow(false) + .when() + .formParam("j_username", "admin") + .formParam("j_password", "wrongpassword") + .post("/j_security_check") + .then() + .assertThat() + .statusCode(302) + .header("location", containsString("/error")); + + } +} diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/TestTrustedIdentityProvider.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/TestTrustedIdentityProvider.java new file mode 100644 index 0000000000000..6d405aa0d7f88 --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/TestTrustedIdentityProvider.java @@ -0,0 +1,33 @@ +package io.quarkus.vertx.http.security; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import javax.inject.Singleton; + +import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.IdentityProvider; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.runtime.QuarkusPrincipal; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; +import io.quarkus.vertx.http.runtime.security.TrustedAuthenticationRequest; + +@Singleton +public class TestTrustedIdentityProvider implements IdentityProvider { + @Override + public Class getRequestType() { + return TrustedAuthenticationRequest.class; + } + + @Override + public CompletionStage authenticate(TrustedAuthenticationRequest request, + AuthenticationRequestContext context) { + TestIdentityController.TestIdentity ident = TestIdentityController.idenitities.get(request.getPrincipal()); + if (ident == null) { + return CompletableFuture.completedFuture(null); + } + return CompletableFuture + .completedFuture(QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal(request.getPrincipal())) + .addRoles(ident.roles).build()); + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java index edebc54880019..e10291c08d6c1 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java @@ -23,10 +23,10 @@ public class AuthConfig { public boolean basic; /** - * If form auth should be enabled. + * Form Auth config */ @ConfigItem - public boolean form; + public FormAuthConfig form; /** * The authentication realm diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/FormAuthConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/FormAuthConfig.java new file mode 100644 index 0000000000000..06f3e44f69bca --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/FormAuthConfig.java @@ -0,0 +1,48 @@ +package io.quarkus.vertx.http.runtime; + +import java.time.Duration; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +/** + * config for the form authentication mechanism + */ +@ConfigGroup +public class FormAuthConfig { + /** + * If form authentication is enabled + */ + @ConfigItem + public boolean enabled; + + /** + * The login page + */ + @ConfigItem(defaultValue = "/login.html") + public String loginPage; + + /** + * The error page + */ + @ConfigItem(defaultValue = "/error.html") + public String errorPage; + + /** + * The landing page to redirect to if there is no saved page to redirect back to + */ + @ConfigItem + public String landingPage; + + /** + * The inactivity timeout + */ + @ConfigItem(defaultValue = "PT30M") + public Duration timeout; + + /** + * The cookie that is used to store the persistent session + */ + @ConfigItem(defaultValue = "quarkus-credential") + public String cookieName; +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java index ac993a5548642..61c2dde28b0f2 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java @@ -85,6 +85,16 @@ public class HttpConfiguration { */ public BodyConfig body; + /** + * The encryption key that is used to store persistent logins (e.g. for form auth). Logins are stored in a persistent + * cookie that is encrypted with AES-256 using a key derived from a SHA-256 hash of the key that is provided here. + * + * If no key is provided then an in-memory one will be generated, this will change on every restart though so it + * is not suitable for production environments. This must be more than 16 characters long for security reasons + */ + @ConfigItem(name = "auth.session.encryption-key") + public String encryptionKey; + public int determinePort(LaunchMode launchMode) { return launchMode == LaunchMode.TEST ? testPort : port; } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/BasicAuthenticationMechanism.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/BasicAuthenticationMechanism.java index b6ccb0c5452b4..e2785fa061048 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/BasicAuthenticationMechanism.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/BasicAuthenticationMechanism.java @@ -29,7 +29,7 @@ import java.util.concurrent.CompletionStage; import java.util.regex.Pattern; -import javax.enterprise.context.ApplicationScoped; +import javax.inject.Singleton; import org.jboss.logging.Logger; @@ -45,9 +45,8 @@ /** * The authentication handler responsible for BASIC authentication as described by RFC2617 * - * @author Darran Lofthouse */ -@ApplicationScoped +@Singleton public class BasicAuthenticationMechanism implements HttpAuthenticationMechanism { private static final Logger log = Logger.getLogger(BasicAuthenticationMechanism.class); diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/FormAuthenticationMechanism.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/FormAuthenticationMechanism.java new file mode 100644 index 0000000000000..2ebd2d4d66075 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/FormAuthenticationMechanism.java @@ -0,0 +1,189 @@ +package io.quarkus.vertx.http.runtime.security; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.BiFunction; +import java.util.function.Function; + +import javax.inject.Singleton; + +import org.jboss.logging.Logger; + +import io.netty.handler.codec.http.HttpHeaderNames; +import io.quarkus.security.credential.PasswordCredential; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest; +import io.quarkus.vertx.http.runtime.FormAuthConfig; +import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.HttpConfiguration; +import io.vertx.core.Handler; +import io.vertx.core.MultiMap; +import io.vertx.core.http.Cookie; +import io.vertx.core.http.HttpMethod; +import io.vertx.ext.web.RoutingContext; + +@Singleton +public class FormAuthenticationMechanism implements HttpAuthenticationMechanism { + + private static final Logger log = Logger.getLogger(FormAuthenticationMechanism.class); + + public static final String DEFAULT_POST_LOCATION = "/j_security_check"; + + private volatile String loginPage; + private volatile String errorPage; + private volatile String postLocation = DEFAULT_POST_LOCATION; + private volatile String locationCookie = "quarkus-redirect-location"; + private volatile String landingPage = "/index.html"; + + private volatile PersistentLoginManager loginManager; + + private static String encryptionKey; + + public FormAuthenticationMechanism() { + } + + public void init(HttpConfiguration httpConfiguration, HttpBuildTimeConfig buildTimeConfig) { + String key; + if (httpConfiguration.encryptionKey.isEmpty()) { + if (encryptionKey != null) { + //persist across dev mode restarts + key = encryptionKey; + } else { + byte[] data = new byte[32]; + new SecureRandom().nextBytes(data); + key = encryptionKey = Base64.getEncoder().encodeToString(data); + log.warn("Encryption key was not specified for persistent FORM auth, using temporary key " + key); + } + } else { + key = httpConfiguration.encryptionKey; + } + FormAuthConfig form = buildTimeConfig.auth.form; + loginManager = new PersistentLoginManager(key, "quarkus-credential", form.timeout.toMillis()); + loginPage = form.loginPage.startsWith("/") ? form.loginPage : "/" + form.loginPage; + errorPage = form.errorPage.startsWith("/") ? form.errorPage : "/" + form.errorPage; + landingPage = form.landingPage.startsWith("/") ? form.landingPage : "/" + form.landingPage; + } + + public CompletionStage runFormAuth(final RoutingContext exchange, + final IdentityProviderManager securityContext) { + exchange.request().setExpectMultipart(true); + CompletableFuture result = new CompletableFuture<>(); + exchange.request().resume(); + exchange.request().endHandler(new Handler() { + @Override + public void handle(Void event) { + try { + MultiMap res = exchange.request().formAttributes(); + + final String jUsername = res.get("j_username"); + final String jPassword = res.get("j_password"); + if (jUsername == null || jPassword == null) { + log.debugf("Could not authenticate as username or password was not present in the posted result for %s", + exchange); + result.complete(null); + return; + } + securityContext + .authenticate(new UsernamePasswordAuthenticationRequest(jUsername, + new PasswordCredential(jPassword.toCharArray()))) + .handle(new BiFunction() { + @Override + public Object apply(SecurityIdentity identity, Throwable throwable) { + if (throwable != null) { + result.completeExceptionally(throwable); + } else { + loginManager.save(identity, exchange, null); + handleRedirectBack(exchange); + //we have authenticated, but we want to just redirect back to the original page + //so we don't actually authenticate the current request + //instead we have just set a cookie so the redirected request will be authenticated + result.complete(null); + } + return null; + } + }); + } catch (Throwable t) { + result.completeExceptionally(t); + } + } + }); + return result; + } + + protected void handleRedirectBack(final RoutingContext exchange) { + Cookie redirect = exchange.getCookie(locationCookie); + String location; + if (redirect != null) { + location = redirect.getValue(); + exchange.response().addCookie(redirect.setMaxAge(0)); + } else { + location = exchange.request().scheme() + "://" + exchange.request().host() + landingPage; + } + exchange.response().setStatusCode(302); + exchange.response().headers().add(HttpHeaderNames.LOCATION, location); + exchange.response().end(); + } + + protected void storeInitialLocation(final RoutingContext exchange) { + exchange.response().addCookie(Cookie.cookie(locationCookie, exchange.request().absoluteURI()).setPath("/")); + } + + protected void servePage(final RoutingContext exchange, final String location) { + sendRedirect(exchange, location); + } + + static void sendRedirect(final RoutingContext exchange, final String location) { + String loc = exchange.request().scheme() + "://" + exchange.request().host() + location; + exchange.response().headers().add(HttpHeaderNames.LOCATION, loc); + exchange.response().setStatusCode(302); + exchange.response().end(); + } + + static CompletionStage getRedirect(final RoutingContext exchange, final String location) { + String loc = exchange.request().scheme() + "://" + exchange.request().host() + location; + return CompletableFuture.completedFuture(new ChallengeData(302, "Location", loc)); + } + + @Override + public CompletionStage authenticate(RoutingContext context, + IdentityProviderManager identityProviderManager) { + + PersistentLoginManager.RestoreResult result = loginManager.restore(context); + if (result != null) { + CompletionStage ret = identityProviderManager + .authenticate(new TrustedAuthenticationRequest(result.getPrincipal())); + ret.thenApply(new Function() { + @Override + public Object apply(SecurityIdentity identity) { + loginManager.save(identity, context, result); + return null; + } + }); + return ret; + } + + if (context.normalisedPath().endsWith(postLocation) && context.request().method().equals(HttpMethod.POST)) { + return runFormAuth(context, identityProviderManager); + } else { + return CompletableFuture.completedFuture(null); + } + } + + @Override + public CompletionStage getChallenge(RoutingContext context) { + if (context.normalisedPath().endsWith(postLocation) && context.request().method().equals(HttpMethod.POST)) { + log.debugf("Serving form auth error page %s for %s", loginPage, context); + // This method would no longer be called if authentication had already occurred. + return getRedirect(context, errorPage); + } else { + log.debugf("Serving login form %s for %s", loginPage, context); + // we need to store the URL + storeInitialLocation(context); + return getRedirect(context, loginPage); + } + } + +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java index 84209d9c482a1..2d1323c852153 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java @@ -92,7 +92,7 @@ public CompletionStage authenticate(RoutingContext context, @Override public CompletionStage getChallenge(RoutingContext context) { - ChallengeData challengeData = new ChallengeData(HttpResponseStatus.FORBIDDEN.code(), null, null); + ChallengeData challengeData = new ChallengeData(HttpResponseStatus.UNAUTHORIZED.code(), null, null); return CompletableFuture.completedFuture(challengeData); } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java index 0637ec29de32e..04d72c49882f6 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java @@ -13,6 +13,7 @@ import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.vertx.core.Handler; import io.vertx.ext.web.RoutingContext; @@ -51,6 +52,9 @@ public void run() { } return null; } + if (event.response().ended()) { + return null; + } if (identity != null) { event.setUser(new QuarkusHttpUser(identity)); } @@ -85,4 +89,9 @@ public void created(BeanContainer container) { } }; } + + public void setupFormAuth(BeanContainer container, HttpConfiguration httpConfiguration, + HttpBuildTimeConfig buildTimeConfig) { + container.instance(FormAuthenticationMechanism.class).init(httpConfiguration, buildTimeConfig); + } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PersistentLoginManager.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PersistentLoginManager.java new file mode 100644 index 0000000000000..70c947bca258b --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PersistentLoginManager.java @@ -0,0 +1,118 @@ +package io.quarkus.vertx.http.runtime.security; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.jboss.logging.Logger; + +import io.quarkus.security.identity.SecurityIdentity; +import io.vertx.core.http.Cookie; +import io.vertx.ext.web.RoutingContext; + +/** + * A class that manages persistent logins. + * + * This is done by encoding an expiry time, and the current username into an encrypted cookie + * + * TODO: make this pluggable + */ +public class PersistentLoginManager { + + private static final Logger log = Logger.getLogger(PersistentLoginManager.class); + + private final SecretKey secretKey; + private final String cookieName; + private final long timeoutMillis; + + public PersistentLoginManager(String encryptionKey, String cookieName, long timeoutMillis) { + try { + this.cookieName = cookieName; + this.timeoutMillis = timeoutMillis; + if (encryptionKey == null) { + secretKey = KeyGenerator.getInstance("AES").generateKey(); + } else if (encryptionKey.length() < 16) { + throw new RuntimeException("Shared keys for persistent logins must be more than 16 characters long"); + } else { + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + sha256.update(encryptionKey.getBytes(StandardCharsets.UTF_8)); + secretKey = new SecretKeySpec(sha256.digest(), "AES"); + } + } catch (Exception t) { + throw new RuntimeException(t); + } + + } + + public RestoreResult restore(RoutingContext context) { + Cookie existing = context.getCookie(cookieName); + if (existing == null) { + return null; + } + String val = existing.getValue(); + try { + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + String result = new String(cipher.doFinal(Base64.getDecoder().decode(val)), StandardCharsets.UTF_8); + int sep = result.indexOf(":"); + if (sep == -1) { + return null; + } + long expire = Long.parseLong(result.substring(0, sep)); + if (System.currentTimeMillis() > expire) { + return null; + } + return new RestoreResult(result.substring(sep + 1), (System.currentTimeMillis() - expire) > 1000 * 60); //new cookie every minute + } catch (Exception e) { + log.debug("Failed to restore persistent user session", e); + return null; + } + } + + public void save(SecurityIdentity identity, RoutingContext context, RestoreResult restoreResult) { + if (restoreResult != null) { + if (!restoreResult.newCookieNeeded) { + return; + } + } + try { + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + + StringBuilder contents = new StringBuilder(); + //TODO: do we need random padding? + long timeout = System.currentTimeMillis() + timeoutMillis; + contents.append(timeout); + contents.append(":"); + contents.append(identity.getPrincipal().getName()); + + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + String cookieValue = Base64.getEncoder() + .encodeToString(cipher.doFinal(contents.toString().getBytes(StandardCharsets.UTF_8))); + context.addCookie(Cookie.cookie(cookieName, cookieValue).setPath("/")); + } catch (Exception e) { + throw new RuntimeException(e); + } + + } + + public static class RestoreResult { + + private final String principal; + final boolean newCookieNeeded; + + public RestoreResult(String principal, boolean newCookieNeeded) { + this.principal = principal; + this.newCookieNeeded = newCookieNeeded; + } + + public String getPrincipal() { + return principal; + } + } + +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/TrustedAuthenticationRequest.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/TrustedAuthenticationRequest.java new file mode 100644 index 0000000000000..aa78531bcb655 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/TrustedAuthenticationRequest.java @@ -0,0 +1,21 @@ +package io.quarkus.vertx.http.runtime.security; + +import io.quarkus.security.identity.request.AuthenticationRequest; + +/** + * A request to authenticate from a trusted source, such as an encrypted cookie + * + * TODO: move to quarkus-security + */ +public class TrustedAuthenticationRequest implements AuthenticationRequest { + + private final String principal; + + public TrustedAuthenticationRequest(String principal) { + this.principal = principal; + } + + public String getPrincipal() { + return principal; + } +}