Skip to content

Commit

Permalink
Support path-based auth with @testsecurity annotation
Browse files Browse the repository at this point in the history
  • Loading branch information
michalvavrik committed Apr 27, 2024
1 parent b77999a commit fcdebde
Show file tree
Hide file tree
Showing 10 changed files with 281 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ s|Custom token verification ^|No ^|With injected JWT parser ^|No
s|JWT as a cookie support ^|No ^|Yes ^|Yes

Check warning on line 481 in docs/src/main/asciidoc/security-authentication-mechanisms.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'.", "location": {"path": "docs/src/main/asciidoc/security-authentication-mechanisms.adoc", "range": {"start": {"line": 481, "column": 7}}}, "severity": "INFO"}
|===


[[combining-authentication-mechanisms]]
== Combining authentication mechanisms

If different sources provide the user credentials, you can combine authentication mechanisms.
Expand Down
69 changes: 69 additions & 0 deletions docs/src/main/asciidoc/security-testing.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,75 @@ If it becomes necessary to test security features using both `@TestSecurity` and
mechanism when none is defined), then Basic Auth needs to be enabled explicitly,

Check warning on line 121 in docs/src/main/asciidoc/security-testing.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.CaseSensitiveTerms] Use 'Basic HTTP authentication (first instance)' or 'Basic authentication' rather than 'Basic Auth'. Raw Output: {"message": "[Quarkus.CaseSensitiveTerms] Use 'Basic HTTP authentication (first instance)' or 'Basic authentication' rather than 'Basic Auth'.", "location": {"path": "docs/src/main/asciidoc/security-testing.adoc", "range": {"start": {"line": 121, "column": 28}}}, "severity": "INFO"}

Check warning on line 121 in docs/src/main/asciidoc/security-testing.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsWarnings] Consider using 'Basic HTTP authentication (first instance)' or 'Basic authentication' rather than 'Basic Auth' unless updating existing content that uses the term. Raw Output: {"message": "[Quarkus.TermsWarnings] Consider using 'Basic HTTP authentication (first instance)' or 'Basic authentication' rather than 'Basic Auth' unless updating existing content that uses the term.", "location": {"path": "docs/src/main/asciidoc/security-testing.adoc", "range": {"start": {"line": 121, "column": 28}}}, "severity": "WARNING"}

Check warning on line 121 in docs/src/main/asciidoc/security-testing.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Fluff] Depending on the context, consider using 'Rewrite the sentence, or use 'must', instead of' rather than 'needs to'. Raw Output: {"message": "[Quarkus.Fluff] Depending on the context, consider using 'Rewrite the sentence, or use 'must', instead of' rather than 'needs to'.", "location": {"path": "docs/src/main/asciidoc/security-testing.adoc", "range": {"start": {"line": 121, "column": 39}}}, "severity": "INFO"}
for example by setting `quarkus.http.auth.basic=true` or `%test.quarkus.http.auth.basic=true`.

=== Path-based authentication

`@TestSecurity` can also be used when xref:security-authentication-mechanisms.adoc#combining-authentication-mechanisms[authentication mechanisms must be combined].
Example below shows how to select authentication mechanism when path-based authentication is enabled.

[source,java]
----
@Test
@TestSecurity(user = "testUser", roles = {"admin", "user"}, authMechanism = "basic") <1>
void basicTestMethod() {
...
}
@Test
@TestSecurity(user = "testUser", roles = {"admin", "user"}, authMechanism = "form") <2>
void formTestMethod() {
...
}
----
<1> The 'authMechanism' attribute selects Basic authentication.

Check warning on line 143 in docs/src/main/asciidoc/security-testing.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Spelling] Use correct American English spelling. Did you really mean 'authMechanism'? Raw Output: {"message": "[Quarkus.Spelling] Use correct American English spelling. Did you really mean 'authMechanism'?", "location": {"path": "docs/src/main/asciidoc/security-testing.adoc", "range": {"start": {"line": 143, "column": 10}}}, "severity": "WARNING"}
<2> The 'authMechanism' attribute selects Form-based authentication.

