diff --git a/examples/pom.xml b/examples/pom.xml index f51747121d1..37e78770f9b 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -206,6 +206,8 @@ security-tomcat-user-identitystore security-custom-identitystore security-openid + security-passkey-2fa + security-passkey-2fa-programmatic diff --git a/examples/security-passkey-2fa-programmatic/pom.xml b/examples/security-passkey-2fa-programmatic/pom.xml new file mode 100644 index 00000000000..9cc6eb49007 --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/pom.xml @@ -0,0 +1,117 @@ + + + + 4.0.0 + org.superbiz + security-passkey-2fa-programmatic + 11.0.0-SNAPSHOT + war + + TomEE :: Examples :: Jakarta Security Passkey 2FA (programmatic SecurityContext.authenticate) + + + UTF-8 + 11.0.0-SNAPSHOT + + 0.30.3.RELEASE + + + + + + org.apache.tomee + jakartaee-api + 11.0.0-M1 + provided + + + + com.webauthn4j + webauthn4j-core + ${version.webauthn4j} + + + + junit + junit + 4.13.2 + test + + + + com.webauthn4j + webauthn4j-test + ${version.webauthn4j} + test + + + + org.apache.tomee.bom + tomee-microprofile + ${version.tomee} + test + + + + + passkey + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + 17 + 17 + 17 + + + + + + org.apache.tomee.maven + tomee-maven-plugin + ${version.tomee} + + microprofile + passkey-programmatic + + + + + + + + localhost + file://${basedir}/target/repo/ + + + localhost + file://${basedir}/target/snapshot-repo/ + + + diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/CredentialStore.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/CredentialStore.java new file mode 100644 index 00000000000..94647deacaf --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/CredentialStore.java @@ -0,0 +1,90 @@ +/* + * 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.credential.CredentialRecord; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@ApplicationScoped +public class CredentialStore { + + public static final class Entry { + private final String username; + private final byte[] credentialId; + private CredentialRecord record; + + private Entry(final String username, final byte[] credentialId, final CredentialRecord record) { + this.username = username; + this.credentialId = credentialId; + this.record = record; + } + + public String getUsername() { + return username; + } + + public byte[] getCredentialId() { + return credentialId; + } + + public CredentialRecord getRecord() { + return record; + } + } + + private final Map byCredentialId = new ConcurrentHashMap<>(); + + public void save(final String username, final byte[] credentialId, final CredentialRecord record) { + byCredentialId.put(key(credentialId), new Entry(username, credentialId, record)); + } + + public Optional find(final byte[] credentialId) { + return Optional.ofNullable(byCredentialId.get(key(credentialId))); + } + + public List credentialIds(final String username) { + final List ids = new ArrayList<>(); + for (final Entry entry : byCredentialId.values()) { + if (entry.username.equals(username)) { + ids.add(entry.credentialId); + } + } + return ids; + } + + public boolean hasCredentials(final String username) { + return !credentialIds(username).isEmpty(); + } + + public void updateRecord(final byte[] credentialId, final CredentialRecord updated) { + final Entry entry = byCredentialId.get(key(credentialId)); + if (entry != null) { + entry.record = updated; + } + } + + private static String key(final byte[] credentialId) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(credentialId); + } +} diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/Http.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/Http.java new file mode 100644 index 00000000000..af96f41c32b --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/Http.java @@ -0,0 +1,45 @@ +/* + * 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 jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public class Http { + + private Http() { + } + + public static String body(final HttpServletRequest request) throws IOException { + return new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } + + public static void json(final HttpServletResponse response, final String json) throws IOException { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(json); + } + + public static void error(final HttpServletResponse response, final int status, final String message) throws IOException { + response.setStatus(status); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"error\":\"" + message.replace("\"", "'") + "\"}"); + } +} diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyAuthenticationMechanism.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyAuthenticationMechanism.java new file mode 100644 index 00000000000..4c5187bb6c3 --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyAuthenticationMechanism.java @@ -0,0 +1,49 @@ +/* + * 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 jakarta.enterprise.context.ApplicationScoped; +import jakarta.security.enterprise.AuthenticationException; +import jakarta.security.enterprise.AuthenticationStatus; +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.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + + +@ApplicationScoped +@AutoApplySession +public class PasskeyAuthenticationMechanism implements HttpAuthenticationMechanism { + + @Override + public AuthenticationStatus validateRequest(final HttpServletRequest request, + final HttpServletResponse response, + final HttpMessageContext httpMessageContext) + throws AuthenticationException { + + final Credential credential = httpMessageContext.getAuthParameters().getCredential(); + + if (credential instanceof PasskeyCredential passkey) { + return httpMessageContext.notifyContainerAboutLogin(passkey.getCallerName(), + passkey.getGroups()); + } + + return httpMessageContext.doNothing(); + } +} diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyCredential.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyCredential.java new file mode 100644 index 00000000000..360d29feff9 --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyCredential.java @@ -0,0 +1,40 @@ +/* + * 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 jakarta.security.enterprise.credential.Credential; + +import java.util.Set; + +public class PasskeyCredential implements Credential { + + private final String callerName; + private final Set groups; + + public PasskeyCredential(final String callerName, final Set groups) { + this.callerName = callerName; + this.groups = groups; + } + + public String getCallerName() { + return callerName; + } + + public Set getGroups() { + return groups; + } +} diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyLoginServlet.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyLoginServlet.java new file mode 100644 index 00000000000..df3ae4f8f8b --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyLoginServlet.java @@ -0,0 +1,135 @@ +/* + * 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 jakarta.inject.Inject; +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.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +import java.io.IOException; +import java.util.Set; + +@WebServlet("/api/login/*") +public class PasskeyLoginServlet extends HttpServlet { + + static final String FIRST_FACTOR_USER = "passkey.firstFactor.user"; + + @Inject + private UserRepository users; + + @Inject + private WebAuthnService webAuthn; + + @Inject + private SecurityContext securityContext; + + @Override + protected void doGet(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + + if ("/assertion-options".equals(request.getPathInfo())) { + final String username = firstFactorUser(request); + if (username == null) { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Complete the password step first"); + return; + } + Http.json(response, webAuthn.assertionOptions(request, username)); + return; + } + + Http.error(response, HttpServletResponse.SC_NOT_FOUND, "Unknown endpoint"); + } + + @Override + protected void doPost(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + + final String path = request.getPathInfo(); + if ("/password".equals(path)) { + handlePassword(request, response); + } else if ("/assertion".equals(path)) { + handleAssertion(request, response); + } else { + Http.error(response, HttpServletResponse.SC_NOT_FOUND, "Unknown endpoint"); + } + } + + private void handlePassword(final HttpServletRequest request, final HttpServletResponse response) + throws IOException { + + final var body = WebAuthnService.parse(Http.body(request)); + final String username = body.getString("username", null); + final String password = body.getString("password", null); + + if (username == null || password == null || !users.validatePassword(username, password)) { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Bad username or password"); + return; + } + + request.getSession(true).setAttribute(FIRST_FACTOR_USER, username); + Http.json(response, "{\"firstFactor\":true}"); + } + + private void handleAssertion(final HttpServletRequest request, final HttpServletResponse response) + throws IOException { + + final String firstFactorUser = firstFactorUser(request); + if (firstFactorUser == null) { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Complete the password step first"); + return; + } + + final String assertedUser; + try { + assertedUser = webAuthn.finishAssertion(request, Http.body(request)); + } catch (final RuntimeException e) { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Passkey verification failed"); + return; + } + + if (!firstFactorUser.equals(assertedUser)) { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Passkey does not match the user"); + return; + } + + final Set roles = users.roles(assertedUser).orElse(Set.of()); + + final AuthenticationStatus status = securityContext.authenticate( + request, response, + AuthenticationParameters.withParams().credential(new PasskeyCredential(assertedUser, roles))); + + request.getSession().removeAttribute(FIRST_FACTOR_USER); + + if (status == AuthenticationStatus.SUCCESS) { + Http.json(response, "{\"authenticated\":true,\"redirect\":\"" + request.getContextPath() + "/app\"}"); + } else { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Authentication failed: " + status); + } + } + + private static String firstFactorUser(final HttpServletRequest request) { + final HttpSession session = request.getSession(false); + return session == null ? null : (String) session.getAttribute(FIRST_FACTOR_USER); + } +} diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyRegistrationServlet.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyRegistrationServlet.java new file mode 100644 index 00000000000..a11751c3783 --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/PasskeyRegistrationServlet.java @@ -0,0 +1,79 @@ +/* + * 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 jakarta.inject.Inject; +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.servlet.http.HttpSession; + +import java.io.IOException; + +@WebServlet("/api/register/*") +public class PasskeyRegistrationServlet extends HttpServlet { + + @Inject + private WebAuthnService webAuthn; + + @Override + protected void doGet(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + + if ("/options".equals(request.getPathInfo())) { + final String username = currentUser(request); + if (username == null) { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Log in with your password first"); + return; + } + Http.json(response, webAuthn.registrationOptions(request, username)); + return; + } + + Http.error(response, HttpServletResponse.SC_NOT_FOUND, "Unknown endpoint"); + } + + @Override + protected void doPost(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + + final String username = currentUser(request); + if (username == null) { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Log in with your password first"); + return; + } + + try { + webAuthn.finishRegistration(request, username, Http.body(request)); + } catch (final RuntimeException e) { + Http.error(response, HttpServletResponse.SC_BAD_REQUEST, "Registration failed"); + return; + } + + Http.json(response, "{\"registered\":true}"); + } + + private static String currentUser(final HttpServletRequest request) { + if (request.getUserPrincipal() != null) { + return request.getUserPrincipal().getName(); + } + final HttpSession session = request.getSession(false); + return session == null ? null : (String) session.getAttribute(PasskeyLoginServlet.FIRST_FACTOR_USER); + } +} diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/ProtectedServlet.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/ProtectedServlet.java new file mode 100644 index 00000000000..5c8590c28c2 --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/ProtectedServlet.java @@ -0,0 +1,55 @@ +/* + * 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 jakarta.annotation.security.DeclareRoles; +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.HttpConstraint; +import jakarta.servlet.annotation.ServletSecurity; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.security.Principal; + +@WebServlet("/app") +@DeclareRoles({"user", "admin"}) +@ServletSecurity(@HttpConstraint(rolesAllowed = "user")) +public class ProtectedServlet extends HttpServlet { + + @Override + protected void doGet(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + + final Principal principal = request.getUserPrincipal(); + final String name = principal == null ? null : principal.getName(); + + response.setContentType("text/html;charset=UTF-8"); + response.getWriter().write( + "" + + "

