Skip to content

Commit bcad5e0

Browse files
authored
Simplify authentication in rest api (#24)
* Set correct authentication roles and clean up no longer needed authentication paths. * Update the MFA end-point to create a new security principal with updated access_token. * Combine IT test reports with the unit test for the api subproject. * Remove unused code, update test to render the QR code.
1 parent cedc62b commit bcad5e0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+435
-260
lines changed

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ subprojects {
2020
apply(plugin = "maven-publish")
2121
apply(plugin = "jacoco")
2222

23-
tasks.test {
23+
tasks.check {
2424
finalizedBy(tasks.jacocoTestReport)
2525
}
2626

fintrack-api/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ tasks.register<Test>("itTest") {
2626
shouldRunAfter(tasks.test)
2727
}
2828

29+
tasks.jacocoTestReport {
30+
executionData(layout.buildDirectory.files("/jacoco/test.exec", "jacoco/itTest.exec"))
31+
}
32+
2933
tasks.check {
3034
dependsOn("itTest")
3135
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.jongsoft.finance;
2+
3+
import com.jongsoft.finance.extension.IntegrationTest;
4+
import com.jongsoft.finance.extension.TestContext;
5+
import org.junit.jupiter.api.DisplayName;
6+
import org.junit.jupiter.api.Test;
7+
8+
import static org.hamcrest.Matchers.equalTo;
9+
10+
@IntegrationTest(phase = 1)
11+
@DisplayName("User registers and logins with MFA")
12+
public class MultiFactorLoginTest {
13+
14+
@Test
15+
void registerAndLoginWithMFA(TestContext testContext) {
16+
var profileContext = testContext
17+
.register("mfa-sample@e", "Zomer2020")
18+
.authenticate("mfa-sample@e", "Zomer2020")
19+
.profile();
20+
21+
profileContext
22+
.get(response -> response
23+
.body("theme", equalTo("dark"))
24+
.body("currency", equalTo("EUR"))
25+
.body("mfa", equalTo(false)))
26+
.qrCode();
27+
28+
testContext.enableMFA();
29+
30+
testContext
31+
.authenticate("mfa-sample@e", "Zomer2020")
32+
.multiFactor()
33+
.profile()
34+
.get(response -> response
35+
.body("theme", equalTo("dark"))
36+
.body("currency", equalTo("EUR"))
37+
.body("mfa", equalTo(true)));
38+
}
39+
}

fintrack-api/src/integration/java/com/jongsoft/finance/extension/IntegrationTestExtension.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public void beforeAll(ExtensionContext context) {
3939
testContext = new TestContext(new TestContext.Server(
4040
server.getScheme() + "://" + server.getHost(),
4141
server.getPort()
42-
));
42+
), applicationContext);
4343
});
4444
}
4545

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.jongsoft.finance.extension;
2+
3+
import io.micronaut.http.MediaType;
4+
import io.restassured.response.ValidatableResponse;
5+
import io.restassured.specification.RequestSpecification;
6+
import org.apache.http.HttpStatus;
7+
8+
import java.util.function.Consumer;
9+
import java.util.function.Supplier;
10+
11+
public class ProfileContext {
12+
private final Supplier<RequestSpecification> requestSpecification;
13+
14+
public ProfileContext(Supplier<RequestSpecification> requestSpecification) {
15+
this.requestSpecification = requestSpecification;
16+
}
17+
18+
public ProfileContext get(Consumer<ValidatableResponse> validator) {
19+
var response = requestSpecification.get()
20+
.when()
21+
.get("/profile")
22+
.then()
23+
.statusCode(HttpStatus.SC_OK);
24+
25+
validator.accept(response);
26+
27+
return this;
28+
}
29+
30+
public ProfileContext qrCode() {
31+
requestSpecification.get()
32+
.when()
33+
.accept(MediaType.IMAGE_PNG)
34+
.get("/profile/multi-factor/qr-code")
35+
.then()
36+
.statusCode(HttpStatus.SC_OK)
37+
.contentType(MediaType.IMAGE_PNG);
38+
39+
return this;
40+
}
41+
}