Check warning on line 144 in docs/src/main/asciidoc/security-testing.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Spelling] Use correct American English spelling. Did you really mean 'authMechanism'? Raw Output: {"message": "[Quarkus.Spelling] Use correct American English spelling. Did you really mean 'authMechanism'?", "location": {"path": "docs/src/main/asciidoc/security-testing.adoc", "range": {"start": {"line": 144, "column": 10}}}, "severity": "WARNING"}

In your Quarkus application, it is possible to use annotations to select an authentication mechanism specific to each Jakarta REST endpoint:

[source,java]
----
package org.acme.security.testing;
import io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication;
import io.quarkus.vertx.http.runtime.security.annotation.FormAuthentication;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@Path("/")
public class TestSecurityResource {
@BasicAuthentication <1>
@GET
@Path("basic-only")
public String basicOnly() {
return "basic-only";
}
@FormAuthentication <2>
@GET
@Path("form-only")
public String formOnly() {
return "form-only";
}
}
----
<1> All HTTP requests to the `/basic-only` path from the `basicTestMethod` test are authenticated successfully.
<2> Same HTTP requests will fail when invoked from the `formTestMethod` test as Basic authentication is required.

Check warning on line 176 in docs/src/main/asciidoc/security-testing.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'.", "location": {"path": "docs/src/main/asciidoc/security-testing.adoc", "range": {"start": {"line": 176, "column": 78}}}, "severity": "INFO"}

Alternatively, it is possible to select path-specific authentication mechanism with HTTP Security Policy:

[source,properties]
----
# require basic authentication for the '/basic-only' path
quarkus.http.auth.permission.basic.paths=/basic-only
quarkus.http.auth.permission.basic.policy=authenticated
quarkus.http.auth.permission.basic.auth-mechanism=basic
# require form-based authentication for the '/form-only' path
quarkus.http.auth.permission.form.paths=/form-only
quarkus.http.auth.permission.form.policy=authenticated
quarkus.http.auth.permission.form.auth-mechanism=form
----

== Use Wiremock for Integration Testing

Check warning on line 193 in docs/src/main/asciidoc/security-testing.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Headings] Use sentence-style capitalization in 'Use Wiremock for Integration Testing'. Raw Output: {"message": "[Quarkus.Headings] Use sentence-style capitalization in 'Use Wiremock for Integration Testing'.", "location": {"path": "docs/src/main/asciidoc/security-testing.adoc", "range": {"start": {"line": 193, "column": 4}}}, "severity": "INFO"}

Check warning on line 193 in docs/src/main/asciidoc/security-testing.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Spelling] Use correct American English spelling. Did you really mean 'Wiremock'? Raw Output: {"message": "[Quarkus.Spelling] Use correct American English spelling. Did you really mean 'Wiremock'?", "location": {"path": "docs/src/main/asciidoc/security-testing.adoc", "range": {"start": {"line": 193, "column": 8}}}, "severity": "WARNING"}

You can also use Wiremock to mock the authorization OAuth2 and OIDC services:

