diff --git a/Changelog.md b/Changelog.md index 8826185..97aba81 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,13 @@ # Changelog +### 1.4.0 - 21 May 2025 + +* PIResponse class can return the transaction based on the mode/type, which currently are Push, WebAuthn, Passkey and OTP. +* HTTP request headers are logged +* WebAuthn class as derived class of Challenge has been removed to allow simple serialization of PIResponse +* allowCredentials for WebAuthnSignRequests are merged when the PIResponse object is created and the combined SignRequest + is set to PIResponse.webAuthnSignRequest. WebAuthn challenges are not in the multi_challenge list anymore! + ### v1.3.1 - 14 May 2025 * PIResponse::isAuthenticationSuccessful will also consider if multi_challenge is present, not just the authentication field diff --git a/src/main/java/org/privacyidea/Challenge.java b/src/main/java/org/privacyidea/Challenge.java index 5e7e9d7..a5a16fb 100644 --- a/src/main/java/org/privacyidea/Challenge.java +++ b/src/main/java/org/privacyidea/Challenge.java @@ -17,11 +17,13 @@ package org.privacyidea; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class Challenge { - protected final List attributes = new ArrayList<>(); + protected final Map attributes = new HashMap<>(); protected final String serial; protected final String clientMode; protected final String message; @@ -39,17 +41,38 @@ public Challenge(String serial, String message, String clientMode, String image, this.type = type; } - public List getAttributes() {return attributes;} + public Map getAttributes() + { + return attributes; + } - public String getSerial() {return serial;} + public String getSerial() + { + return serial; + } - public String getMessage() {return message;} + public String getMessage() + { + return message; + } - public String getClientMode() {return clientMode;} + public String getClientMode() + { + return clientMode; + } - public String getImage() {return image.replaceAll("\"", "");} + public String getImage() + { + return image.replaceAll("\"", ""); + } - public String getTransactionID() {return transactionID;} + public String getTransactionID() + { + return transactionID; + } - public String getType() {return type;} -} + public String getType() + { + return type; + } +} \ No newline at end of file diff --git a/src/main/java/org/privacyidea/Endpoint.java b/src/main/java/org/privacyidea/Endpoint.java index 021b49a..3710ffe 100644 --- a/src/main/java/org/privacyidea/Endpoint.java +++ b/src/main/java/org/privacyidea/Endpoint.java @@ -186,7 +186,7 @@ void sendRequestAsync(String endpoint, Map params, Map webauthnSignRequests = new ArrayList<>(); for (int i = 0; i < arrChallenges.size(); i++) { JsonObject challenge = arrChallenges.get(i).getAsJsonObject(); @@ -300,28 +301,39 @@ else if ("interactive".equals(modeFromResponse)) if (TOKEN_TYPE_WEBAUTHN.equals(type)) { String webauthnSignRequest = getItemFromAttributes(challenge); - response.multiChallenge.add(new WebAuthn(serial, message, clientMode, image, transactionID, webauthnSignRequest)); + response.webAuthnTransactionId = transactionID; + if (webauthnSignRequest != null && !webauthnSignRequest.isEmpty()) + { + webauthnSignRequests.add(webauthnSignRequest); + } } else { response.multiChallenge.add(new Challenge(serial, message, clientMode, image, transactionID, type)); } } + if (!webauthnSignRequests.isEmpty()) + { + response.webAuthnSignRequest = mergeWebAuthnSignRequest(webauthnSignRequests); + } } } return response; } - static String mergeWebAuthnSignRequest(WebAuthn webauthn, List arr) throws JsonSyntaxException + String mergeWebAuthnSignRequest(List webAuthnSignRequests) throws JsonSyntaxException { + String first = webAuthnSignRequests.get(0); + //webAuthnSignRequests.remove(0); + List extracted = new ArrayList<>(); - for (String signRequest : arr) + for (String signRequest : webAuthnSignRequests) { JsonObject obj = JsonParser.parseString(signRequest).getAsJsonObject(); extracted.add(obj.getAsJsonArray("allowCredentials")); } - JsonObject signRequest = JsonParser.parseString(webauthn.signRequest()).getAsJsonObject(); + JsonObject signRequest = JsonParser.parseString(first).getAsJsonObject(); JsonArray allowCredentials = new JsonArray(); extracted.forEach(allowCredentials::addAll); diff --git a/src/main/java/org/privacyidea/PIConstants.java b/src/main/java/org/privacyidea/PIConstants.java index 5d64902..7cb0614 100644 --- a/src/main/java/org/privacyidea/PIConstants.java +++ b/src/main/java/org/privacyidea/PIConstants.java @@ -97,7 +97,7 @@ public class PIConstants public static final String SIGNATUREDATA = "signaturedata"; public static final String AUTHENTICATORDATA = "authenticatordata"; public static final String AUTHENTICATOR_DATA = "authenticatorData"; - public static final String USERHANDLE = "userhandle"; + public static final String USERHANDLE = "userHandle"; public static final String ASSERTIONCLIENTEXTENSIONS = "assertionclientextensions"; public static final String PASSKEY = "passkey"; public static final String RAW_ID = "rawId"; diff --git a/src/main/java/org/privacyidea/PIResponse.java b/src/main/java/org/privacyidea/PIResponse.java index 8a1bfc3..298a19e 100644 --- a/src/main/java/org/privacyidea/PIResponse.java +++ b/src/main/java/org/privacyidea/PIResponse.java @@ -16,6 +16,8 @@ */ package org.privacyidea; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.JsonSyntaxException; import java.util.ArrayList; import java.util.List; @@ -55,6 +57,9 @@ public class PIResponse public String username = ""; public String enrollmentLink = ""; + public String webAuthnSignRequest = ""; + public String webAuthnTransactionId = ""; + public boolean authenticationSuccessful() { if (authentication == AuthenticationStatus.ACCEPT && (multiChallenge == null || multiChallenge.isEmpty())) @@ -87,6 +92,29 @@ public String pushMessage() return reduceChallengeMessagesWhere(c -> TOKEN_TYPE_PUSH.equals(c.getType())); } + public String otpTransactionId() + { + for (Challenge challenge : multiChallenge) + { + if (!TOKEN_TYPE_PUSH.equals(challenge.getType()) && !TOKEN_TYPE_WEBAUTHN.equals(challenge.getType())) + { + return challenge.transactionID; + } + } + return null; + } + + public String pushTransactionId() { + for (Challenge challenge : multiChallenge) + { + if (TOKEN_TYPE_PUSH.equals(challenge.getType())) + { + return challenge.transactionID; + } + } + return null; + } + /** * Get the messages of all token that require an input field (HOTP, TOTP, SMS, Email...) reduced to a single string. * @@ -115,25 +143,12 @@ private String reduceChallengeMessagesWhere(Predicate predicate) */ public List triggeredTokenTypes() { - return multiChallenge.stream().map(Challenge::getType).distinct().collect(Collectors.toList()); - } - - /** - * Get all WebAuthn challenges from the multi_challenge. - * - * @return List of WebAuthn objects or empty list - */ - public List webAuthnSignRequests() - { - List ret = new ArrayList<>(); - multiChallenge.stream().filter(c -> TOKEN_TYPE_WEBAUTHN.equals(c.getType())).collect(Collectors.toList()).forEach(c -> - { - if (c instanceof WebAuthn) - { - ret.add((WebAuthn) c); - } - }); - return ret; + List types = multiChallenge.stream().map(Challenge::getType).distinct().collect(Collectors.toList()); + if (this.webAuthnSignRequest != null && !this.webAuthnSignRequest.isEmpty()) + { + types.add(TOKEN_TYPE_WEBAUTHN); + } + return types; } /** @@ -146,27 +161,24 @@ public List webAuthnSignRequests() */ public String mergedSignRequest() { - List webauthnSignRequests = webAuthnSignRequests(); - if (webauthnSignRequests.isEmpty()) + if (this.webAuthnSignRequest == null || this.webAuthnSignRequest.isEmpty()) { return ""; } - if (webauthnSignRequests.size() == 1) - { - return webauthnSignRequests.get(0).signRequest(); - } + return this.webAuthnSignRequest; + } - WebAuthn webauthn = webauthnSignRequests.get(0); - List stringSignRequests = webauthnSignRequests.stream().map(WebAuthn::signRequest).collect(Collectors.toList()); + public String toJSON() + { + GsonBuilder builder = new GsonBuilder(); + builder.setPrettyPrinting(); + Gson gson = builder.create(); + return gson.toJson(this); + } - try - { - return JSONParser.mergeWebAuthnSignRequest(webauthn, stringSignRequests); - } - catch (JsonSyntaxException e) - { - return ""; - } + public static PIResponse fromJSON(String json) + { + return new Gson().fromJson(json, PIResponse.class); } @Override diff --git a/src/main/java/org/privacyidea/WebAuthn.java b/src/main/java/org/privacyidea/WebAuthn.java deleted file mode 100644 index 8a8977a..0000000 --- a/src/main/java/org/privacyidea/WebAuthn.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2023 NetKnights GmbH - nils.behlen@netknights.it - * lukas.matusiewicz@netknights.it - * - Modified - *