fintrack-api/src/integration/java/com/jongsoft/finance/extension/TestContext.java

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@
66
import com.fasterxml.jackson.databind.ObjectMapper;
77
import com.fasterxml.jackson.databind.SerializerProvider;
88
import com.fasterxml.jackson.databind.module.SimpleModule;
9+
import com.jongsoft.finance.domain.user.UserAccount;
10+
import com.jongsoft.finance.providers.UserProvider;
11+
import com.jongsoft.lang.Control;
12+
import dev.samstevens.totp.code.DefaultCodeGenerator;
13+
import dev.samstevens.totp.time.SystemTimeProvider;
14+
import io.micronaut.context.ApplicationContext;
915
import io.restassured.RestAssured;
1016
import io.restassured.builder.RequestSpecBuilder;
1117
import io.restassured.config.ObjectMapperConfig;
1218
import io.restassured.specification.RequestSpecification;
19+
import org.apache.http.HttpStatus;
20+
import org.assertj.core.api.Assertions;
1321
import org.slf4j.Logger;
1422
import org.slf4j.LoggerFactory;
1523

@@ -24,9 +32,12 @@ public class TestContext {
2432
public record Server(String baseUri, int port) {
2533
}
2634

35+
private String authenticatedWith;
2736
private String authenticationToken;
37+
private final ApplicationContext applicationContext;
2838

29-
public TestContext(Server server) {
39+
public TestContext(Server server, ApplicationContext applicationContext) {
40+
this.applicationContext = applicationContext;
3041
RestAssured.requestSpecification = new RequestSpecBuilder()
3142
.setBaseUri(server.baseUri)
3243
.setPort(server.port)
@@ -73,7 +84,7 @@ public TestContext register(String username, String password) {
7384

7485
public TestContext authenticate(String username, String password) {
7586
log.info("Authenticating user: {}", username);
76-
authenticationToken = RestAssured.given()
87+
var jsonPath = RestAssured.given()
7788
.body("""
7889
{
7990
"username": "%s",
@@ -84,11 +95,52 @@ public TestContext authenticate(String username, String password) {
8495
.statusCode(200)
8596
.extract()
8697
.body()
98+
.jsonPath();
99+
100+
authenticationToken = jsonPath.getString("access_token");
101+
authenticatedWith = username;
102+
return this;
103+
}
104+
105+
public TestContext multiFactor() {
106+
authenticationToken = authRequest()
107+
.given()
108+
.contentType("application/json")
109+
.body("""
110+
{
111+
"verificationCode": "%s"
112+
}""".formatted(generateToken()))
113+
.when()
114+
.post("/security/2-factor")
115+
.then()
116+
.statusCode(HttpStatus.SC_OK)
117+
.extract()
87118
.jsonPath()
88119
.getString("access_token");
120+
121+
return this;
122+
}
123+
124+
public TestContext enableMFA() {
125+
authRequest()
126+
.given()
127+
.contentType("application/json")
128+
.body("""
129+
{
130+
"verificationCode": "%s"
131+
}""".formatted(generateToken()))
132+
.when()
133+
.post("/profile/multi-factor/enable")
134+
.then()
135+
.statusCode(HttpStatus.SC_NO_CONTENT);
136+
89137
return this;
90138
}
91139

140+
public ProfileContext profile() {
141+
return new ProfileContext(this::authRequest);
142+
}
143+
92144
public AccountContext accounts() {
93145
return new AccountContext(authRequest());
94146
}
@@ -106,7 +158,7 @@ public String upload(InputStream inputStream) {
106158
.contentType("multipart/form-data")
107159
.multiPart("upload", "account1.svg", inputStream)
108160
.post("/attachment")
109-
.then()
161+
.then()
110162
.statusCode(201)
111163
.extract()
112164
.body()
@@ -118,4 +170,16 @@ public RequestSpecification authRequest() {
118170
return RestAssured.given()
119171
.header("Authorization", "Bearer " + authenticationToken);
120172
}
173+
174+
private String generateToken() {
175+
var optionalUser = applicationContext.getBean(UserProvider.class)
176+
.lookup(authenticatedWith);
177+
Assertions.assertThat(optionalUser.isPresent()).isTrue();
178+
179+
return optionalUser.map(UserAccount::getSecret)
180+
.map(secret -> Control.Try(() -> new DefaultCodeGenerator()
181+
.generate(secret, Math.floorDiv(new SystemTimeProvider().getTime(), 30)))
182+
.get())
183+
.get();
184+
}
121185
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.jongsoft.finance.filter;
2+
3+
import io.micronaut.context.annotation.Replaces;
4+
import io.micronaut.http.HttpRequest;
5+
import io.micronaut.http.HttpResponse;
6+
import io.micronaut.http.HttpStatus;
7+
import io.micronaut.http.annotation.Produces;
8+
import io.micronaut.http.hateoas.JsonError;
9+
import io.micronaut.http.server.exceptions.ExceptionHandler;
10+
import io.micronaut.security.authentication.AuthorizationException;
11+
import io.micronaut.security.authentication.DefaultAuthorizationExceptionHandler;
12+
import jakarta.inject.Singleton;
13+
import org.slf4j.Logger;
14+
import org.slf4j.LoggerFactory;
15+
16+
@Produces
17+
@Singleton
18+
@Replaces(DefaultAuthorizationExceptionHandler.class)
19+
public class AuthenticationFailureHandler implements ExceptionHandler<AuthorizationException, HttpResponse<JsonError>> {
20+
21+
private final Logger log = LoggerFactory.getLogger(AuthenticationFailureHandler.class);
22+
23+
@Override
24+
public HttpResponse<JsonError> handle(HttpRequest request, AuthorizationException exception) {
25+
if (exception.isForbidden()) {
26+
log.info("{}: {} - User {} does not have access based upon the roles {}.",
27+
request.getMethod(),
28+
request.getPath(),
29+
exception.getAuthentication().getName(),
30+
exception.getAuthentication().getRoles());
31+
32+
return HttpResponse.status(HttpStatus.FORBIDDEN)
33+
.body(new JsonError("User does not have access based upon the roles"));
34+
}
35+
36+
log.info("{}: {} - User {} is not authenticated.",
37+
request.getMethod(),
38+
request.getPath(),
39+
exception.getAuthentication().getName());
40+
41+
return HttpResponse.unauthorized();
42+
}
43+
}

fintrack-api/src/main/java/com/jongsoft/finance/filter/AuthenticationFilter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public Publisher<MutableHttpResponse<?>> doFilter(final HttpRequest<?> request,
6363
}
6464

6565
private void handleAuthentication(Principal principal) {
66-
log.debug("Authenticated user {}", principal.getName());
66+
log.trace("Authenticated user {}", principal.getName());
6767
eventPublisher.publishEvent(new InternalAuthenticationEvent(this, principal.getName()));
6868
}
6969
}

fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountResource.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
import com.jongsoft.finance.providers.SettingProvider;
88
import com.jongsoft.finance.rest.model.AccountResponse;
99
import com.jongsoft.finance.rest.model.ResultPageResponse;
10+
import com.jongsoft.finance.security.AuthenticationRoles;
1011
import com.jongsoft.finance.security.CurrentUserProvider;
1112
import com.jongsoft.lang.Collections;
1213
import io.micronaut.core.annotation.Nullable;
1314
import io.micronaut.http.annotation.*;
1415
import io.micronaut.security.annotation.Secured;
15-
import io.micronaut.security.rules.SecurityRule;
1616
import io.swagger.v3.oas.annotations.Operation;
1717
import io.swagger.v3.oas.annotations.Parameter;
1818
import io.swagger.v3.oas.annotations.enums.ParameterIn;
@@ -24,7 +24,7 @@
2424

2525
@Controller("/api/accounts")
2626
@Tag(name = "Account information")
27-
@Secured(SecurityRule.IS_AUTHENTICATED)
27+
@Secured(AuthenticationRoles.IS_AUTHENTICATED)
2828
public class AccountResource {
2929

3030
private final SettingProvider settingProvider;

fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTopResource.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
import com.jongsoft.finance.providers.AccountProvider;
55
import com.jongsoft.finance.providers.SettingProvider;
66
import com.jongsoft.finance.rest.DateFormat;
7+
import com.jongsoft.finance.security.AuthenticationRoles;
78
import com.jongsoft.lang.Collections;
89
import com.jongsoft.lang.Dates;
910
import io.micronaut.http.annotation.Controller;
1011
import io.micronaut.http.annotation.Get;
1112
import io.micronaut.http.annotation.PathVariable;
1213
import io.micronaut.security.annotation.Secured;
13-
import io.micronaut.security.rules.SecurityRule;
1414
import io.swagger.v3.oas.annotations.Operation;
1515
import io.swagger.v3.oas.annotations.tags.Tag;
1616

@@ -19,7 +19,7 @@
1919

2020
@Controller("/api/accounts/top")
2121
@Tag(name = "Account information")
22-
@Secured(SecurityRule.IS_AUTHENTICATED)
22+
@Secured(AuthenticationRoles.IS_AUTHENTICATED)
2323
public class AccountTopResource {
2424

2525
private final AccountProvider accountProvider;

fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTransactionResource.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@
1111
import com.jongsoft.finance.providers.TransactionProvider;
1212
import com.jongsoft.finance.rest.model.ResultPageResponse;
1313
import com.jongsoft.finance.rest.model.TransactionResponse;
14+
import com.jongsoft.finance.security.AuthenticationRoles;
1415
import com.jongsoft.lang.Collections;
1516
import com.jongsoft.lang.Control;
1617
import com.jongsoft.lang.Dates;
1718
import io.micronaut.core.annotation.Nullable;
1819
import io.micronaut.http.HttpStatus;
1920
import io.micronaut.http.annotation.*;
2021
import io.micronaut.security.annotation.Secured;
21-
import io.micronaut.security.rules.SecurityRule;
2222
import io.swagger.v3.oas.annotations.Operation;
2323
import io.swagger.v3.oas.annotations.Parameter;
2424
import io.swagger.v3.oas.annotations.enums.ParameterIn;
@@ -31,7 +31,7 @@
3131
import java.util.function.Consumer;
3232

3333
@Tag(name = "Transactions")
34-
@Secured(SecurityRule.IS_AUTHENTICATED)
34+
@Secured(AuthenticationRoles.IS_AUTHENTICATED)
3535
@Controller("/api/accounts/{accountId}/transactions")
3636
public class AccountTransactionResource {
3737

fintrack-api/src/main/java/com/jongsoft/finance/rest/account/AccountTypeResource.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
package com.jongsoft.finance.rest.account;
22

33
import com.jongsoft.finance.providers.AccountTypeProvider;
4+
import com.jongsoft.finance.security.AuthenticationRoles;
45
import io.micronaut.http.annotation.Controller;
56
import io.micronaut.http.annotation.Get;
67
import io.micronaut.security.annotation.Secured;
7-
import io.micronaut.security.rules.SecurityRule;
88
import io.swagger.v3.oas.annotations.Operation;
99
import io.swagger.v3.oas.annotations.tags.Tag;
1010
import jakarta.inject.Singleton;
@@ -14,7 +14,7 @@
1414
@Singleton
1515
@Tag(name = "Account information")
1616
@Controller("/api/account-types")
17-
@Secured(SecurityRule.IS_AUTHENTICATED)
17+
@Secured(AuthenticationRoles.IS_AUTHENTICATED)
1818
public class AccountTypeResource {
1919

2020
private final AccountTypeProvider accountTypeProvider;

0 commit comments

Comments
 (0)