Skip to content

Commit b1da830

Browse files
authored
Merge pull request #46429 from michalvavrik/feature/fix-form-auth-logout-logic
Provide a reliable way to perform form-based authentication logout
2 parents 349b908 + cc73513 commit b1da830

File tree

4 files changed

+202
-14
lines changed

4 files changed

+202
-14
lines changed

docs/src/main/asciidoc/security-authentication-mechanisms.adoc

+7-9
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,10 @@ code to destroy the cookie.
194194

195195
[source,java]
196196
----
197-
@ConfigProperty(name = "quarkus.http.auth.form.cookie-name")
198-
String cookieName;
197+
import io.quarkus.security.identity.CurrentIdentityAssociation;
198+
import io.quarkus.vertx.http.runtime.security.FormAuthenticationMechanism;
199+
import jakarta.ws.rs.core.Response;
200+
import jakarta.ws.rs.POST;
199201
200202
@Inject
201203
CurrentIdentityAssociation identity;
@@ -205,15 +207,11 @@ public Response logout() {
205207
if (identity.getIdentity().isAnonymous()) {
206208
throw new UnauthorizedException("Not authenticated");
207209
}
208-
final NewCookie removeCookie = new NewCookie.Builder(cookieName)
209-
.maxAge(0)
210-
.expiry(Date.from(Instant.EPOCH))
211-
.path("/")
212-
.build();
213-
return Response.noContent().cookie(removeCookie).build();
210+
FormAuthenticationMechanism.logout(identity.getIdentity()); <1>
211+
return Response.noContent().build();
214212
}
215-
216213
----
214+
<1> Perform the logout by removing the session cookie.
217215

218216
The following properties can be used to configure form-based authentication:
219217

extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/security/FormAuthRedirectTestCase.java

+152-3
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,45 @@
11
package io.quarkus.resteasy.reactive.server.test.security;
22

3+
import static io.restassured.matcher.RestAssuredMatchers.detailedCookie;
34
import static org.hamcrest.Matchers.containsString;
5+
import static org.hamcrest.Matchers.equalTo;
6+
import static org.hamcrest.Matchers.notNullValue;
47
import static org.hamcrest.Matchers.nullValue;
8+
import static org.junit.jupiter.api.Assertions.assertNull;
59

10+
import java.net.URI;
11+
import java.time.Duration;
612
import java.util.function.Supplier;
713

14+
import jakarta.enterprise.context.ApplicationScoped;
15+
import jakarta.ws.rs.GET;
16+
import jakarta.ws.rs.Path;
17+
import jakarta.ws.rs.core.Response;
18+
819
import org.jboss.shrinkwrap.api.ShrinkWrap;
920
import org.jboss.shrinkwrap.api.asset.StringAsset;
1021
import org.jboss.shrinkwrap.api.spec.JavaArchive;
22+
import org.junit.jupiter.api.Assertions;
1123
import org.junit.jupiter.api.BeforeAll;
1224
import org.junit.jupiter.api.Test;
1325
import org.junit.jupiter.api.extension.RegisterExtension;
1426

27+
import io.quarkus.security.Authenticated;
28+
import io.quarkus.security.UnauthorizedException;
29+
import io.quarkus.security.identity.AuthenticationRequestContext;
30+
import io.quarkus.security.identity.CurrentIdentityAssociation;
31+
import io.quarkus.security.identity.IdentityProvider;
32+
import io.quarkus.security.identity.SecurityIdentity;
33+
import io.quarkus.security.identity.request.TrustedAuthenticationRequest;
34+
import io.quarkus.security.runtime.QuarkusPrincipal;
35+
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
1536
import io.quarkus.security.test.utils.TestIdentityController;
1637
import io.quarkus.security.test.utils.TestIdentityProvider;
1738
import io.quarkus.test.QuarkusUnitTest;
39+
import io.quarkus.vertx.http.runtime.security.FormAuthenticationMechanism;
1840
import io.restassured.RestAssured;
1941
import io.restassured.filter.cookie.CookieFilter;
42+
import io.smallrye.mutiny.Uni;
2043

2144
public class FormAuthRedirectTestCase {
2245

@@ -25,14 +48,21 @@ public class FormAuthRedirectTestCase {
2548
@Override
2649
public JavaArchive get() {
2750
return ShrinkWrap.create(JavaArchive.class)
28-
.addClasses(TestIdentityProvider.class, TestIdentityController.class)
29-
.addAsResource(new StringAsset("quarkus.http.auth.form.enabled=true\n"), "application.properties");
51+
.addClasses(TestIdentityProvider.class, TestIdentityController.class, FormAuthResource.class,
52+
TrustedIdentityProvider.class)
53+
.addAsResource(new StringAsset("""
54+
quarkus.http.auth.form.enabled=true
55+
quarkus.http.auth.form.landing-page=/hello
56+
quarkus.http.auth.form.new-cookie-interval=PT1S
57+
"""), "application.properties");
3058
}
3159
});
3260

