Skip to content

Commit fd81283

Browse files
NFC-47 Codereview findings. Readme update, tests and filter.
1 parent 35aa06c commit fd81283

File tree

7 files changed

+202
-80
lines changed

7 files changed

+202
-80
lines changed

README.md

Lines changed: 86 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Implement the session-backed challenge nonce store as follows:
4848
import org.springframework.beans.factory.ObjectFactory;
4949
import eu.webeid.security.challenge.ChallengeNonce;
5050
import eu.webeid.security.challenge.ChallengeNonceStore;
51-
import javax.servlet.http.HttpSession;
51+
import jakarta.servlet.http.HttpSession;
5252

5353
public class SessionBackedChallengeNonceStore implements ChallengeNonceStore {
5454

@@ -134,36 +134,102 @@ import eu.webeid.security.validator.AuthTokenValidatorBuilder;
134134
...
135135
```
136136

137-
## 6. Add a REST endpoint for issuing challenge nonces
137+
## 6. Add a filter for issuing challenge nonces
138138

139-
A REST endpoint that issues challenge nonces is required for authentication. The endpoint must support `GET` requests.
139+
A REST endpoint that issues challenge nonces is required for authentication.
140+
Since this step is part of the authentication flow, it is implemented as a Spring Security filter instead of a regular controller. The filter must support `POST` requests.
140141

141-
In the following example, we are using the [Spring RESTful Web Services framework](https://spring.io/guides/gs/rest-service/) to implement the endpoint, see also the full implementation [here](example/blob/main/src/main/java/eu/webeid/example/web/rest/ChallengeController.java).
142+
The `WebEidChallengeNonceFilter` handles `/auth/challenge` requests and issues a new nonce.
143+
See the full implementation [here](example/src/main/java/eu/webeid/example/security/WebEidChallengeNonceFilter.java).
142144

143145
```java
144-
import org.springframework.web.bind.annotation.GetMapping;
145-
import org.springframework.web.bind.annotation.RequestMapping;
146-
import org.springframework.web.bind.annotation.RestController;
147-
import eu.webeid.security.challenge.ChallengeNonceGenerator;
148-
...
146+
public final class WebEidChallengeNonceFilter extends OncePerRequestFilter {
147+
private static final ObjectWriter OBJECT_WRITER = new ObjectMapper().writer();
148+
private final RequestMatcher requestMatcher;
149+
private final ChallengeNonceGenerator nonceGenerator;
150+
151+
public WebEidChallengeNonceFilter(String path, ChallengeNonceGenerator nonceGenerator) {
152+
this.requestMatcher = PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.POST, path);
153+
this.nonceGenerator = nonceGenerator;
154+
}
155+
156+
@Override
157+
protected void doFilterInternal(
158+
@NonNull HttpServletRequest request,
159+
@NonNull HttpServletResponse response,
160+
@NonNull FilterChain chain
161+
) throws ServletException, IOException {
162+
if (!requestMatcher.matches(request)) {
163+
chain.doFilter(request, response);
164+
return;
165+
}
166+
167+
var dto = new ChallengeDTO(nonceGenerator.generateAndStoreNonce().getBase64EncodedNonce());
168+
169+
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
170+
OBJECT_WRITER.writeValue(response.getWriter(), dto);
171+
}
172+
173+
public record ChallengeDTO(String nonce) {}
174+
}
175+
```
149176

150-
@RestController
151-
@RequestMapping("auth")
152-
public class ChallengeController {
177+
Similarly, the `WebEidMobileAuthInitFilter` handles `/auth/mobile/init` requests and issues a deep link for mobile authentication.
178+
See the full implementation [here](example/src/main/java/eu/webeid/example/security/WebEidMobileAuthInitFilter.java).
153179

154-
@Autowired // for brevity, prefer constructor dependency injection
155-
private ChallengeNonceGenerator nonceGenerator;
180+
```java
181+
public final class WebEidMobileAuthInitFilter extends OncePerRequestFilter {
182+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
183+
private final RequestMatcher requestMatcher;
184+
private final ChallengeNonceGenerator nonceGenerator;
185+
private final String loginPath;
186+
187+
public WebEidMobileAuthInitFilter(String path, String loginPath, ChallengeNonceGenerator nonceGenerator) {
188+
this.requestMatcher = PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.POST, path);
189+
this.nonceGenerator = nonceGenerator;
190+
this.loginPath = loginPath;
191+
}
156192

