diff --git a/example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java b/example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java index df54366a..85dc2122 100644 --- a/example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java +++ b/example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java @@ -40,7 +40,16 @@ public ChallengeController(ChallengeNonceGenerator challengeNonceGenerator) { @GetMapping("challenge") public ChallengeDTO challenge() { - final ChallengeDTO challenge = new ChallengeDTO(); + return generateChallenge(); + } + + @GetMapping("mobile/challenge") + public ChallengeDTO mobileChallenge() { + return generateChallenge(); + } + + private ChallengeDTO generateChallenge() { + ChallengeDTO challenge = new ChallengeDTO(); challenge.setNonce(challengeNonceGenerator.generateAndStoreNonce().getBase64EncodedNonce()); return challenge; } diff --git a/example/src/main/resources/templates/index.html b/example/src/main/resources/templates/index.html index 051ab855..edd8a21c 100644 --- a/example/src/main/resources/templates/index.html +++ b/example/src/main/resources/templates/index.html @@ -265,7 +265,11 @@

For developers

authButton.disabled = true; try { - const challengeResponse = await fetch("/auth/challenge", { + const challengeUrl = isMobileDevice() + ? "/auth/mobile/challenge" + : "/auth/challenge"; + + const challengeResponse = await fetch(challengeUrl, { method: "GET", headers: { "Content-Type": "application/json" @@ -274,6 +278,22 @@

For developers

await checkHttpError(challengeResponse); const {nonce} = await challengeResponse.json(); + if (isMobileDevice()) { + const loginUri = encodeURIComponent(window.location.origin + "/"); + const payload = { + challenge: nonce, + login_uri: loginUri + }; + const encoded = btoa(JSON.stringify(payload)); + + // TODO: Replace with actual URL once DigiDoc app supports app links + const eidAppUri = `TODO-MOBILE-EID-APP-LAUNCH-URL#${encoded}`; + + alert("Opening eID app for authentication...\n\nIf nothing happens, the app may not yet support mobile login."); + window.location.href = eidAppUri; + return; + } + const authToken = await webeid.authenticate(nonce, {lang}); const authTokenResponse = await fetch("/auth/login", { @@ -299,6 +319,10 @@

For developers

} }); + function isMobileDevice() { + return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); + } + document.addEventListener('DOMContentLoaded', function () { setTimeout(function () { document.querySelector(".eu-logo-fixed").style.display = 'none' diff --git a/example/src/test/java/eu/webeid/example/WebApplicationTest.java b/example/src/test/java/eu/webeid/example/WebApplicationTest.java index f7f5a3de..38cfbf1d 100644 --- a/example/src/test/java/eu/webeid/example/WebApplicationTest.java +++ b/example/src/test/java/eu/webeid/example/WebApplicationTest.java @@ -22,6 +22,9 @@ package eu.webeid.example; +import eu.webeid.example.service.SigningService; +import eu.webeid.example.service.dto.FileDTO; +import eu.webeid.example.service.dto.SignatureDTO; import eu.webeid.example.testutil.Dates; import eu.webeid.example.testutil.HttpHelper; import eu.webeid.example.testutil.ObjectMother; @@ -99,6 +102,14 @@ public void validateOcspResponse(XadesSignature xadesSignature) { } }; + new MockUp() { + @Mock + public FileDTO signContainer(SignatureDTO signatureDTO) { + // Return a dummy FileDTO in tests + return new FileDTO("example-for-signing.asice"); + } + }; + MockHttpSession session = new MockHttpSession(); session.setAttribute("challenge-nonce", new ChallengeNonce(ObjectMother.VALID_CHALLENGE_NONCE, DateAndTime.utcNow().plusMinutes(1))); @@ -132,4 +143,37 @@ public static MockMultipartFile mockMultipartFile() { assertEquals(HttpStatus.OK.value(), response.getStatus()); assertEquals("attachment; filename=example-for-signing.asice", response.getHeader("Content-Disposition")); } + + @Test + public void testHappyFlow_NfcLoginAuthToken() throws Exception { + new MockUp() { + @Mock + public void validateCertificateNotRevoked(X509Certificate subjectCertificate) { + // Do not call real OCSP service in tests. + } + }; + + new MockUp() { + @Mock + public void validateOcspResponse(XadesSignature xadesSignature) { + // Do not call real OCSP service in tests. + } + }; + + new MockUp() { + @Mock + public FileDTO signContainer(SignatureDTO signatureDTO) { + // Return a dummy FileDTO in tests + return new FileDTO("example-for-signing.asice"); + } + }; + + MockHttpSession session = new MockHttpSession(); + session.setAttribute("challenge-nonce", new ChallengeNonce(ObjectMother.VALID_CHALLENGE_NONCE, DateAndTime.utcNow().plusMinutes(1))); + + MvcResult result = HttpHelper.login(mvcBuilder, session, ObjectMother.mockNfcAuthToken()); + MockHttpServletResponse response = result.getResponse(); + assertEquals("{\"sub\":\"JAAK-KRISTJAN JÕEORG\",\"auth\":\"[ROLE_USER]\"}", response.getContentAsString()); + } + } diff --git a/example/src/test/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilterTest.java b/example/src/test/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilterTest.java index 828399b1..e0c08dcf 100644 --- a/example/src/test/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilterTest.java +++ b/example/src/test/java/eu/webeid/example/security/WebEidAjaxLoginProcessingFilterTest.java @@ -22,6 +22,7 @@ package eu.webeid.example.security; +import eu.webeid.example.testutil.ObjectMother; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.Test; @@ -30,6 +31,7 @@ import java.io.BufferedReader; import java.io.StringReader; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.Mockito.mock; @@ -58,4 +60,23 @@ void testAttemptAuthentication() throws Exception { new WebEidAjaxLoginProcessingFilter("/auth/login", authenticationManager) .attemptAuthentication(request, response)); } -} \ No newline at end of file + + @Test + void testAttemptAuthentication_NfcToken() throws Exception { + final HttpServletRequest request = mock(HttpServletRequest.class); + final HttpServletResponse response = mock(HttpServletResponse.class); + when(request.getMethod()).thenReturn(HttpMethod.POST.name()); + when(request.getHeader("Content-type")).thenReturn("application/json"); + + var jsonBody = ObjectMother.toJson(Map.of("auth-token", ObjectMother.mockNfcAuthToken().getToken())); + + when(request.getReader()).thenReturn(new BufferedReader(new StringReader(jsonBody))); + + final AuthenticationManager authenticationManager = mock(AuthenticationManager.class); + + assertDoesNotThrow(() -> + new WebEidAjaxLoginProcessingFilter("/auth/login", authenticationManager) + .attemptAuthentication(request, response) + ); + } +} diff --git a/example/src/test/java/eu/webeid/example/testutil/ObjectMother.java b/example/src/test/java/eu/webeid/example/testutil/ObjectMother.java index 288b1368..e92e70f0 100644 --- a/example/src/test/java/eu/webeid/example/testutil/ObjectMother.java +++ b/example/src/test/java/eu/webeid/example/testutil/ObjectMother.java @@ -48,6 +48,7 @@ public class ObjectMother { private static final String TEST_PKI_CONTAINER = "src/test/resources/signout.p12"; private static final String TEST_PKI_CONTAINER_PASSWORD = "test"; private static final WebEidAuthToken VALID_AUTH_TOKEN; + private static final WebEidAuthToken VALID_NFC_AUTH_TOKEN; static { try { @@ -63,6 +64,22 @@ public class ObjectMother { } } + static { + try { + VALID_NFC_AUTH_TOKEN = MAPPER.readValue( + "{\"algorithm\":\"ES384\"," + + "\"unverifiedCertificate\":\"MIIEBDCCA2WgAwIBAgIQY5OGshxoPMFg+Wfc0gFEaTAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTIxMDcyMjEyNDMwOFoXDTI2MDcwOTIxNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQmwEKsJTjaMHSaZj19hb9EJaJlwbKc5VFzmlGMFSJVk4dDy+eUxa5KOA7tWXqzcmhh5SYdv+MxcaQKlKWLMa36pfgv20FpEDb03GCtLqjLTRZ7649PugAQ5EmAqIic29CjggHDMIIBvzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIDiDBHBgNVHSAEQDA+MDIGCysGAQQBg5EhAQIBMCMwIQYIKwYBBQUHAgEWFWh0dHBzOi8vd3d3LnNrLmVlL0NQUzAIBgYEAI96AQIwHwYDVR0RBBgwFoEUMzgwMDEwODU3MThAZWVzdGkuZWUwHQYDVR0OBBYEFPlp/ceABC52itoqppEmbf71TJz6MGEGCCsGAQUFBwEDBFUwUzBRBgYEAI5GAQUwRzBFFj9odHRwczovL3NrLmVlL2VuL3JlcG9zaXRvcnkvY29uZGl0aW9ucy1mb3ItdXNlLW9mLWNlcnRpZmljYXRlcy8TAkVOMCAGA1UdJQEB/wQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAfBgNVHSMEGDAWgBTAhJkpxE6fOwI09pnhClYACCk+ezBzBggrBgEFBQcBAQRnMGUwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MDUGCCsGAQUFBzAChilodHRwOi8vYy5zay5lZS9UZXN0X29mX0VTVEVJRDIwMTguZGVyLmNydDAKBggqhkjOPQQDBAOBjAAwgYgCQgDCAgybz0u3W+tGI+AX+PiI5CrE9ptEHO5eezR1Jo4j7iGaO0i39xTGUB+NSC7P6AQbyE/ywqJjA1a62jTLcS9GHAJCARxN4NO4eVdWU3zVohCXm8WN3DWA7XUcn9TZiLGQ29P4xfQZOXJi/z4PNRRsR4plvSNB3dfyBvZn31HhC7my8woi\"," + + "\"unverifiedSigningCertificate\":\"MIIEBDCCA2WgAwIBAgIQY5OGshxoPMFg+Wfc0gFEaTAKBggqhkjOPQQDBDBgMQswCQYDVQQGEwJFRTEbMBkGA1UECgwSU0sgSUQgU29sdXRpb25zIEFTMRcwFQYDVQRhDA5OVFJFRS0xMDc0NzAxMzEbMBkGA1UEAwwSVEVTVCBvZiBFU1RFSUQyMDE4MB4XDTIxMDcyMjEyNDMwOFoXDTI2MDcwOTIxNTk1OVowfzELMAkGA1UEBhMCRUUxKjAoBgNVBAMMIUrDlUVPUkcsSkFBSy1LUklTVEpBTiwzODAwMTA4NTcxODEQMA4GA1UEBAwHSsOVRU9SRzEWMBQGA1UEKgwNSkFBSy1LUklTVEpBTjEaMBgGA1UEBRMRUE5PRUUtMzgwMDEwODU3MTgwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQmwEKsJTjaMHSaZj19hb9EJaJlwbKc5VFzmlGMFSJVk4dDy+eUxa5KOA7tWXqzcmhh5SYdv+MxcaQKlKWLMa36pfgv20FpEDb03GCtLqjLTRZ7649PugAQ5EmAqIic29CjggHDMIIBvzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIDiDBHBgNVHSAEQDA+MDIGCysGAQQBg5EhAQIBMCMwIQYIKwYBBQUHAgEWFWh0dHBzOi8vd3d3LnNrLmVlL0NQUzAIBgYEAI96AQIwHwYDVR0RBBgwFoEUMzgwMDEwODU3MThAZWVzdGkuZWUwHQYDVR0OBBYEFPlp/ceABC52itoqppEmbf71TJz6MGEGCCsGAQUFBwEDBFUwUzBRBgYEAI5GAQUwRzBFFj9odHRwczovL3NrLmVlL2VuL3JlcG9zaXRvcnkvY29uZGl0aW9ucy1mb3ItdXNlLW9mLWNlcnRpZmljYXRlcy8TAkVOMCAGA1UdJQEB/wQWMBQGCCsGAQUFBwMCBggrBgEFBQcDBDAfBgNVHSMEGDAWgBTAhJkpxE6fOwI09pnhClYACCk+ezBzBggrBgEFBQcBAQRnMGUwLAYIKwYBBQUHMAGGIGh0dHA6Ly9haWEuZGVtby5zay5lZS9lc3RlaWQyMDE4MDUGCCsGAQUFBzAChilodHRwOi8vYy5zay5lZS9UZXN0X29mX0VTVEVJRDIwMTguZGVyLmNydDAKBggqhkjOPQQDBAOBjAAwgYgCQgDCAgybz0u3W+tGI+AX+PiI5CrE9ptEHO5eezR1Jo4j7iGaO0i39xTGUB+NSC7P6AQbyE/ywqJjA1a62jTLcS9GHAJCARxN4NO4eVdWU3zVohCXm8WN3DWA7XUcn9TZiLGQ29P4xfQZOXJi/z4PNRRsR4plvSNB3dfyBvZn31HhC7my8woi\"," + + "\"supportedSignatureAlgorithms\":[{\"cryptoAlgorithm\":\"RSA\",\"hashFunction\":\"SHA-256\",\"paddingScheme\":\"PKCS1.5\"}]," + + "\"issuerApp\":\"https://web-eid.eu/web-eid-mobile-app/releases/v1.0.0\"," + + "\"signature\":\"0Ov7ME6pTY1K2GXMj8Wxov/o2fGIMEds8OMY5dKdkB0nrqQX7fG1E5mnsbvyHpMDecMUH6Yg+p1HXdgB/lLqOcFZjt/OVXPjAAApC5d1YgRYATDcxsR1zqQwiNcHdmWn\"," + + "\"format\":\"web-eid:1.1\"}", + WebEidAuthToken.class); + } catch (JsonProcessingException e) { + throw new RuntimeException("NFC token parsing failed"); + } + } + public static final String VALID_CHALLENGE_NONCE = "12345678123456781234567812345678912356789123"; public static AuthTokenDTO mockAuthToken() { @@ -71,6 +88,12 @@ public static AuthTokenDTO mockAuthToken() { return authToken; } + public static AuthTokenDTO mockNfcAuthToken() { + AuthTokenDTO authToken = new AuthTokenDTO(); + authToken.setToken(VALID_NFC_AUTH_TOKEN); + return authToken; + } + public static String toJson(Object object) throws JsonProcessingException { return MAPPER.writeValueAsString(object); }