allowCredentials = null;
+ final AuthenticationParameters parameters =
+ new AuthenticationParameters(serverProperty, entry.getRecord(), allowCredentials, false, true);
+
+ webAuthnManager.verify(authenticationData, parameters);
+
+ entry.getRecord().setCounter(authenticationData.getAuthenticatorData().getSignCount());
+ credentialStore.updateRecord(authenticationData.getCredentialId(), entry.getRecord());
+
+ request.getSession().removeAttribute(AUTH_CHALLENGE);
+ return entry.getUsername();
+ }
+
+ private ServerProperty serverProperty(final HttpServletRequest request, final String challengeAttr) {
+ final String stored = (String) request.getSession().getAttribute(challengeAttr);
+ if (stored == null) {
+ throw new IllegalStateException("No challenge in session - call the options endpoint first");
+ }
+ final Challenge challenge = new DefaultChallenge(decode(stored));
+ return new ServerProperty(origin(request), rpId(request), challenge, null);
+ }
+
+ private static String rpId(final HttpServletRequest request) {
+ return request.getServerName();
+ }
+
+ private static Origin origin(final HttpServletRequest request) {
+ final String scheme = request.getScheme();
+ final int port = request.getServerPort();
+ final boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
+ final String authority = defaultPort
+ ? request.getServerName()
+ : request.getServerName() + ":" + port;
+ return new Origin(scheme + "://" + authority);
+ }
+
+ private static String encode(final byte[] bytes) {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ private static byte[] decode(final String base64Url) {
+ return Base64.getUrlDecoder().decode(base64Url);
+ }
+
+ static JsonObject parse(final String json) {
+ return Json.createReader(new java.io.StringReader(json)).readObject();
+ }
+}
diff --git a/examples/security-passkey-2fa/src/main/webapp/WEB-INF/beans.xml b/examples/security-passkey-2fa/src/main/webapp/WEB-INF/beans.xml
new file mode 100644
index 00000000000..2d6f5444a4d
--- /dev/null
+++ b/examples/security-passkey-2fa/src/main/webapp/WEB-INF/beans.xml
@@ -0,0 +1,23 @@
+
+
+
+
diff --git a/examples/security-passkey-2fa/src/main/webapp/index.html b/examples/security-passkey-2fa/src/main/webapp/index.html
new file mode 100644
index 00000000000..44e02051997
--- /dev/null
+++ b/examples/security-passkey-2fa/src/main/webapp/index.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ TomEE Passkey 2FA demo
+
+
+
+TomEE - Passkey as a second factor
+This demo authenticates in two steps: a password (first factor) followed by a
+ passkey / WebAuthn assertion (second factor).
+
+ - Register a passkey - log in with your password, then enrol an authenticator.
+ - Log in - password, then passkey, then reach the protected page.
+ - Protected page - only reachable once both factors have passed.
+
+Demo users: jon / doe and iron / man.
+Passkeys require a secure context: use http://localhost or HTTPS.
+
+
diff --git a/examples/security-passkey-2fa/src/main/webapp/js/webauthn.js b/examples/security-passkey-2fa/src/main/webapp/js/webauthn.js
new file mode 100644
index 00000000000..336c2923af3
--- /dev/null
+++ b/examples/security-passkey-2fa/src/main/webapp/js/webauthn.js
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// --- base64url <-> ArrayBuffer helpers -------------------------------------
+
+function b64urlToBuf(value) {
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/');
+ const binary = atob(padded);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+}
+
+function bufToB64url(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (let i = 0; i < bytes.length; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+function log(message) {
+ const el = document.getElementById('log');
+ if (el) {
+ el.textContent += message + '\n';
+ }
+}
+
+// --- first factor ----------------------------------------------------------
+
+async function passwordStep(username, password) {
+ const res = await fetch('api/login/password', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({username, password})
+ });
+ if (!res.ok) {
+ throw new Error('Password step failed (' + res.status + ')');
+ }
+ log('Password accepted for ' + username);
+}
+
+// --- registration (enrol a passkey) ----------------------------------------
+
+async function registerPasskey() {
+ const optionsRes = await fetch('api/register/options');
+ if (!optionsRes.ok) {
+ throw new Error('Could not get registration options (' + optionsRes.status + ')');
+ }
+ const options = await optionsRes.json();
+
+ // decode the server-provided base64url fields into ArrayBuffers
+ options.challenge = b64urlToBuf(options.challenge);
+ options.user.id = b64urlToBuf(options.user.id);
+ (options.excludeCredentials || []).forEach(c => c.id = b64urlToBuf(c.id));
+
+ const credential = await navigator.credentials.create({publicKey: options});
+
+ const payload = {
+ id: credential.id,
+ rawId: bufToB64url(credential.rawId),
+ type: credential.type,
+ clientExtensionResults: credential.getClientExtensionResults(),
+ response: {
+ clientDataJSON: bufToB64url(credential.response.clientDataJSON),
+ attestationObject: bufToB64url(credential.response.attestationObject),
+ transports: credential.response.getTransports ? credential.response.getTransports() : []
+ }
+ };
+
+ const res = await fetch('api/register', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(payload)
+ });
+ if (!res.ok) {
+ throw new Error('Registration failed (' + res.status + ')');
+ }
+ log('Passkey registered.');
+}
+
+// --- login (assert with a passkey - the 2nd factor) ------------------------
+
+async function loginWithPasskey() {
+ const optionsRes = await fetch('api/login/assertion-options');
+ if (!optionsRes.ok) {
+ throw new Error('Could not get assertion options (' + optionsRes.status + ')');
+ }
+ const options = await optionsRes.json();
+
+ options.challenge = b64urlToBuf(options.challenge);
+ (options.allowCredentials || []).forEach(c => c.id = b64urlToBuf(c.id));
+
+ const assertion = await navigator.credentials.get({publicKey: options});
+
+ const payload = {
+ id: assertion.id,
+ rawId: bufToB64url(assertion.rawId),
+ type: assertion.type,
+ clientExtensionResults: assertion.getClientExtensionResults(),
+ response: {
+ clientDataJSON: bufToB64url(assertion.response.clientDataJSON),
+ authenticatorData: bufToB64url(assertion.response.authenticatorData),
+ signature: bufToB64url(assertion.response.signature),
+ userHandle: assertion.response.userHandle ? bufToB64url(assertion.response.userHandle) : null
+ }
+ };
+
+ const res = await fetch('api/login/assertion', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(payload)
+ });
+ if (!res.ok) {
+ throw new Error('Passkey login failed (' + res.status + ')');
+ }
+ const result = await res.json();
+ log('Authenticated. Following redirect to the protected page...');
+ // Deliberately a fresh navigation: proves the login survived to a new request.
+ window.location = result.redirect;
+}
diff --git a/examples/security-passkey-2fa/src/main/webapp/login.html b/examples/security-passkey-2fa/src/main/webapp/login.html
new file mode 100644
index 00000000000..1d0c6048a3d
--- /dev/null
+++ b/examples/security-passkey-2fa/src/main/webapp/login.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+ Log in
+
+
+
+Log in
+Password first, then your passkey. On success you are redirected to the
+ protected page - a brand new request that only works if the login was
+ persisted to the session.
+
+
+back
+
+
+
+
+
diff --git a/examples/security-passkey-2fa/src/main/webapp/register.html b/examples/security-passkey-2fa/src/main/webapp/register.html
new file mode 100644
index 00000000000..ece7b2b4bd5
--- /dev/null
+++ b/examples/security-passkey-2fa/src/main/webapp/register.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+ Register a passkey
+
+
+
+Register a passkey
+First authenticate with your password, then enrol an authenticator as a passkey.
+
+
+back
+
+
+
+
+
diff --git a/examples/security-passkey-2fa/src/test/java/org/superbiz/passkey/PasskeyFlowTest.java b/examples/security-passkey-2fa/src/test/java/org/superbiz/passkey/PasskeyFlowTest.java
new file mode 100644
index 00000000000..a236715b2ca
--- /dev/null
+++ b/examples/security-passkey-2fa/src/test/java/org/superbiz/passkey/PasskeyFlowTest.java
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.superbiz.passkey;
+
+import com.webauthn4j.converter.util.ObjectConverter;
+import com.webauthn4j.data.AttestationConveyancePreference;
+import com.webauthn4j.data.AuthenticatorAttachment;
+import com.webauthn4j.data.AuthenticatorSelectionCriteria;
+import com.webauthn4j.data.PublicKeyCredentialCreationOptions;
+import com.webauthn4j.data.PublicKeyCredentialParameters;
+import com.webauthn4j.data.PublicKeyCredentialRequestOptions;
+import com.webauthn4j.data.PublicKeyCredentialRpEntity;
+import com.webauthn4j.data.PublicKeyCredentialType;
+import com.webauthn4j.data.PublicKeyCredentialUserEntity;
+import com.webauthn4j.data.UserVerificationRequirement;
+import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier;
+import com.webauthn4j.data.client.Origin;
+import com.webauthn4j.data.client.challenge.DefaultChallenge;
+import com.webauthn4j.data.extension.client.AuthenticationExtensionsClientInputs;
+import com.webauthn4j.test.EmulatorUtil;
+import com.webauthn4j.test.authenticator.webauthn.WebAuthnAuthenticatorAdaptor;
+import com.webauthn4j.test.client.ClientPlatform;
+import jakarta.json.Json;
+import jakarta.json.JsonObject;
+import org.apache.tomee.bootstrap.Archive;
+import org.apache.tomee.bootstrap.Server;
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.io.StringReader;
+import java.net.CookieManager;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+
+public class PasskeyFlowTest {
+
+ private static String baseUrl;
+
+ private static String origin;
+
+ private static boolean external;
+
+ private final ObjectConverter objectConverter = new ObjectConverter();
+
+ @BeforeClass
+ public static void setup() {
+ final String override = System.getProperty("passkey.baseUri");
+ if (override != null && !override.isBlank()) {
+ external = true;
+ baseUrl = stripTrailingSlash(override.trim());
+ } else {
+ external = false;
+ baseUrl = stripTrailingSlash(bootEmbeddedTomEE().toString());
+ }
+ origin = originOf(baseUrl);
+ }
+
+ private static URI bootEmbeddedTomEE() {
+ final Archive classes = Archive.archive()
+ .add(PasskeyAuthenticationMechanism.class)
+ .add(PasskeyLoginServlet.class)
+ .add(PasskeyRegistrationServlet.class)
+ .add(ProtectedServlet.class)
+ .add(WebAuthnService.class)
+ .add(CredentialStore.class)
+ .add(UserRepository.class)
+ .add(Http.class);
+
+ final Server server = Server.builder()
+ .add("webapps/ROOT/WEB-INF/classes", classes)
+ .add("webapps/ROOT/WEB-INF/beans.xml", "")
+ .build();
+
+ return server.getURI();
+ }
+
+ @Test
+ public void protectedResourceRejectsAnonymous() throws Exception {
+ final HttpClient client = newClient();
+ final HttpResponse response = client.send(
+ HttpRequest.newBuilder(uri("/app")).GET().build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ Assert.assertTrue("expected the protected page to reject anonymous access, got " + response.statusCode(),
+ response.statusCode() == 401 || response.statusCode() == 403);
+ }
+
+ @Test
+ public void firstFactorRejectsBadPassword() throws Exception {
+ final HttpClient client = newClient();
+ Assert.assertEquals(401,
+ postJson(client, "/api/login/password", "{\"username\":\"jon\",\"password\":\"wrong\"}").statusCode());
+ }
+
+ @Test
+ public void secondFactorRequiresFirstFactor() throws Exception {
+ final HttpClient client = newClient();
+ final HttpResponse response = client.send(
+ HttpRequest.newBuilder(uri("/api/login/assertion-options")).GET().build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ Assert.assertEquals(401, response.statusCode());
+ }
+
+ @Test
+ public void passwordThenPasskeyAuthenticates() throws Exception {
+ final HttpClient client = newClient();
+ final ClientPlatform authenticator = softwareAuthenticator();
+
+ firstFactor(client);
+ registerPasskey(client, authenticator, "jon");
+
+ final HttpResponse assertion = login(client, authenticator);
+ Assert.assertEquals(200, assertion.statusCode());
+ Assert.assertTrue(assertion.body().contains("\"authenticated\":true"));
+ }
+
+ @Test
+ public void loginPersistsToNextRequest() throws Exception {
+ final HttpClient client = newClient();
+ final ClientPlatform authenticator = softwareAuthenticator();
+
+ firstFactor(client);
+ registerPasskey(client, authenticator, "jon");
+ Assert.assertEquals(200, login(client, authenticator).statusCode());
+
+ // A brand new request on the same session - this is where persistence matters.
+ final HttpResponse protectedResp = client.send(
+ HttpRequest.newBuilder(uri("/app")).GET().build(),
+ HttpResponse.BodyHandlers.ofString());
+
+ Assert.assertEquals("follow-up request to /app was not authenticated - "
+ + "the login did not persist to the session on " + baseUrl,
+ 200, protectedResp.statusCode());
+ Assert.assertTrue(protectedResp.body().contains("caller: jon"));
+ }
+
+ private void firstFactor(final HttpClient client) throws Exception {
+ Assert.assertEquals(200,
+ postJson(client, "/api/login/password", "{\"username\":\"jon\",\"password\":\"doe\"}").statusCode());
+ }
+
+ private void registerPasskey(final HttpClient client, final ClientPlatform authenticator, final String username)
+ throws Exception {
+
+ final JsonObject options = getJson(client, "/api/register/options");
+
+ final PublicKeyCredentialCreationOptions creationOptions = new PublicKeyCredentialCreationOptions(
+ new PublicKeyCredentialRpEntity(options.getJsonObject("rp").getString("id"),
+ options.getJsonObject("rp").getString("name")),
+ new PublicKeyCredentialUserEntity(decode(options.getJsonObject("user").getString("id")),
+ username, username),
+ new DefaultChallenge(decode(options.getString("challenge"))),
+ List.of(new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY,
+ COSEAlgorithmIdentifier.ES256)),
+ null,
+ Collections.emptyList(),
+ new AuthenticatorSelectionCriteria(AuthenticatorAttachment.CROSS_PLATFORM, true,
+ UserVerificationRequirement.PREFERRED),
+ AttestationConveyancePreference.NONE,
+ new AuthenticationExtensionsClientInputs<>());
+
+ final String responseJson = objectConverter.getJsonConverter()
+ .writeValueAsString(authenticator.create(creationOptions));
+
+ Assert.assertEquals(200, postJson(client, "/api/register", responseJson).statusCode());
+ }
+
+ private HttpResponse login(final HttpClient client, final ClientPlatform authenticator) throws Exception {
+ final JsonObject options = getJson(client, "/api/login/assertion-options");
+
+ final PublicKeyCredentialRequestOptions requestOptions = new PublicKeyCredentialRequestOptions(
+ new DefaultChallenge(decode(options.getString("challenge"))),
+ 0L,
+ options.getString("rpId"),
+ null,
+ UserVerificationRequirement.PREFERRED,
+ null);
+
+ final String responseJson = objectConverter.getJsonConverter()
+ .writeValueAsString(authenticator.get(requestOptions));
+
+ return postJson(client, "/api/login/assertion", responseJson);
+ }
+
+ private static ClientPlatform softwareAuthenticator() {
+ return new ClientPlatform(new Origin(origin), new WebAuthnAuthenticatorAdaptor(EmulatorUtil.PACKED_AUTHENTICATOR));
+ }
+
+ private static HttpClient newClient() {
+ // a cookie manager so the JSESSIONID is carried between requests
+ return HttpClient.newBuilder().cookieHandler(new CookieManager()).build();
+ }
+
+ private static URI uri(final String path) {
+ return URI.create(baseUrl + path);
+ }
+
+ private static HttpResponse postJson(final HttpClient client, final String path, final String json)
+ throws Exception {
+ return client.send(
+ HttpRequest.newBuilder(uri(path))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(json))
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+ }
+
+ private static JsonObject getJson(final HttpClient client, final String path) throws Exception {
+ final HttpResponse response = client.send(
+ HttpRequest.newBuilder(uri(path)).GET().build(),
+ HttpResponse.BodyHandlers.ofString());
+ Assert.assertEquals("GET " + path + " -> " + response.statusCode(), 200, response.statusCode());
+ return Json.createReader(new StringReader(response.body())).readObject();
+ }
+
+ private static byte[] decode(final String base64Url) {
+ return Base64.getUrlDecoder().decode(base64Url);
+ }
+
+ private static String stripTrailingSlash(final String url) {
+ return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ }
+
+ private static String originOf(final String url) {
+ final URI u = URI.create(url);
+ final String authority = u.getPort() == -1 ? u.getHost() : u.getHost() + ":" + u.getPort();
+ return u.getScheme() + "://" + authority;
+ }
+}
diff --git a/tomee/tomee-security/src/main/java/org/apache/tomee/security/TomEESecurityContext.java b/tomee/tomee-security/src/main/java/org/apache/tomee/security/TomEESecurityContext.java
index 5b6816d9131..c146ca8940d 100644
--- a/tomee/tomee-security/src/main/java/org/apache/tomee/security/TomEESecurityContext.java
+++ b/tomee/tomee-security/src/main/java/org/apache/tomee/security/TomEESecurityContext.java
@@ -16,7 +16,6 @@
*/
package org.apache.tomee.security;
-import org.apache.catalina.authenticator.jaspic.CallbackHandlerImpl;
import org.apache.catalina.connector.Request;
import org.apache.catalina.realm.GenericPrincipal;
import org.apache.openejb.core.security.JaccProvider;
@@ -28,27 +27,18 @@
import org.apache.tomee.security.message.TomEEMessageInfo;
import jakarta.annotation.PostConstruct;
-import javax.security.auth.Subject;
-import jakarta.security.auth.message.AuthException;
-import jakarta.security.auth.message.AuthStatus;
-import jakarta.security.auth.message.MessageInfo;
-import jakarta.security.auth.message.config.AuthConfigFactory;
-import jakarta.security.auth.message.config.AuthConfigProvider;
-import jakarta.security.auth.message.config.ServerAuthConfig;
-import jakarta.security.auth.message.config.ServerAuthContext;
import jakarta.security.enterprise.AuthenticationStatus;
import jakarta.security.enterprise.SecurityContext;
import jakarta.security.enterprise.authentication.mechanism.http.AuthenticationParameters;
+import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Set;
-import static jakarta.security.auth.message.AuthStatus.SEND_CONTINUE;
-import static jakarta.security.auth.message.AuthStatus.SEND_FAILURE;
-import static jakarta.security.auth.message.AuthStatus.SUCCESS;
import static org.apache.tomee.catalina.Contexts.toAppContext;
public class TomEESecurityContext implements SecurityContext {
@@ -82,7 +72,7 @@ public boolean isCallerInRole(final String role) {
@Override
public boolean hasAccessToWebResource(final String resource, final String... methods) {
- return jaccProvider.hasAccessToWebResource(resource, methods);
+ return jaccProvider != null && jaccProvider.hasAccessToWebResource(resource, methods);
}
@Override
@@ -90,43 +80,34 @@ public AuthenticationStatus authenticate(final HttpServletRequest request,
final HttpServletResponse response,
final AuthenticationParameters parameters) {
- try {
- final MessageInfo messageInfo = new TomEEMessageInfo(request, response, true, parameters);
- final ServerAuthContext serverAuthContext = getServerAuthContext(request);
- final AuthStatus authStatus = serverAuthContext.validateRequest(messageInfo, new Subject(), null);
-
- return mapToAuthenticationStatus(authStatus);
+ // Delegate to HttpServletRequest.authenticate() rather than driving JASPIC directly.
+ request.removeAttribute(TomEEMessageInfo.LAST_AUTH_STATUS);
- } catch (final AuthException e) {
- return AuthenticationStatus.SEND_FAILURE;
+ if (parameters != null) {
+ request.setAttribute(TomEEMessageInfo.AUTH_PARAMS, parameters);
}
- }
+ request.setAttribute(TomEEMessageInfo.AUTHENTICATE, Boolean.toString(true));
- private AuthenticationStatus mapToAuthenticationStatus(final AuthStatus authStatus) {
- if (SUCCESS.equals(authStatus)) {
- return AuthenticationStatus.SUCCESS;
- }
+ try {
+ if (request.authenticate(response)) {
+ return AuthenticationStatus.SUCCESS;
+ }
- if (SEND_FAILURE.equals(authStatus)) {
- return AuthenticationStatus.SEND_FAILURE;
- }
+ return lastAuthenticationStatus(request);
- if (SEND_CONTINUE.equals(authStatus)) {
- return AuthenticationStatus.SEND_CONTINUE;
+ } catch (final ServletException | IOException e) {
+ return AuthenticationStatus.SEND_FAILURE;
+ } finally {
+ request.removeAttribute(TomEEMessageInfo.AUTH_PARAMS);
+ request.removeAttribute(TomEEMessageInfo.AUTHENTICATE);
}
-
- throw new IllegalArgumentException();
}
- private ServerAuthContext getServerAuthContext(final HttpServletRequest request) throws AuthException {
- final String appContext = toAppContext(request.getServletContext(), request.getContextPath());
-
- final AuthConfigProvider authConfigProvider =
- AuthConfigFactory.getFactory().getConfigProvider("HttpServlet", appContext, null);
- final ServerAuthConfig serverAuthConfig =
- authConfigProvider.getServerAuthConfig("HttpServlet", appContext, new CallbackHandlerImpl());
-
- return serverAuthConfig.getAuthContext(null, null, null);
+ private static AuthenticationStatus lastAuthenticationStatus(final HttpServletRequest request) {
+ final Object status = request.getAttribute(TomEEMessageInfo.LAST_AUTH_STATUS);
+ return status instanceof AuthenticationStatus
+ ? (AuthenticationStatus) status
+ : AuthenticationStatus.SEND_FAILURE;
}
public static void registerContainerAboutLogin(final Principal principal, final Set groups) {
@@ -134,10 +115,13 @@ public static void registerContainerAboutLogin(final Principal principal, final
final SecurityService securityService = SystemInstance.get().getComponent(SecurityService.class);
if (securityService instanceof TomcatSecurityService tomcatSecurityService) {
final Request request = OpenEJBSecurityListener.requests.get();
+ if (request == null || request.getWrapper() == null) {
+ return;
+ }
+
final GenericPrincipal genericPrincipal =
new GenericPrincipal(
principal.getName(),
- null,
groups == null ? Collections.emptyList() : new ArrayList<>(groups),
principal);
@@ -147,7 +131,20 @@ public static void registerContainerAboutLogin(final Principal principal, final
tomcatSecurityService.enterWebApp(request.getWrapper().getRealm(),
genericPrincipal,
request.getWrapper().getRunAs());
+
+ if (genericPrincipal.getName() != null) {
+ request.setAuthType("JASPIC");
+ request.setUserPrincipal(genericPrincipal);
+ }
+ }
+ }
+
+ private String getAppContextId() {
+ final Request request = OpenEJBSecurityListener.requests.get();
+ if (request == null || request.getServletContext() == null) {
+ return null;
}
+ return toAppContext(request.getServletContext(), request.getContextPath());
}
diff --git a/tomee/tomee-security/src/main/java/org/apache/tomee/security/cdi/LoginToContinueInterceptor.java b/tomee/tomee-security/src/main/java/org/apache/tomee/security/cdi/LoginToContinueInterceptor.java
index 0c1e639cab1..825a8e5dc03 100644
--- a/tomee/tomee-security/src/main/java/org/apache/tomee/security/cdi/LoginToContinueInterceptor.java
+++ b/tomee/tomee-security/src/main/java/org/apache/tomee/security/cdi/LoginToContinueInterceptor.java
@@ -30,6 +30,7 @@
import jakarta.security.enterprise.authentication.mechanism.http.LoginToContinue;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
import java.util.Arrays;
import static jakarta.interceptor.Interceptor.Priority.PLATFORM_BEFORE;
@@ -86,14 +87,20 @@ private void clearStaleState(final HttpMessageContext httpMessageContext) {
!hasAuthentication(httpMessageContext.getRequest()) &&
!httpMessageContext.getRequest().getRequestURI().endsWith("j_security_check")) {
- httpMessageContext.getRequest().getSession().removeAttribute(ORIGINAL_REQUEST);
- httpMessageContext.getRequest().getSession().removeAttribute(CALLER_AUTHENTICATION);
+ final HttpSession staleSession = httpMessageContext.getRequest().getSession(false);
+ if (staleSession != null) {
+ staleSession.removeAttribute(ORIGINAL_REQUEST);
+ staleSession.removeAttribute(CALLER_AUTHENTICATION);
+ }
}
if (httpMessageContext.getAuthParameters().isNewAuthentication()) {
httpMessageContext.getRequest().getSession().setAttribute(CALLER_AUTHENTICATION, true);
- httpMessageContext.getRequest().getSession().removeAttribute(ORIGINAL_REQUEST);
- httpMessageContext.getRequest().getSession().removeAttribute(AUTHENTICATION);
+ final HttpSession newAuthSession = httpMessageContext.getRequest().getSession(false);
+ if (newAuthSession != null) {
+ newAuthSession.removeAttribute(ORIGINAL_REQUEST);
+ newAuthSession.removeAttribute(AUTHENTICATION);
+ }
}
}
diff --git a/tomee/tomee-security/src/main/java/org/apache/tomee/security/http/LoginToContinueMechanism.java b/tomee/tomee-security/src/main/java/org/apache/tomee/security/http/LoginToContinueMechanism.java
index 3978e3ed71f..57a93007b70 100644
--- a/tomee/tomee-security/src/main/java/org/apache/tomee/security/http/LoginToContinueMechanism.java
+++ b/tomee/tomee-security/src/main/java/org/apache/tomee/security/http/LoginToContinueMechanism.java
@@ -63,11 +63,13 @@ static boolean matchRequest(final HttpServletRequest request) {
}
static boolean hasRequest(final HttpServletRequest request) {
- return request.getSession().getAttribute(ORIGINAL_REQUEST) != null;
+ final HttpSession session = request.getSession(false);
+ return session != null && session.getAttribute(ORIGINAL_REQUEST) != null;
}
static SavedRequest getRequest(final HttpServletRequest request) {
- return (SavedRequest) request.getSession().getAttribute(ORIGINAL_REQUEST);
+ final HttpSession session = request.getSession(false);
+ return session != null ? (SavedRequest) session.getAttribute(ORIGINAL_REQUEST) : null;
}
static void saveAuthentication(final HttpServletRequest request,
@@ -78,15 +80,20 @@ static void saveAuthentication(final HttpServletRequest request,
}
static boolean hasAuthentication(final HttpServletRequest request) {
- return request.getSession().getAttribute(AUTHENTICATION) != null;
+ final HttpSession session = request.getSession(false);
+ return session != null && session.getAttribute(AUTHENTICATION) != null;
}
static SavedAuthentication getAuthentication(final HttpServletRequest request) {
- return (SavedAuthentication) request.getSession().getAttribute(AUTHENTICATION);
+ final HttpSession session = request.getSession(false);
+ return session != null ? (SavedAuthentication) session.getAttribute(AUTHENTICATION) : null;
}
static void clearRequestAndAuthentication(final HttpServletRequest request) {
- request.getSession().removeAttribute(ORIGINAL_REQUEST);
- request.getSession().removeAttribute(AUTHENTICATION);
+ final HttpSession session = request.getSession(false);
+ if (session != null) {
+ session.removeAttribute(ORIGINAL_REQUEST);
+ session.removeAttribute(AUTHENTICATION);
+ }
}
}
diff --git a/tomee/tomee-security/src/main/java/org/apache/tomee/security/http/TomEEHttpMessageContext.java b/tomee/tomee-security/src/main/java/org/apache/tomee/security/http/TomEEHttpMessageContext.java
index 49ee5c00e4c..ad0833acc49 100644
--- a/tomee/tomee-security/src/main/java/org/apache/tomee/security/http/TomEEHttpMessageContext.java
+++ b/tomee/tomee-security/src/main/java/org/apache/tomee/security/http/TomEEHttpMessageContext.java
@@ -82,6 +82,10 @@ public boolean isProtected() {
@Override
public boolean isAuthenticationRequest() {
+ final Object fromRequest = getRequest().getAttribute(TomEEMessageInfo.AUTHENTICATE);
+ if (fromRequest != null) {
+ return Boolean.parseBoolean(String.valueOf(fromRequest));
+ }
return Boolean.parseBoolean((String) messageInfo.getMap().getOrDefault(TomEEMessageInfo.AUTHENTICATE, "false"));
}
@@ -104,6 +108,10 @@ public void cleanClientSubject() {
@Override
public AuthenticationParameters getAuthParameters() {
+ final Object fromRequest = getRequest().getAttribute(TomEEMessageInfo.AUTH_PARAMS);
+ if (fromRequest instanceof AuthenticationParameters) {
+ return (AuthenticationParameters) fromRequest;
+ }
return (AuthenticationParameters) messageInfo.getMap()
.getOrDefault(TomEEMessageInfo.AUTH_PARAMS,
new AuthenticationParameters());
diff --git a/tomee/tomee-security/src/main/java/org/apache/tomee/security/message/TomEEMessageInfo.java b/tomee/tomee-security/src/main/java/org/apache/tomee/security/message/TomEEMessageInfo.java
index 9fea1c9c13e..6a2c69d51ee 100644
--- a/tomee/tomee-security/src/main/java/org/apache/tomee/security/message/TomEEMessageInfo.java
+++ b/tomee/tomee-security/src/main/java/org/apache/tomee/security/message/TomEEMessageInfo.java
@@ -28,6 +28,8 @@ public class TomEEMessageInfo extends MessageInfoImpl {
public static final String IS_MANDATORY = "jakarta.security.auth.message.MessagePolicy.isMandatory";
public static final String REGISTER_SESSION = "jakarta.servlet.http.registerSession";
+ public static final String LAST_AUTH_STATUS = "org.apache.tomee.security.context.lastAuthStatus";
+
public TomEEMessageInfo(final HttpServletRequest request,
final HttpServletResponse response,
final boolean authMandatory) {
diff --git a/tomee/tomee-security/src/main/java/org/apache/tomee/security/provider/TomEESecurityServerAuthModule.java b/tomee/tomee-security/src/main/java/org/apache/tomee/security/provider/TomEESecurityServerAuthModule.java
index 502d385c814..7de548dd0e2 100644
--- a/tomee/tomee-security/src/main/java/org/apache/tomee/security/provider/TomEESecurityServerAuthModule.java
+++ b/tomee/tomee-security/src/main/java/org/apache/tomee/security/provider/TomEESecurityServerAuthModule.java
@@ -32,6 +32,8 @@
import jakarta.security.enterprise.authentication.mechanism.http.HttpMessageContext;
import java.util.Map;
+import org.apache.tomee.security.message.TomEEMessageInfo;
+
import static org.apache.tomee.security.http.TomEEHttpMessageContext.httpMessageContext;
public class TomEESecurityServerAuthModule implements ServerAuthModule {
@@ -109,11 +111,14 @@ public AuthStatus validateRequest(final MessageInfo messageInfo, final Subject c
} catch (final AuthenticationException e) {
+ httpMessageContext.getRequest().setAttribute(TomEEMessageInfo.LAST_AUTH_STATUS,
+ AuthenticationStatus.SEND_FAILURE);
final AuthException authException = new AuthException(e.getMessage());
authException.initCause(e);
throw authException;
}
+ httpMessageContext.getRequest().setAttribute(TomEEMessageInfo.LAST_AUTH_STATUS, authenticationStatus);
return mapToAuthStatus(authenticationStatus);
}
diff --git a/tomee/tomee-security/src/test/java/org/apache/tomee/security/context/SecurityContextTest.java b/tomee/tomee-security/src/test/java/org/apache/tomee/security/context/SecurityContextTest.java
index 460b57992ae..91674492f48 100644
--- a/tomee/tomee-security/src/test/java/org/apache/tomee/security/context/SecurityContextTest.java
+++ b/tomee/tomee-security/src/test/java/org/apache/tomee/security/context/SecurityContextTest.java
@@ -25,8 +25,10 @@
import jakarta.security.enterprise.AuthenticationStatus;
import jakarta.security.enterprise.SecurityContext;
import jakarta.security.enterprise.authentication.mechanism.http.AuthenticationParameters;
+import jakarta.security.enterprise.authentication.mechanism.http.AutoApplySession;
import jakarta.security.enterprise.authentication.mechanism.http.HttpAuthenticationMechanism;
import jakarta.security.enterprise.authentication.mechanism.http.HttpMessageContext;
+import jakarta.security.enterprise.credential.Credential;
import jakarta.security.enterprise.credential.UsernamePasswordCredential;
import jakarta.security.enterprise.identitystore.CredentialValidationResult;
import jakarta.security.enterprise.identitystore.IdentityStoreHandler;
@@ -35,6 +37,7 @@
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.ClientBuilder;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
@@ -42,6 +45,7 @@
import static jakarta.security.enterprise.identitystore.CredentialValidationResult.Status.VALID;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
public class SecurityContextTest extends AbstractTomEESecurityTest {
@Test
@@ -98,6 +102,51 @@ public void wrongPassword() throws Exception {
.get().getStatus());
}
+ @Test
+ public void authenticateReturnsSuccessStatus() throws Exception {
+ final Response response = ClientBuilder.newBuilder().build()
+ .target(getAppUrl() + "/securityContextStatus")
+ .queryParam("username", "tomcat")
+ .queryParam("password", "tomcat")
+ .request()
+ .get();
+ assertEquals("SUCCESS", response.readEntity(String.class));
+ }
+
+ @Test
+ public void authenticateReturnsSendFailureWhenMechanismThrows() throws Exception {
+ final Response response = ClientBuilder.newBuilder().build()
+ .target(getAppUrl() + "/securityContextStatus")
+ .queryParam("username", "throws")
+ .queryParam("password", "whatever")
+ .request()
+ .get();
+ assertEquals("SEND_FAILURE", response.readEntity(String.class));
+ }
+
+ @Test
+ public void authenticatePersistsAcrossRequests() throws Exception {
+ final Client client = ClientBuilder.newBuilder().build();
+
+ final Response login = client.target(getAppUrl() + "/securityContextPrincipal")
+ .queryParam("username", "tomcat")
+ .queryParam("password", "tomcat")
+ .request()
+ .get();
+ assertEquals(200, login.getStatus());
+ assertEquals("tomcat", login.readEntity(String.class));
+
+ assertNotNull("expected a session to be created by @AutoApplySession", login.getCookies().get("JSESSIONID"));
+ final String sessionId = login.getCookies().get("JSESSIONID").getValue();
+
+ final Response whoami = client.target(getAppUrl() + "/securityContextWhoAmI")
+ .request()
+ .cookie("JSESSIONID", sessionId)
+ .get();
+ assertEquals(200, whoami.getStatus());
+ assertEquals("tomcat", whoami.readEntity(String.class));
+ }
+
@TomcatUserIdentityStoreDefinition
@WebServlet(urlPatterns = "/securityContext")
public static class TestServlet extends HttpServlet {
@@ -166,6 +215,39 @@ protected void doGet(final HttpServletRequest req, final HttpServletResponse res
}
}
+ @TomcatUserIdentityStoreDefinition
+ @WebServlet(urlPatterns = "/securityContextStatus")
+ public static class StatusServlet extends HttpServlet {
+ @Inject
+ private SecurityContext securityContext;
+
+ @Override
+ protected void doGet(final HttpServletRequest req, final HttpServletResponse resp)
+ throws ServletException, IOException {
+
+ final AuthenticationParameters parameters =
+ AuthenticationParameters.withParams()
+ .credential(new UsernamePasswordCredential(req.getParameter("username"),
+ req.getParameter("password")))
+ .newAuthentication(true);
+
+ final AuthenticationStatus status = securityContext.authenticate(req, resp, parameters);
+ resp.getWriter().write(status.name());
+ }
+ }
+
+ @WebServlet(urlPatterns = "/securityContextWhoAmI")
+ public static class WhoAmIServlet extends HttpServlet {
+ @Override
+ protected void doGet(final HttpServletRequest req, final HttpServletResponse resp)
+ throws ServletException, IOException {
+
+ final Principal principal = req.getUserPrincipal();
+ resp.getWriter().write(principal == null ? "null" : principal.getName());
+ }
+ }
+
+ @AutoApplySession
public static class SecurityContextHttpAuthenticationMechanism implements HttpAuthenticationMechanism {
@Inject
private IdentityStoreHandler identityStoreHandler;
@@ -177,9 +259,17 @@ public AuthenticationStatus validateRequest(final HttpServletRequest request,
throws AuthenticationException {
if (httpMessageContext.isAuthenticationRequest()) {
+ final Credential credential = httpMessageContext.getAuthParameters().getCredential();
+
+ // Sentinel used by the tests to exercise the "mechanism throws" path: authenticate()
+ // must recover SEND_FAILURE for this even though request.authenticate() only sees a boolean.
+ if (credential instanceof UsernamePasswordCredential
+ && "throws".equals(((UsernamePasswordCredential) credential).getCaller())) {
+ throw new AuthenticationException("simulated mechanism failure");
+ }
+
try {
- final CredentialValidationResult result =
- identityStoreHandler.validate(httpMessageContext.getAuthParameters().getCredential());
+ final CredentialValidationResult result = identityStoreHandler.validate(credential);
if (result.getStatus().equals(VALID)) {
return httpMessageContext.notifyContainerAboutLogin(result);
diff --git a/tomee/tomee-security/src/test/java/org/apache/tomee/security/http/LoginToContinueMechanismTest.java b/tomee/tomee-security/src/test/java/org/apache/tomee/security/http/LoginToContinueMechanismTest.java
new file mode 100644
index 00000000000..f8b1a6520b8
--- /dev/null
+++ b/tomee/tomee-security/src/test/java/org/apache/tomee/security/http/LoginToContinueMechanismTest.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tomee.security.http;
+
+import org.junit.Test;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class LoginToContinueMechanismTest {
+
+ @Test
+ public void hasRequest_noSession_returnsFalseWithoutCreatingSession() {
+ final HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession(false)).thenReturn(null);
+
+ assertFalse(LoginToContinueMechanism.hasRequest(request));
+
+ verify(request).getSession(false);
+ verify(request, never()).getSession();
+ }
+
+ @Test
+ public void getRequest_noSession_returnsNullWithoutCreatingSession() {
+ final HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession(false)).thenReturn(null);
+
+ assertNull(LoginToContinueMechanism.getRequest(request));
+
+ verify(request).getSession(false);
+ verify(request, never()).getSession();
+ }
+
+ @Test
+ public void hasAuthentication_noSession_returnsFalseWithoutCreatingSession() {
+ final HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession(false)).thenReturn(null);
+
+ assertFalse(LoginToContinueMechanism.hasAuthentication(request));
+
+ verify(request).getSession(false);
+ verify(request, never()).getSession();
+ }
+
+ @Test
+ public void getAuthentication_noSession_returnsNullWithoutCreatingSession() {
+ final HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession(false)).thenReturn(null);
+
+ assertNull(LoginToContinueMechanism.getAuthentication(request));
+
+ verify(request).getSession(false);
+ verify(request, never()).getSession();
+ }
+
+ @Test
+ public void clearRequestAndAuthentication_noSession_doesNotCreateSession() {
+ final HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession(false)).thenReturn(null);
+
+ LoginToContinueMechanism.clearRequestAndAuthentication(request);
+
+ verify(request).getSession(false);
+ verify(request, never()).getSession();
+ }
+}
diff --git a/tomee/tomee-security/src/test/java/org/apache/tomee/security/servlet/LoginToContinueNoSessionTest.java b/tomee/tomee-security/src/test/java/org/apache/tomee/security/servlet/LoginToContinueNoSessionTest.java
new file mode 100644
index 00000000000..a4e51da2934
--- /dev/null
+++ b/tomee/tomee-security/src/test/java/org/apache/tomee/security/servlet/LoginToContinueNoSessionTest.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tomee.security.servlet;
+
+import org.apache.tomee.security.AbstractTomEESecurityTest;
+import org.junit.Test;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.annotation.WebServlet;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.client.ClientBuilder;
+import jakarta.ws.rs.core.NewCookie;
+import jakarta.ws.rs.core.Response;
+import java.io.IOException;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class LoginToContinueNoSessionTest extends AbstractTomEESecurityTest {
+
+ @Test
+ public void unprotectedResource_doesNotCreateSession() {
+ final Client client = ClientBuilder.newBuilder().build();
+ final Response response =
+ client.target(getAppUrl() + "/public")
+ .request()
+ .get();
+
+ assertEquals(200, response.getStatus());
+ assertEquals("public", response.readEntity(String.class));
+
+ final Map cookies = response.getCookies();
+ assertNull("JSESSIONID must not be set for unprotected resources",
+ cookies.get("JSESSIONID"));
+ }
+
+ @WebServlet(urlPatterns = "/public")
+ public static class PublicServlet extends HttpServlet {
+ @Override
+ protected void doGet(final HttpServletRequest req, final HttpServletResponse resp)
+ throws ServletException, IOException {
+ resp.getWriter().write("public");
+ }
+ }
+}