3361
@BeforeAll
3462
public static void setup() {
35-
TestIdentityController.resetRoles().add("a d m i n", "a d m i n", "a d m i n");
63+
TestIdentityController.resetRoles()
64+
.add("a d m i n", "a d m i n", "a d m i n")
65+
.add("user", "user");
3666
}
3767

3868
@Test
@@ -53,4 +83,123 @@ public void testFormAuthFailure() {
5383
.header("quarkus-credential", nullValue());
5484
}
5585

86+
@Test
87+
public void testFormAuthLoginLogout() throws InterruptedException {
88+
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
89+
CookieFilter cookies = new CookieFilter();
90+
var response = RestAssured
91+
.given()
92+
.filter(cookies)
93+
.redirects().follow(false)
94+
.when()
95+
.get("/hello")
96+
.then()
97+
.assertThat()
98+
.statusCode(302)
99+
.header("location", containsString("/login.html"))
100+
.extract();
101+
assertNull(response.cookie("quarkus-credential"));
102+
103+
RestAssured
104+
.given()
105+
.filter(cookies)
106+
.redirects().follow(false)
107+
.when()
108+
.formParam("j_username", "user")
109+
.formParam("j_password", "user")
110+
.post("/j_security_check")
111+
.then()
112+
.assertThat()
113+
.statusCode(302)
114+
.header("location", containsString("/hello"))
115+
.cookie("quarkus-credential", detailedCookie().value(notNullValue()).sameSite("Strict").path("/"));
116+
117+
RestAssured
118+
.given()
119+
.filter(cookies)
120+
.redirects().follow(false)
121+
.when()
122+
.get("/hello")
123+
.then()
124+
.assertThat()
125+
.statusCode(200)
126+
.body(equalTo("hello user"));
127+
128+
Thread.sleep(Duration.ofSeconds(2).toMillis());
129+
130+
response = RestAssured
131+
.given()
132+
.filter(cookies)
133+
.redirects().follow(false)
134+
.when()
135+
.get("/logout")
136+
.then()
137+
.assertThat()
138+
.statusCode(303)
139+
.header("location", containsString("/"))
140+
.extract();
141+
String credentialsCookieValue = response.cookie("quarkus-credential");
142+
Assertions.assertTrue(credentialsCookieValue == null || credentialsCookieValue.isEmpty(),
143+
"Expected credentials cookie was removed, but actual value was " + credentialsCookieValue);
144+
145+
response = RestAssured
146+
.given()
147+
.filter(cookies)
148+
.redirects().follow(false)
149+
.when()
150+
.get("/hello")
151+
.then()
152+
.assertThat()
153+
.statusCode(302)
154+
.header("location", containsString("/login.html"))
155+
.extract();
156+
credentialsCookieValue = response.cookie("quarkus-credential");
157+
Assertions.assertTrue(credentialsCookieValue == null || credentialsCookieValue.isEmpty());
158+
}
159+
160+
@Path("/")
161+
public static class FormAuthResource {
162+
163+
private final CurrentIdentityAssociation identity;
164+
165+
public FormAuthResource(CurrentIdentityAssociation identity) {
166+
this.identity = identity;
167+
}
168+
169+
@Authenticated
170+
@GET
171+
@Path("hello")
172+
public String hello() {
173+
return "hello " + identity.getIdentity().getPrincipal().getName();
174+
}
175+
176+
@GET
177+
@Path("logout")
178+
public Response logout() {
179+
if (identity.getIdentity().isAnonymous()) {
180+
throw new UnauthorizedException("Not authenticated");
181+
}
182+
FormAuthenticationMechanism.logout(identity.getIdentity());
183+
return Response.seeOther(URI.create("/"))
184+
.build();
185+
}
186+
}
187+
188+
@ApplicationScoped
189+
public static class TrustedIdentityProvider implements IdentityProvider<TrustedAuthenticationRequest> {
190+
@Override
191+
public Class<TrustedAuthenticationRequest> getRequestType() {
192+
return TrustedAuthenticationRequest.class;
193+
}
194+
195+
@Override
196+
public Uni<SecurityIdentity> authenticate(TrustedAuthenticationRequest trustedAuthenticationRequest,
197+
AuthenticationRequestContext authenticationRequestContext) {
198+
if ("user".equals(trustedAuthenticationRequest.getPrincipal())) {
199+
return Uni.createFrom()
200+
.item(QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal("user")).build());
201+
}
202+
return Uni.createFrom().nullItem();
203+
}
204+
}
56205
}

extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/FormAuthenticationMechanism.java

+39-2
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22