157-
@GetMapping("challenge")
158-
public ChallengeDTO challenge() {
159-
// a simple DTO with a single 'nonce' field
160-
final ChallengeDTO challenge = new ChallengeDTO();
161-
challenge.setNonce(nonceGenerator.generateAndStoreNonce().getBase64EncodedNonce());
162-
return challenge;
193+
@Override
194+
protected void doFilterInternal(
195+
@NonNull HttpServletRequest request,
196+
@NonNull HttpServletResponse response,
197+
@NonNull FilterChain chain
198+
) throws IOException, ServletException {
199+
if (!requestMatcher.matches(request)) {
200+
chain.doFilter(request, response);
201+
return;
202+
}
203+
204+
var challenge = nonceGenerator.generateAndStoreNonce();
205+
206+
String loginUri = ServletUriComponentsBuilder.fromCurrentContextPath()
207+
.path(loginPath).build().toUriString();
208+
209+
String payloadJson = OBJECT_MAPPER.writeValueAsString(
210+
new AuthPayload(challenge.getBase64EncodedNonce(), loginUri)
211+
);
212+
String encoded = Base64.getEncoder().encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8));
213+
String eidAuthUri = "web-eid-mobile://auth#" + encoded;
214+
215+
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
216+
OBJECT_MAPPER.writeValue(response.getWriter(), new AuthUri(eidAuthUri));
163217
}
218+
219+
record AuthPayload(String challenge, @JsonProperty("login_uri") String loginUri) {}
220+
record AuthUri(@JsonProperty("auth_uri") String authUri) {}
164221
}
165222
```
166223

224+
Both filters are registered in the Spring Security filter chain in ApplicationConfiguration:
225+
```java
226+
http
227+
.addFilterBefore(new WebEidMobileAuthInitFilter("/auth/mobile/init", "/auth/mobile/login", challengeNonceGenerator),
228+
UsernamePasswordAuthenticationFilter.class)
229+
.addFilterBefore(new WebEidChallengeNonceFilter("/auth/challenge", challengeNonceGenerator),
230+
UsernamePasswordAuthenticationFilter.class);
231+
```
232+
167233
Also, see general guidelines for implementing secure authentication services [here](https://github.com/SK-EID/smart-id-documentation/wiki/Secure-Implementation-Guide).
168234

169235
## 7. Implement authentication

example/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,13 @@ The source code folder `src` contains the application source code and resources
114114
The `src/main/java/eu/webeid/example` directory contains the Spring Boot application Java class and the following subdirectories:
115115

116116
- `config`: Spring and HTTP security configuration, Web eID authentication token validation library configuration, trusted CA certificates loading etc,
117-
- `security`: Web eID authentication token validation library integration with Spring Security via an `AuthenticationProvider` and `AuthenticationProcessingFilter`,
117+
- `security`: Web eID authentication token validation library integration with Spring Security
118+
- `AuthenticationProvider` and `AuthenticationProcessingFilter` for handling Web eID authentication tokens,
119+
- `WebEidChallengeNonceFilter` for issuing the challenge nonce required by the authentication flow,
120+
- `WebEidMobileAuthInitFilter` for generating the deep link (`auth_uri`) used in mobile login,
121+
- `WebEidAjaxLoginProcessingFilter` and `WebEidLoginPageGeneratingFilter` for handling login requests.
118122
- `service`: Web eID signing service implementation that uses DigiDoc4j, and DigiDoc4j runtime configuration,
119-
- `web`: Spring Web MVC controller for the welcome page and Spring Web REST controllers that provide endpoints
120-
- for getting the challenge nonce used by the authentication token validation library,
121-
- for digital signing.
122-
- for initializing and handling the mobile login and digital signing flow
123+
- `web`: Spring Web MVC controller for the welcome page and Spring Web REST controller that provides a digital signing endpoint.
123124

124125
The `src/resources` directory contains the resources used by the application:
125126

example/src/main/java/eu/webeid/example/security/ajax/AjaxAuthenticationSuccessHandler.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,15 @@ public class AjaxAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuc
5050

5151
@Override
5252
public void onAuthenticationSuccess(
53-
HttpServletRequest request,
54-
HttpServletResponse response,
55-
Authentication authentication
53+
HttpServletRequest request,
54+
HttpServletResponse response,
55+
Authentication authentication
5656
)
57-
throws IOException {
57+
throws IOException {
5858
LOG.info("onAuthenticationSuccess(): {}", authentication);
5959

6060
response.setStatus(HttpServletResponse.SC_OK);
61-
response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
61+
response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
6262

6363
response.getWriter().write(AuthSuccessDTO.asJson(authentication));
6464
}

example/src/main/java/eu/webeid/example/security/ui/WebEidLoginPageGeneratingFilter.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public WebEidLoginPageGeneratingFilter(String path) {
7171
7272
if (payload["error"]) {
7373
showErrorMessage({
74-
code: "MOPP_ERROR",
74+
code: payload["code"] ?? "MOPP_ERROR",
7575
message: payload["message"] ?? "Authentication failed in mobile app"
7676
});
7777
return;
@@ -118,7 +118,7 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht
118118

119119
String html = generateHtml(csrf);
120120

121-
response.setContentType(MediaType.TEXT_HTML_VALUE + ";charset=UTF-8");
121+
response.setContentType(MediaType.TEXT_HTML_VALUE);
122122
response.getWriter().write(html);
123123
}
124124

example/src/test/java/eu/webeid/example/WebApplicationTest.java

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
package eu.webeid.example;
2424

25+
import eu.webeid.example.security.dto.AuthTokenDTO;
2526
import eu.webeid.example.testutil.Dates;
2627
import eu.webeid.example.testutil.HttpHelper;
2728
import eu.webeid.example.testutil.ObjectMother;
@@ -31,6 +32,9 @@
3132
import org.digidoc4j.impl.asic.xades.XadesSignature;
3233
import org.junit.jupiter.api.BeforeEach;
3334
import org.junit.jupiter.api.Test;
35+
import org.junit.jupiter.params.ParameterizedTest;
36+
import org.junit.jupiter.params.provider.Arguments;
37+
import org.junit.jupiter.params.provider.MethodSource;
3438
import org.springframework.beans.factory.annotation.Autowired;
3539
import org.springframework.boot.test.context.SpringBootTest;
3640
import org.springframework.http.HttpStatus;
@@ -45,12 +49,14 @@
4549
import eu.webeid.security.challenge.ChallengeNonce;
4650
import eu.webeid.security.util.DateAndTime;
4751

52+
import java.util.stream.Stream;
53+
4854
import static org.junit.jupiter.api.Assertions.assertEquals;
4955
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
5056

5157
@SpringBootTest
5258
@WebAppConfiguration
53-
public class WebApplicationTest {
59+
class WebApplicationTest {
5460

5561
@Autowired
5662
private WebApplicationContext context;
@@ -61,12 +67,12 @@ public class WebApplicationTest {
6167
private static DefaultMockMvcBuilder mvcBuilder;
6268

6369
@BeforeEach
64-
public void setup() {
70+
void setup() {
6571
mvcBuilder = MockMvcBuilders.webAppContextSetup(context).addFilters(springSecurityFilterChain);
6672
}
6773

6874
@Test
69-
public void testRoot() throws Exception {
75+
void testRoot() throws Exception {
7076
// @formatter:off
7177
MockHttpServletResponse response = mvcBuilder
7278
.build()
@@ -78,8 +84,9 @@ public void testRoot() throws Exception {
7884
System.out.println(response.getContentAsString());
7985
}
8086

81-
@Test
82-
public void testHappyFlow_LoginPrepareSignDownload() throws Exception {
87+
@ParameterizedTest
88+
@MethodSource("provideAuthToken")
89+
void testHappyFlow_LoginPrepareSignDownload(AuthTokenDTO authToken) throws Exception {
8390
new MockUp<AsicSignatureFinalizer>() {
8491
@Mock
8592
public void validateOcspResponse(XadesSignature xadesSignature) {
@@ -95,7 +102,7 @@ public void validateOcspResponse(XadesSignature xadesSignature) {
95102
// Act and assert
96103
mvcBuilder.build().perform(get("/auth/challenge"));
97104

98-
MvcResult result = HttpHelper.login(mvcBuilder, session, ObjectMother.mockAuthToken());
105+
MvcResult result = HttpHelper.login(mvcBuilder, session, authToken);
99106
session = (MockHttpSession) result.getRequest().getSession();
100107
MockHttpServletResponse response = result.getResponse();
101108
assertEquals("{\"sub\":\"JAAK-KRISTJAN JÕEORG\",\"auth\":\"[ROLE_USER]\"}", response.getContentAsString());
@@ -121,21 +128,10 @@ public static MockMultipartFile mockMultipartFile() {
121128
assertEquals("attachment; filename=example-for-signing.asice", response.getHeader("Content-Disposition"));
122129
}
123130

124-
@Test
125-
public void testHappyFlow_V11LoginAuthToken() throws Exception {
126-
new MockUp<AsicSignatureFinalizer>() {
127-
@Mock
128-
public void validateOcspResponse(XadesSignature xadesSignature) {
129-
// Do not call real OCSP service in tests.
130-
}
131-
};
132-
133-
MockHttpSession session = new MockHttpSession();
134-
session.setAttribute("challenge-nonce", new ChallengeNonce(ObjectMother.VALID_CHALLENGE_NONCE, DateAndTime.utcNow().plusMinutes(1)));
135-
136-
MvcResult result = HttpHelper.login(mvcBuilder, session, ObjectMother.mockV11AuthToken());
137-
MockHttpServletResponse response = result.getResponse();
138-
assertEquals("{\"sub\":\"JAAK-KRISTJAN JÕEORG\",\"auth\":\"[ROLE_USER]\"}", response.getContentAsString());
131+
static Stream<Arguments> provideAuthToken() {
132+
return Stream.of(
133+
Arguments.of(ObjectMother.mockAuthToken()),
134+
Arguments.of(ObjectMother.mockV11AuthToken())
135+
);
139136
}
140-
141137
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright (c) 2020-2025 Estonian Information System Authority
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
23+
package eu.webeid.example.security;
24+
25+
import com.fasterxml.jackson.databind.JsonNode;
26+
import com.fasterxml.jackson.databind.ObjectMapper;
27+
import org.junit.jupiter.api.Test;
28+
import org.springframework.beans.factory.annotation.Autowired;
29+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
30+
import org.springframework.boot.test.context.SpringBootTest;
31+
import org.springframework.http.MediaType;
32+
import org.springframework.test.web.servlet.MockMvc;
33+
import org.springframework.test.web.servlet.MvcResult;
34+
35+
import java.util.Base64;
36+
37+
import static eu.webeid.security.challenge.ChallengeNonceGenerator.NONCE_LENGTH;
38+
import static org.assertj.core.api.Assertions.assertThat;
39+
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
40+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
41+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
42+
43+
@SpringBootTest
44+
@AutoConfigureMockMvc
45+
class WebEidChallengeNonceFilterTest {
46+
47+
@Autowired
48+
private ObjectMapper mapper;
49+
50+
@Autowired
51+
private MockMvc mvc;
52+
53+
@Test
54+
void challengeReturnsNonceWithExpectedBase64Length() throws Exception {
55+
MvcResult result = mvc.perform(post("/auth/challenge")
56+
.with(csrf())
57+
.accept(MediaType.APPLICATION_JSON))
58+
.andExpect(status().isOk())
59+
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
60+
.andReturn();
61+
62+
JsonNode json = mapper.readTree(result.getResponse().getContentAsByteArray());
63+
String nonce = json.get("nonce").asText();
64+
65+
assertThat(nonce)
66+
.isNotBlank()
67+
.hasSize(expectedBase64Len());
68+
}
69+
70+
private static int expectedBase64Len() {
71+
return Base64.getEncoder().encodeToString(new byte[NONCE_LENGTH]).length();
72+
}
73+
}

0 commit comments

Comments
 (0)