+ If an account exists for this address, a reset link is on its way. + The link is valid for 30 minutes. +
+ +Forgot password
++ Enter your account email; we will send you a single-use reset link. +
+ + + + +Security properties (see issues #53 and #55): + *
+ If an account exists for this address, a reset link is on its way. + The link is valid for 30 minutes. +
+ ++ Enter your account email; we will send you a single-use reset link. +
+ + + + +Account created, please log in.
++ Your password has been changed, please log in. +
You have been logged out.
@@ -35,7 +38,8 @@+ This reset link is invalid, expired, or has already been used. +
++ Reset links are single use and expire after 30 minutes. + You can request a new one. +
+Runs against the shared Testcontainers PostgreSQL plus a Mailpit + * container as the SMTP target (the same image used by docker compose). + * + *
Named with the {@code Test} suffix (not {@code IT}) so Surefire runs it
+ * as part of {@code mvn test}; this project does not use the Failsafe plugin.
+ */
+@SpringBootTest(properties = {
+ // This feature does not use MongoDB; keep the test context Postgres-only.
+ "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration,"
+ + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration"
+})
+@AutoConfigureMockMvc
+class PasswordResetFlowTest extends AbstractPostgresIT {
+
+ /** Shared like the Postgres container: started once for the class. */
+ static final GenericContainer> MAILPIT =
+ new GenericContainer<>("axllent/mailpit")
+ .withExposedPorts(1025, 8025);
+
+ static {
+ MAILPIT.start();
+ }
+
+ @DynamicPropertySource
+ static void mailProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.mail.host", MAILPIT::getHost);
+ registry.add("spring.mail.port", () -> MAILPIT.getMappedPort(1025));
+ }
+
+ @Autowired
+ MockMvc mvc;
+
+ final HttpClient http = HttpClient.newHttpClient();
+ final ObjectMapper json = new ObjectMapper();
+
+ @Test
+ void full_reset_loop_changes_the_password_and_burns_the_token() throws Exception {
+ // An account to reset.
+ mvc.perform(post("/auth/register").with(csrf())
+ .param("username", "dave")
+ .param("email", "dave@example.com")
+ .param("password", "oldpassword1"))
+ .andExpect(status().is3xxRedirection());
+
+ // Request the reset: neutral redirect, whatever the outcome.
+ mvc.perform(post("/auth/forgot-password").with(csrf())
+ .param("email", "dave@example.com"))
+ .andExpect(redirectedUrl("/auth/forgot-password?sent"));
+
+ // The email went through real SMTP into Mailpit; read it back.
+ String rawToken = tokenFromLatestEmail();
+ assertThat(rawToken).isNotBlank();
+
+ // The emailed link shows the new-password form.
+ mvc.perform(get("/auth/reset-password").param("token", rawToken))
+ .andExpect(status().isOk())
+ .andExpect(content().string(containsString("New password")));
+
+ // Consume the token.
+ mvc.perform(post("/auth/reset-password").with(csrf())
+ .param("token", rawToken)
+ .param("password", "newpassword2"))
+ .andExpect(redirectedUrl("/auth/login?reset"));
+
+ // The old password no longer works; the new one does.
+ mvc.perform(formLogin("/auth/login").user("dave").password("oldpassword1"))
+ .andExpect(unauthenticated());
+ mvc.perform(formLogin("/auth/login").user("dave").password("newpassword2"))
+ .andExpect(authenticated().withUsername("dave"));
+
+ // Single use: the same token is now rejected.
+ mvc.perform(get("/auth/reset-password").param("token", rawToken))
+ .andExpect(status().isOk())
+ .andExpect(content().string(containsString("invalid, expired, or has already been used")));
+ mvc.perform(post("/auth/reset-password").with(csrf())
+ .param("token", rawToken)
+ .param("password", "anotherpass3"))
+ .andExpect(status().isOk())
+ .andExpect(content().string(containsString("invalid, expired, or has already been used")));
+ }
+
+ /**
+ * Polls Mailpit's REST API for the latest message and extracts the raw
+ * token from the reset link in its body.
+ */
+ private String tokenFromLatestEmail() throws Exception {
+ String api = "http://" + MAILPIT.getHost() + ":" + MAILPIT.getMappedPort(8025);
+ String messageId = null;
+ for (int attempt = 0; attempt < 20 && messageId == null; attempt++) {
+ HttpResponse