33
import static io.quarkus.security.spi.runtime.SecurityEventHelper.fire;
44
import static io.quarkus.vertx.http.runtime.security.FormAuthenticationEvent.createLoginEvent;
5+
import static io.quarkus.vertx.http.runtime.security.RoutingContextAwareSecurityIdentity.addRoutingCtxToIdentityIfMissing;
56

67
import java.net.URI;
78
import java.security.SecureRandom;
89
import java.time.Duration;
910
import java.util.Arrays;
1011
import java.util.Base64;
1112
import java.util.HashSet;
13+
import java.util.Objects;
1214
import java.util.Optional;
1315
import java.util.Set;
1416
import java.util.function.Consumer;
17+
import java.util.function.Function;
1518

1619
import jakarta.enterprise.event.Event;
1720
import jakarta.enterprise.inject.spi.BeanManager;
@@ -41,11 +44,13 @@
4144
import io.vertx.core.http.Cookie;
4245
import io.vertx.core.http.CookieSameSite;
4346
import io.vertx.core.http.HttpMethod;
47+
import io.vertx.core.http.impl.CookieImpl;
4448
import io.vertx.ext.web.RoutingContext;
4549

4650
public class FormAuthenticationMechanism implements HttpAuthenticationMechanism {
4751
private static final String FORM = "form";
48-
52+
private static final String COOKIE_NAME = "io.quarkus.vertx.http.runtime.security.form.cookie-name";
53+
private static final String COOKIE_PATH = "io.quarkus.vertx.http.runtime.security.form.cookie-path";
4954
private static final Logger log = Logger.getLogger(FormAuthenticationMechanism.class);
5055

5156
private final String loginPage;
@@ -261,7 +266,16 @@ public Uni<SecurityIdentity> authenticate(RoutingContext context,
261266
if (context.normalizedPath().endsWith(postLocation) && context.request().method().equals(HttpMethod.POST)) {
262267
//we always re-auth if it is a post to the auth URL
263268
context.put(HttpAuthenticationMechanism.class.getName(), this);
264-
return runFormAuth(context, identityProviderManager);
269+
return runFormAuth(context, identityProviderManager)
270+
.onItem().ifNotNull().transform(new Function<SecurityIdentity, SecurityIdentity>() {
271+
@Override
272+
public SecurityIdentity apply(SecurityIdentity identity) {
273+
// used for logout
274+
context.put(COOKIE_NAME, loginManager.getCookieName());
275+
context.put(COOKIE_PATH, cookiePath);
276+
return addRoutingCtxToIdentityIfMissing(identity, context);
277+
}
278+
});
265279
} else {
266280
PersistentLoginManager.RestoreResult result = loginManager.restore(context);
267281
if (result != null) {
@@ -274,6 +288,14 @@ public Uni<SecurityIdentity> authenticate(RoutingContext context,
274288
public void accept(SecurityIdentity securityIdentity) {
275289
loginManager.save(securityIdentity, context, result, context.request().isSSL());
276290
}
291+
}).onItem().ifNotNull().transform(new Function<SecurityIdentity, SecurityIdentity>() {
292+
@Override
293+
public SecurityIdentity apply(SecurityIdentity identity) {
294+
// used for logout
295+
context.put(COOKIE_NAME, loginManager.getCookieName());
296+
context.put(COOKIE_PATH, cookiePath);
297+
return addRoutingCtxToIdentityIfMissing(identity, context);
298+
}
277299
});
278300
}
279301
return Uni.createFrom().optional(Optional.empty());
@@ -311,6 +333,21 @@ public Uni<HttpCredentialTransport> getCredentialTransport(RoutingContext contex
311333
return Uni.createFrom().item(new HttpCredentialTransport(HttpCredentialTransport.Type.POST, postLocation, FORM));
312334
}
313335

336+
public static void logout(SecurityIdentity securityIdentity) {
337+
RoutingContext routingContext = HttpSecurityUtils.getRoutingContextAttribute(securityIdentity);
338+
logout(routingContext);
339+
}
340+
341+
public static void logout(RoutingContext routingContext) {
342+
Objects.requireNonNull(routingContext);
343+
String cookieName = Objects.requireNonNull(routingContext.get(COOKIE_NAME));
344+
String cookiePath = Objects.requireNonNull(routingContext.get(COOKIE_PATH));
345+
Cookie cookie = new CookieImpl(cookieName, "");
346+
cookie.setMaxAge(0);
347+
cookie.setPath(cookiePath);
348+
routingContext.response().addCookie(cookie);
349+
}
350+
314351
private static String startWithSlash(String page) {
315352
if (page == null) {
316353
return null;

extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PersistentLoginManager.java

+4
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ public void clear(RoutingContext ctx) {
167167
ctx.response().removeCookie(cookieName);
168168
}
169169

170+
String getCookieName() {
171+
return cookieName;
172+
}
173+
170174
public static class RestoreResult {
171175

172176
private final String principal;

0 commit comments

Comments
 (0)