Protected area

" + + "

You reached this page on a follow-up request, so the passkey login " + + "was persisted across requests.

" + + "

caller: " + name + "

" + + "

role \"user\": " + request.isUserInRole("user") + "

" + + "

role \"admin\": " + request.isUserInRole("admin") + "

" + + "

home

" + + ""); + } +} diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/UserRepository.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/UserRepository.java new file mode 100644 index 00000000000..d9b47949195 --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/UserRepository.java @@ -0,0 +1,48 @@ +/* + * 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 jakarta.enterprise.context.ApplicationScoped; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +@ApplicationScoped +public class UserRepository { + + private static final Map PASSWORDS = Map.of( + "jon", "doe", + "iron", "man"); + + private static final Map> ROLES = Map.of( + "jon", Set.of("user"), + "iron", Set.of("user", "admin")); + + public boolean validatePassword(final String username, final String password) { + final String expected = PASSWORDS.get(username); + return expected != null && expected.equals(password); + } + + public boolean exists(final String username) { + return PASSWORDS.containsKey(username); + } + + public Optional> roles(final String username) { + return Optional.ofNullable(ROLES.get(username)); + } +} diff --git a/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/WebAuthnService.java b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/WebAuthnService.java new file mode 100644 index 00000000000..a1fb676ccb5 --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/src/main/java/org/superbiz/passkey/WebAuthnService.java @@ -0,0 +1,191 @@ +/* + * 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.WebAuthnManager; +import com.webauthn4j.credential.CredentialRecord; +import com.webauthn4j.credential.CredentialRecordImpl; +import com.webauthn4j.data.AuthenticationData; +import com.webauthn4j.data.AuthenticationParameters; +import com.webauthn4j.data.RegistrationData; +import com.webauthn4j.data.RegistrationParameters; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.server.ServerProperty; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.servlet.http.HttpServletRequest; + +import java.util.Base64; +import java.util.List; + +@ApplicationScoped +public class WebAuthnService { + + private static final String RP_NAME = "TomEE Passkey Demo"; + private static final long TIMEOUT_MS = 60_000L; + + static final String REG_CHALLENGE = "passkey.registration.challenge"; + static final String AUTH_CHALLENGE = "passkey.assertion.challenge"; + + private final WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager(); + + @Inject + private CredentialStore credentialStore; + + public String registrationOptions(final HttpServletRequest request, final String username) { + final Challenge challenge = new DefaultChallenge(); + request.getSession().setAttribute(REG_CHALLENGE, encode(challenge.getValue())); + + final var pubKeyCredParams = Json.createArrayBuilder() + .add(Json.createObjectBuilder().add("type", "public-key").add("alg", -7)) // ES256 + .add(Json.createObjectBuilder().add("type", "public-key").add("alg", -257)); // RS256 + + final var excludeCredentials = Json.createArrayBuilder(); + for (final byte[] id : credentialStore.credentialIds(username)) { + excludeCredentials.add(Json.createObjectBuilder() + .add("type", "public-key") + .add("id", encode(id))); + } + + return Json.createObjectBuilder() + .add("challenge", encode(challenge.getValue())) + .add("rp", Json.createObjectBuilder().add("id", rpId(request)).add("name", RP_NAME)) + .add("user", Json.createObjectBuilder() + .add("id", encode(username.getBytes())) + .add("name", username) + .add("displayName", username)) + .add("pubKeyCredParams", pubKeyCredParams) + .add("timeout", TIMEOUT_MS) + .add("attestation", "none") + .add("authenticatorSelection", Json.createObjectBuilder() + .add("residentKey", "preferred") + .add("userVerification", "preferred")) + .add("excludeCredentials", excludeCredentials) + .build() + .toString(); + } + + public void finishRegistration(final HttpServletRequest request, + final String username, + final String responseJson) { + + final ServerProperty serverProperty = serverProperty(request, REG_CHALLENGE); + + final RegistrationData registrationData = webAuthnManager.parseRegistrationResponseJSON(responseJson); + + // pubKeyCredParams = null (accept what we offered), UV not required, UP required + final RegistrationParameters parameters = + new RegistrationParameters(serverProperty, null, false, true); + + webAuthnManager.verify(registrationData, parameters); + + final CredentialRecord record = new CredentialRecordImpl( + registrationData.getAttestationObject(), + registrationData.getCollectedClientData(), + registrationData.getClientExtensions(), + registrationData.getTransports()); + + final byte[] credentialId = registrationData.getAttestationObject() + .getAuthenticatorData() + .getAttestedCredentialData() + .getCredentialId(); + + credentialStore.save(username, credentialId, record); + request.getSession().removeAttribute(REG_CHALLENGE); + } + + public String assertionOptions(final HttpServletRequest request, final String username) { + final Challenge challenge = new DefaultChallenge(); + request.getSession().setAttribute(AUTH_CHALLENGE, encode(challenge.getValue())); + + final var allowCredentials = Json.createArrayBuilder(); + for (final byte[] id : credentialStore.credentialIds(username)) { + allowCredentials.add(Json.createObjectBuilder() + .add("type", "public-key") + .add("id", encode(id))); + } + + return Json.createObjectBuilder() + .add("challenge", encode(challenge.getValue())) + .add("rpId", rpId(request)) + .add("timeout", TIMEOUT_MS) + .add("userVerification", "preferred") + .add("allowCredentials", allowCredentials) + .build() + .toString(); + } + + public String finishAssertion(final HttpServletRequest request, final String responseJson) { + final ServerProperty serverProperty = serverProperty(request, AUTH_CHALLENGE); + + final AuthenticationData authenticationData = webAuthnManager.parseAuthenticationResponseJSON(responseJson); + + final CredentialStore.Entry entry = credentialStore.find(authenticationData.getCredentialId()) + .orElseThrow(() -> new IllegalStateException("Unknown credential")); + + final List 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-programmatic/src/main/webapp/WEB-INF/beans.xml b/examples/security-passkey-2fa-programmatic/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 00000000000..2d6f5444a4d --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/src/main/webapp/WEB-INF/beans.xml @@ -0,0 +1,23 @@ + + + + diff --git a/examples/security-passkey-2fa-programmatic/src/main/webapp/index.html b/examples/security-passkey-2fa-programmatic/src/main/webapp/index.html new file mode 100644 index 00000000000..44e02051997 --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/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).