Check warning on line 195 in docs/src/main/asciidoc/security-testing.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Spelling] Use correct American English spelling. Did you really mean 'Wiremock'? Raw Output: {"message": "[Quarkus.Spelling] Use correct American English spelling. Did you really mean 'Wiremock'?", "location": {"path": "docs/src/main/asciidoc/security-testing.adoc", "range": {"start": {"line": 195, "column": 7}}}, "severity": "WARNING"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* Authorization header
* POST
*
* Not that using multiple HTTP authentication mechanisms to use the same credential
* Note that using multiple HTTP authentication mechanisms to use the same credential
* transport type can lead to unexpected authentication failures as they will not be able to figure out which mechanisms should
* process which
* request.
Expand Down Expand Up @@ -54,7 +54,12 @@ public enum Type {
/**
* Authorization code, type target is the query 'code' parameter
*/
AUTHORIZATION_CODE
AUTHORIZATION_CODE,
/**
* Reserved for HTTP credential transport used during the security testing
* with the 'io.quarkus.test.security.TestSecurity' annotation.
*/
TEST_SECURITY
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package io.quarkus.it.keycloak;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.SecurityContext;

import io.quarkus.oidc.BearerTokenAuthentication;
import io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication;

@Path("multiple-auth-mech")
public class MultipleAuthMechResource {

@GET
@Path("bearer/policy")
public String bearerPolicy(@Context SecurityContext sec) {
return sec.getUserPrincipal().getName();
}

@GET
@Path("basic/policy")
public String basicPolicy(@Context SecurityContext sec) {
return sec.getUserPrincipal().getName();
}

@BearerTokenAuthentication
@GET
@Path("bearer/annotation")
public String bearerAnnotation(@Context SecurityContext sec) {
return sec.getUserPrincipal().getName();
}

@BasicAuthentication
@GET
@Path("basic/annotation")
public String basicAnnotation(@Context SecurityContext sec) {
return sec.getUserPrincipal().getName();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,10 @@ quarkus.oidc.tenant-f.auth-server-url=${keycloak.url}/realms/quarkus-f
quarkus.oidc.tenant-f.client-id=quarkus-app-f
quarkus.oidc.tenant-f.credentials.secret=secret
quarkus.oidc.tenant-f.application-type=service

quarkus.http.auth.permission.basic-policy.paths=/multiple-auth-mech/basic/policy
quarkus.http.auth.permission.basic-policy.policy=authenticated
quarkus.http.auth.permission.basic-policy.auth-mechanism=basic
quarkus.http.auth.permission.bearer-policy.paths=/multiple-auth-mech/bearer/policy
quarkus.http.auth.permission.bearer-policy.policy=authenticated
quarkus.http.auth.permission.bearer-policy.auth-mechanism=Bearer
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package io.quarkus.it.keycloak;

import org.junit.jupiter.api.Test;

import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;

@TestHTTPEndpoint(MultipleAuthMechResource.class)
@QuarkusTest
public class TestSecurityCombiningAuthMechTest {

@TestSecurity(user = "testUser", authMechanism = "basic")
@Test
public void testBasicAuthentication() {
RestAssured
.given()
.contentType(ContentType.TEXT)
.get("basic/policy")
.then()
.statusCode(200);
RestAssured
.given()
.contentType(ContentType.TEXT)
.redirects().follow(false)
.get("bearer/policy")
.then()
.statusCode(401);
RestAssured
.given()
.contentType(ContentType.TEXT)
.get("basic/annotation")
.then()
.statusCode(200);
RestAssured
.given()
.contentType(ContentType.TEXT)
.redirects().follow(false)
.get("bearer/annotation")
.then()
.statusCode(401);
}

@TestSecurity(user = "testUser", authMechanism = "Bearer")
@Test
public void testBearerBasedAuthentication() {
RestAssured
.given()
.contentType(ContentType.TEXT)
.get("basic/policy")
.then()
.statusCode(401);
RestAssured
.given()
.contentType(ContentType.TEXT)
.get("bearer/policy")
.then()
.statusCode(200);
RestAssured
.given()
.contentType(ContentType.TEXT)
.get("basic/annotation")
.then()
.statusCode(401);
RestAssured
.given()
.contentType(ContentType.TEXT)
.get("bearer/annotation")
.then()
.statusCode(200);
}

@TestSecurity(user = "testUser", authMechanism = "custom")
@Test
public void testCustomAuthentication() {
RestAssured
.given()
.contentType(ContentType.TEXT)
.get("basic/policy")
.then()
.statusCode(401);
RestAssured
.given()
.contentType(ContentType.TEXT)
.redirects().follow(false)
.get("bearer/policy")
.then()
.statusCode(401);
RestAssured
.given()
.contentType(ContentType.TEXT)
.get("basic/annotation")
.then()
.statusCode(401);
RestAssured
.given()
.contentType(ContentType.TEXT)
.redirects().follow(false)
.get("bearer/annotation")
.then()
.statusCode(401);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ public void afterEach(QuarkusTestMethodContext context) {
try {
if (getAnnotationContainer(context).isPresent()) {
CDI.current().select(TestAuthController.class).get().setEnabled(true);
CDI.current().select(TestIdentityAssociation.class).get().setTestIdentity(null);
CDI.current().select(TestHttpAuthenticationMechanism.class).get().setAuthMechanism(null);
var testIdentity = CDI.current().select(TestIdentityAssociation.class).get();
testIdentity.setTestIdentity(null);
testIdentity.setPathBasedIdentity(false);
}
} catch (Exception e) {
throw new RuntimeException("Unable to reset TestAuthController and TestIdentityAssociation", e);
throw new RuntimeException(
"Unable to reset TestAuthController, TestIdentityAssociation and TestHttpAuthenticationMechanism", e);
}

}
Expand Down Expand Up @@ -61,6 +65,11 @@ public void beforeEach(QuarkusTestMethodContext context) {

SecurityIdentity userIdentity = augment(user.build(), allAnnotations);
CDI.current().select(TestIdentityAssociation.class).get().setTestIdentity(userIdentity);
if (!testSecurity.authMechanism().isEmpty()) {
CDI.current().select(TestHttpAuthenticationMechanism.class).get()
.setAuthMechanism(testSecurity.authMechanism());
CDI.current().select(TestIdentityAssociation.class).get().setPathBasedIdentity(true);
}
}
} catch (Exception e) {
throw new RuntimeException("Unable to setup @TestSecurity", e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public class TestHttpAuthenticationMechanism implements HttpAuthenticationMechan
@Inject
TestIdentityAssociation testIdentityAssociation;

volatile String authMechanism = null;

@PostConstruct
public void check() {
if (LaunchMode.current() != LaunchMode.TEST) {
Expand All @@ -47,7 +49,17 @@ public Set<Class<? extends AuthenticationRequest>> getCredentialTypes() {
}

@Override
public HttpCredentialTransport getCredentialTransport() {
return null;
public Uni<HttpCredentialTransport> getCredentialTransport(RoutingContext context) {
return authMechanism == null ? Uni.createFrom().nullItem()
: Uni.createFrom().item(new HttpCredentialTransport(HttpCredentialTransport.Type.TEST_SECURITY, authMechanism));
}

@Override
public int getPriority() {
return 3000;
}

void setAuthMechanism(String authMechanism) {
this.authMechanism = authMechanism;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public void check() {

volatile SecurityIdentity testIdentity;

/**
* Whether authentication is successful only if right mechanism was used to authenticate.
*/
volatile boolean isPathBasedIdentity = false;

/**
* A request scoped delegate that allows the system to function as normal when
* the user has not been explicitly overridden
Expand Down Expand Up @@ -60,7 +65,7 @@ public Uni<SecurityIdentity> getDeferredIdentity() {
return delegate.getDeferredIdentity();
}
return delegate.getDeferredIdentity().onItem()
.transform(underlying -> underlying.isAnonymous() ? testIdentity : underlying);
.transform(underlying -> underlying.isAnonymous() && !isPathBasedIdentity ? testIdentity : underlying);
}

@Override
Expand All @@ -71,12 +76,16 @@ public SecurityIdentity getIdentity() {
//the identity ends up in the routing context
SecurityIdentity underlying = delegate.getIdentity();
if (underlying.isAnonymous()) {
if (testIdentity != null) {
if (testIdentity != null && !isPathBasedIdentity) {
return testIdentity;
}
}
return delegate.getIdentity();
}

void setPathBasedIdentity(boolean pathBasedIdentity) {
isPathBasedIdentity = pathBasedIdentity;
}
}

@RequestScoped
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import io.quarkus.security.identity.SecurityIdentity;

@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE, ElementType.METHOD })
@Inherited
Expand All @@ -26,5 +28,20 @@
*/
String[] roles() default {};

/**
* Adds attributes to a {@link SecurityIdentity} configured by this annotation.
* The attributes can be retrieved by the {@link SecurityIdentity#getAttributes()} method.
*/
SecurityAttribute[] attributes() default {};

/**
* Selects authentication mechanism used in a path-based authentication.
* If an HTTP Security Policy is used to enable path-based authentication,
* then a {@link SecurityIdentity} will only be provided by this annotation if this attribute
* matches the 'quarkus.http.auth.permission."permissions".auth-mechanism' configuration property.
* Situation is similar when annotations are used to enable path-based authentication for Jakarta REST endpoints.
* For example, set this attribute to 'basic' if an HTTP request to Jakarta REST endpoint annotated with the
* {@link io.quarkus.vertx.http.runtime.security.annotation.BasicAuthentication} should be successfully authenticated.
*/
String authMechanism() default "";
}

0 comments on commit fcdebde

Please sign in to comment.