Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add form based authentication #4821

Merged
merged 4 commits into from
Oct 27, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/src/main/asciidoc/security-guide.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 `quarkus.http.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -56,12 +57,12 @@ void addBeans(BuildProducer<AdditionalBeanBuildItem> beans, List<ElytronPassword
List<ElytronTokenMarkerBuildItem> 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));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<TrustedAuthenticationRequest> {

private static Logger log = Logger.getLogger(ElytronTrustedIdentityProvider.class);

@Inject
SecurityDomain domain;

@Override
public Class<TrustedAuthenticationRequest> getRequestType() {
return TrustedAuthenticationRequest.class;
}

@Override
public CompletionStage<SecurityIdentity> authenticate(TrustedAuthenticationRequest request,
AuthenticationRequestContext context) {
return context.runBlocking(new Supplier<SecurityIdentity>() {
@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();
}
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ public class DenyAllJaxRsTest {
@Test
public void shouldDenyUnannotated() {
String path = "/unsecured/defaultSecurity";
assertStatus(path, 401, IdentityMock.ANONYMOUS);
assertStatus(path, 403, IdentityMock.ANONYMOUS);
assertStatus(path, 403, IdentityMock.USER, IdentityMock.ADMIN);
}

@Test
public void shouldDenyDenyAllMethod() {
String path = "/unsecured/denyAll";
assertStatus(path, 401, IdentityMock.ANONYMOUS);
assertStatus(path, 403, IdentityMock.ANONYMOUS);
assertStatus(path, 403, IdentityMock.USER, IdentityMock.ADMIN);
}

Expand All @@ -48,7 +48,7 @@ public void shouldPermitPermitAllMethod() {
@Test
public void shouldDenySubResource() {
String path = "/unsecured/sub/subMethod";
assertStatus(path, 401, IdentityMock.ANONYMOUS);
assertStatus(path, 403, IdentityMock.ANONYMOUS);
assertStatus(path, 403, IdentityMock.USER, IdentityMock.ADMIN);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ public void callEchoBASIC() throws Exception {
.get("/endp/echo").andReturn();

Assertions.assertEquals(HttpURLConnection.HTTP_UNAUTHORIZED, response.getStatusCode());
String replyString = response.body().asString();
Assertions.assertEquals("Not authorized", replyString);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ public class SmallryeJwtDisabledTest {
public void serviceIsNotSecured() throws Exception {
io.restassured.response.Response response = RestAssured.given().get("/endp/echo").andReturn();

Assertions.assertEquals(HttpURLConnection.HTTP_UNAUTHORIZED, response.getStatusCode());
Assertions.assertEquals(HttpURLConnection.HTTP_FORBIDDEN, response.getStatusCode());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,20 @@
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;
import io.quarkus.deployment.annotations.BuildStep;
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;
Expand All @@ -40,6 +43,18 @@ public void builtins(BuildProducer<HttpSecurityPolicyBuildItem> 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(
Expand All @@ -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));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<JavaArchive>() {
@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"));

}
}
Loading