+
    +
  1. Register a passkey - log in with your password, then enrol an authenticator.
  2. +
  3. Log in - password, then passkey, then reach the protected page.
  4. +
  5. Protected page - only reachable once both factors have passed.
  6. +
+

Demo users: jon / doe and iron / man.

+

Passkeys require a secure context: use http://localhost or HTTPS.

+ + diff --git a/examples/security-passkey-2fa-programmatic/src/main/webapp/js/webauthn.js b/examples/security-passkey-2fa-programmatic/src/main/webapp/js/webauthn.js new file mode 100644 index 00000000000..336c2923af3 --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/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-programmatic/src/main/webapp/login.html b/examples/security-passkey-2fa-programmatic/src/main/webapp/login.html new file mode 100644 index 00000000000..1d0c6048a3d --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/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-programmatic/src/main/webapp/register.html b/examples/security-passkey-2fa-programmatic/src/main/webapp/register.html new file mode 100644 index 00000000000..ece7b2b4bd5 --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/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-programmatic/src/test/java/org/superbiz/passkey/PasskeyFlowTest.java b/examples/security-passkey-2fa-programmatic/src/test/java/org/superbiz/passkey/PasskeyFlowTest.java new file mode 100644 index 00000000000..c3afce6445b --- /dev/null +++ b/examples/security-passkey-2fa-programmatic/src/test/java/org/superbiz/passkey/PasskeyFlowTest.java @@ -0,0 +1,251 @@ +/* + * 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(PasskeyCredential.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/examples/security-passkey-2fa/pom.xml b/examples/security-passkey-2fa/pom.xml new file mode 100644 index 00000000000..7ffa056d2ec --- /dev/null +++ b/examples/security-passkey-2fa/pom.xml @@ -0,0 +1,117 @@ + + + + 4.0.0 + org.superbiz + security-passkey-2fa + 11.0.0-SNAPSHOT + war + + TomEE :: Examples :: Jakarta Security Passkey (WebAuthn) as a 2nd factor + + + UTF-8 + 11.0.0-SNAPSHOT + + 0.30.3.RELEASE + + + + + + org.apache.tomee + jakartaee-api + 11.0.0-M1 + provided + + + + com.webauthn4j + webauthn4j-core + ${version.webauthn4j} + + + + junit + junit + 4.13.2 + test + + + + com.webauthn4j + webauthn4j-test + ${version.webauthn4j} + test + + + + org.apache.tomee.bom + tomee-microprofile + ${version.tomee} + test + + + + + passkey + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + 17 + 17 + 17 + + + + + + org.apache.tomee.maven + tomee-maven-plugin + ${version.tomee} + + microprofile + passkey + + + + + + + + localhost + file://${basedir}/target/repo/ + + + localhost + file://${basedir}/target/snapshot-repo/ + + + diff --git a/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/CredentialStore.java b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/CredentialStore.java new file mode 100644 index 00000000000..94647deacaf --- /dev/null +++ b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/CredentialStore.java @@ -0,0 +1,90 @@ +/* + * 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.credential.CredentialRecord; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@ApplicationScoped +public class CredentialStore { + + public static final class Entry { + private final String username; + private final byte[] credentialId; + private CredentialRecord record; + + private Entry(final String username, final byte[] credentialId, final CredentialRecord record) { + this.username = username; + this.credentialId = credentialId; + this.record = record; + } + + public String getUsername() { + return username; + } + + public byte[] getCredentialId() { + return credentialId; + } + + public CredentialRecord getRecord() { + return record; + } + } + + private final Map byCredentialId = new ConcurrentHashMap<>(); + + public void save(final String username, final byte[] credentialId, final CredentialRecord record) { + byCredentialId.put(key(credentialId), new Entry(username, credentialId, record)); + } + + public Optional find(final byte[] credentialId) { + return Optional.ofNullable(byCredentialId.get(key(credentialId))); + } + + public List credentialIds(final String username) { + final List ids = new ArrayList<>(); + for (final Entry entry : byCredentialId.values()) { + if (entry.username.equals(username)) { + ids.add(entry.credentialId); + } + } + return ids; + } + + public boolean hasCredentials(final String username) { + return !credentialIds(username).isEmpty(); + } + + public void updateRecord(final byte[] credentialId, final CredentialRecord updated) { + final Entry entry = byCredentialId.get(key(credentialId)); + if (entry != null) { + entry.record = updated; + } + } + + private static String key(final byte[] credentialId) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(credentialId); + } +} diff --git a/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/Http.java b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/Http.java new file mode 100644 index 00000000000..af96f41c32b --- /dev/null +++ b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/Http.java @@ -0,0 +1,45 @@ +/* + * 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 jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public class Http { + + private Http() { + } + + public static String body(final HttpServletRequest request) throws IOException { + return new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } + + public static void json(final HttpServletResponse response, final String json) throws IOException { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(json); + } + + public static void error(final HttpServletResponse response, final int status, final String message) throws IOException { + response.setStatus(status); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"error\":\"" + message.replace("\"", "'") + "\"}"); + } +} diff --git a/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/PasskeyAuthenticationMechanism.java b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/PasskeyAuthenticationMechanism.java new file mode 100644 index 00000000000..dfeff7036fe --- /dev/null +++ b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/PasskeyAuthenticationMechanism.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.superbiz.passkey; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.security.enterprise.AuthenticationException; +import jakarta.security.enterprise.AuthenticationStatus; +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.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +import java.io.IOException; +import java.util.Set; + + +@ApplicationScoped +@AutoApplySession +public class PasskeyAuthenticationMechanism implements HttpAuthenticationMechanism { + + @Inject + private WebAuthnService webAuthn; + + @Inject + private UserRepository users; + + @Override + public AuthenticationStatus validateRequest(final HttpServletRequest request, + final HttpServletResponse response, + final HttpMessageContext httpMessageContext) + throws AuthenticationException { + + if (!"POST".equalsIgnoreCase(request.getMethod()) + || !request.getRequestURI().endsWith("/api/login/assertion")) { + return httpMessageContext.doNothing(); + } + + final HttpSession session = request.getSession(false); + final String firstFactorUser = + session == null ? null : (String) session.getAttribute(PasskeyLoginServlet.FIRST_FACTOR_USER); + if (firstFactorUser == null) { + return httpMessageContext.responseUnauthorized(); + } + + final String assertedUser; + try { + assertedUser = webAuthn.finishAssertion(request, readBody(request)); + } catch (final RuntimeException e) { + return httpMessageContext.responseUnauthorized(); + } + + if (!firstFactorUser.equals(assertedUser)) { + return httpMessageContext.responseUnauthorized(); + } + + final Set roles = users.roles(assertedUser).orElse(Set.of()); + session.removeAttribute(PasskeyLoginServlet.FIRST_FACTOR_USER); + + return httpMessageContext.notifyContainerAboutLogin(assertedUser, roles); + } + + private static String readBody(final HttpServletRequest request) throws AuthenticationException { + try { + return Http.body(request); + } catch (final IOException e) { + throw new AuthenticationException(e.getMessage()); + } + } +} diff --git a/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/PasskeyLoginServlet.java b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/PasskeyLoginServlet.java new file mode 100644 index 00000000000..589e4fb914a --- /dev/null +++ b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/PasskeyLoginServlet.java @@ -0,0 +1,103 @@ +/* + * 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 jakarta.inject.Inject; +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.servlet.http.HttpSession; + +import java.io.IOException; +import java.security.Principal; + +@WebServlet("/api/login/*") +public class PasskeyLoginServlet extends HttpServlet { + + static final String FIRST_FACTOR_USER = "passkey.firstFactor.user"; + + @Inject + private UserRepository users; + + @Inject + private WebAuthnService webAuthn; + + @Override + protected void doGet(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + + if ("/assertion-options".equals(request.getPathInfo())) { + final String username = firstFactorUser(request); + if (username == null) { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Complete the password step first"); + return; + } + Http.json(response, webAuthn.assertionOptions(request, username)); + return; + } + + Http.error(response, HttpServletResponse.SC_NOT_FOUND, "Unknown endpoint"); + } + + @Override + protected void doPost(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + + final String path = request.getPathInfo(); + if ("/password".equals(path)) { + handlePassword(request, response); + } else if ("/assertion".equals(path)) { + reportAssertionResult(request, response); + } else { + Http.error(response, HttpServletResponse.SC_NOT_FOUND, "Unknown endpoint"); + } + } + + private void handlePassword(final HttpServletRequest request, final HttpServletResponse response) + throws IOException { + + final var body = WebAuthnService.parse(Http.body(request)); + final String username = body.getString("username", null); + final String password = body.getString("password", null); + + if (username == null || password == null || !users.validatePassword(username, password)) { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Bad username or password"); + return; + } + + request.getSession(true).setAttribute(FIRST_FACTOR_USER, username); + Http.json(response, "{\"firstFactor\":true}"); + } + + private void reportAssertionResult(final HttpServletRequest request, final HttpServletResponse response) + throws IOException { + + final Principal principal = request.getUserPrincipal(); + if (principal != null) { + Http.json(response, "{\"authenticated\":true,\"redirect\":\"" + request.getContextPath() + "/app\"}"); + } else { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Authentication failed"); + } + } + + private static String firstFactorUser(final HttpServletRequest request) { + final HttpSession session = request.getSession(false); + return session == null ? null : (String) session.getAttribute(FIRST_FACTOR_USER); + } +} diff --git a/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/PasskeyRegistrationServlet.java b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/PasskeyRegistrationServlet.java new file mode 100644 index 00000000000..a11751c3783 --- /dev/null +++ b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/PasskeyRegistrationServlet.java @@ -0,0 +1,79 @@ +/* + * 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 jakarta.inject.Inject; +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.servlet.http.HttpSession; + +import java.io.IOException; + +@WebServlet("/api/register/*") +public class PasskeyRegistrationServlet extends HttpServlet { + + @Inject + private WebAuthnService webAuthn; + + @Override + protected void doGet(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + + if ("/options".equals(request.getPathInfo())) { + final String username = currentUser(request); + if (username == null) { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Log in with your password first"); + return; + } + Http.json(response, webAuthn.registrationOptions(request, username)); + return; + } + + Http.error(response, HttpServletResponse.SC_NOT_FOUND, "Unknown endpoint"); + } + + @Override + protected void doPost(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + + final String username = currentUser(request); + if (username == null) { + Http.error(response, HttpServletResponse.SC_UNAUTHORIZED, "Log in with your password first"); + return; + } + + try { + webAuthn.finishRegistration(request, username, Http.body(request)); + } catch (final RuntimeException e) { + Http.error(response, HttpServletResponse.SC_BAD_REQUEST, "Registration failed"); + return; + } + + Http.json(response, "{\"registered\":true}"); + } + + private static String currentUser(final HttpServletRequest request) { + if (request.getUserPrincipal() != null) { + return request.getUserPrincipal().getName(); + } + final HttpSession session = request.getSession(false); + return session == null ? null : (String) session.getAttribute(PasskeyLoginServlet.FIRST_FACTOR_USER); + } +} diff --git a/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/ProtectedServlet.java b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/ProtectedServlet.java new file mode 100644 index 00000000000..5c8590c28c2 --- /dev/null +++ b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/ProtectedServlet.java @@ -0,0 +1,55 @@ +/* + * 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 jakarta.annotation.security.DeclareRoles; +import jakarta.servlet.ServletException; +import jakarta.servlet.annotation.HttpConstraint; +import jakarta.servlet.annotation.ServletSecurity; +import jakarta.servlet.annotation.WebServlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.security.Principal; + +@WebServlet("/app") +@DeclareRoles({"user", "admin"}) +@ServletSecurity(@HttpConstraint(rolesAllowed = "user")) +public class ProtectedServlet extends HttpServlet { + + @Override + protected void doGet(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + + final Principal principal = request.getUserPrincipal(); + final String name = principal == null ? null : principal.getName(); + + response.setContentType("text/html;charset=UTF-8"); + response.getWriter().write( + "" + + "

Protected area

" + + "

You reached this page on a follow-up request, so the passkey login " + + "was persisted across requests.

" + + "

caller: " + name + "

" + + "

role \"user\": " + request.isUserInRole("user") + "

" + + "

role \"admin\": " + request.isUserInRole("admin") + "

" + + "

home

" + + ""); + } +} diff --git a/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/UserRepository.java b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/UserRepository.java new file mode 100644 index 00000000000..d9b47949195 --- /dev/null +++ b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/UserRepository.java @@ -0,0 +1,48 @@ +/* + * 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 jakarta.enterprise.context.ApplicationScoped; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +@ApplicationScoped +public class UserRepository { + + private static final Map PASSWORDS = Map.of( + "jon", "doe", + "iron", "man"); + + private static final Map> ROLES = Map.of( + "jon", Set.of("user"), + "iron", Set.of("user", "admin")); + + public boolean validatePassword(final String username, final String password) { + final String expected = PASSWORDS.get(username); + return expected != null && expected.equals(password); + } + + public boolean exists(final String username) { + return PASSWORDS.containsKey(username); + } + + public Optional> roles(final String username) { + return Optional.ofNullable(ROLES.get(username)); + } +} diff --git a/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/WebAuthnService.java b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/WebAuthnService.java new file mode 100644 index 00000000000..a1fb676ccb5 --- /dev/null +++ b/examples/security-passkey-2fa/src/main/java/org/superbiz/passkey/WebAuthnService.java @@ -0,0 +1,191 @@ +/* + * 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.WebAuthnManager; +import com.webauthn4j.credential.CredentialRecord; +import com.webauthn4j.credential.CredentialRecordImpl; +import com.webauthn4j.data.AuthenticationData; +import com.webauthn4j.data.AuthenticationParameters; +import com.webauthn4j.data.RegistrationData; +import com.webauthn4j.data.RegistrationParameters; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.server.ServerProperty; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.servlet.http.HttpServletRequest; + +import java.util.Base64; +import java.util.List; + +@ApplicationScoped +public class WebAuthnService { + + private static final String RP_NAME = "TomEE Passkey Demo"; + private static final long TIMEOUT_MS = 60_000L; + + static final String REG_CHALLENGE = "passkey.registration.challenge"; + static final String AUTH_CHALLENGE = "passkey.assertion.challenge"; + + private final WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager(); + + @Inject + private CredentialStore credentialStore; + + public String registrationOptions(final HttpServletRequest request, final String username) { + final Challenge challenge = new DefaultChallenge(); + request.getSession().setAttribute(REG_CHALLENGE, encode(challenge.getValue())); + + final var pubKeyCredParams = Json.createArrayBuilder() + .add(Json.createObjectBuilder().add("type", "public-key").add("alg", -7)) // ES256 + .add(Json.createObjectBuilder().add("type", "public-key").add("alg", -257)); // RS256 + + final var excludeCredentials = Json.createArrayBuilder(); + for (final byte[] id : credentialStore.credentialIds(username)) { + excludeCredentials.add(Json.createObjectBuilder() + .add("type", "public-key") + .add("id", encode(id))); + } + + return Json.createObjectBuilder() + .add("challenge", encode(challenge.getValue())) + .add("rp", Json.createObjectBuilder().add("id", rpId(request)).add("name", RP_NAME)) + .add("user", Json.createObjectBuilder() + .add("id", encode(username.getBytes())) + .add("name", username) + .add("displayName", username)) + .add("pubKeyCredParams", pubKeyCredParams) + .add("timeout", TIMEOUT_MS) + .add("attestation", "none") + .add("authenticatorSelection", Json.createObjectBuilder() + .add("residentKey", "preferred") + .add("userVerification", "preferred")) + .add("excludeCredentials", excludeCredentials) + .build() + .toString(); + } + + public void finishRegistration(final HttpServletRequest request, + final String username, + final String responseJson) { + + final ServerProperty serverProperty = serverProperty(request, REG_CHALLENGE); + + final RegistrationData registrationData = webAuthnManager.parseRegistrationResponseJSON(responseJson); + + // pubKeyCredParams = null (accept what we offered), UV not required, UP required + final RegistrationParameters parameters = + new RegistrationParameters(serverProperty, null, false, true); + + webAuthnManager.verify(registrationData, parameters); + + final CredentialRecord record = new CredentialRecordImpl( + registrationData.getAttestationObject(), + registrationData.getCollectedClientData(), + registrationData.getClientExtensions(), + registrationData.getTransports()); + + final byte[] credentialId = registrationData.getAttestationObject() + .getAuthenticatorData() + .getAttestedCredentialData() + .getCredentialId(); + + credentialStore.save(username, credentialId, record); + request.getSession().removeAttribute(REG_CHALLENGE); + } + + public String assertionOptions(final HttpServletRequest request, final String username) { + final Challenge challenge = new DefaultChallenge(); + request.getSession().setAttribute(AUTH_CHALLENGE, encode(challenge.getValue())); + + final var allowCredentials = Json.createArrayBuilder(); + for (final byte[] id : credentialStore.credentialIds(username)) { + allowCredentials.add(Json.createObjectBuilder() + .add("type", "public-key") + .add("id", encode(id))); + } + + return Json.createObjectBuilder() + .add("challenge", encode(challenge.getValue())) + .add("rpId", rpId(request)) + .add("timeout", TIMEOUT_MS) + .add("userVerification", "preferred") + .add("allowCredentials", allowCredentials) + .build() + .toString(); + } + + public String finishAssertion(final HttpServletRequest request, final String responseJson) { + final ServerProperty serverProperty = serverProperty(request, AUTH_CHALLENGE); + + final AuthenticationData authenticationData = webAuthnManager.parseAuthenticationResponseJSON(responseJson); + + final CredentialStore.Entry entry = credentialStore.find(authenticationData.getCredentialId()) + .orElseThrow(() -> new IllegalStateException("Unknown credential")); + + final List 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).

+
    +
  1. Register a passkey - log in with your password, then enrol an authenticator.
  2. +
  3. Log in - password, then passkey, then reach the protected page.
  4. +
  5. Protected page - only reachable once both factors have passed.
  6. +
+

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 eb9dd659b3f..19a3a0704cc 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 @@ -49,8 +49,10 @@ import jakarta.security.jacc.PolicyFactory; import jakarta.security.jacc.WebResourcePermission; import jakarta.security.jacc.WebRoleRefPermission; +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; @@ -232,52 +234,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 CallbackHandlerImpl callbackHandler = new CallbackHandlerImpl(); - final Request currentRequest = OpenEJBSecurityListener.requests.get(); - if (currentRequest != null) { - final Container container = currentRequest.getWrapper() != null ? currentRequest.getWrapper() : currentRequest.getContext(); - if (container != null) { - callbackHandler.setContainer(container); - } - } - - final AuthConfigProvider authConfigProvider = - AuthConfigFactory.getFactory().getConfigProvider("HttpServlet", appContext, null); - final ServerAuthConfig serverAuthConfig = - authConfigProvider.getServerAuthConfig("HttpServlet", appContext, callbackHandler); - - 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) { 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 3201ee501b0..ccfaa577a43 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 @@ -33,6 +33,8 @@ import java.util.Map; import org.apache.tomee.security.cdi.DefaultAuthenticationMechanismHandler; +import org.apache.tomee.security.message.TomEEMessageInfo; + import static org.apache.tomee.security.http.TomEEHttpMessageContext.httpMessageContext; public class TomEESecurityServerAuthModule implements ServerAuthModule { @@ -101,11 +103,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 f1068f1cf3d..1312d337a5b 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 @@ -29,8 +29,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; @@ -39,6 +41,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; @@ -48,6 +51,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 @@ -138,6 +142,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 { @@ -269,6 +318,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; @@ -280,9 +362,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);