- * Licensed 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 here: - * License - *

- * 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.privacyidea; - -public class WebAuthn extends Challenge -{ - private final String signRequest; - - public WebAuthn(String serial, String message, String clientMode, String image, String transactionID, String signRequest) - { - super(serial, message, clientMode, image, transactionID, PIConstants.TOKEN_TYPE_WEBAUTHN); - this.signRequest = signRequest; - } - - /** - * Returns the WebAuthn sign request in JSON format as a string, ready to use with pi-webauthn.js. - * If this returns an empty string, it *might* indicate that the PIN of this token should be changed. - * - * @return sign request or empty string - */ - public String signRequest() - { - return signRequest; - } -} diff --git a/src/test/java/org/privacyidea/TestWebAuthn.java b/src/test/java/org/privacyidea/TestWebAuthn.java index e0faa0c..f27767e 100644 --- a/src/test/java/org/privacyidea/TestWebAuthn.java +++ b/src/test/java/org/privacyidea/TestWebAuthn.java @@ -23,6 +23,7 @@ import org.mockserver.integration.ClientAndServer; import org.mockserver.model.HttpRequest; import org.mockserver.model.HttpResponse; +import shaded_package.org.apache.commons.lang3.StringUtils; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -49,68 +50,23 @@ public void teardown() mockServer.stop(); } - @Test - public void testSuccess() - { - String webauthnSignResponse = - "{" + "\"credentialid\":\"X9FrwMfmzj...saw21\"," + "\"authenticatordata\":\"xGzvgAAACA\"," + - "\"clientdata\":\"eyJjaGFsbG...dfhs\"," + "\"signaturedata\":\"MEUCIQDNrG...43hc\"," + - "\"assertionclientextensions\":\"alsjdlfkjsadjeiw\"," + "\"userhandle\":\"jalsdkjflsjvccco2\"\n" + "}"; - - mockServer.when(HttpRequest.request() - .withPath(PIConstants.ENDPOINT_VALIDATE_CHECK) - .withMethod("POST") - .withBody("user=Test&transaction_id=16786665691788289392&pass=" + - "&credentialid=X9FrwMfmzj...saw21&clientdata=eyJjaGFsbG...dfhs&signaturedata=MEUCIQDNrG...43hc" + - "&authenticatordata=xGzvgAAACA&userhandle=jalsdkjflsjvccco2&assertionclientextensions=alsjdlfkjsadjeiw")) - .respond(HttpResponse.response().withBody(Utils.matchingOneToken())); - - PIResponse response = privacyIDEA.validateCheckWebAuthn("Test", "16786665691788289392", webauthnSignResponse, "test.it"); - - assertNotNull(response); - assertEquals("matching 1 tokens", response.message); - assertEquals("PISP0001C673", response.serial); - assertEquals("totp", response.type); - assertEquals(1, response.id); - assertEquals("2.0", response.jsonRPCVersion); - assertEquals("3.2.1", response.piVersion); - assertEquals("rsa_sha256_pss:AAAAAAAAAAA", response.signature); - assertEquals(6, response.otpLength); - assertTrue(response.status); - assertTrue(response.value); - } - @Test public void testTriggerWebAuthn() { String username = "Test"; String pass = "Test"; - mockServer.when( - HttpRequest.request().withPath(PIConstants.ENDPOINT_VALIDATE_CHECK).withMethod("POST").withBody("user=" + username + "&pass=" + pass)) - .respond(HttpResponse.response() - // This response is simplified because it is very long and contains info that is not (yet) processed anyway - .withBody(Utils.triggerWebauthn())); + mockServer.when(HttpRequest.request() + .withPath(PIConstants.ENDPOINT_VALIDATE_CHECK) + .withMethod("POST") + .withBody("user=" + username + "&pass=" + pass)).respond(HttpResponse.response() + // This response is simplified because it is very long and contains info that is not (yet) processed anyway + .withBody(Utils.triggerWebauthn())); PIResponse response = privacyIDEA.validateCheck(username, pass); - - Optional opt = response.multiChallenge.stream().filter(challenge -> TOKEN_TYPE_WEBAUTHN.equals(challenge.getType())).findFirst(); - assertTrue(opt.isPresent()); assertEquals(AuthenticationStatus.CHALLENGE, response.authentication); assertEquals("webauthn", response.preferredClientMode); - Challenge a = opt.get(); - if (a instanceof WebAuthn) - { - WebAuthn b = (WebAuthn) a; - String trimmedRequest = Utils.webauthnSignRequest().replaceAll("\n", "").replaceAll(" ", ""); - assertEquals(trimmedRequest, b.signRequest()); - assertEquals("static/img/FIDO-U2F-Security-Key-444x444.png", b.getImage()); - assertEquals("webauthn", b.getClientMode()); - } - else - { - fail(); - } + assertTrue(response.webAuthnSignRequest != null && !response.webAuthnSignRequest.isEmpty()); } @Test @@ -122,12 +78,8 @@ public void testMergedSignRequestSuccess() String merged1 = piResponse1.mergedSignRequest(); assertEquals(trimmedRequest, merged1); - - // short test otpMessage() - String otpMessage = piResponse1.otpMessage(); - assertEquals("Please confirm with your WebAuthn token (FT BioPass FIDO2 USB), " + - "Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)", otpMessage); + "Please confirm with your WebAuthn token (Yubico U2F EE Serial 61730834)", piResponse1.message); } @Test @@ -150,4 +102,4 @@ public void testMergedSignRequestIncompleteSignRequest() assertEquals(trimmedRequest, merged1); } -} +} \ No newline at end of file