Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions GLOSSAIRE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- Email sending (password reset); Mailpit catches it locally -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

<!-- Testcontainers: real PostgreSQL for repository/integration tests -->
<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down
45 changes: 45 additions & 0 deletions src/main/java/com/ericbouchut/learndev/audit/AuditService.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
62 changes: 62 additions & 0 deletions src/main/java/com/ericbouchut/learndev/audit/entity/AuditLog.java
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<AuditLog, Long> {
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* <b>Web</b> 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";
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading