diff --git a/UnityAuth/src/main/java/io/unityfoundation/auth/AuthController.java b/UnityAuth/src/main/java/io/unityfoundation/auth/AuthController.java index 7fafe31..9b54712 100644 --- a/UnityAuth/src/main/java/io/unityfoundation/auth/AuthController.java +++ b/UnityAuth/src/main/java/io/unityfoundation/auth/AuthController.java @@ -7,7 +7,6 @@ import io.micronaut.http.exceptions.HttpStatusException; import io.micronaut.security.annotation.Secured; import io.micronaut.security.authentication.Authentication; -import io.micronaut.security.rules.SecurityRule; import io.micronaut.serde.annotation.Serdeable; import io.unityfoundation.auth.entities.*; import io.unityfoundation.auth.entities.Service.ServiceStatus; @@ -15,7 +14,7 @@ import java.util.List; import java.util.Optional; -@Secured(SecurityRule.IS_AUTHENTICATED) +@Secured("USER") @Controller("/api") public class AuthController { diff --git a/UnityAuth/src/main/java/io/unityfoundation/auth/InternalAuthTokenReader.java b/UnityAuth/src/main/java/io/unityfoundation/auth/InternalAuthTokenReader.java new file mode 100644 index 0000000..668e363 --- /dev/null +++ b/UnityAuth/src/main/java/io/unityfoundation/auth/InternalAuthTokenReader.java @@ -0,0 +1,18 @@ +package io.unityfoundation.auth; + +import io.micronaut.security.token.reader.HttpHeaderTokenReader; +import jakarta.inject.Singleton; + +@Singleton +public class InternalAuthTokenReader extends HttpHeaderTokenReader { + + @Override + protected String getPrefix() { + return null; + } + + @Override + protected String getHeaderName() { + return "X-Unity-Auth-Internal"; + } +} diff --git a/UnityAuth/src/main/java/io/unityfoundation/auth/InternalAuthTokenValidator.java b/UnityAuth/src/main/java/io/unityfoundation/auth/InternalAuthTokenValidator.java new file mode 100644 index 0000000..bb710df --- /dev/null +++ b/UnityAuth/src/main/java/io/unityfoundation/auth/InternalAuthTokenValidator.java @@ -0,0 +1,30 @@ +package io.unityfoundation.auth; + +import io.micronaut.context.annotation.Value; +import io.micronaut.http.HttpRequest; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.token.validator.TokenValidator; +import jakarta.inject.Singleton; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +@Singleton +public class InternalAuthTokenValidator implements TokenValidator> { + + @Value("${unity.auth.internal-token}") + private String internalToken; + + @Override + public Publisher validateToken(String token, HttpRequest request) { + if (internalToken != null && MessageDigest.isEqual( + internalToken.getBytes(StandardCharsets.UTF_8), + token.getBytes(StandardCharsets.UTF_8))) { + return Mono.just(Authentication.build("internal-service", List.of("INTERNAL_SERVICE"))); + } + return Mono.empty(); + } +} diff --git a/UnityAuth/src/main/java/io/unityfoundation/auth/PasswordResetController.java b/UnityAuth/src/main/java/io/unityfoundation/auth/PasswordResetController.java new file mode 100644 index 0000000..4bce706 --- /dev/null +++ b/UnityAuth/src/main/java/io/unityfoundation/auth/PasswordResetController.java @@ -0,0 +1,106 @@ +package io.unityfoundation.auth; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.*; +import io.micronaut.http.exceptions.HttpStatusException; +import io.micronaut.security.annotation.Secured; +import io.micronaut.serde.annotation.Serdeable; +import io.unityfoundation.auth.entities.PasswordResetToken; +import io.unityfoundation.auth.entities.PasswordResetTokenRepo; +import io.unityfoundation.auth.entities.User; +import io.unityfoundation.auth.entities.UserRepo; +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HexFormat; +import java.util.Optional; +import java.util.UUID; + +@Secured("INTERNAL_SERVICE") +@Controller("/api/password-reset") +public class PasswordResetController { + + private final UserRepo userRepo; + private final PasswordResetTokenRepo tokenRepo; + private final PasswordEncoder passwordEncoder; + + public PasswordResetController(UserRepo userRepo, PasswordResetTokenRepo tokenRepo, PasswordEncoder passwordEncoder) { + this.userRepo = userRepo; + this.tokenRepo = tokenRepo; + this.passwordEncoder = passwordEncoder; + } + + @Post("/generate") + public HttpResponse generateToken(@Body @Valid GenerateTokenRequest request) { + Optional userOptional = userRepo.findByEmail(request.email()); + if (userOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.NOT_FOUND, "User not found"); + } + + User user = userOptional.get(); + tokenRepo.deleteByUserId(user.getId()); + + String rawToken = UUID.randomUUID().toString(); + PasswordResetToken token = new PasswordResetToken(); + token.setToken(sha256(rawToken)); + token.setUserId(user.getId()); + token.setExpiry(Instant.now().plus(1, ChronoUnit.HOURS)); + tokenRepo.save(token); + + return HttpResponse.ok(new GenerateTokenResponse(rawToken)); + } + + @Post("/reset") + @Transactional + public HttpResponse resetPassword(@Body @Valid ResetPasswordRequest request) { + Optional tokenOptional = tokenRepo.findByToken(sha256(request.token())); + + if (tokenOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid token"); + } + + PasswordResetToken token = tokenOptional.get(); + if (token.getExpiry().isBefore(Instant.now())) { + tokenRepo.delete(token); + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Token expired"); + } + + Optional userOptional = userRepo.findById(token.getUserId()); + if (userOptional.isEmpty()) { + throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "User not found"); + } + + User user = userOptional.get(); + user.setPassword(passwordEncoder.encode(request.newPassword())); + userRepo.update(user); + + tokenRepo.delete(token); + + return HttpResponse.ok(); + } + + private static String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(input.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + @Serdeable + public record GenerateTokenRequest(@NotBlank String email) {} + + @Serdeable + public record GenerateTokenResponse(@NotBlank String token) {} + + @Serdeable + public record ResetPasswordRequest(@NotBlank String token, @NotBlank String newPassword) {} +} diff --git a/UnityAuth/src/main/java/io/unityfoundation/auth/UnityAuthenticationProvider.java b/UnityAuth/src/main/java/io/unityfoundation/auth/UnityAuthenticationProvider.java index 0d97a79..c32109f 100644 --- a/UnityAuth/src/main/java/io/unityfoundation/auth/UnityAuthenticationProvider.java +++ b/UnityAuth/src/main/java/io/unityfoundation/auth/UnityAuthenticationProvider.java @@ -16,6 +16,7 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -68,6 +69,7 @@ private User findUser(AuthenticationRequest authRequest) { } else { return Mono.just(AuthenticationResponse.success( (String) authenticationRequest.getIdentity(), + List.of("USER"), Map.of( "first_name", Objects.toString(user.getFirstName(), ""), "last_name", Objects.toString(user.getLastName(), "") diff --git a/UnityAuth/src/main/java/io/unityfoundation/auth/UserController.java b/UnityAuth/src/main/java/io/unityfoundation/auth/UserController.java index 48051d1..2f6b18a 100644 --- a/UnityAuth/src/main/java/io/unityfoundation/auth/UserController.java +++ b/UnityAuth/src/main/java/io/unityfoundation/auth/UserController.java @@ -6,7 +6,6 @@ import io.micronaut.http.exceptions.HttpStatusException; import io.micronaut.security.annotation.Secured; import io.micronaut.security.authentication.Authentication; -import io.micronaut.security.rules.SecurityRule; import io.micronaut.serde.annotation.Serdeable; import io.unityfoundation.auth.entities.*; import jakarta.transaction.Transactional; @@ -18,7 +17,7 @@ import java.util.Objects; import java.util.Optional; -@Secured(SecurityRule.IS_AUTHENTICATED) +@Secured("USER") @Controller("/api/users") public class UserController { diff --git a/UnityAuth/src/main/java/io/unityfoundation/auth/entities/PasswordResetToken.java b/UnityAuth/src/main/java/io/unityfoundation/auth/entities/PasswordResetToken.java new file mode 100644 index 0000000..5a24c31 --- /dev/null +++ b/UnityAuth/src/main/java/io/unityfoundation/auth/entities/PasswordResetToken.java @@ -0,0 +1,59 @@ +package io.unityfoundation.auth.entities; + +import io.micronaut.core.annotation.Introspected; +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; + +@Serdeable +@MappedEntity +public class PasswordResetToken { + @Id + @GeneratedValue + private Long id; + + @NotNull + private String token; + + @NotNull + private Long userId; + + @NotNull + private Instant expiry; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Instant getExpiry() { + return expiry; + } + + public void setExpiry(Instant expiry) { + this.expiry = expiry; + } +} diff --git a/UnityAuth/src/main/java/io/unityfoundation/auth/entities/PasswordResetTokenRepo.java b/UnityAuth/src/main/java/io/unityfoundation/auth/entities/PasswordResetTokenRepo.java new file mode 100644 index 0000000..bd263b9 --- /dev/null +++ b/UnityAuth/src/main/java/io/unityfoundation/auth/entities/PasswordResetTokenRepo.java @@ -0,0 +1,13 @@ +package io.unityfoundation.auth.entities; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.CrudRepository; + +import java.util.Optional; + +@JdbcRepository(dialect = Dialect.MYSQL) +public interface PasswordResetTokenRepo extends CrudRepository { + Optional findByToken(String token); + void deleteByUserId(Long userId); +} diff --git a/UnityAuth/src/main/resources/application-docker.yml b/UnityAuth/src/main/resources/application-docker.yml index 056a618..8b8c175 100644 --- a/UnityAuth/src/main/resources/application-docker.yml +++ b/UnityAuth/src/main/resources/application-docker.yml @@ -42,6 +42,10 @@ flyway: - classpath:db/migration - classpath:local +unity: + auth: + internal-token: unity-internal-secret + jwk: primary: '{"p":"_OZyH1Mk3wR0oXw1C31t4kWOcaHFB6Njro1cYx52REnPiznn_JTtwvlAMpvV6LVCIZPgKMzdIEMY1gYs1LsO-5IFqWwegXmYJ0iKXbRrZshfWBCzRLK3QK5fER1le1XUBDhtDk7KIW_Xg-SZF4pf_LUEVKMnyUpspGI5F77jlJ8","kty":"RSA","q":"s9wvl7z8vkHQvo9xOUp-z0a2Z7LFBDil2uIjPh1FQzs34gFXH8dQPRox83TuN5d4KzdLPqQNQAfMXU9_KmxihNb_qDQahYugeELmcem04munxXqBdyZqWhWCy5YmujYqn44irwvoTbw6_RkMqjCmINPTPadptlPivsZ6RhKn8zk","d":"ok3wmhOy8NZEHAotnFiH6ecFD6xf_9x33_fMRkqa3_KE8NZM7vmvNgElox2UvcP_2K5E7jOdL2XQdJCTIW3Qlj66yE2a84SYlbvxIc4hDrIog0XNt4FhavvshxxUIfDQo6Q8qXDR5v7nwt6SCopYC3t3KVRdJh08GzKoVxysd7afJjxXxx178gY29uMRqnwxFN1OGnWaiBr-xGKb1frJ6jOI1zvuuCaljZ4aZjc9vOR4y9ZmobgrzkMFnpDAmQZ7MWcVMyodRMOA2dEOckywPhg-dIVNiVIqzJqe5Yg1ilNookjwtqj2TpNU7Z9gPqzYB73PmQ2p5LMDheAPxcOmEQ","e":"AQAB","use":"sig","kid":"e3be37177a7c42bcbadd7cc63715f216","qi":"r--nAtaYPAgJq_8R1-kynpd53E17n-loDUgtVWBCx_RmdORX4Auilv1S83dD1mbcnYCbV_LmxiEjOiz-4gS_E0qVGqakAqQrO1hVUvJa_Y2uftDgwFmuJNGbpRU-K4Td_uUzdm48za8yJCgOdYsWp6PNMCcmQgiInzkR3XYV83I","dp":"oQUcvmMSw8gzdin-IB2xW_MLecAVEgLu0dGBdD6N8HbKZQvub_xm0dAfFtnvvWXDAFwFyhR96i-uXX67Bos_Q9-6KSAE4E0KGmDucDESfPOw-QJREbl0QgOD1gLQfVGtVy6SCR0TR2zNXFWtP7bD3MNoSXdEOr5fI97CGSNaBWM","alg":"RS256","dq":"DM-WJDy10-dkMu6MpgQEXEcxHtnA5rgSODD7SaVUFaHWLSbjScQslu2SuUCO5y7GxG0_0spklzb2-356FE98BPI7a4Oqj_COEYLSXzLCS45XeN1s80utL5Vwp4eeYo0RJCQ_nDBA76iEmxp5qHWmn5f25-FQykfXUrdYZj1V8SE","n":"sa6m2i-iNvj6ZSTdSHZaBrnv6DId4AqAXhOyl0yA5fNWYe6r51h24SXqk7DsGYHHh74ii74tP1lTpmy6RD67tCK-tbN-d6yc4Z6FfM8R83v2QZUfaAixgHGtw0n2toqsiHf6EloDV-B8q4GYyKDD6cLecoaIuTmMBTY3kts59U2t9W10YoLGsmFqLSz8qNF5HkahzB6_--2DiBfVGUKAXHC-SICGZCi-8efOetv6pt9vFiWEgwU_DgjRNYzLFt1SEmbGFUU4kbjQ7tNTMkHfzfwcT6qLt4kVKy2FNYsEMk24keWtCvW_RyO_fisZc0W9smX7WtYjEXhcAjDeqHgEZw"}' secondary: '{"p":"4qJ9RNlu6SuDT_MLArfzimvKEwmet_j12Z9EQeb5nMjZIOHTcWw__duebUytfWwxsRHhtSVXeMt-EryQAOulm2p1bfiVuparq93z9P5cPnb0oArFaw3eFNFEmX5U-lY8PzUTTsFxO4aVQYAKXD6DP7p5uPzuwpHFuNc71nNIXZE","kty":"RSA","q":"v4OhkWMbS_nq77HFanwZAT_obfJuQfOFOQBORL4ATAHGUXm2y4YqLNExZs7Wj1MA_6ya6Y00s2JBM7fWq_fPe4d9xo5aGrPdcp0G8W21kkfh9vuVPlHVQTgSP7FQ9qahvXxNwK_11yNr3p1HBmScJ5mHlMBpIJsFcvHA-uXe0Ps","d":"EunrjnQ1-jJPSCrt2L94PUpDrakup8a4pXys52YSkJY-W6XidM0roOS6kr06P3G6VQgc6AL_BkvTQ_XS0oXHbXVprDQ5Syam5p9oxHBhhW_vSqIMgUOfm28uyB3Mtw9rBxdUxW3yElHioaR8a-exYhhyVXb1QEhxL_rcnthmhAkM2NcHi2UnxGKFTsC0abQ2MuQc1OAuW5veDiIF2hfdC41qE0_d8vB6FDWbblgUpbwB6uSZaViPs15Buq2oX9dCCw54-PgzkfehDt7lyqgupktbV1psnVVhL86shzt4QFnhd3k7VpFbjCNFtiJTrufV-XBWT0pl2w3VR9wrHJ1bYQ","e":"AQAB","use":"sig","kid":"0794e938379540dc8eaa559508524a79","qi":"jy-TNyXVy_44_n4KGAwIbZO2C4r6uNWuEdehBfQKkPhiP90myG1KZVfOoKNOK9bCv2mvZJcBz4c1ArElgpuSCV4-KFac1ZzQo_ic5aoIej8Qa80y2ogc-_Yv6_ZLHC1S76M-lm4jayk2-rvuBpy2pUvHbW6Srhs_szwz7ZfSkLg","dp":"ApqdV9ortRAj7Ro8ySY17SQ56SgWI8T_hiWXUi6GNa_1FrShik8VGSSZ2GWmJKfGlmM_NaadL60e4LY77VbHy1ZYzQ-rIL60cEAXmnwFsU4Kl4AoLoe1QoX5BM53yXyOKqfAdgow898i_eKru82YEnZhCagWUjP8kpgefuNKNJE","alg":"RS256","dq":"bFF78WoXh0pMCdQHL2oPDnjh8kWa_OxKHmpA2nqIWnTqgSyRKd2xPvX2tgooqpmsx-8NEymNdCQPcrv4y_z2OgzxI3tiFRZEGs4bnjOJ7bmAYZv71mqcbi3TjHiyrT6j3jNPGrurFUpweVGFWWVQOMmKOKT3ELz9QPzhREb9Vj8","n":"qYvDpV8DRU5hx9eXpE4Ms8nUXicEwrxUUz5gb5gkXpIeY82mqfQKKCP6PSFnkKYtRFTOUSm9cgGGfOd7O4NFsIsxLwXCj34X7ORr19eXKBLvG3bZJLxqRlbYuQshDMkQOui1sDBxvYnj5p4iHne6l2btH5grHOCShUWG-bKps5Y8bKNHod1pIOOBabVCmn3sUVUkZw8nyXkQqZbv-c8x6z0TEfhNOPOIt2AmmlNgrE_8g7-dnCvqfJnhv0c7qkOJzsb7OMmvVwsQNiM59D6uaWZr-vdANo6NggiZmCKUS3tpUvdXW7ec9WMPJWhrVEkRcbWXQnZ_C7pXFrz7rLeNKw"}' diff --git a/UnityAuth/src/main/resources/application-local.yml b/UnityAuth/src/main/resources/application-local.yml index f820a5f..07b1c82 100644 --- a/UnityAuth/src/main/resources/application-local.yml +++ b/UnityAuth/src/main/resources/application-local.yml @@ -22,6 +22,9 @@ micronaut: port: 9090 security: authentication: bearer +unity: + auth: + internal-token: unity-internal-secret datasources: default: url: jdbc:mysql://localhost:13306/test?allowPublicKeyRetrieval=true&useSSL=false diff --git a/UnityAuth/src/main/resources/application.yml b/UnityAuth/src/main/resources/application.yml index 1246474..d0b14b0 100644 --- a/UnityAuth/src/main/resources/application.yml +++ b/UnityAuth/src/main/resources/application.yml @@ -16,6 +16,9 @@ micronaut: access: - isAnonymous() authentication: bearer +unity: + auth: + internal-token: ${UNITY_AUTH_INTERNAL_TOKEN} server: cors: enabled: true diff --git a/UnityAuth/src/main/resources/db/migration/V3__add_password_reset_token_table.sql b/UnityAuth/src/main/resources/db/migration/V3__add_password_reset_token_table.sql new file mode 100644 index 0000000..4a5c906 --- /dev/null +++ b/UnityAuth/src/main/resources/db/migration/V3__add_password_reset_token_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE password_reset_token +( + id bigint AUTO_INCREMENT PRIMARY KEY, + token varchar(255) NOT NULL, + user_id bigint NOT NULL, + expiry timestamp NOT NULL, + UNIQUE (token), + CONSTRAINT password_reset_token_user_FK FOREIGN KEY (user_id) REFERENCES user (id) +); diff --git a/UnityAuth/src/test/java/io/unityfoundation/UnityPasswordResetTest.java b/UnityAuth/src/test/java/io/unityfoundation/UnityPasswordResetTest.java new file mode 100644 index 0000000..94fe2a6 --- /dev/null +++ b/UnityAuth/src/test/java/io/unityfoundation/UnityPasswordResetTest.java @@ -0,0 +1,96 @@ +package io.unityfoundation; + +import io.micronaut.context.annotation.Property; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.unityfoundation.auth.PasswordResetController; +import io.unityfoundation.auth.entities.User; +import io.unityfoundation.auth.entities.UserRepo; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@Property(name = "jwk.primary", value = "{\"p\":\"_OZyH1Mk3wR0oXw1C31t4kWOcaHFB6Njro1cYx52REnPiznn_JTtwvlAMpvV6LVCIZPgKMzdIEMY1gYs1LsO-5IFqWwegXmYJ0iKXbRrZshfWBCzRLK3QK5fER1le1XUBDhtDk7KIW_Xg-SZF4pf_LUEVKMnyUpspGI5F77jlJ8\",\"kty\":\"RSA\",\"q\":\"s9wvl7z8vkHQvo9xOUp-z0a2Z7LFBDil2uIjPh1FQzs34gFXH8dQPRox83TuN5d4KzdLPqQNQAfMXU9_KmxihNb_qDQahYugeELmcem04munxXqBdyZqWhWCy5YmujYqn44irwvoTbw6_RkMqjCmINPTPadptlPivsZ6RhKn8zk\",\"d\":\"ok3wmhOy8NZEHAotnFiH6ecFD6xf_9x33_fMRkqa3_KE8NZM7vmvNgElox2UvcP_2K5E7jOdL2XQdJCTIW3Qlj66yE2a84SYlbvxIc4hDrIog0XNt4FhavvshxxUIfDQo6Q8qXDR5v7nwt6SCopYC3t3KVRdJh08GzKoVxysd7afJjxXxx178gY29uMRqnwxFN1OGnWaiBr-xGKb1frJ6jOI1zvuuCaljZ4aZjc9vOR4y9ZmobgrzkMFnpDAmQZ7MWcVMyodRMOA2dEOckywPhg-dIVNiVIqzJqe5Yg1ilNookjwtqj2TpNU7Z9gPqzYB73PmQ2p5LMDheAPxcOmEQ\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"e3be37177a7c42bcbadd7cc63715f216\",\"qi\":\"r--nAtaYPAgJq_8R1-kynpd53E17n-loDUgtVWBCx_RmdORX4Auilv1S83dD1mbcnYCbV_LmxiEjOiz-4gS_E0qVGqakAqQrO1hVUvJa_Y2uftDgwFmuJNGbpRU-K4Td_uUzdm48za8yJCgOdYsWp6PNMCcmQgiInzkR3XYV83I\",\"dp\":\"oQUcvmMSw8gzdin-IB2xW_MLecAVEgLu0dGBdD6N8HbKZQvub_xm0dAfFtnvvWXDAFwFyhR96i-uXX67Bos_Q9-6KSAE4E0KGmDucDESfPOw-QJREbl0QgOD1gLQfVGtVy6SCR0TR2zNXFWtP7bD3MNoSXdEOr5fI97CGSNaBWM\",\"alg\":\"RS256\",\"dq\":\"DM-WJDy10-dkMu6MpgQEXEcxHtnA5rgSODD7SaVUFaHWLSbjScQslu2SuUCO5y7GxG0_0spklzb2-356FE98BPI7a4Oqj_COEYLSXzLCS45XeN1s80utL5Vwp4eeYo0RJCQ_nDBA76iEmxp5qHWmn5f25-FQykfXUrdYZj1V8SE\",\"n\":\"sa6m2i-iNvj6ZSTdSHZaBrnv6DId4AqAXhOyl0yA5fNWYe6r51h24SXqk7DsGYHHh74ii74tP1lTpmy6RD67tCK-tbN-d6yc4Z6FfM8R83v2QZUfaAixgHGtw0n2toqsiHf6EloDV-B8q4GYyKDD6cLecoaIuTmMBTY3kts59U2t9W10YoLGsmFqLSz8qNF5HkahzB6_--2DiBfVGUKAXHC-SICGZCi-8efOetv6pt9vFiWEgwU_DgjRNYzLFt1SEmbGFUU4kbjQ7tNTMkHfzfwcT6qLt4kVKy2FNYsEMk24keWtCvW_RyO_fisZc0W9smX7WtYjEXhcAjDeqHgEZw\"}") +@Property(name = "jwk.secondary", value = "{\"p\":\"4qJ9RNlu6SuDT_MLArfzimvKEwmet_j12Z9EQeb5nMjZIOHTcWw__duebUytfWwxsRHhtSVXeMt-EryQAOulm2p1bfiVuparq93z9P5cPnb0oArFaw3eFNFEmX5U-lY8PzUTTsFxO4aVQYAKXD6DP7p5uPzuwpHFuNc71nNIXZE\",\"kty\":\"RSA\",\"q\":\"v4OhkWMbS_nq77HFanwZAT_obfJuQfOFOQBORL4ATAHGUXm2y4YqLNExZs7Wj1MA_6ya6Y00s2JBM7fWq_fPe4d9xo5aGrPdcp0G8W21kkfh9vuVPlHVQTgSP7FQ9qahvXxNwK_11yNr3p1HBmScJ5mHlMBpIJsFcvHA-uXe0Ps\",\"d\":\"EunrjnQ1-jJPSCrt2L94PUpDrakup8a4pXys52YSkJY-W6XidM0roOS6kr06P3G6VQgc6AL_BkvTQ_XS0oXHbXVprDQ5Syam5p9oxHBhhW_vSqIMgUOfm28uyB3Mtw9rBxdUxW3yElHioaR8a-exYhhyVXb1QEhxL_rcnthmhAkM2NcHi2UnxGKFTsC0abQ2MuQc1OAuW5veDiIF2hfdC41qE0_d8vB6FDWbblgUpbwB6uSZaViPs15Buq2oX9dCCw54-PgzkfehDt7lyqgupktbV1psnVVhL86shzt4QFnhd3k7VpFbjCNFtiJTrufV-XBWT0pl2w3VR9wrHJ1bYQ\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"0794e938379540dc8eaa559508524a79\",\"qi\":\"jy-TNyXVy_44_n4KGAwIbZO2C4r6uNWuEdehBfQKkPhiP90myG1KZVfOoKNOK9bCv2mvZJcBz4c1ArElgpuSCV4-KFac1ZzQo_ic5aoIej8Qa80y2ogc-_Yv6_ZLHC1S76M-lm4jayk2-rvuBpy2pUvHbW6Srhs_szwz7ZfSkLg\",\"dp\":\"ApqdV9ortRAj7Ro8ySY17SQ56SgWI8T_hiWXUi6GNa_1FrShik8VGSSZ2GWmJKfGlmM_NaadL60e4LY77VbHy1ZYzQ-rIL60cEAXmnwFsU4Kl4AoLoe1QoX5BM53yXyOKqfAdgow898i_eKru82YEnZhCagWUjP8kpgefuNKNJE\",\"alg\":\"RS256\",\"dq\":\"bFF78WoXh0pMCdQHL2oPDnjh8kWa_OxKHmpA2nqIWnTqgSyRKd2xPvX2tgooqpmsx-8NEymNdCQPcrv4y_z2OgzxI3tiFRZEGs4bnjOJ7bmAYZv71mqcbi3TjHiyrT6j3jNPGrurFUpweVGFWWVQOMmKOKT3ELz9QPzhREb9Vj8\",\"n\":\"qYvDpV8DRU5hx9eXpE4Ms8nUXicEwrxUUz5gb5gkXpIeY82mqfQKKCP6PSFnkKYtRFTOUSm9cgGGfOd7O4NFsIsxLwXCj34X7ORr19eXKBLvG3bZJLxqRlbYuQshDMkQOui1sDBxvYnj5p4iHne6l2btH5grHOCShUWG-bKps5Y8bKNHod1pIOOBabVCmn3sUVUkZw8nyXkQqZbv-c8x6z0TEfhNOPOIt2AmmlNgrE_8g7-dnCvqfJnhv0c7qkOJzsb7OMmvVwsQNiM59D6uaWZr-vdANo6NggiZmCKUS3tpUvdXW7ec9WMPJWhrVEkRcbWXQnZ_C7pXFrz7rLeNKw\"}") +@Property(name = "unity.auth.internal-token", value = "test-secret") +@MicronautTest +public class UnityPasswordResetTest { + + @Inject + @Client("/") + HttpClient client; + + @Inject + UserRepo userRepo; + + @Test + void testPasswordResetFlow() { + // 1. Generate token + HttpRequest generateRequest = HttpRequest.POST("/api/password-reset/generate", + new PasswordResetController.GenerateTokenRequest("person1@test.io")) + .header("X-Unity-Auth-Internal", "test-secret"); + + HttpResponse generateResponse = client.toBlocking() + .exchange(generateRequest, PasswordResetController.GenerateTokenResponse.class); + + assertEquals(HttpStatus.OK, generateResponse.getStatus()); + String token = generateResponse.getBody().get().token(); + assertNotNull(token); + + // 2. Reset password + HttpRequest resetRequest = HttpRequest.POST("/api/password-reset/reset", + new PasswordResetController.ResetPasswordRequest(token, "new-secure-password")) + .header("X-Unity-Auth-Internal", "test-secret"); + + HttpResponse resetResponse = client.toBlocking().exchange(resetRequest); + assertEquals(HttpStatus.OK, resetResponse.getStatus()); + + // 3. Verify password was changed + Optional userOptional = userRepo.findByEmail("person1@test.io"); + assertTrue(userOptional.isPresent()); + } + + @Test + void testGenerateTokenInvalidSecret() { + HttpRequest generateRequest = HttpRequest.POST("/api/password-reset/generate", + new PasswordResetController.GenerateTokenRequest("person1@test.io")) + .header("X-Unity-Auth-Internal", "wrong-secret"); + + HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> { + client.toBlocking().exchange(generateRequest); + }); + assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus()); + } + + @Test + void testResetWithInvalidToken() { + HttpRequest resetRequest = HttpRequest.POST("/api/password-reset/reset", + new PasswordResetController.ResetPasswordRequest("invalid-token", "new-password")) + .header("X-Unity-Auth-Internal", "test-secret"); + + HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> { + client.toBlocking().exchange(resetRequest); + }); + assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); + } + + @Test + void testResetWithInvalidSecret() { + HttpRequest resetRequest = HttpRequest.POST("/api/password-reset/reset", + new PasswordResetController.ResetPasswordRequest("any-token", "new-password")) + .header("X-Unity-Auth-Internal", "wrong-secret"); + + HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> { + client.toBlocking().exchange(resetRequest); + }); + assertEquals(HttpStatus.UNAUTHORIZED, exception.getStatus()); + } +}