From a6af22c23a0c425454bebf706c24a2bb36995b30 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Sat, 11 Jul 2026 18:17:10 +0200 Subject: [PATCH 1/3] feat(mail): Add Mailpit and Spring Mail for local email Add the Mailpit fake SMTP catcher as a Docker Compose service (SMTP on localhost:1025, web UI on http://localhost:8025, literal ports on purpose to avoid adding variables to the externally managed .env file) and wire the dev profile to it via spring.mail. Add the spring-boot-starter-mail dependency and the learndev.mail.from and learndev.password-reset settings (token TTL, rate limit) that the reset flow will consume. Chosen over the abandoned MailHog (ADR-0004). Document the service in the README and define Mailpit and SMTP in both glossaries. Fixes #52 --- GLOSSAIRE.md | 8 ++++++++ GLOSSARY.md | 7 +++++++ README.md | 13 +++++++++++++ docker-compose.yaml | 14 ++++++++++++++ pom.xml | 6 ++++++ src/main/resources/application-dev.yaml | 9 +++++++++ src/main/resources/application.yaml | 15 +++++++++++++++ 7 files changed, 72 insertions(+) diff --git a/GLOSSAIRE.md b/GLOSSAIRE.md index 77745d2..7aff2c6 100644 --- a/GLOSSAIRE.md +++ b/GLOSSAIRE.md @@ -175,11 +175,19 @@ pour la justification des décisions de conception, voir les [ADR](docs/adr/READ - **GitHub Actions** — Le service de CI de GitHub. Chaque workflow est un fichier YAML sous `.github/workflows/` ; ce projet utilise un workflow ciblé par préoccupation (voir [ADR-0010](docs/adr/0010-structure-ci-as-focused-workflows-per-concern.md)). +- **Mailpit** — Un faux serveur SMTP pour le développement : il accepte tous + les e-mails envoyés par l'application, n'en délivre aucun, et les affiche + dans une interface web (http://localhost:8025) et une API REST. Tourne comme + service Docker Compose (voir + [ADR-0004](docs/adr/0004-use-mailpit-as-local-smtp-catcher.md)). - **Podman** — Un moteur de conteneurs sans démon, utilisé comme remplaçant de `docker`. - **Runner (exécuteur)** — La machine qui exécute un job GitHub Actions (`ubuntu-latest` ici) ; elle embarque un démon Docker, que Testcontainers utilise directement. +- **SMTP (Simple Mail Transfer Protocol)** — Le protocole d'envoi des + e-mails. L'application parle SMTP à Mailpit en développement (port 1025) et + parlerait à un vrai fournisseur en production. - **Profil Spring (Spring profile)** — Un jeu de configuration nommé (par exemple `dev`) qui sélectionne des propriétés spécifiques et des contextes Liquibase. diff --git a/GLOSSARY.md b/GLOSSARY.md index 10cb237..75c65ee 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -143,9 +143,16 @@ rationale behind design decisions, see the [ADRs](docs/adr/README.md). - **GitHub Actions** — GitHub's CI service. Each workflow is a YAML file under `.github/workflows/`; this project uses one focused workflow per concern (see [ADR-0010](docs/adr/0010-structure-ci-as-focused-workflows-per-concern.md)). +- **Mailpit** — A fake SMTP server for development: it accepts every email the + app sends, delivers nothing, and shows the messages in a web UI + (http://localhost:8025) and a REST API. Runs as a Docker Compose service + (see [ADR-0004](docs/adr/0004-use-mailpit-as-local-smtp-catcher.md)). - **Podman** — A daemonless container engine, used as the `docker` drop-in. - **Runner** — The machine that executes a GitHub Actions job (`ubuntu-latest` here); it ships with a Docker daemon, which Testcontainers uses directly. +- **SMTP (Simple Mail Transfer Protocol)** — The protocol used to send email. + The app talks SMTP to Mailpit in development (port 1025) and would talk it + to a real provider in production. - **Spring profile** — A named configuration set (for example `dev`) selecting profile-specific properties and Liquibase contexts. - **Temurin** — The Eclipse Adoptium distribution of the OpenJDK; the Java 21 diff --git a/README.md b/README.md index 53cea89..6d3c271 100644 --- a/README.md +++ b/README.md @@ -366,6 +366,19 @@ Where: - `-v` request Compose to remove the named data volumes created for this service +### Mailpit Service (fake SMTP) + +**Mailpit** catches every email the application sends in development +(for example the password-reset email): nothing leaves your machine. +It starts with the other services (`docker compose up -d`). + +- **Web UI (browse the caught emails):** http://localhost:8025 +- SMTP endpoint used by the app (dev profile): `localhost:1025` + +See [ADR-0004](docs/adr/0004-use-mailpit-as-local-smtp-catcher.md) for why +Mailpit was chosen. + + ## Project Status For up-to-date information about the status of the project, diff --git a/docker-compose.yaml b/docker-compose.yaml index 634038c..5bc2dcf 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -40,6 +40,20 @@ services: retries: 5 start_period: 20s + mailpit: + # Fake SMTP catcher for local development (see ADR-0004): the app sends + # real SMTP to port 1025, and every message is browsable in the web UI + # at http://localhost:8025 (nothing ever leaves the machine). + # Ports are literal on purpose: no secrets are involved, and this avoids + # adding variables to the externally managed .env file. + image: axllent/mailpit + restart: unless-stopped + ports: + - "127.0.0.1:1025:1025" # SMTP (the app sends here) + - "127.0.0.1:8025:8025" # Web UI (browse the caught emails) + networks: + - net + networks: net: # A bridge network connects your services together diff --git a/pom.xml b/pom.xml index 6b3386a..1fd6b47 100644 --- a/pom.xml +++ b/pom.xml @@ -120,6 +120,12 @@ spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-mail + + org.springframework.boot diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index 6293cdf..51f182e 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -32,6 +32,15 @@ spring: # Set the execution context for Liquibase changeset contexts: dev + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Mail: Mailpit fake SMTP catcher (docker compose service) + # Web UI: http://localhost:8025 + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + mail: + host: localhost + port: 1025 + # Mailpit needs no authentication and no TLS locally. + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Thymeleaf: Server-Side Rendering Engine # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 0f8e4d4..74b1853 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -47,6 +47,21 @@ server: # secure: true # enable once served over HTTPS (cookie sent only over TLS) +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# Application settings (learn-dev) +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +learndev: + mail: + # Sender address for transactional emails (password reset) + from: noreply@learn-dev.local + password-reset: + # How long a reset token stays valid + token-ttl: 30m + # Rate limit: max reset requests per user within the window + max-requests: 3 + window: 15m + + # ~~~~~~~~~~~~~~~~~~ # Logging Level # ~~~~~~~~~~~~~~~~~~ From 938d2783b544bb3a640189ceca985c2cc2af66f5 Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Sat, 11 Jul 2026 18:24:27 +0200 Subject: [PATCH 2/3] feat(auth): Add the password reset flow A user who cannot log in proves account ownership via an emailed, single-use, expiring link, then sets a new password. Two anonymous pages under /auth/: forgot-password (request) and reset-password (consume). Security properties, by design rather than bolted on: - Opaque high-entropy token: 32 SecureRandom bytes, base64url in the link; only its SHA-256 hash is stored in reset_tokens, so a database leak yields no usable link. - Short-lived and strictly single-use: expires_at enforces the configured TTL (30 minutes), used_at enforces single use, and both a new request and a successful reset invalidate every other outstanding token. - Enumeration-safe: the response to a request is identical whether the email exists, is rate-limited, or a mail was sent; only the audit trail differs. - Rate-limited per user and per IP (recency derived from expires_at since the table has no created_at column). - Audited: a minimal audit feature (AuditLog entity on the existing audit_logs table, AuditService) records request, rate-limit, and completion events, including attempts on unknown emails. The reset email is sent over SMTP (Mailpit in development); the new spring.mail default in application.yaml also makes the mail sender bean available to test contexts. Templates follow the design system with the same accessible error wiring as registration; the login page links the flow and shows the post-reset confirmation. Fixes #53 Fixes #54 Fixes #55 --- .../learndev/audit/AuditService.java | 45 ++++ .../learndev/audit/entity/AuditLog.java | 62 ++++++ .../audit/repository/AuditLogRepository.java | 7 + .../auth/PasswordResetController.java | 104 ++++++++++ .../learndev/auth/PasswordResetMailer.java | 53 +++++ .../learndev/auth/PasswordResetService.java | 194 ++++++++++++++++++ .../learndev/auth/dto/ForgotPasswordForm.java | 18 ++ .../learndev/auth/dto/ResetPasswordForm.java | 22 ++ .../auth/entity/PasswordResetToken.java | 55 +++++ .../PasswordResetTokenRepository.java | 29 +++ src/main/resources/application.yaml | 11 + .../resources/templates/forgot-password.html | 43 ++++ src/main/resources/templates/login.html | 6 +- .../resources/templates/reset-password.html | 49 +++++ 14 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/ericbouchut/learndev/audit/AuditService.java create mode 100644 src/main/java/com/ericbouchut/learndev/audit/entity/AuditLog.java create mode 100644 src/main/java/com/ericbouchut/learndev/audit/repository/AuditLogRepository.java create mode 100644 src/main/java/com/ericbouchut/learndev/auth/PasswordResetController.java create mode 100644 src/main/java/com/ericbouchut/learndev/auth/PasswordResetMailer.java create mode 100644 src/main/java/com/ericbouchut/learndev/auth/PasswordResetService.java create mode 100644 src/main/java/com/ericbouchut/learndev/auth/dto/ForgotPasswordForm.java create mode 100644 src/main/java/com/ericbouchut/learndev/auth/dto/ResetPasswordForm.java create mode 100644 src/main/java/com/ericbouchut/learndev/auth/entity/PasswordResetToken.java create mode 100644 src/main/java/com/ericbouchut/learndev/auth/repository/PasswordResetTokenRepository.java create mode 100644 src/main/resources/templates/forgot-password.html create mode 100644 src/main/resources/templates/reset-password.html diff --git a/src/main/java/com/ericbouchut/learndev/audit/AuditService.java b/src/main/java/com/ericbouchut/learndev/audit/AuditService.java new file mode 100644 index 0000000..1014d47 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/audit/AuditService.java @@ -0,0 +1,45 @@ +package com.ericbouchut.learndev.audit; + +import com.ericbouchut.learndev.audit.entity.AuditLog; +import com.ericbouchut.learndev.audit.repository.AuditLogRepository; +import com.ericbouchut.learndev.user.entity.User; +import org.springframework.stereotype.Service; + +/** + * Minimal security audit trail: internal use only, no controller. + * Callers record one event per security-relevant action (see the + * password-reset flow). + */ +@Service +public class AuditService { + + private final AuditLogRepository auditLogs; + + public AuditService(AuditLogRepository auditLogs) { + this.auditLogs = auditLogs; + } + + /** + * Records one audit event. + * + * @param actionType machine-readable event name, e.g. {@code PASSWORD_RESET_REQUESTED} + * @param user the subject, or {@code null} when there is none + * @param ipAddress requester IP, or {@code null} + * @param successful whether the action succeeded + * @param description human-readable detail (never include secrets) + */ + public void record(String actionType, User user, String ipAddress, + boolean successful, String description) { + AuditLog log = new AuditLog(); + log.setActionType(actionType); + log.setUser(user); + log.setIpAddress(ipAddress); + log.setWasSuccessful(successful); + log.setDescription(description); + if (user != null) { + log.setEntityType("user"); + log.setEntityId(user.getUserId().toString()); + } + auditLogs.save(log); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/audit/entity/AuditLog.java b/src/main/java/com/ericbouchut/learndev/audit/entity/AuditLog.java new file mode 100644 index 0000000..b419e2e --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/audit/entity/AuditLog.java @@ -0,0 +1,62 @@ +package com.ericbouchut.learndev.audit.entity; + +import com.ericbouchut.learndev.user.entity.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.OffsetDateTime; + +/** + * One security-relevant event (maps the {@code audit_logs} table). + * {@code user} is nullable on purpose: some events have no authenticated + * subject (for example a reset request for an unknown email), and the trail + * must survive user deletion (FK is ON DELETE SET NULL). + */ +@Entity +@Table(name = "audit_logs") +@Getter +@Setter +@NoArgsConstructor +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "log_id") + private Long logId; + + @Column(name = "action_type", nullable = false) + private String actionType; + + @Column(name = "entity_type") + private String entityType; + + @Column(name = "entity_id") + private String entityId; + + @Column(name = "description") + private String description; + + @JdbcTypeCode(SqlTypes.INET) + @Column(name = "ip_address") + private String ipAddress; + + @Column(name = "user_agent") + private String userAgent; + + @Column(name = "was_successful", nullable = false) + private boolean wasSuccessful = true; + + @Column(name = "error_message") + private String errorMessage; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; +} diff --git a/src/main/java/com/ericbouchut/learndev/audit/repository/AuditLogRepository.java b/src/main/java/com/ericbouchut/learndev/audit/repository/AuditLogRepository.java new file mode 100644 index 0000000..4583258 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/audit/repository/AuditLogRepository.java @@ -0,0 +1,7 @@ +package com.ericbouchut.learndev.audit.repository; + +import com.ericbouchut.learndev.audit.entity.AuditLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AuditLogRepository extends JpaRepository { +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/PasswordResetController.java b/src/main/java/com/ericbouchut/learndev/auth/PasswordResetController.java new file mode 100644 index 0000000..16a4906 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/PasswordResetController.java @@ -0,0 +1,104 @@ +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.auth.dto.ForgotPasswordForm; +import com.ericbouchut.learndev.auth.dto.ResetPasswordForm; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +/** + * Web endpoints for the password-reset flow: + * the "forgot password" request page and the "reset password" page that + * consumes the emailed token. Both are anonymous pages under {@code /auth/}. + */ +@Controller +public class PasswordResetController { + + private final PasswordResetService passwordReset; + + public PasswordResetController(PasswordResetService passwordReset) { + this.passwordReset = passwordReset; + } + + /** + * Display the "forgot password" request form. + * @param model carries the empty form-backing bean + * @return the name of the forgot-password template + */ + @GetMapping("/auth/forgot-password") + public String forgotPasswordForm(Model model) { + model.addAttribute("form", new ForgotPasswordForm("")); + return "forgot-password"; + } + + /** + * Processes a "forgot password" request. The response never reveals + * whether the email exists (enumeration-safe): every valid submission + * redirects to the same neutral confirmation. + * + * @return redirect to the confirmation state, or the re-rendered form + * on validation errors + */ + @PostMapping("/auth/forgot-password") + public String requestReset( + @Valid @ModelAttribute("form") ForgotPasswordForm form, + BindingResult binding, + HttpServletRequest request) { + if (binding.hasErrors()) { + return "forgot-password"; + } + String resetUrlBase = ServletUriComponentsBuilder.fromRequestUri(request) + .replacePath("/auth/reset-password") + .toUriString(); + passwordReset.requestReset(form.email(), request.getRemoteAddr(), resetUrlBase); + return "redirect:/auth/forgot-password?sent"; + } + + /** + * Display the "reset password" form when the emailed token is valid; + * otherwise render the invalid-link state (expired, used, or unknown). + * + * @param token the raw token from the emailed link + * @return the name of the reset-password template + */ + @GetMapping("/auth/reset-password") + public String resetPasswordForm(@RequestParam(name = "token", required = false) String token, + Model model) { + boolean usable = token != null && passwordReset.findUsableToken(token).isPresent(); + model.addAttribute("tokenValid", usable); + model.addAttribute("form", new ResetPasswordForm(token == null ? "" : token, "")); + return "reset-password"; + } + + /** + * Consumes the token and sets the new password. Success redirects to the + * login page with a confirmation; an unusable token renders the + * invalid-link state. + */ + @PostMapping("/auth/reset-password") + public String resetPassword( + @Valid @ModelAttribute("form") ResetPasswordForm form, + BindingResult binding, + HttpServletRequest request, + Model model) { + if (binding.hasErrors()) { + boolean usable = passwordReset.findUsableToken(form.token()).isPresent(); + model.addAttribute("tokenValid", usable); + return "reset-password"; + } + boolean done = passwordReset.resetPassword( + form.token(), form.password(), request.getRemoteAddr()); + if (!done) { + model.addAttribute("tokenValid", false); + return "reset-password"; + } + return "redirect:/auth/login?reset"; + } +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/PasswordResetMailer.java b/src/main/java/com/ericbouchut/learndev/auth/PasswordResetMailer.java new file mode 100644 index 0000000..8decb74 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/PasswordResetMailer.java @@ -0,0 +1,53 @@ +package com.ericbouchut.learndev.auth; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * Sends the password-reset email. In development the message is caught by + * Mailpit (web UI: http://localhost:8025); nothing leaves the machine. + */ +@Component +public class PasswordResetMailer { + + private final JavaMailSender mailSender; + private final String from; + private final Duration tokenTtl; + + public PasswordResetMailer(JavaMailSender mailSender, + @Value("${learndev.mail.from}") String from, + @Value("${learndev.password-reset.token-ttl}") Duration tokenTtl) { + this.mailSender = mailSender; + this.from = from; + this.tokenTtl = tokenTtl; + } + + /** + * @param to recipient email address + * @param resetLink absolute URL containing the RAW token; the raw token + * exists only in this email and in the URL bar, never + * in the database or the logs + */ + public void sendResetEmail(String to, String resetLink) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(from); + message.setTo(to); + message.setSubject("Reset your learn-dev password"); + message.setText(""" + Someone (hopefully you) asked to reset the password of the \ + learn-dev account linked to this address. + + To choose a new password, open this link (valid for %d minutes, single use): + + %s + + If you did not ask for this, you can safely ignore this email; \ + your password is unchanged. + """.formatted(tokenTtl.toMinutes(), resetLink)); + mailSender.send(message); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/PasswordResetService.java b/src/main/java/com/ericbouchut/learndev/auth/PasswordResetService.java new file mode 100644 index 0000000..c0ad816 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/PasswordResetService.java @@ -0,0 +1,194 @@ +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.audit.AuditService; +import com.ericbouchut.learndev.auth.entity.PasswordResetToken; +import com.ericbouchut.learndev.auth.repository.PasswordResetTokenRepository; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.MailException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Optional; + +/** + * The password-reset ("forgot password") flow. + * + *

Security properties (see issues #53 and #55): + *

    + *
  • Opaque, high-entropy token: 32 random bytes (256 bits) from + * {@link SecureRandom}, sent as base64url in the email link.
  • + *
  • Hashed at rest: only the SHA-256 of the token is stored; a + * database leak yields no usable link.
  • + *
  • Short-lived and single-use: {@code expires_at} enforces the + * TTL, {@code used_at} enforces single use; a new request or a + * successful reset invalidates all other outstanding tokens.
  • + *
  • Enumeration-safe: every outcome of a request (unknown email, + * rate-limited, sent) is invisible to the caller; only the audit trail + * differs.
  • + *
  • Rate-limited per user and per IP. The table has no + * created_at column, so recency is derived from expires_at: + * created > now - window equals expires_at > now - window + ttl.
  • + *
+ */ +@Service +public class PasswordResetService { + + private static final Logger log = LoggerFactory.getLogger(PasswordResetService.class); + + private final UserRepository users; + private final PasswordResetTokenRepository tokens; + private final PasswordEncoder passwordEncoder; + private final PasswordResetMailer mailer; + private final AuditService audit; + private final Duration tokenTtl; + private final long maxRequests; + private final Duration window; + private final SecureRandom random = new SecureRandom(); + + public PasswordResetService(UserRepository users, + PasswordResetTokenRepository tokens, + PasswordEncoder passwordEncoder, + PasswordResetMailer mailer, + AuditService audit, + @Value("${learndev.password-reset.token-ttl}") Duration tokenTtl, + @Value("${learndev.password-reset.max-requests}") long maxRequests, + @Value("${learndev.password-reset.window}") Duration window) { + this.users = users; + this.tokens = tokens; + this.passwordEncoder = passwordEncoder; + this.mailer = mailer; + this.audit = audit; + this.tokenTtl = tokenTtl; + this.maxRequests = maxRequests; + this.window = window; + } + + /** + * Handles a "forgot password" request. Always succeeds from the caller's + * point of view (enumeration-safe): whether the email exists, is + * rate-limited, or a mail was actually sent is only visible in the audit + * trail. + * + * @param email address typed in the form + * @param ipAddress requester IP (rate limiting and audit) + * @param resetUrlBase absolute URL of the reset page, without the token, + * e.g. {@code https://host/auth/reset-password} + */ + @Transactional + public void requestReset(String email, String ipAddress, String resetUrlBase) { + Optional found = users.findByEmail(email); + if (found.isEmpty()) { + audit.record("PASSWORD_RESET_REQUESTED", null, ipAddress, false, + "Reset requested for an unknown email"); + return; + } + User user = found.get(); + + // Recency is derived from expires_at (no created_at column). + OffsetDateTime recentCutoff = OffsetDateTime.now().minus(window).plus(tokenTtl); + if (tokens.countByUserAndExpiresAtAfter(user, recentCutoff) >= maxRequests + || (ipAddress != null + && tokens.countByIpAddressAndExpiresAtAfter(ipAddress, recentCutoff) >= maxRequests)) { + audit.record("PASSWORD_RESET_RATE_LIMITED", user, ipAddress, false, + "Too many reset requests in the window"); + return; + } + + // A new request supersedes all outstanding tokens (single active link). + invalidateOutstandingTokens(user); + + String rawToken = generateRawToken(); + PasswordResetToken token = new PasswordResetToken(); + token.setToken(sha256(rawToken)); + token.setExpiresAt(OffsetDateTime.now().plus(tokenTtl)); + token.setIpAddress(ipAddress); + token.setUser(user); + tokens.save(token); + + try { + mailer.sendResetEmail(user.getEmail(), resetUrlBase + "?token=" + rawToken); + audit.record("PASSWORD_RESET_REQUESTED", user, ipAddress, true, + "Reset email sent"); + } catch (MailException e) { + // Keep the response neutral; the failure lives in logs and audit. + log.error("Could not send the password reset email", e); + audit.record("PASSWORD_RESET_REQUESTED", user, ipAddress, false, + "Reset email could not be sent"); + } + } + + /** + * Returns the token entity when the raw token is valid (exists, not + * expired, not used). Read-only: used by the GET page to decide between + * the form and the invalid-link message. + */ + @Transactional(readOnly = true) + public Optional findUsableToken(String rawToken) { + return tokens.findByToken(sha256(rawToken)) + .filter(t -> t.isUsable(OffsetDateTime.now())); + } + + /** + * Consumes the token and sets the new password. + * + * @return true on success; false when the token is invalid, expired, or + * already used (the caller shows the invalid-link message) + */ + @Transactional + public boolean resetPassword(String rawToken, String newPassword, String ipAddress) { + Optional found = findUsableToken(rawToken); + if (found.isEmpty()) { + audit.record("PASSWORD_RESET_COMPLETED", null, ipAddress, false, + "Invalid, expired, or already used token"); + return false; + } + PasswordResetToken token = found.get(); + User user = token.getUser(); + + user.setPassword(passwordEncoder.encode(newPassword)); + user.setPasswordChangedAt(OffsetDateTime.now()); + token.setUsedAt(OffsetDateTime.now()); + // Strict single active link: consuming one kills the others too. + invalidateOutstandingTokens(user); + + audit.record("PASSWORD_RESET_COMPLETED", user, ipAddress, true, + "Password changed via reset token"); + return true; + } + + private void invalidateOutstandingTokens(User user) { + OffsetDateTime now = OffsetDateTime.now(); + tokens.findByUserAndUsedAtIsNull(user).forEach(t -> t.setUsedAt(now)); + } + + /** 32 random bytes (256 bits), base64url without padding: URL-safe. */ + private String generateRawToken() { + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** SHA-256 hex of the raw token; what the database stores and looks up. */ + static String sha256(String rawToken) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex( + digest.digest(rawToken.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/dto/ForgotPasswordForm.java b/src/main/java/com/ericbouchut/learndev/auth/dto/ForgotPasswordForm.java new file mode 100644 index 0000000..a120c94 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/dto/ForgotPasswordForm.java @@ -0,0 +1,18 @@ +package com.ericbouchut.learndev.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Form backing the "forgot password" request page. + * Inbound DTO: Web Browser => Controller. + * + * @param email the address to send the reset link to + */ +public record ForgotPasswordForm( + @NotBlank + @Email @Size(max = 255) + String email +) { +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/dto/ResetPasswordForm.java b/src/main/java/com/ericbouchut/learndev/auth/dto/ResetPasswordForm.java new file mode 100644 index 0000000..a8cf3ab --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/dto/ResetPasswordForm.java @@ -0,0 +1,22 @@ +package com.ericbouchut.learndev.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Form backing the "reset password" page. + * Inbound DTO: Web Browser => Controller. + * + * @param token the raw reset token, carried in a hidden field + * @param password the new raw password (same policy as registration; + * hashed before storage) + */ +public record ResetPasswordForm( + @NotBlank + String token, + + @NotBlank + @Size(min = 8, max = 100) + String password +) { +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/entity/PasswordResetToken.java b/src/main/java/com/ericbouchut/learndev/auth/entity/PasswordResetToken.java new file mode 100644 index 0000000..2d4fc97 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/entity/PasswordResetToken.java @@ -0,0 +1,55 @@ +package com.ericbouchut.learndev.auth.entity; + +import com.ericbouchut.learndev.user.entity.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.OffsetDateTime; + +/** + * A single-use, expiring password-reset token (maps the {@code reset_tokens} + * table). The {@code token} column stores the SHA-256 hash of the raw + * secret sent by email, never the secret itself: a database leak does not + * yield usable reset links. + */ +@Entity +@Table(name = "reset_tokens") +@Getter +@Setter +@NoArgsConstructor +public class PasswordResetToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "token_id") + private Long tokenId; + + /** SHA-256 hash (hex) of the raw token; unique so lookups are by hash. */ + @Column(name = "token", nullable = false, unique = true) + private String token; + + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + /** Set when the token is consumed or invalidated; single-use guard. */ + @Column(name = "used_at") + private OffsetDateTime usedAt; + + /** Requester's IP (PostgreSQL {@code inet} column). */ + @JdbcTypeCode(SqlTypes.INET) + @Column(name = "ip_address") + private String ipAddress; + + @ManyToOne(optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + /** True when the token can still be consumed (not used, not expired). */ + public boolean isUsable(OffsetDateTime now) { + return usedAt == null && expiresAt.isAfter(now); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/auth/repository/PasswordResetTokenRepository.java b/src/main/java/com/ericbouchut/learndev/auth/repository/PasswordResetTokenRepository.java new file mode 100644 index 0000000..6c3e782 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/auth/repository/PasswordResetTokenRepository.java @@ -0,0 +1,29 @@ +package com.ericbouchut.learndev.auth.repository; + +import com.ericbouchut.learndev.auth.entity.PasswordResetToken; +import com.ericbouchut.learndev.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +public interface PasswordResetTokenRepository extends JpaRepository { + + /** Lookup by the SHA-256 hash of the raw token. */ + Optional findByToken(String tokenHash); + + /** Outstanding (not yet consumed) tokens of a user, for invalidation. */ + List findByUserAndUsedAtIsNull(User user); + + /** + * Requests made recently by this user. The table has no created_at + * column, so recency is derived from expires_at (creation + TTL): a + * token created within the last {@code window} has + * {@code expires_at > now - window + ttl}. + */ + long countByUserAndExpiresAtAfter(User user, OffsetDateTime expiresAfter); + + /** Same recency approximation, keyed by the requester's IP. */ + long countByIpAddressAndExpiresAtAfter(String ipAddress, OffsetDateTime expiresAfter); +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 74b1853..fc27f5c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -25,6 +25,17 @@ spring: hibernate: ddl-auto: validate # Ensure the database and its schema are in sync + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Mail (SMTP) + # Default: the local Mailpit catcher (docker compose service). + # Setting a host here also makes Spring Boot create the JavaMailSender + # bean in every context, including tests. Production overrides this with + # a real provider in application-prod.yaml. + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + mail: + host: localhost + port: 1025 + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Database schema change manager # See: https://www.liquibase.com/ diff --git a/src/main/resources/templates/forgot-password.html b/src/main/resources/templates/forgot-password.html new file mode 100644 index 0000000..8e15913 --- /dev/null +++ b/src/main/resources/templates/forgot-password.html @@ -0,0 +1,43 @@ + + + + + + +
+ +

+ 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. +

+ +
+
+ + + +
+ + +
+ + +
+
+ + + + diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index 189c044..bd05264 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -8,6 +8,9 @@

Account created, please log in.

+

+ Your password has been changed, please log in. +

You have been logged out.

@@ -35,7 +38,8 @@

Log in

diff --git a/src/main/resources/templates/reset-password.html b/src/main/resources/templates/reset-password.html new file mode 100644 index 0000000..bbac5a9 --- /dev/null +++ b/src/main/resources/templates/reset-password.html @@ -0,0 +1,49 @@ + + + + + + +
+ + + + +
+

Reset password

+

+ Reset links are single use and expire after 30 minutes. + You can request a new one. +

+
+
+ + +
+

Reset password

+ +
+ + +
+ + 8 to 100 characters. + + +
+ + +
+
+
+ + + + From 7a47ec4b503682de7fae48e543ea2d6e288ed2ca Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Sat, 11 Jul 2026 18:24:28 +0200 Subject: [PATCH 3/3] test(auth): Add the end-to-end password reset test Drive the full loop against the shared Testcontainers PostgreSQL plus a Mailpit container as the real SMTP target: register, request a reset, read the email back through Mailpit's REST API, extract the raw token from the link, render the form, consume the token, then prove the old password is rejected, the new one authenticates, and the same token is refused on reuse (single use). Fixes #56 --- .../learndev/auth/PasswordResetFlowTest.java | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/test/java/com/ericbouchut/learndev/auth/PasswordResetFlowTest.java diff --git a/src/test/java/com/ericbouchut/learndev/auth/PasswordResetFlowTest.java b/src/test/java/com/ericbouchut/learndev/auth/PasswordResetFlowTest.java new file mode 100644 index 0000000..08b25e2 --- /dev/null +++ b/src/test/java/com/ericbouchut/learndev/auth/PasswordResetFlowTest.java @@ -0,0 +1,149 @@ +package com.ericbouchut.learndev.auth; + +import com.ericbouchut.learndev.support.AbstractPostgresIT; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.GenericContainer; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * End-to-end password reset loop (issue #56): request a reset, capture the + * real email through Mailpit's REST API, extract the raw token from the + * link, consume it, and prove the password changed and the token is + * single-use. + * + *

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 list = http.send( + HttpRequest.newBuilder(URI.create(api + "/api/v1/messages")).GET().build(), + HttpResponse.BodyHandlers.ofString()); + JsonNode messages = json.readTree(list.body()).path("messages"); + if (!messages.isEmpty()) { + messageId = messages.get(0).path("ID").asText(); + } else { + Thread.sleep(250); + } + } + assertThat(messageId).as("Mailpit received the reset email").isNotNull(); + + HttpResponse message = http.send( + HttpRequest.newBuilder(URI.create(api + "/api/v1/message/" + messageId)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + String text = json.readTree(message.body()).path("Text").asText(); + Matcher matcher = Pattern.compile("token=([A-Za-z0-9_-]+)").matcher(text); + assertThat(matcher.find()).as("reset link present in the email body").isTrue(); + return matcher.group(1); + } +}