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/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): + *

+ */ +@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-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..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/ @@ -47,6 +58,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 # ~~~~~~~~~~~~~~~~~~ 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. + + +
+ + +
+
+
+ + + + 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); + } +}