Skip to content

Commit f41a9e1

Browse files
authored
Add openid integration (#121)
1 parent a5c00b5 commit f41a9e1

File tree

16 files changed

+268
-13
lines changed

16 files changed

+268
-13
lines changed

domain/src/main/java/com/jongsoft/finance/domain/FinTrack.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import com.jongsoft.finance.core.Encoder;
44
import com.jongsoft.finance.domain.user.UserAccount;
55
import com.jongsoft.finance.messaging.commands.user.RegisterTokenCommand;
6+
import com.jongsoft.lang.Collections;
67
import java.time.LocalDateTime;
8+
import java.util.List;
79
import lombok.Getter;
810

911
public class FinTrack {
@@ -18,6 +20,10 @@ public UserAccount createUser(String username, String password) {
1820
return new UserAccount(username, password);
1921
}
2022

23+
public UserAccount createOathUser(String username, String oathKey, List<String> roles) {
24+
return new UserAccount(username, oathKey, Collections.List(roles));
25+
}
26+
2127
public void registerToken(String username, String token, Integer expiresIn) {
2228
RegisterTokenCommand.tokenRegistered(
2329
username, token, LocalDateTime.now().plusSeconds(expiresIn));

domain/src/main/java/com/jongsoft/finance/domain/user/UserAccount.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,9 @@
99
import com.jongsoft.finance.domain.transaction.Tag;
1010
import com.jongsoft.finance.domain.transaction.TransactionRule;
1111
import com.jongsoft.finance.messaging.commands.tag.CreateTagCommand;
12-
import com.jongsoft.finance.messaging.commands.user.ChangeMultiFactorCommand;
13-
import com.jongsoft.finance.messaging.commands.user.ChangePasswordCommand;
14-
import com.jongsoft.finance.messaging.commands.user.ChangeUserSettingCommand;
15-
import com.jongsoft.finance.messaging.commands.user.CreateUserCommand;
12+
import com.jongsoft.finance.messaging.commands.user.*;
1613
import com.jongsoft.lang.Collections;
14+
import com.jongsoft.lang.collection.Collectors;
1715
import com.jongsoft.lang.collection.List;
1816
import java.io.Serializable;
1917
import java.time.LocalDate;
@@ -33,6 +31,7 @@ public class UserAccount implements AggregateBase, Serializable {
3331

3432
private Long id;
3533
private UserIdentifier username;
34+
private String externalUserId;
3635
private String password;
3736
private List<Role> roles;
3837

@@ -49,6 +48,13 @@ public UserAccount(String username, String password) {
4948
CreateUserCommand.userCreated(username, password);
5049
}
5150

51+
public UserAccount(String username, String externalUserId, List<String> roles) {
52+
this.username = new UserIdentifier(username);
53+
this.externalUserId = externalUserId;
54+
this.roles = roles.stream().map(Role::new).collect(Collectors.toList());
55+
CreateExternalUserCommand.externalUserCreated(username, externalUserId, roles);
56+
}
57+
5258
/**
5359
* Change the password of the user to the provided new password.
5460
*
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.jongsoft.finance.messaging.commands.user;
2+
3+
import com.jongsoft.finance.messaging.ApplicationEvent;
4+
import com.jongsoft.lang.collection.List;
5+
6+
public record CreateExternalUserCommand(String username, String oauthToken, List<String> roles)
7+
implements ApplicationEvent {
8+
9+
public static void externalUserCreated(String username, String oauthToken, List<String> roles) {
10+
new CreateExternalUserCommand(username, oauthToken, roles).publish();
11+
}
12+
}

fintrack-api/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ dependencies {
4848
implementation(mn.micronaut.security.jwt)
4949
implementation(mn.micronaut.http.server.jetty)
5050
implementation(mn.micronaut.http.validation)
51+
implementation(mn.micronaut.http.client)
5152
implementation(mn.micronaut.email.template)
5253
implementation(mn.micronaut.views.velocity)
5354

fintrack-api/src/integration/java/com/jongsoft/finance/MultiFactorLoginTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ void registerAndLoginWithMFA(TestContext testContext) {
2020

2121
profileContext
2222
.get(response -> response
23-
.body("theme", equalTo("dark"))
23+
.body("theme", equalTo("light"))
2424
.body("currency", equalTo("EUR"))
2525
.body("mfa", equalTo(false)))
2626
.qrCode();
@@ -32,7 +32,7 @@ void registerAndLoginWithMFA(TestContext testContext) {
3232
.multiFactor()
3333
.profile()
3434
.get(response -> response
35-
.body("theme", equalTo("dark"))
35+
.body("theme", equalTo("light"))
3636
.body("currency", equalTo("EUR"))
3737
.body("mfa", equalTo(true)));
3838
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ public HttpResponse<JsonError> handle(HttpRequest request, AuthorizationExceptio
3030
"{}: {} - User {} does not have access based upon the roles {}.",
3131
request.getMethod(),
3232
request.getPath(),
33-
exception.getAuthentication().getName(),
33+
exception
34+
.getAuthentication()
35+
.getAttributes()
36+
.getOrDefault("email", exception.getAuthentication().getName()),
3437
exception.getAuthentication().getRoles());
3538

3639
return HttpResponse.status(HttpStatus.FORBIDDEN)

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.jongsoft.finance.filter;
22

3+
import com.jongsoft.finance.domain.FinTrack;
34
import com.jongsoft.finance.messaging.InternalAuthenticationEvent;
45
import com.jongsoft.lang.Control;
56
import io.micronaut.context.event.ApplicationEventPublisher;
@@ -10,10 +11,12 @@
1011
import io.micronaut.http.filter.HttpServerFilter;
1112
import io.micronaut.http.filter.ServerFilterChain;
1213
import io.micronaut.http.filter.ServerFilterPhase;
14+
import io.micronaut.security.authentication.ServerAuthentication;
1315
import io.micronaut.serde.ObjectMapper;
1416
import java.security.Principal;
1517
import java.time.Duration;
1618
import java.time.Instant;
19+
import java.util.List;
1720
import java.util.UUID;
1821
import org.reactivestreams.Publisher;
1922
import org.slf4j.Logger;
@@ -27,12 +30,15 @@ public class AuthenticationFilter implements HttpServerFilter {
2730

2831
private final ApplicationEventPublisher<InternalAuthenticationEvent> eventPublisher;
2932
private final ObjectMapper objectMapper;
33+
private final FinTrack finTrack;
3034

3135
public AuthenticationFilter(
3236
ApplicationEventPublisher<InternalAuthenticationEvent> eventPublisher,
33-
ObjectMapper objectMapper) {
37+
ObjectMapper objectMapper,
38+
FinTrack finTrack) {
3439
this.eventPublisher = eventPublisher;
3540
this.objectMapper = objectMapper;
41+
this.finTrack = finTrack;
3642
}
3743

3844
@Override
@@ -79,7 +85,16 @@ public Publisher<MutableHttpResponse<?>> doFilter(
7985
}
8086

8187
private void handleAuthentication(Principal principal) {
82-
log.trace("Authenticated user {}", principal.getName());
83-
eventPublisher.publishEvent(new InternalAuthenticationEvent(this, principal.getName()));
88+
var userName = principal.getName();
89+
if (principal instanceof ServerAuthentication authentication) {
90+
var hasEmail = authentication.getAttributes().containsKey("email");
91+
if (hasEmail) {
92+
userName = authentication.getAttributes().get("email").toString();
93+
finTrack.createOathUser(
94+
userName, principal.getName(), List.copyOf(authentication.getRoles()));
95+
}
96+
}
97+
log.trace("Authenticated user {}", userName);
98+
eventPublisher.publishEvent(new InternalAuthenticationEvent(this, userName));
8499
}
85100
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.jongsoft.finance.rest.security;
2+
3+
import com.jongsoft.finance.security.OpenIdConfiguration;
4+
import io.micronaut.context.annotation.Requires;
5+
import io.micronaut.http.annotation.Controller;
6+
import io.micronaut.http.annotation.Get;
7+
import io.micronaut.security.annotation.Secured;
8+
import io.micronaut.security.rules.SecurityRule;
9+
import io.swagger.v3.oas.annotations.Operation;
10+
import io.swagger.v3.oas.annotations.tags.Tag;
11+
12+
@Controller
13+
@Requires(env = "openid")
14+
@Tag(name = "Authentication")
15+
public class OpenIdResource {
16+
17+
private final OpenIdConfiguration configuration;
18+
19+
public OpenIdResource(OpenIdConfiguration openIdConfiguration) {
20+
this.configuration = openIdConfiguration;
21+
}
22+
23+
@Secured(SecurityRule.IS_ANONYMOUS)
24+
@Get(value = "/.well-known/openid-connect")
25+
@Operation(
26+
summary = "Get the OpenId Connect",
27+
description = "Use this operation to get the OpenId connect details.")
28+
public OpenIdConfiguration openIdConfiguration() {
29+
return configuration;
30+
}
31+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.jongsoft.finance.security;
2+
3+
import io.micronaut.context.annotation.ConfigurationProperties;
4+
import io.micronaut.context.annotation.Requires;
5+
import io.micronaut.serde.annotation.Serdeable;
6+
7+
@Requires(env = "openid")
8+
@Serdeable.Serializable
9+
@ConfigurationProperties("application.openid")
10+
public class OpenIdConfiguration {
11+
12+
private String authority;
13+
private String clientId;
14+
private String clientSecret;
15+
16+
public String getAuthority() {
17+
return authority;
18+
}
19+
20+
public void setAuthority(String authority) {
21+
this.authority = authority;
22+
}
23+
24+
public String getClientId() {
25+
return clientId;
26+
}
27+
28+
public void setClientId(String clientId) {
29+
this.clientId = clientId;
30+
}
31+
32+
public String getClientSecret() {
33+
return clientSecret;
34+
}
35+
36+
public void setClientSecret(String clientSecret) {
37+
this.clientSecret = clientSecret;
38+
}
39+
}

fintrack-api/src/main/resources/application-demo.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ datasources:
1010
flyway:
1111
datasources:
1212
default:
13-
locations: ["classpath:db/camunda/h2", "classpath:db/migration", "classpath:db/sample"]
13+
locations: ["classpath:db/camunda/h2", "classpath:db/migration/mysql", "classpath:db/sample"]
1414
fail-on-missing-locations: true
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
micronaut:
2+
security:
3+
token:
4+
jwt:
5+
signatures:
6+
jwks:
7+
keycloak:
8+
url: ${OPENID_URI}
9+
key-type: RSA
10+
11+
application:
12+
openid:
13+
client-id: ${OPENID_CLIENT:pledger-io}
14+
client-secret: ${OPENID_SECRET:-}
15+
authority: ${OPENID_AUTHORITY:-}
16+

fintrack-api/src/main/resources/i18n/messages.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,3 +602,4 @@ page.transactions.generate.info=Paste a transaction description from your bank s
602602
page.transactions.generated=Extracted transaction
603603
page.transactions.generate.confirm=Create transaction
604604
common.action.back=Back
605+
page.login.openid_connect=OpenId Connect
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.jongsoft.finance.jpa.user;
2+
3+
import com.jongsoft.finance.annotation.BusinessEventListener;
4+
import com.jongsoft.finance.jpa.query.ReactiveEntityManager;
5+
import com.jongsoft.finance.jpa.user.entity.RoleJpa;
6+
import com.jongsoft.finance.jpa.user.entity.UserAccountJpa;
7+
import com.jongsoft.finance.messaging.CommandHandler;
8+
import com.jongsoft.finance.messaging.commands.user.CreateExternalUserCommand;
9+
import dev.samstevens.totp.secret.SecretGenerator;
10+
import jakarta.inject.Singleton;
11+
import jakarta.transaction.Transactional;
12+
import org.slf4j.Logger;
13+
14+
@Singleton
15+
@Transactional
16+
class CreateExternalUserHandler implements CommandHandler<CreateExternalUserCommand> {
17+
private final Logger log = org.slf4j.LoggerFactory.getLogger(this.getClass());
18+
19+
private final ReactiveEntityManager entityManager;
20+
private final SecretGenerator secretGenerator;
21+
22+
public CreateExternalUserHandler(ReactiveEntityManager entityManager) {
23+
this.entityManager = entityManager;
24+
secretGenerator = new dev.samstevens.totp.secret.DefaultSecretGenerator();
25+
}
26+
27+
@Override
28+
@BusinessEventListener
29+
public void handle(CreateExternalUserCommand command) {
30+
if (validateUserExists(command.username())) {
31+
log.debug("[{}] - External user already exists, skipping creation.", command.username());
32+
return;
33+
}
34+
35+
log.info("[{}] - Creating external user", command.username());
36+
var builder =
37+
UserAccountJpa.builder()
38+
.username(command.username())
39+
.password("")
40+
.theme("light")
41+
.twoFactorSecret(secretGenerator.generate());
42+
43+
for (var role : command.roles()) {
44+
entityManager
45+
.from(RoleJpa.class)
46+
.fieldEq("name", role)
47+
.singleResult()
48+
.ifPresent(builder::role);
49+
}
50+
51+
entityManager.persist(builder.build());
52+
}
53+
54+
private synchronized boolean validateUserExists(String username) {
55+
return entityManager
56+
.from(UserAccountJpa.class)
57+
.fieldEq("username", username)
58+
.singleResult()
59+
.isPresent();
60+
}
61+
}

jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/CreateUserHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public void handle(CreateUserCommand command) {
3737
.username(command.username())
3838
.password(command.password())
3939
.twoFactorSecret(secretGenerator.generate())
40-
.theme("dark")
40+
.theme("light")
4141
.roles(
4242
new HashSet<>(
4343
Arrays.asList(

jpa-repository/src/main/java/com/jongsoft/finance/jpa/user/entity/UserAccountJpa.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import java.util.Set;
88
import lombok.Builder;
99
import lombok.Getter;
10+
import lombok.Singular;
1011

1112
@Getter
1213
@Entity
@@ -45,7 +46,7 @@ private UserAccountJpa(
4546
String theme,
4647
Currency currency,
4748
byte[] gravatar,
48-
Set<RoleJpa> roles) {
49+
@Singular Set<RoleJpa> roles) {
4950
this.username = username;
5051
this.password = password;
5152
this.twoFactorEnabled = twoFactorEnabled;

0 commit comments

Comments
 (0)