diff --git a/README.md b/README.md
index 2268d3e..8bf93af 100644
--- a/README.md
+++ b/README.md
@@ -334,15 +334,15 @@ No other naming style (`shouldX()`, `testX()`, `givenX_whenY_thenZ()`) is used a
### Tasks
-- [ ] `core/auth`: `User`, `ActivationToken`, `BlacklistedToken`, `PasswordResetToken` **entity** classes (`entity/`, `@Table("users")`/`@Table("activation_tokens")`/etc., `@Id`/`@Column` on fields, plain Java otherwise) and their matching `UserDto`, `ActivationTokenDto`, `BlacklistedTokenDto`, `PasswordResetTokenDto` classes (`dto/`)
-- [ ] `UserRowMapper`, `ActivationTokenRowMapper`, `BlacklistedTokenRowMapper`, `PasswordResetTokenRowMapper` (`ResultSet` → entity), and `UserMapper` (entity ↔ dto, the only one of these four actually needed yet - the token entities/dtos are used directly by `AuthServiceImpl` without a full bidirectional mapper, since nothing outside the service ever needs a token dto's shape beyond what `AuthController` reads directly off `AuthService`'s own return types)
-- [ ] `UserDao` + `UserDaoImpl` (and the token DAOs), all `JdbcTemplate`-based, all unannotated, all working in `entity/` types
-- [ ] `AuthService` (interface) + `AuthServiceImpl`: registration, activation, login (password hashing/verification via `org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder`, used standalone), logout, forgot/reset password - every public method takes/returns `dto/` types, converting to/from `entity/` via `UserMapper` around each `UserDao` call
-- [ ] `forgotPassword` returns the exact same outcome - same behavior, roughly the same timing - whether or not the submitted email matches an existing account, so the endpoint can't be used to enumerate registered emails
-- [ ] `core/auth`'s `DaoConfig`/`ServiceConfig`
-- [ ] `ws`: `JwtService` (issuance/validation via `jjwt`, unique `jti` per token), `JwtAuthFilter`, `AuthController`, `UserConverter` (`core.auth.dto.UserDto` ↔ `ws.payload.auth.*`)
-- [ ] `WebMvcConfig` updated to import `core/auth`'s `DaoConfig`/`ServiceConfig`
-- [ ] Tests at all five layers (see [Testing strategy](#testing-strategy)): `UserDao`/token DAOs against Testcontainers with their own fixture files, each `RowMapper` against a mocked `ResultSet`, `UserMapper` against a hand-built entity/dto pair (pure unit test, no mocks needed), `AuthServiceImpl` with `UserDao` mocked, `AuthController` via `MockMvc` with `AuthService` mocked - plus one full register → activate → login → `/me` flow
+- [x] `core/auth`: `User`, `ActivationToken`, `BlacklistedToken`, `PasswordResetToken` **entity** classes (`entity/`, `@Table("users")`/`@Table("activation_tokens")`/etc., `@Id`/`@Column` on fields, plain Java otherwise) and their matching `UserDto`, `ActivationTokenDto`, `BlacklistedTokenDto`, `PasswordResetTokenDto` classes (`dto/`)
+- [x] `UserRowMapper`, `ActivationTokenRowMapper`, `BlacklistedTokenRowMapper`, `PasswordResetTokenRowMapper` (`ResultSet` → entity), and `UserMapper` (entity ↔ dto, the only one of these four actually needed yet - the token entities/dtos are used directly by `AuthServiceImpl` without a full bidirectional mapper, since nothing outside the service ever needs a token dto's shape beyond what `AuthController` reads directly off `AuthService`'s own return types)
+- [x] `UserDao` + `UserDaoImpl` (and the token DAOs), all `JdbcTemplate`-based, all unannotated, all working in `entity/` types
+- [x] `AuthService` (interface) + `AuthServiceImpl`: registration, activation, login (password hashing/verification via `org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder`, used standalone), logout, forgot/reset password - every public method takes/returns `dto/` types, converting to/from `entity/` via `UserMapper` around each `UserDao` call
+- [x] `forgotPassword` returns the exact same outcome - same behavior, roughly the same timing - whether or not the submitted email matches an existing account, so the endpoint can't be used to enumerate registered emails
+- [x] `core/auth`'s `DaoConfig`/`ServiceConfig`
+- [x] `ws`: `JwtService` (issuance/validation via `jjwt`, unique `jti` per token), `JwtAuthFilter`, `AuthController`, `UserConverter` (`core.auth.dto.UserDto` ↔ `ws.payload.auth.*`)
+- [x] `WebMvcConfig` updated to import `core/auth`'s `DaoConfig`/`ServiceConfig`
+- [x] Tests at all five layers (see [Testing strategy](#testing-strategy)): `UserDao`/token DAOs against Testcontainers with their own fixture files, each `RowMapper` against a mocked `ResultSet`, `UserMapper` against a hand-built entity/dto pair (pure unit test, no mocks needed), `AuthServiceImpl` with `UserDao` mocked, `AuthController` via `MockMvc` with `AuthService` mocked - plus one full register → activate → login → `/me` flow
## feature/rbac
diff --git a/core/auth/pom.xml b/core/auth/pom.xml
index def61c8..1469698 100644
--- a/core/auth/pom.xml
+++ b/core/auth/pom.xml
@@ -19,5 +19,28 @@
+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +public class DaoConfig { + + @Bean + public UserDao userDao(JdbcTemplate jdbcTemplate) { + return new UserDaoImpl(jdbcTemplate); + } + + @Bean + public ActivationTokenDao activationTokenDao(JdbcTemplate jdbcTemplate) { + return new ActivationTokenDaoImpl(jdbcTemplate); + } + + @Bean + public PasswordResetTokenDao passwordResetTokenDao(JdbcTemplate jdbcTemplate) { + return new PasswordResetTokenDaoImpl(jdbcTemplate); + } + + @Bean + public BlacklistedTokenDao blacklistedTokenDao(JdbcTemplate jdbcTemplate) { + return new BlacklistedTokenDaoImpl(jdbcTemplate); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/ServiceConfig.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/ServiceConfig.java new file mode 100644 index 0000000..2fee538 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/ServiceConfig.java @@ -0,0 +1,57 @@ +package com.edgareldy.springjdbctutorial.core.auth.config; + +import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.auth.service.impl.AuthServiceImpl; +import com.edgareldy.springjdbctutorial.core.auth.service.impl.ExpiredTokenCleanupJob; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.Clock; + +/** + * Declares the service beans of the auth module. Imports its DaoConfig so importing this class alone + * is enough. The Clock bean comes from CommonConfig, the JdbcTemplate from DataSourceConfig. + *
+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@Configuration +// @EnableScheduling registers the post-processor that finds @Scheduled methods on beans and runs them +@EnableScheduling +@Import(DaoConfig.class) +public class ServiceConfig { + + // PasswordEncoder is Spring Security's abstraction for one-way password hashing. BCrypt is adaptive + // (tunable cost) and salted per hash, so equal passwords give different hashes and brute force is + // slow. Only the spring-security-crypto artifact is needed, not the whole security stack. + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + // Returned as the AuthService interface: Spring wraps the bean in a JDK dynamic proxy that applies + // the @Transactional advice, which only works when callers go through the interface. + @Bean + public AuthService authService(UserDao userDao, ActivationTokenDao activationTokenDao, + PasswordResetTokenDao passwordResetTokenDao, + BlacklistedTokenDao blacklistedTokenDao, PasswordEncoder passwordEncoder, + Clock clock) { + return new AuthServiceImpl(userDao, activationTokenDao, passwordResetTokenDao, blacklistedTokenDao, + passwordEncoder, clock); + } + + @Bean + public ExpiredTokenCleanupJob expiredTokenCleanupJob(AuthService authService) { + return new ExpiredTokenCleanupJob(authService); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/ActivationTokenDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/ActivationTokenDao.java new file mode 100644 index 0000000..fe269b2 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/ActivationTokenDao.java @@ -0,0 +1,25 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao; + +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; + +import java.time.Instant; +import java.util.Optional; + +/** + * Data access for activation tokens (stored as SHA-256 hash). Entity types only. + *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface ActivationTokenDao {
+
+ ActivationToken insert(ActivationToken token);
+
+ Optional
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface BlacklistedTokenDao {
+
+ BlacklistedToken insert(BlacklistedToken token);
+
+ boolean existsByTokenHash(String tokenHash);
+
+ /** Deletes the rows whose expires_at is strictly before the given instant and returns how many. */
+ int deleteExpiredBefore(Instant instant);
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PasswordResetTokenDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PasswordResetTokenDao.java
new file mode 100644
index 0000000..68661f8
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PasswordResetTokenDao.java
@@ -0,0 +1,22 @@
+package com.edgareldy.springjdbctutorial.core.auth.dao;
+
+import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken;
+
+import java.util.Optional;
+
+/**
+ * Data access for password reset tokens (stored as SHA-256 hash). Entity types only.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface PasswordResetTokenDao {
+
+ PasswordResetToken insert(PasswordResetToken token);
+
+ Optional
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface UserDao {
+
+ Optional
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ActivationTokenDaoImpl extends AbstractDao implements ActivationTokenDao {
+
+ private final ActivationTokenRowMapper rowMapper = new ActivationTokenRowMapper();
+
+ public ActivationTokenDaoImpl(JdbcTemplate jdbcTemplate) {
+ super(jdbcTemplate);
+ }
+
+ @Override
+ public ActivationToken insert(ActivationToken token) {
+ long id = insertReturningId(
+ "INSERT INTO activation_tokens (user_id, token, created_at, expires_at, validated_at) "
+ + "VALUES (?, ?, ?, ?, ?) RETURNING id",
+ token.getUserId(), token.getToken(), toTimestamp(token.getCreatedAt()),
+ toTimestamp(token.getExpiresAt()), toTimestamp(token.getValidatedAt()));
+ token.setId(id);
+ return token;
+ }
+
+ @Override
+ public Optional
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class BlacklistedTokenDaoImpl extends AbstractDao implements BlacklistedTokenDao {
+
+ public BlacklistedTokenDaoImpl(JdbcTemplate jdbcTemplate) {
+ super(jdbcTemplate);
+ }
+
+ @Override
+ public BlacklistedToken insert(BlacklistedToken token) {
+ long id = insertReturningId(
+ "INSERT INTO blacklisted_tokens (user_id, token, jti, blacklisted_at, created_at, expires_at, "
+ + "validated_at) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id",
+ token.getUserId(), token.getToken(), token.getJti(), toTimestamp(token.getBlacklistedAt()),
+ toTimestamp(token.getCreatedAt()), toTimestamp(token.getExpiresAt()),
+ toTimestamp(token.getValidatedAt()));
+ token.setId(id);
+ return token;
+ }
+
+ @Override
+ public boolean existsByTokenHash(String tokenHash) {
+ return count("SELECT COUNT(*) FROM blacklisted_tokens WHERE token = ?", tokenHash) > 0;
+ }
+
+ @Override
+ public int deleteExpiredBefore(Instant instant) {
+ return getJdbcTemplate().update("DELETE FROM blacklisted_tokens WHERE expires_at < ?",
+ Timestamp.from(instant));
+ }
+
+ private static Timestamp toTimestamp(Instant instant) {
+ return instant == null ? null : Timestamp.from(instant);
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImpl.java
new file mode 100644
index 0000000..ff75246
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImpl.java
@@ -0,0 +1,48 @@
+package com.edgareldy.springjdbctutorial.core.auth.dao.impl;
+
+import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao;
+import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken;
+import com.edgareldy.springjdbctutorial.core.auth.mapper.PasswordResetTokenRowMapper;
+import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.sql.Timestamp;
+import java.util.Optional;
+
+/**
+ * JdbcTemplate implementation of PasswordResetTokenDao. Declared by @Bean in DaoConfig, no stereotype.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class PasswordResetTokenDaoImpl extends AbstractDao implements PasswordResetTokenDao {
+
+ private final PasswordResetTokenRowMapper rowMapper = new PasswordResetTokenRowMapper();
+
+ public PasswordResetTokenDaoImpl(JdbcTemplate jdbcTemplate) {
+ super(jdbcTemplate);
+ }
+
+ @Override
+ public PasswordResetToken insert(PasswordResetToken token) {
+ long id = insertReturningId(
+ "INSERT INTO password_reset_tokens (user_id, token, type, expiry_date) "
+ + "VALUES (?, ?, ?, ?) RETURNING id",
+ token.getUserId(), token.getToken(), token.getType(), Timestamp.from(token.getExpiryDate()));
+ token.setId(id);
+ return token;
+ }
+
+ @Override
+ public Optional
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class UserDaoImpl extends AbstractDao implements UserDao {
+
+ private static final String COLUMNS = "id, first_name, last_name, email, password, enabled, account_locked";
+
+ private final UserRowMapper rowMapper = new UserRowMapper();
+
+ public UserDaoImpl(JdbcTemplate jdbcTemplate) {
+ super(jdbcTemplate);
+ }
+
+ @Override
+ public Optional
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ActivationTokenDto {
+
+ private Long id;
+
+ private Long userId;
+
+ private String token;
+
+ private Instant createdAt;
+
+ private Instant expiresAt;
+
+ private Instant validatedAt;
+
+ public ActivationTokenDto() {
+ }
+
+ public ActivationTokenDto(Long id, Long userId, String token, Instant createdAt, Instant expiresAt, Instant validatedAt) {
+ this.id = id;
+ this.userId = userId;
+ this.token = token;
+ this.createdAt = createdAt;
+ this.expiresAt = expiresAt;
+ this.validatedAt = validatedAt;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getUserId() {
+ return userId;
+ }
+
+ public void setUserId(Long userId) {
+ this.userId = userId;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public void setToken(String token) {
+ this.token = token;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(Instant createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ public void setExpiresAt(Instant expiresAt) {
+ this.expiresAt = expiresAt;
+ }
+
+ public Instant getValidatedAt() {
+ return validatedAt;
+ }
+
+ public void setValidatedAt(Instant validatedAt) {
+ this.validatedAt = validatedAt;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ ActivationTokenDto that = (ActivationTokenDto) o;
+ return Objects.equals(id, that.id)
+ && Objects.equals(userId, that.userId)
+ && Objects.equals(token, that.token)
+ && Objects.equals(createdAt, that.createdAt)
+ && Objects.equals(expiresAt, that.expiresAt)
+ && Objects.equals(validatedAt, that.validatedAt);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, userId, token, createdAt, expiresAt, validatedAt);
+ }
+
+ @Override
+ public String toString() {
+ return "ActivationTokenDto{" +
+ "id=" + id +
+ ", userId=" + userId +
+ ", token=" + (token == null ? "null" : "****") +
+ ", createdAt=" + createdAt +
+ ", expiresAt=" + expiresAt +
+ ", validatedAt=" + validatedAt +
+ "}";
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/BlacklistedTokenDto.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/BlacklistedTokenDto.java
new file mode 100644
index 0000000..f6be65e
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/BlacklistedTokenDto.java
@@ -0,0 +1,108 @@
+package com.edgareldy.springjdbctutorial.core.auth.dto;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Input of a logout: the SHA-256 hash of the presented JWT, its jti and its validity window.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class BlacklistedTokenDto {
+
+ private Long userId;
+
+ private String tokenHash;
+
+ private String jti;
+
+ private Instant issuedAt;
+
+ private Instant expiresAt;
+
+ public BlacklistedTokenDto() {
+ }
+
+ public BlacklistedTokenDto(Long userId, String tokenHash, String jti, Instant issuedAt, Instant expiresAt) {
+ this.userId = userId;
+ this.tokenHash = tokenHash;
+ this.jti = jti;
+ this.issuedAt = issuedAt;
+ this.expiresAt = expiresAt;
+ }
+
+ public Long getUserId() {
+ return userId;
+ }
+
+ public void setUserId(Long userId) {
+ this.userId = userId;
+ }
+
+ public String getTokenHash() {
+ return tokenHash;
+ }
+
+ public void setTokenHash(String tokenHash) {
+ this.tokenHash = tokenHash;
+ }
+
+ public String getJti() {
+ return jti;
+ }
+
+ public void setJti(String jti) {
+ this.jti = jti;
+ }
+
+ public Instant getIssuedAt() {
+ return issuedAt;
+ }
+
+ public void setIssuedAt(Instant issuedAt) {
+ this.issuedAt = issuedAt;
+ }
+
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ public void setExpiresAt(Instant expiresAt) {
+ this.expiresAt = expiresAt;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ BlacklistedTokenDto that = (BlacklistedTokenDto) o;
+ return Objects.equals(userId, that.userId)
+ && Objects.equals(tokenHash, that.tokenHash)
+ && Objects.equals(jti, that.jti)
+ && Objects.equals(issuedAt, that.issuedAt)
+ && Objects.equals(expiresAt, that.expiresAt);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(userId, tokenHash, jti, issuedAt, expiresAt);
+ }
+
+ @Override
+ public String toString() {
+ return "BlacklistedTokenDto{" +
+ "userId=" + userId +
+ ", tokenHash=" + (tokenHash == null ? "null" : "****") +
+ ", jti=" + jti +
+ ", issuedAt=" + issuedAt +
+ ", expiresAt=" + expiresAt +
+ "}";
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/PasswordResetTokenDto.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/PasswordResetTokenDto.java
new file mode 100644
index 0000000..7660e8b
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/PasswordResetTokenDto.java
@@ -0,0 +1,108 @@
+package com.edgareldy.springjdbctutorial.core.auth.dto;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Business shape of a password reset token (the token field is the hash, never the raw value).
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class PasswordResetTokenDto {
+
+ private Long id;
+
+ private Long userId;
+
+ private String token;
+
+ private String type;
+
+ private Instant expiryDate;
+
+ public PasswordResetTokenDto() {
+ }
+
+ public PasswordResetTokenDto(Long id, Long userId, String token, String type, Instant expiryDate) {
+ this.id = id;
+ this.userId = userId;
+ this.token = token;
+ this.type = type;
+ this.expiryDate = expiryDate;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getUserId() {
+ return userId;
+ }
+
+ public void setUserId(Long userId) {
+ this.userId = userId;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public void setToken(String token) {
+ this.token = token;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public Instant getExpiryDate() {
+ return expiryDate;
+ }
+
+ public void setExpiryDate(Instant expiryDate) {
+ this.expiryDate = expiryDate;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ PasswordResetTokenDto that = (PasswordResetTokenDto) o;
+ return Objects.equals(id, that.id)
+ && Objects.equals(userId, that.userId)
+ && Objects.equals(token, that.token)
+ && Objects.equals(type, that.type)
+ && Objects.equals(expiryDate, that.expiryDate);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, userId, token, type, expiryDate);
+ }
+
+ @Override
+ public String toString() {
+ return "PasswordResetTokenDto{" +
+ "id=" + id +
+ ", userId=" + userId +
+ ", token=" + (token == null ? "null" : "****") +
+ ", type=" + type +
+ ", expiryDate=" + expiryDate +
+ "}";
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/UserDto.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/UserDto.java
new file mode 100644
index 0000000..680a588
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/UserDto.java
@@ -0,0 +1,161 @@
+package com.edgareldy.springjdbctutorial.core.auth.dto;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Business shape of a user. On input the password carries the RAW password, on output it is always null.
+ * Roles are role names, permissions are "RESOURCE:ACTION" codes; both are filled by the service.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class UserDto {
+
+ private Long id;
+
+ private String firstName;
+
+ private String lastName;
+
+ private String email;
+
+ private String password;
+
+ private boolean enabled;
+
+ private boolean accountLocked;
+
+ private List
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+@Table(name = "activation_tokens")
+public class ActivationToken {
+
+ @Id
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "user_id")
+ private Long userId;
+
+ @Column(name = "token")
+ private String token;
+
+ @Column(name = "created_at")
+ private Instant createdAt;
+
+ @Column(name = "expires_at")
+ private Instant expiresAt;
+
+ @Column(name = "validated_at")
+ private Instant validatedAt;
+
+ public ActivationToken() {
+ }
+
+ public ActivationToken(Long id, Long userId, String token, Instant createdAt, Instant expiresAt, Instant validatedAt) {
+ this.id = id;
+ this.userId = userId;
+ this.token = token;
+ this.createdAt = createdAt;
+ this.expiresAt = expiresAt;
+ this.validatedAt = validatedAt;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getUserId() {
+ return userId;
+ }
+
+ public void setUserId(Long userId) {
+ this.userId = userId;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public void setToken(String token) {
+ this.token = token;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(Instant createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ public void setExpiresAt(Instant expiresAt) {
+ this.expiresAt = expiresAt;
+ }
+
+ public Instant getValidatedAt() {
+ return validatedAt;
+ }
+
+ public void setValidatedAt(Instant validatedAt) {
+ this.validatedAt = validatedAt;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ ActivationToken that = (ActivationToken) o;
+ return Objects.equals(id, that.id)
+ && Objects.equals(userId, that.userId)
+ && Objects.equals(token, that.token)
+ && Objects.equals(createdAt, that.createdAt)
+ && Objects.equals(expiresAt, that.expiresAt)
+ && Objects.equals(validatedAt, that.validatedAt);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, userId, token, createdAt, expiresAt, validatedAt);
+ }
+
+ @Override
+ public String toString() {
+ return "ActivationToken{" +
+ "id=" + id +
+ ", userId=" + userId +
+ ", token=" + (token == null ? "null" : "****") +
+ ", createdAt=" + createdAt +
+ ", expiresAt=" + expiresAt +
+ ", validatedAt=" + validatedAt +
+ "}";
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/BlacklistedToken.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/BlacklistedToken.java
new file mode 100644
index 0000000..233affd
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/BlacklistedToken.java
@@ -0,0 +1,160 @@
+package com.edgareldy.springjdbctutorial.core.auth.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Row of the blacklisted_tokens table (revoked JWT). The token column holds the SHA-256 hex digest of the JWT, never the JWT.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+@Table(name = "blacklisted_tokens")
+public class BlacklistedToken {
+
+ @Id
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "user_id")
+ private Long userId;
+
+ @Column(name = "token")
+ private String token;
+
+ @Column(name = "jti")
+ private String jti;
+
+ @Column(name = "blacklisted_at")
+ private Instant blacklistedAt;
+
+ @Column(name = "created_at")
+ private Instant createdAt;
+
+ @Column(name = "expires_at")
+ private Instant expiresAt;
+
+ @Column(name = "validated_at")
+ private Instant validatedAt;
+
+ public BlacklistedToken() {
+ }
+
+ public BlacklistedToken(Long id, Long userId, String token, String jti, Instant blacklistedAt, Instant createdAt, Instant expiresAt, Instant validatedAt) {
+ this.id = id;
+ this.userId = userId;
+ this.token = token;
+ this.jti = jti;
+ this.blacklistedAt = blacklistedAt;
+ this.createdAt = createdAt;
+ this.expiresAt = expiresAt;
+ this.validatedAt = validatedAt;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getUserId() {
+ return userId;
+ }
+
+ public void setUserId(Long userId) {
+ this.userId = userId;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public void setToken(String token) {
+ this.token = token;
+ }
+
+ public String getJti() {
+ return jti;
+ }
+
+ public void setJti(String jti) {
+ this.jti = jti;
+ }
+
+ public Instant getBlacklistedAt() {
+ return blacklistedAt;
+ }
+
+ public void setBlacklistedAt(Instant blacklistedAt) {
+ this.blacklistedAt = blacklistedAt;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(Instant createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ public void setExpiresAt(Instant expiresAt) {
+ this.expiresAt = expiresAt;
+ }
+
+ public Instant getValidatedAt() {
+ return validatedAt;
+ }
+
+ public void setValidatedAt(Instant validatedAt) {
+ this.validatedAt = validatedAt;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ BlacklistedToken that = (BlacklistedToken) o;
+ return Objects.equals(id, that.id)
+ && Objects.equals(userId, that.userId)
+ && Objects.equals(token, that.token)
+ && Objects.equals(jti, that.jti)
+ && Objects.equals(blacklistedAt, that.blacklistedAt)
+ && Objects.equals(createdAt, that.createdAt)
+ && Objects.equals(expiresAt, that.expiresAt)
+ && Objects.equals(validatedAt, that.validatedAt);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, userId, token, jti, blacklistedAt, createdAt, expiresAt, validatedAt);
+ }
+
+ @Override
+ public String toString() {
+ return "BlacklistedToken{" +
+ "id=" + id +
+ ", userId=" + userId +
+ ", token=" + (token == null ? "null" : "****") +
+ ", jti=" + jti +
+ ", blacklistedAt=" + blacklistedAt +
+ ", createdAt=" + createdAt +
+ ", expiresAt=" + expiresAt +
+ ", validatedAt=" + validatedAt +
+ "}";
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/PasswordResetToken.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/PasswordResetToken.java
new file mode 100644
index 0000000..afb40fd
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/PasswordResetToken.java
@@ -0,0 +1,118 @@
+package com.edgareldy.springjdbctutorial.core.auth.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Row of the password_reset_tokens table. The token column holds the SHA-256 hex digest, never the raw token.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+@Table(name = "password_reset_tokens")
+public class PasswordResetToken {
+
+ @Id
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "user_id")
+ private Long userId;
+
+ @Column(name = "token")
+ private String token;
+
+ @Column(name = "type")
+ private String type;
+
+ @Column(name = "expiry_date")
+ private Instant expiryDate;
+
+ public PasswordResetToken() {
+ }
+
+ public PasswordResetToken(Long id, Long userId, String token, String type, Instant expiryDate) {
+ this.id = id;
+ this.userId = userId;
+ this.token = token;
+ this.type = type;
+ this.expiryDate = expiryDate;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public Long getUserId() {
+ return userId;
+ }
+
+ public void setUserId(Long userId) {
+ this.userId = userId;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public void setToken(String token) {
+ this.token = token;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public Instant getExpiryDate() {
+ return expiryDate;
+ }
+
+ public void setExpiryDate(Instant expiryDate) {
+ this.expiryDate = expiryDate;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ PasswordResetToken that = (PasswordResetToken) o;
+ return Objects.equals(id, that.id)
+ && Objects.equals(userId, that.userId)
+ && Objects.equals(token, that.token)
+ && Objects.equals(type, that.type)
+ && Objects.equals(expiryDate, that.expiryDate);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, userId, token, type, expiryDate);
+ }
+
+ @Override
+ public String toString() {
+ return "PasswordResetToken{" +
+ "id=" + id +
+ ", userId=" + userId +
+ ", token=" + (token == null ? "null" : "****") +
+ ", type=" + type +
+ ", expiryDate=" + expiryDate +
+ "}";
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/User.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/User.java
new file mode 100644
index 0000000..1b05f13
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/User.java
@@ -0,0 +1,146 @@
+package com.edgareldy.springjdbctutorial.core.auth.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.util.Objects;
+
+/**
+ * Row of the users table. The password field holds the bcrypt hash, never a clear password.
+ * The jakarta.persistence annotations are documentation only, nothing reads them at runtime.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+@Table(name = "users")
+public class User {
+
+ @Id
+ @Column(name = "id")
+ private Long id;
+
+ @Column(name = "first_name")
+ private String firstName;
+
+ @Column(name = "last_name")
+ private String lastName;
+
+ @Column(name = "email")
+ private String email;
+
+ @Column(name = "password")
+ private String password;
+
+ @Column(name = "enabled")
+ private boolean enabled;
+
+ @Column(name = "account_locked")
+ private boolean accountLocked;
+
+ public User() {
+ }
+
+ public User(Long id, String firstName, String lastName, String email, String password, boolean enabled, boolean accountLocked) {
+ this.id = id;
+ this.firstName = firstName;
+ this.lastName = lastName;
+ this.email = email;
+ this.password = password;
+ this.enabled = enabled;
+ this.accountLocked = accountLocked;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+
+ public String getLastName() {
+ return lastName;
+ }
+
+ public void setLastName(String lastName) {
+ this.lastName = lastName;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public boolean isAccountLocked() {
+ return accountLocked;
+ }
+
+ public void setAccountLocked(boolean accountLocked) {
+ this.accountLocked = accountLocked;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ User that = (User) o;
+ return Objects.equals(id, that.id)
+ && Objects.equals(firstName, that.firstName)
+ && Objects.equals(lastName, that.lastName)
+ && Objects.equals(email, that.email)
+ && Objects.equals(password, that.password)
+ && enabled == that.enabled
+ && accountLocked == that.accountLocked;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, firstName, lastName, email, password, enabled, accountLocked);
+ }
+
+ @Override
+ public String toString() {
+ return "User{" +
+ "id=" + id +
+ ", firstName=" + firstName +
+ ", lastName=" + lastName +
+ ", email=" + email +
+ ", password=" + (password == null ? "null" : "****") +
+ ", enabled=" + enabled +
+ ", accountLocked=" + accountLocked +
+ "}";
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AccountNotActiveException.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AccountNotActiveException.java
new file mode 100644
index 0000000..505d6a3
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AccountNotActiveException.java
@@ -0,0 +1,19 @@
+package com.edgareldy.springjdbctutorial.core.auth.exception;
+
+/**
+ * The password was correct but the account is not activated or is locked. Only thrown after the
+ * password check, so it never reveals whether an email exists.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class AccountNotActiveException extends AuthenticationFailedException {
+
+ private static final long serialVersionUID = 1L;
+
+ public AccountNotActiveException() {
+ super("Account is not activated or is locked");
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AuthenticationFailedException.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AuthenticationFailedException.java
new file mode 100644
index 0000000..45dcb82
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AuthenticationFailedException.java
@@ -0,0 +1,18 @@
+package com.edgareldy.springjdbctutorial.core.auth.exception;
+
+/**
+ * Base of every authentication failure (mapped to HTTP 401 by the web layer).
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class AuthenticationFailedException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ public AuthenticationFailedException(String message) {
+ super(message);
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/InvalidCredentialsException.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/InvalidCredentialsException.java
new file mode 100644
index 0000000..a3f77cd
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/InvalidCredentialsException.java
@@ -0,0 +1,18 @@
+package com.edgareldy.springjdbctutorial.core.auth.exception;
+
+/**
+ * Unknown email or wrong password: one message for both so an email's existence is never revealed.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class InvalidCredentialsException extends AuthenticationFailedException {
+
+ private static final long serialVersionUID = 1L;
+
+ public InvalidCredentialsException() {
+ super("Invalid email or password");
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapper.java
new file mode 100644
index 0000000..a430aa6
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapper.java
@@ -0,0 +1,29 @@
+package com.edgareldy.springjdbctutorial.core.auth.mapper;
+
+import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+/**
+ * Converts one row of the activation_tokens table into an ActivationToken entity (validated_at may be NULL).
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ActivationTokenRowMapper implements RowMapper
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class BlacklistedTokenRowMapper implements RowMapper
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class PasswordResetTokenRowMapper implements RowMapper
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public final class RowMapperSupport {
+
+ private RowMapperSupport() {
+ }
+
+ /**
+ * Reads a timestamptz column as an Instant, or null when the column is SQL NULL.
+ */
+ public static Instant instant(ResultSet rs, String column) throws SQLException {
+ Timestamp value = rs.getTimestamp(column);
+ return value == null ? null : value.toInstant();
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapper.java
new file mode 100644
index 0000000..c8d218c
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapper.java
@@ -0,0 +1,40 @@
+package com.edgareldy.springjdbctutorial.core.auth.mapper;
+
+import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto;
+import com.edgareldy.springjdbctutorial.core.auth.entity.User;
+
+import java.util.ArrayList;
+
+/**
+ * Converts between the User entity and the UserDto. The password hash is never copied to a dto;
+ * roles and permissions are filled by the service.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class UserMapper {
+
+ /**
+ * Entity to dto: password is always null, roles and permissions start empty.
+ */
+ public UserDto toDto(User user) {
+ if (user == null) {
+ return null;
+ }
+ return new UserDto(user.getId(), user.getFirstName(), user.getLastName(), user.getEmail(), null,
+ user.isEnabled(), user.isAccountLocked(), new ArrayList<>(), new ArrayList<>());
+ }
+
+ /**
+ * Dto to entity: the caller supplies the bcrypt hash, the dto's raw password is never read here.
+ */
+ public User toEntity(UserDto dto, String passwordHash) {
+ if (dto == null) {
+ return null;
+ }
+ return new User(dto.getId(), dto.getFirstName(), dto.getLastName(), dto.getEmail(), passwordHash,
+ dto.isEnabled(), dto.isAccountLocked());
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapper.java
new file mode 100644
index 0000000..c4376c7
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapper.java
@@ -0,0 +1,30 @@
+package com.edgareldy.springjdbctutorial.core.auth.mapper;
+
+import com.edgareldy.springjdbctutorial.core.auth.entity.User;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+/**
+ * Converts one row of the users table into a User entity.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class UserRowMapper implements RowMapper
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public interface AuthService {
+
+ /** Creates a disabled account and its activation token (raw token logged at INFO, no mailer). */
+ UserDto register(UserDto user);
+
+ void activateAccount(String rawToken);
+
+ /** Returns the user with roles and permissions filled and a null password. */
+ UserDto login(String email, String rawPassword);
+
+ /** Revokes a JWT by storing its hash. Idempotent. */
+ void logout(BlacklistedTokenDto token);
+
+ boolean isTokenBlacklisted(String tokenHash);
+
+ UserDto getProfile(Long userId);
+
+ /** Never reveals whether the email exists. */
+ void forgotPassword(String email);
+
+ void resetPassword(String rawToken, String newPassword);
+
+ /** Deletes the blacklisted rows that already expired and returns how many were removed. */
+ int purgeExpiredTokens();
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java
new file mode 100644
index 0000000..5cfd6a3
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java
@@ -0,0 +1,240 @@
+package com.edgareldy.springjdbctutorial.core.auth.service.impl;
+
+import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao;
+import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao;
+import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao;
+import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao;
+import com.edgareldy.springjdbctutorial.core.auth.dto.BlacklistedTokenDto;
+import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto;
+import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken;
+import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken;
+import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken;
+import com.edgareldy.springjdbctutorial.core.auth.entity.User;
+import com.edgareldy.springjdbctutorial.core.auth.exception.AccountNotActiveException;
+import com.edgareldy.springjdbctutorial.core.auth.exception.InvalidCredentialsException;
+import com.edgareldy.springjdbctutorial.core.auth.mapper.UserMapper;
+import com.edgareldy.springjdbctutorial.core.auth.service.AuthService;
+import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException;
+import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Locale;
+
+/**
+ * Implementation of AuthService. Declared by @Bean in ServiceConfig (no stereotype) and returned as
+ * the interface so the @Transactional advice applies through a JDK proxy.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class AuthServiceImpl implements AuthService {
+
+ private static final Log LOG = LogFactory.getLog(AuthServiceImpl.class);
+
+ private static final int MAX_PASSWORD_BYTES = 72;
+ private static final Duration ACTIVATION_VALIDITY = Duration.ofHours(24);
+ private static final Duration RESET_VALIDITY = Duration.ofHours(1);
+ private static final String RESET_TYPE = "PASSWORD_RESET";
+
+ private final UserDao userDao;
+ private final ActivationTokenDao activationTokenDao;
+ private final PasswordResetTokenDao passwordResetTokenDao;
+ private final BlacklistedTokenDao blacklistedTokenDao;
+ private final PasswordEncoder passwordEncoder;
+ private final Clock clock;
+ private final UserMapper userMapper = new UserMapper();
+ private final TokenHasher tokenHasher = new TokenHasher();
+ // Verified against when the email is unknown, so a failed login costs one bcrypt check either way
+ private final String dummyHash;
+
+ public AuthServiceImpl(UserDao userDao, ActivationTokenDao activationTokenDao,
+ PasswordResetTokenDao passwordResetTokenDao, BlacklistedTokenDao blacklistedTokenDao,
+ PasswordEncoder passwordEncoder, Clock clock) {
+ this.userDao = userDao;
+ this.activationTokenDao = activationTokenDao;
+ this.passwordResetTokenDao = passwordResetTokenDao;
+ this.blacklistedTokenDao = blacklistedTokenDao;
+ this.passwordEncoder = passwordEncoder;
+ this.clock = clock;
+ this.dummyHash = passwordEncoder.encode("timing-equalisation-dummy-password");
+ }
+
+ // @Transactional (enabled by @EnableTransactionManagement in DataSourceConfig) makes the Spring proxy
+ // open a transaction before the method and commit it on return, or roll back on a RuntimeException.
+ // Every write below therefore succeeds or fails as one unit (e.g. user row + activation token).
+ @Override
+ @Transactional
+ public UserDto register(UserDto input) {
+ if (input == null) {
+ throw new BusinessRuleException("User is required");
+ }
+ String email = normalizeEmail(input.getEmail());
+ requirePasswordWithinLimit(input.getPassword());
+ if (userDao.existsByEmail(email)) {
+ throw new BusinessRuleException("Email is already registered");
+ }
+ UserDto toCreate = new UserDto(null, input.getFirstName(), input.getLastName(), email, null,
+ false, false, null, null);
+ User created;
+ try {
+ created = userDao.insert(userMapper.toEntity(toCreate, passwordEncoder.encode(input.getPassword())));
+ } catch (DuplicateKeyException e) {
+ // Lost a race against a concurrent registration of the same email: the unique index decided
+ throw new BusinessRuleException("Email is already registered", e);
+ }
+ String rawToken = tokenHasher.generateRawToken();
+ Instant now = clock.instant();
+ activationTokenDao.insert(new ActivationToken(null, created.getId(), tokenHasher.sha256Hex(rawToken),
+ now, now.plus(ACTIVATION_VALIDITY), null));
+ // No mailer exists in this tutorial: the raw token is only ever visible in this log line
+ LOG.info("Activation token for " + email + ": " + rawToken);
+ return userMapper.toDto(created);
+ }
+
+ @Override
+ @Transactional
+ public void activateAccount(String rawToken) {
+ Instant now = clock.instant();
+ ActivationToken token = rawToken == null ? null
+ : activationTokenDao.findByTokenHash(tokenHasher.sha256Hex(rawToken)).orElse(null);
+ if (token == null || token.getValidatedAt() != null || !token.getExpiresAt().isAfter(now)) {
+ throw new BusinessRuleException("Invalid or expired activation token");
+ }
+ userDao.updateEnabled(token.getUserId(), true);
+ activationTokenDao.markValidated(token.getId(), now);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public UserDto login(String email, String rawPassword) {
+ User user = email == null ? null : userDao.findByEmail(normalizeEmail(email)).orElse(null);
+ boolean passwordOk;
+ if (user == null) {
+ // Fake verification: same bcrypt cost as a real one, result ignored
+ passwordEncoder.matches("x", dummyHash);
+ passwordOk = false;
+ } else if (rawPassword == null || exceedsLimit(rawPassword)) {
+ // Over 72 bytes can never match a bcrypt hash: plain invalid credentials, never a 500
+ passwordEncoder.matches("x", dummyHash);
+ passwordOk = false;
+ } else {
+ passwordOk = passwordEncoder.matches(rawPassword, user.getPassword());
+ }
+ if (!passwordOk) {
+ throw new InvalidCredentialsException();
+ }
+ if (!user.isEnabled() || user.isAccountLocked()) {
+ throw new AccountNotActiveException();
+ }
+ return withRolesAndPermissions(user);
+ }
+
+ @Override
+ @Transactional
+ public void logout(BlacklistedTokenDto token) {
+ if (blacklistedTokenDao.existsByTokenHash(token.getTokenHash())) {
+ return;
+ }
+ Instant now = clock.instant();
+ try {
+ blacklistedTokenDao.insert(new BlacklistedToken(null, token.getUserId(), token.getTokenHash(),
+ token.getJti(), now, token.getIssuedAt() == null ? now : token.getIssuedAt(),
+ token.getExpiresAt(), null));
+ } catch (DuplicateKeyException e) {
+ // Concurrent or repeated logout of the same token (same hash or jti): already revoked, accept
+ LOG.debug("Token already blacklisted, logout accepted");
+ }
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public boolean isTokenBlacklisted(String tokenHash) {
+ return blacklistedTokenDao.existsByTokenHash(tokenHash);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public UserDto getProfile(Long userId) {
+ User user = userDao.findById(userId)
+ .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId));
+ return withRolesAndPermissions(user);
+ }
+
+ @Override
+ @Transactional
+ public void forgotPassword(String email) {
+ // Token and hash are generated whether or not the account exists, so the work is alike
+ String rawToken = tokenHasher.generateRawToken();
+ String tokenHash = tokenHasher.sha256Hex(rawToken);
+ // A null or blank email is simply an unknown account here: normalizeEmail would throw a 422 and
+ // reveal a different behaviour than the generic answer
+ String normalized = email == null || email.isBlank() ? "" : normalizeEmail(email);
+ User user = userDao.findByEmail(normalized).orElse(null);
+ // The delete runs for an unknown account too (on an id that matches nothing), so the only work
+ // skipped when the email is unknown is the final insert and the log line
+ passwordResetTokenDao.deleteByUserId(user == null ? -1L : user.getId());
+ if (user == null) {
+ return;
+ }
+ passwordResetTokenDao.insert(new PasswordResetToken(null, user.getId(), tokenHash, RESET_TYPE,
+ clock.instant().plus(RESET_VALIDITY)));
+ LOG.info("Password reset token for " + normalized + ": " + rawToken);
+ }
+
+ @Override
+ @Transactional
+ public void resetPassword(String rawToken, String newPassword) {
+ PasswordResetToken token = rawToken == null ? null
+ : passwordResetTokenDao.findByTokenHash(tokenHasher.sha256Hex(rawToken)).orElse(null);
+ if (token == null || !token.getExpiryDate().isAfter(clock.instant())) {
+ throw new BusinessRuleException("Invalid or expired reset token");
+ }
+ requirePasswordWithinLimit(newPassword);
+ userDao.updatePassword(token.getUserId(), passwordEncoder.encode(newPassword));
+ passwordResetTokenDao.deleteByUserId(token.getUserId());
+ }
+
+ @Override
+ @Transactional
+ public int purgeExpiredTokens() {
+ return blacklistedTokenDao.deleteExpiredBefore(clock.instant());
+ }
+
+ private UserDto withRolesAndPermissions(User user) {
+ UserDto dto = userMapper.toDto(user);
+ dto.setRoles(userDao.findRoleNamesByUserId(user.getId()));
+ dto.setPermissions(userDao.findPermissionCodesByUserId(user.getId()));
+ return dto;
+ }
+
+ private static String normalizeEmail(String email) {
+ if (email == null || email.isBlank()) {
+ throw new BusinessRuleException("Email is required");
+ }
+ return email.trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static boolean exceedsLimit(String password) {
+ return password.getBytes(StandardCharsets.UTF_8).length > MAX_PASSWORD_BYTES;
+ }
+
+ private static void requirePasswordWithinLimit(String password) {
+ if (password == null || password.isEmpty()) {
+ throw new BusinessRuleException("Password is required");
+ }
+ // bcrypt only reads the first 72 bytes: refuse longer input instead of silently truncating it
+ if (exceedsLimit(password)) {
+ throw new BusinessRuleException("Password must not exceed 72 bytes");
+ }
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJob.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJob.java
new file mode 100644
index 0000000..6ea28e2
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJob.java
@@ -0,0 +1,35 @@
+package com.edgareldy.springjdbctutorial.core.auth.service.impl;
+
+import com.edgareldy.springjdbctutorial.core.auth.service.AuthService;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+
+/**
+ * Daily job purging expired blacklisted tokens. run() is public so tests can call it directly.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ExpiredTokenCleanupJob {
+
+ private static final Log LOG = LogFactory.getLog(ExpiredTokenCleanupJob.class);
+
+ private final AuthService authService;
+
+ public ExpiredTokenCleanupJob(AuthService authService) {
+ this.authService = authService;
+ }
+
+ // @Scheduled runs this method by itself at 03:00 every day (Spring cron: second minute hour day
+ // month weekday). It only fires because @EnableScheduling is on ServiceConfig and this class is a
+ // bean. Expired rows are useless (an expired JWT is refused anyway), so deleting them keeps the
+ // blacklist small and its lookups fast.
+ @Scheduled(cron = "0 0 3 * * *")
+ public void run() {
+ int removed = authService.purgeExpiredTokens();
+ LOG.info("Expired blacklisted tokens purged: " + removed);
+ }
+}
diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/TokenHasher.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/TokenHasher.java
new file mode 100644
index 0000000..348d3e1
--- /dev/null
+++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/TokenHasher.java
@@ -0,0 +1,51 @@
+package com.edgareldy.springjdbctutorial.core.auth.service.impl;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.HexFormat;
+
+/**
+ * Generates random raw tokens and computes the SHA-256 hex digest that is the only form stored.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+final class TokenHasher {
+
+ private static final int TOKEN_BYTES = 32;
+
+ // Instance field on purpose (never static): one generator per service instance, seeded by the OS.
+ private final SecureRandom secureRandom = new SecureRandom();
+
+ /**
+ * 32 random bytes, URL-safe base64 without padding (43 characters).
+ */
+ String generateRawToken() {
+ byte[] bytes = new byte[TOKEN_BYTES];
+ secureRandom.nextBytes(bytes);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ // Why SHA-256 and not bcrypt for tokens: a token is 256 bits of randomness, so it cannot be
+ // brute-forced or found in a dictionary and needs no slow, salted hash. It must also be looked up
+ // BY VALUE (WHERE token = ?), which a per-hash random salt (bcrypt) makes impossible. A database
+ // leak still yields only digests, which cannot be replayed as a token. Passwords are the opposite
+ // case (low entropy, chosen by humans), hence bcrypt for those.
+ /**
+ * SHA-256 of the UTF-8 bytes of the value, as 64 lowercase hex characters.
+ */
+ String sha256Hex(String value) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException e) {
+ // SHA-256 is mandatory in every Java platform implementation
+ throw new IllegalStateException("SHA-256 is not available", e);
+ }
+ }
+}
diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImplTest.java
new file mode 100644
index 0000000..3afb0f4
--- /dev/null
+++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImplTest.java
@@ -0,0 +1,85 @@
+package com.edgareldy.springjdbctutorial.core.auth.dao.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.edgareldy.springjdbctutorial.core.auth.config.DaoConfig;
+import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao;
+import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken;
+import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig;
+import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+/**
+ * Tests ActivationTokenDaoImpl against the real PostgreSQL, with its own fixture file
+ * activation-token-dao-dataset.sql.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class})
+@Sql("/activation-token-dao-dataset.sql")
+class ActivationTokenDaoImplTest {
+
+ private static final String HASH_USER_1 = "a1".repeat(32);
+ private static final String HASH_USER_2 = "b2".repeat(32);
+
+ @DynamicPropertySource
+ static void datasource(DynamicPropertyRegistry registry) {
+ PostgresTestContainer.register(registry);
+ }
+
+ @Autowired
+ private ActivationTokenDao dao;
+
+ @Test
+ void _01_ShouldInsertAndGenerateId_WhenTokenIsNew() {
+ Instant created = Instant.parse("2026-09-19T12:00:00Z");
+ ActivationToken inserted = dao.insert(new ActivationToken(null, 1L, "9f".repeat(32), created,
+ created.plusSeconds(3600), null));
+
+ assertThat(inserted.getId()).isNotNull().isGreaterThanOrEqualTo(100L);
+ ActivationToken reloaded = dao.findByTokenHash("9f".repeat(32)).orElseThrow();
+ assertThat(reloaded.getUserId()).isEqualTo(1L);
+ assertThat(reloaded.getCreatedAt()).isEqualTo(created);
+ assertThat(reloaded.getExpiresAt()).isEqualTo(created.plusSeconds(3600));
+ assertThat(reloaded.getValidatedAt()).isNull();
+ }
+
+ @Test
+ void _02_ShouldFindToken_WhenHashExists() {
+ ActivationToken token = dao.findByTokenHash(HASH_USER_2).orElseThrow();
+
+ assertThat(token.getUserId()).isEqualTo(2L);
+ assertThat(token.getValidatedAt()).isEqualTo(Instant.parse("2026-09-18T11:00:00Z"));
+ }
+
+ @Test
+ void _03_ShouldReturnEmpty_WhenHashIsUnknown() {
+ assertThat(dao.findByTokenHash("00".repeat(32))).isEmpty();
+ }
+
+ @Test
+ void _04_ShouldStoreValidationDate_WhenTokenIsMarkedValidated() {
+ Instant validatedAt = Instant.parse("2026-09-19T12:30:00Z");
+
+ dao.markValidated(1L, validatedAt);
+
+ assertThat(dao.findByTokenHash(HASH_USER_1).orElseThrow().getValidatedAt()).isEqualTo(validatedAt);
+ }
+
+ @Test
+ void _05_ShouldDeleteOnlyThatUsersTokens_WhenDeletedByUserId() {
+ dao.deleteByUserId(1L);
+
+ assertThat(dao.findByTokenHash(HASH_USER_1)).isEmpty();
+ assertThat(dao.findByTokenHash(HASH_USER_2)).isPresent();
+ }
+}
diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImplTest.java
new file mode 100644
index 0000000..b6cb7d8
--- /dev/null
+++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImplTest.java
@@ -0,0 +1,88 @@
+package com.edgareldy.springjdbctutorial.core.auth.dao.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.edgareldy.springjdbctutorial.core.auth.config.DaoConfig;
+import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao;
+import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken;
+import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig;
+import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+/**
+ * Tests BlacklistedTokenDaoImpl against the real PostgreSQL, with its own fixture file
+ * blacklisted-token-dao-dataset.sql.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class})
+@Sql("/blacklisted-token-dao-dataset.sql")
+class BlacklistedTokenDaoImplTest {
+
+ private static final String HASH_EXPIRED_1 = "e5".repeat(32);
+ private static final String HASH_EXPIRED_2 = "f6".repeat(32);
+ private static final String HASH_LIVE = "a7".repeat(32);
+
+ @DynamicPropertySource
+ static void datasource(DynamicPropertyRegistry registry) {
+ PostgresTestContainer.register(registry);
+ }
+
+ @Autowired
+ private BlacklistedTokenDao dao;
+
+ @Test
+ void _01_ShouldInsertAndGenerateId_WhenTokenIsNew() {
+ Instant now = Instant.parse("2026-09-19T12:00:00Z");
+ BlacklistedToken inserted = dao.insert(new BlacklistedToken(null, 1L, "1c".repeat(32), "jti-new", now,
+ now.minusSeconds(60), now.plusSeconds(3600), null));
+
+ assertThat(inserted.getId()).isNotNull().isGreaterThanOrEqualTo(100L);
+ assertThat(dao.existsByTokenHash("1c".repeat(32))).isTrue();
+ }
+
+ @Test
+ void _02_ShouldReturnTrue_WhenHashIsBlacklisted() {
+ assertThat(dao.existsByTokenHash(HASH_LIVE)).isTrue();
+ }
+
+ @Test
+ void _03_ShouldReturnFalse_WhenHashIsNotBlacklisted() {
+ assertThat(dao.existsByTokenHash("00".repeat(32))).isFalse();
+ }
+
+ @Test
+ void _04_ShouldThrowDuplicateKeyException_WhenSameJtiIsInsertedTwice() {
+ Instant now = Instant.parse("2026-09-19T12:00:00Z");
+ BlacklistedToken sameJti = new BlacklistedToken(null, 1L, "2d".repeat(32), "jti-live", now,
+ now, now.plusSeconds(3600), null);
+
+ assertThatThrownBy(() -> dao.insert(sameJti)).isInstanceOf(DuplicateKeyException.class);
+ }
+
+ @Test
+ void _05_ShouldDeleteOnlyExpiredRowsAndReturnTheirCount_WhenPurgedBeforeAnInstant() {
+ int deleted = dao.deleteExpiredBefore(Instant.parse("2026-09-19T00:00:00Z"));
+
+ assertThat(deleted).isEqualTo(2);
+ assertThat(dao.existsByTokenHash(HASH_EXPIRED_1)).isFalse();
+ assertThat(dao.existsByTokenHash(HASH_EXPIRED_2)).isFalse();
+ assertThat(dao.existsByTokenHash(HASH_LIVE)).isTrue();
+ }
+
+ @Test
+ void _06_ShouldDeleteNothing_WhenNoRowIsExpiredBeforeTheInstant() {
+ assertThat(dao.deleteExpiredBefore(Instant.parse("2026-09-01T00:00:00Z"))).isZero();
+ }
+}
diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImplTest.java
new file mode 100644
index 0000000..aeba82c
--- /dev/null
+++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImplTest.java
@@ -0,0 +1,75 @@
+package com.edgareldy.springjdbctutorial.core.auth.dao.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.edgareldy.springjdbctutorial.core.auth.config.DaoConfig;
+import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao;
+import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken;
+import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig;
+import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+/**
+ * Tests PasswordResetTokenDaoImpl against the real PostgreSQL, with its own fixture file
+ * password-reset-token-dao-dataset.sql.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class})
+@Sql("/password-reset-token-dao-dataset.sql")
+class PasswordResetTokenDaoImplTest {
+
+ private static final String HASH_USER_1 = "c3".repeat(32);
+ private static final String HASH_USER_2 = "d4".repeat(32);
+
+ @DynamicPropertySource
+ static void datasource(DynamicPropertyRegistry registry) {
+ PostgresTestContainer.register(registry);
+ }
+
+ @Autowired
+ private PasswordResetTokenDao dao;
+
+ @Test
+ void _01_ShouldInsertAndGenerateId_WhenTokenIsNew() {
+ Instant expiry = Instant.parse("2026-09-19T13:00:00Z");
+ PasswordResetToken inserted = dao.insert(new PasswordResetToken(null, 2L, "8e".repeat(32),
+ "PASSWORD_RESET", expiry));
+
+ assertThat(inserted.getId()).isNotNull().isGreaterThanOrEqualTo(100L);
+ PasswordResetToken reloaded = dao.findByTokenHash("8e".repeat(32)).orElseThrow();
+ assertThat(reloaded.getUserId()).isEqualTo(2L);
+ assertThat(reloaded.getType()).isEqualTo("PASSWORD_RESET");
+ assertThat(reloaded.getExpiryDate()).isEqualTo(expiry);
+ }
+
+ @Test
+ void _02_ShouldFindToken_WhenHashExists() {
+ PasswordResetToken token = dao.findByTokenHash(HASH_USER_1).orElseThrow();
+
+ assertThat(token.getUserId()).isEqualTo(1L);
+ assertThat(token.getExpiryDate()).isEqualTo(Instant.parse("2026-09-19T11:00:00Z"));
+ }
+
+ @Test
+ void _03_ShouldReturnEmpty_WhenHashIsUnknown() {
+ assertThat(dao.findByTokenHash("00".repeat(32))).isEmpty();
+ }
+
+ @Test
+ void _04_ShouldDeleteOnlyThatUsersTokens_WhenDeletedByUserId() {
+ dao.deleteByUserId(1L);
+
+ assertThat(dao.findByTokenHash(HASH_USER_1)).isEmpty();
+ assertThat(dao.findByTokenHash(HASH_USER_2)).isPresent();
+ }
+}
diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImplTest.java
new file mode 100644
index 0000000..dacc9e3
--- /dev/null
+++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImplTest.java
@@ -0,0 +1,128 @@
+package com.edgareldy.springjdbctutorial.core.auth.dao.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.edgareldy.springjdbctutorial.core.auth.config.DaoConfig;
+import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao;
+import com.edgareldy.springjdbctutorial.core.auth.entity.User;
+import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig;
+import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.jdbc.Sql;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+/**
+ * Tests UserDaoImpl against the real PostgreSQL, with its own fixture file user-dao-dataset.sql.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+// DAO tests run real SQL on a real PostgreSQL started by Testcontainers (Docker), migrated by Flyway
+// exactly like production. @SpringJUnitConfig builds a Spring context from the two real configuration
+// classes; @DynamicPropertySource injects the container's random JDBC url into the Environment because
+// the port is only known once the container runs. @Sql executes this class's own fixture file before
+// EACH test, so every test starts from the same known rows.
+@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class})
+@Sql("/user-dao-dataset.sql")
+class UserDaoImplTest {
+
+ @DynamicPropertySource
+ static void datasource(DynamicPropertyRegistry registry) {
+ PostgresTestContainer.register(registry);
+ }
+
+ @Autowired
+ private UserDao userDao;
+
+ @Test
+ void _01_ShouldReturnUser_WhenIdExists() {
+ User user = userDao.findById(1L).orElseThrow();
+
+ assertThat(user.getFirstName()).isEqualTo("Alice");
+ assertThat(user.getLastName()).isEqualTo("Martin");
+ assertThat(user.getEmail()).isEqualTo("alice@example.com");
+ assertThat(user.isEnabled()).isTrue();
+ assertThat(user.isAccountLocked()).isFalse();
+ }
+
+ @Test
+ void _02_ShouldReturnEmpty_WhenIdDoesNotExist() {
+ assertThat(userDao.findById(999L)).isEmpty();
+ }
+
+ @Test
+ void _03_ShouldFindUser_WhenEmailDiffersOnlyByCase() {
+ assertThat(userDao.findByEmail("ALICE@Example.COM")).get().extracting(User::getId).isEqualTo(1L);
+ }
+
+ @Test
+ void _04_ShouldReturnEmpty_WhenEmailIsUnknown() {
+ assertThat(userDao.findByEmail("nobody@example.com")).isEmpty();
+ }
+
+ @Test
+ void _05_ShouldReturnTrue_WhenEmailExistsWhateverItsCase() {
+ assertThat(userDao.existsByEmail("BOB@EXAMPLE.com")).isTrue();
+ }
+
+ @Test
+ void _06_ShouldReturnFalse_WhenEmailDoesNotExist() {
+ assertThat(userDao.existsByEmail("nobody@example.com")).isFalse();
+ }
+
+ @Test
+ void _07_ShouldGenerateIdAndPersistRow_WhenUserIsInserted() {
+ User created = userDao.insert(new User(null, "Dave", "Roux", "dave@example.com", "hash-dave", false, false));
+
+ assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L);
+ User reloaded = userDao.findById(created.getId()).orElseThrow();
+ assertThat(reloaded.getEmail()).isEqualTo("dave@example.com");
+ assertThat(reloaded.getPassword()).isEqualTo("hash-dave");
+ assertThat(reloaded.isEnabled()).isFalse();
+ }
+
+ @Test
+ void _08_ShouldThrowDuplicateKeyException_WhenEmailDiffersOnlyByCaseFromAnExistingOne() {
+ User duplicate = new User(null, "Alice", "Clone", "ALICE@EXAMPLE.COM", "hash-clone", false, false);
+
+ assertThatThrownBy(() -> userDao.insert(duplicate)).isInstanceOf(DuplicateKeyException.class);
+ }
+
+ @Test
+ void _09_ShouldStoreNewHash_WhenPasswordIsUpdated() {
+ userDao.updatePassword(2L, "new-hash");
+
+ assertThat(userDao.findById(2L).orElseThrow().getPassword()).isEqualTo("new-hash");
+ }
+
+ @Test
+ void _10_ShouldEnableAccount_WhenEnabledIsUpdated() {
+ userDao.updateEnabled(2L, true);
+
+ assertThat(userDao.findById(2L).orElseThrow().isEnabled()).isTrue();
+ }
+
+ @Test
+ void _11_ShouldReturnRoleNamesSorted_WhenUserHasRoles() {
+ assertThat(userDao.findRoleNamesByUserId(1L)).containsExactly("ADMIN", "EDITOR");
+ }
+
+ @Test
+ void _12_ShouldReturnDistinctSortedPermissionCodes_WhenTwoRolesGrantTheSamePermission() {
+ // ADMIN grants USER:READ, USER:WRITE, ROLE:READ and EDITOR grants USER:READ again
+ assertThat(userDao.findPermissionCodesByUserId(1L)).containsExactly("ROLE:READ", "USER:READ", "USER:WRITE");
+ }
+
+ @Test
+ void _13_ShouldReturnEmptyLists_WhenUserHasNoRole() {
+ assertThat(userDao.findRoleNamesByUserId(2L)).isEmpty();
+ assertThat(userDao.findPermissionCodesByUserId(2L)).isEmpty();
+ }
+}
diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapperTest.java
new file mode 100644
index 0000000..d0e1096
--- /dev/null
+++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapperTest.java
@@ -0,0 +1,62 @@
+package com.edgareldy.springjdbctutorial.core.auth.mapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests ActivationTokenRowMapper against a mocked ResultSet, including a NULL validated_at.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+class ActivationTokenRowMapperTest {
+
+ private static final Instant CREATED = Instant.parse("2026-09-19T10:00:00Z");
+ private static final Instant EXPIRES = Instant.parse("2026-09-20T10:00:00Z");
+
+ private final ActivationTokenRowMapper mapper = new ActivationTokenRowMapper();
+
+ private ResultSet baseRow() throws SQLException {
+ ResultSet rs = mock(ResultSet.class);
+ when(rs.getLong("id")).thenReturn(3L);
+ when(rs.getLong("user_id")).thenReturn(9L);
+ when(rs.getString("token")).thenReturn("hash");
+ when(rs.getTimestamp("created_at")).thenReturn(Timestamp.from(CREATED));
+ when(rs.getTimestamp("expires_at")).thenReturn(Timestamp.from(EXPIRES));
+ return rs;
+ }
+
+ @Test
+ void _01_ShouldMapEveryColumn_WhenTokenIsValidated() throws SQLException {
+ ResultSet rs = baseRow();
+ Instant validated = Instant.parse("2026-09-19T11:00:00Z");
+ when(rs.getTimestamp("validated_at")).thenReturn(Timestamp.from(validated));
+
+ ActivationToken token = mapper.mapRow(rs, 0);
+
+ assertThat(token.getId()).isEqualTo(3L);
+ assertThat(token.getUserId()).isEqualTo(9L);
+ assertThat(token.getToken()).isEqualTo("hash");
+ assertThat(token.getCreatedAt()).isEqualTo(CREATED);
+ assertThat(token.getExpiresAt()).isEqualTo(EXPIRES);
+ assertThat(token.getValidatedAt()).isEqualTo(validated);
+ }
+
+ @Test
+ void _02_ShouldMapNullValidationDate_WhenColumnIsNull() throws SQLException {
+ ResultSet rs = baseRow();
+ when(rs.getTimestamp("validated_at")).thenReturn(null);
+
+ assertThat(mapper.mapRow(rs, 0).getValidatedAt()).isNull();
+ }
+}
diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapperTest.java
new file mode 100644
index 0000000..627d8fa
--- /dev/null
+++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapperTest.java
@@ -0,0 +1,67 @@
+package com.edgareldy.springjdbctutorial.core.auth.mapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests BlacklistedTokenRowMapper against a mocked ResultSet, including a NULL validated_at.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+class BlacklistedTokenRowMapperTest {
+
+ private static final Instant BLACKLISTED = Instant.parse("2026-09-19T10:30:00Z");
+ private static final Instant CREATED = Instant.parse("2026-09-19T10:00:00Z");
+ private static final Instant EXPIRES = Instant.parse("2026-09-19T11:00:00Z");
+
+ private final BlacklistedTokenRowMapper mapper = new BlacklistedTokenRowMapper();
+
+ private ResultSet baseRow() throws SQLException {
+ ResultSet rs = mock(ResultSet.class);
+ when(rs.getLong("id")).thenReturn(4L);
+ when(rs.getLong("user_id")).thenReturn(9L);
+ when(rs.getString("token")).thenReturn("hash");
+ when(rs.getString("jti")).thenReturn("jti-1");
+ when(rs.getTimestamp("blacklisted_at")).thenReturn(Timestamp.from(BLACKLISTED));
+ when(rs.getTimestamp("created_at")).thenReturn(Timestamp.from(CREATED));
+ when(rs.getTimestamp("expires_at")).thenReturn(Timestamp.from(EXPIRES));
+ return rs;
+ }
+
+ @Test
+ void _01_ShouldMapEveryColumn_WhenValidationDateIsSet() throws SQLException {
+ ResultSet rs = baseRow();
+ Instant validated = Instant.parse("2026-09-19T10:45:00Z");
+ when(rs.getTimestamp("validated_at")).thenReturn(Timestamp.from(validated));
+
+ BlacklistedToken token = mapper.mapRow(rs, 0);
+
+ assertThat(token.getId()).isEqualTo(4L);
+ assertThat(token.getUserId()).isEqualTo(9L);
+ assertThat(token.getToken()).isEqualTo("hash");
+ assertThat(token.getJti()).isEqualTo("jti-1");
+ assertThat(token.getBlacklistedAt()).isEqualTo(BLACKLISTED);
+ assertThat(token.getCreatedAt()).isEqualTo(CREATED);
+ assertThat(token.getExpiresAt()).isEqualTo(EXPIRES);
+ assertThat(token.getValidatedAt()).isEqualTo(validated);
+ }
+
+ @Test
+ void _02_ShouldMapNullValidationDate_WhenColumnIsNull() throws SQLException {
+ ResultSet rs = baseRow();
+ when(rs.getTimestamp("validated_at")).thenReturn(null);
+
+ assertThat(mapper.mapRow(rs, 0).getValidatedAt()).isNull();
+ }
+}
diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapperTest.java
new file mode 100644
index 0000000..9a9d1f7
--- /dev/null
+++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapperTest.java
@@ -0,0 +1,52 @@
+package com.edgareldy.springjdbctutorial.core.auth.mapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests PasswordResetTokenRowMapper against a mocked ResultSet, including a NULL expiry_date.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+class PasswordResetTokenRowMapperTest {
+
+ private final PasswordResetTokenRowMapper mapper = new PasswordResetTokenRowMapper();
+
+ @Test
+ void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException {
+ Instant expiry = Instant.parse("2026-09-19T11:00:00Z");
+ ResultSet rs = mock(ResultSet.class);
+ when(rs.getLong("id")).thenReturn(5L);
+ when(rs.getLong("user_id")).thenReturn(9L);
+ when(rs.getString("token")).thenReturn("hash");
+ when(rs.getString("type")).thenReturn("PASSWORD_RESET");
+ when(rs.getTimestamp("expiry_date")).thenReturn(Timestamp.from(expiry));
+
+ PasswordResetToken token = mapper.mapRow(rs, 0);
+
+ assertThat(token.getId()).isEqualTo(5L);
+ assertThat(token.getUserId()).isEqualTo(9L);
+ assertThat(token.getToken()).isEqualTo("hash");
+ assertThat(token.getType()).isEqualTo("PASSWORD_RESET");
+ assertThat(token.getExpiryDate()).isEqualTo(expiry);
+ }
+
+ @Test
+ void _02_ShouldMapNullExpiry_WhenColumnIsNull() throws SQLException {
+ ResultSet rs = mock(ResultSet.class);
+ when(rs.getTimestamp("expiry_date")).thenReturn(null);
+
+ assertThat(mapper.mapRow(rs, 0).getExpiryDate()).isNull();
+ }
+}
diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapperTest.java
new file mode 100644
index 0000000..5a0cc14
--- /dev/null
+++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapperTest.java
@@ -0,0 +1,64 @@
+package com.edgareldy.springjdbctutorial.core.auth.mapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto;
+import com.edgareldy.springjdbctutorial.core.auth.entity.User;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests UserMapper (entity to dto and back) on hand-built objects, no mocks.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+class UserMapperTest {
+
+ private final UserMapper mapper = new UserMapper();
+
+ @Test
+ void _01_ShouldCopyProfileFieldsButNeverThePassword_WhenConvertingEntityToDto() {
+ User user = new User(1L, "Alice", "Martin", "alice@example.com", "secret-hash", true, false);
+
+ UserDto dto = mapper.toDto(user);
+
+ assertThat(dto.getId()).isEqualTo(1L);
+ assertThat(dto.getFirstName()).isEqualTo("Alice");
+ assertThat(dto.getLastName()).isEqualTo("Martin");
+ assertThat(dto.getEmail()).isEqualTo("alice@example.com");
+ assertThat(dto.isEnabled()).isTrue();
+ assertThat(dto.isAccountLocked()).isFalse();
+ assertThat(dto.getPassword()).isNull();
+ assertThat(dto.getRoles()).isEmpty();
+ assertThat(dto.getPermissions()).isEmpty();
+ }
+
+ @Test
+ void _02_ShouldReturnNull_WhenEntityIsNull() {
+ assertThat(mapper.toDto(null)).isNull();
+ }
+
+ @Test
+ void _03_ShouldUseTheGivenHashAndIgnoreTheDtoPassword_WhenConvertingDtoToEntity() {
+ UserDto dto = new UserDto(2L, "Bob", "Durand", "bob@example.com", "clear-password", false, true,
+ List.of("ADMIN"), List.of("USER:READ"));
+
+ User user = mapper.toEntity(dto, "the-bcrypt-hash");
+
+ assertThat(user.getId()).isEqualTo(2L);
+ assertThat(user.getFirstName()).isEqualTo("Bob");
+ assertThat(user.getLastName()).isEqualTo("Durand");
+ assertThat(user.getEmail()).isEqualTo("bob@example.com");
+ assertThat(user.getPassword()).isEqualTo("the-bcrypt-hash");
+ assertThat(user.isEnabled()).isFalse();
+ assertThat(user.isAccountLocked()).isTrue();
+ }
+
+ @Test
+ void _04_ShouldReturnNull_WhenDtoIsNull() {
+ assertThat(mapper.toEntity(null, "hash")).isNull();
+ }
+}
diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapperTest.java
new file mode 100644
index 0000000..90087f9
--- /dev/null
+++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapperTest.java
@@ -0,0 +1,60 @@
+package com.edgareldy.springjdbctutorial.core.auth.mapper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.auth.entity.User;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests UserRowMapper against a mocked ResultSet, without any database.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+class UserRowMapperTest {
+
+ private final UserRowMapper mapper = new UserRowMapper();
+
+ // A RowMapper only reads columns from the ResultSet it is given. Mockito.mock(ResultSet.class)
+ // creates a fake one whose getters return what the test stubs, column by column: no database, no
+ // driver, so the test only checks the column-to-field mapping.
+ @Test
+ void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException {
+ ResultSet rs = mock(ResultSet.class);
+ when(rs.getLong("id")).thenReturn(7L);
+ when(rs.getString("first_name")).thenReturn("Alice");
+ when(rs.getString("last_name")).thenReturn("Martin");
+ when(rs.getString("email")).thenReturn("alice@example.com");
+ when(rs.getString("password")).thenReturn("hash");
+ when(rs.getBoolean("enabled")).thenReturn(true);
+ when(rs.getBoolean("account_locked")).thenReturn(false);
+
+ User user = mapper.mapRow(rs, 0);
+
+ assertThat(user.getId()).isEqualTo(7L);
+ assertThat(user.getFirstName()).isEqualTo("Alice");
+ assertThat(user.getLastName()).isEqualTo("Martin");
+ assertThat(user.getEmail()).isEqualTo("alice@example.com");
+ assertThat(user.getPassword()).isEqualTo("hash");
+ assertThat(user.isEnabled()).isTrue();
+ assertThat(user.isAccountLocked()).isFalse();
+ }
+
+ @Test
+ void _02_ShouldMapLockedAndDisabledFlags_WhenColumnsAreInverted() throws SQLException {
+ ResultSet rs = mock(ResultSet.class);
+ when(rs.getBoolean("enabled")).thenReturn(false);
+ when(rs.getBoolean("account_locked")).thenReturn(true);
+
+ User user = mapper.mapRow(rs, 0);
+
+ assertThat(user.isEnabled()).isFalse();
+ assertThat(user.isAccountLocked()).isTrue();
+ }
+}
diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java
new file mode 100644
index 0000000..493768a
--- /dev/null
+++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java
@@ -0,0 +1,525 @@
+package com.edgareldy.springjdbctutorial.core.auth.service.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao;
+import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao;
+import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao;
+import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao;
+import com.edgareldy.springjdbctutorial.core.auth.dto.BlacklistedTokenDto;
+import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto;
+import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken;
+import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken;
+import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken;
+import com.edgareldy.springjdbctutorial.core.auth.entity.User;
+import com.edgareldy.springjdbctutorial.core.auth.exception.AccountNotActiveException;
+import com.edgareldy.springjdbctutorial.core.auth.exception.InvalidCredentialsException;
+import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException;
+import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+/**
+ * Unit tests of AuthServiceImpl: every DAO is a Mockito mock, the password encoder is a real
+ * low-cost BCryptPasswordEncoder and the clock is fixed, so expiry rules are deterministic.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+// MockitoExtension creates the @Mock fields before each test and fails a test whose stubbing is never
+// used (strict stubs), which keeps the tests free of dead set-up. No Spring context is started.
+@ExtendWith(MockitoExtension.class)
+class AuthServiceImplTest {
+
+ private static final Instant NOW = Instant.parse("2026-09-19T12:00:00Z");
+ private static final String PASSWORD = "Secret123!";
+ private static final String HEX_64 = "[0-9a-f]{64}";
+
+ @Mock
+ private UserDao userDao;
+ @Mock
+ private ActivationTokenDao activationTokenDao;
+ @Mock
+ private PasswordResetTokenDao passwordResetTokenDao;
+ @Mock
+ private BlacklistedTokenDao blacklistedTokenDao;
+
+ // A spy wraps the real encoder (cost 4 keeps the tests fast) and records the calls made on it
+ private final PasswordEncoder encoder = spy(new BCryptPasswordEncoder(4));
+
+ // Computed by a separate encoder, so building a User inside a when(...) never calls the spy
+ private final String passwordHash = new BCryptPasswordEncoder(4).encode(PASSWORD);
+
+ private AuthServiceImpl service;
+
+ @BeforeEach
+ void setUp() {
+ service = new AuthServiceImpl(userDao, activationTokenDao, passwordResetTokenDao, blacklistedTokenDao,
+ encoder, Clock.fixed(NOW, ZoneOffset.UTC));
+ }
+
+ private static String sha256(String value) throws Exception {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
+ .digest(value.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ private static UserDto registration(String email, String password) {
+ UserDto dto = new UserDto();
+ dto.setFirstName("Alice");
+ dto.setLastName("Martin");
+ dto.setEmail(email);
+ dto.setPassword(password);
+ return dto;
+ }
+
+ private User storedUser(boolean enabled, boolean locked) {
+ return new User(5L, "Alice", "Martin", "alice@example.com", passwordHash, enabled, locked);
+ }
+
+ private void stubInsertAssigningId() {
+ when(userDao.insert(any(User.class))).thenAnswer(invocation -> {
+ User user = invocation.getArgument(0);
+ user.setId(42L);
+ return user;
+ });
+ }
+
+ // ---------------------------------------------------------------- register
+
+ @Test
+ void _01_ShouldCreateDisabledUserWithHashedPassword_WhenRegistrationIsValid() {
+ stubInsertAssigningId();
+
+ UserDto created = service.register(registration("alice@example.com", PASSWORD));
+
+ assertThat(created.getId()).isEqualTo(42L);
+ assertThat(created.getPassword()).isNull();
+ assertThat(created.isEnabled()).isFalse();
+ ArgumentCaptor
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+class ExpiredTokenCleanupJobTest {
+
+ @Test
+ void _01_ShouldPurgeExpiredTokens_WhenRunIsCalled() {
+ AuthService authService = mock(AuthService.class);
+ when(authService.purgeExpiredTokens()).thenReturn(2);
+
+ new ExpiredTokenCleanupJob(authService).run();
+
+ verify(authService).purgeExpiredTokens();
+ }
+}
diff --git a/core/auth/src/test/resources/activation-token-dao-dataset.sql b/core/auth/src/test/resources/activation-token-dao-dataset.sql
new file mode 100644
index 0000000..304a4ab
--- /dev/null
+++ b/core/auth/src/test/resources/activation-token-dao-dataset.sql
@@ -0,0 +1,14 @@
+-- Fixture of ActivationTokenDaoImplTest only. Wipes the identity tables, then loads two users (1 and 2)
+-- and two activation tokens: 'a1...' for user 1 (not validated), 'b2...' for user 2 (already validated).
+TRUNCATE TABLE audit_logs, users, roles, permissions RESTART IDENTITY CASCADE;
+
+INSERT INTO users (id, first_name, last_name, email, password, enabled, account_locked) VALUES
+ (1, 'Alice', 'Martin', 'alice@example.com', 'hash-1', FALSE, FALSE),
+ (2, 'Bob', 'Durand', 'bob@example.com', 'hash-2', TRUE, FALSE);
+
+INSERT INTO activation_tokens (id, user_id, token, created_at, expires_at, validated_at) VALUES
+ (1, 1, repeat('a1', 32), '2026-09-19T10:00:00Z', '2026-09-20T10:00:00Z', NULL),
+ (2, 2, repeat('b2', 32), '2026-09-18T10:00:00Z', '2026-09-19T10:00:00Z', '2026-09-18T11:00:00Z');
+
+ALTER SEQUENCE users_id_seq RESTART WITH 100;
+ALTER SEQUENCE activation_tokens_id_seq RESTART WITH 100;
diff --git a/core/auth/src/test/resources/blacklisted-token-dao-dataset.sql b/core/auth/src/test/resources/blacklisted-token-dao-dataset.sql
new file mode 100644
index 0000000..d3cc860
--- /dev/null
+++ b/core/auth/src/test/resources/blacklisted-token-dao-dataset.sql
@@ -0,0 +1,14 @@
+-- Fixture of BlacklistedTokenDaoImplTest only. Wipes the identity tables, then loads one user and three
+-- blacklisted tokens: two expired before 2026-09-19T00:00:00Z ('e5...', 'f6...') and one still live ('a7...').
+TRUNCATE TABLE audit_logs, users, roles, permissions RESTART IDENTITY CASCADE;
+
+INSERT INTO users (id, first_name, last_name, email, password, enabled, account_locked) VALUES
+ (1, 'Alice', 'Martin', 'alice@example.com', 'hash-1', TRUE, FALSE);
+
+INSERT INTO blacklisted_tokens (id, user_id, token, jti, blacklisted_at, created_at, expires_at, validated_at) VALUES
+ (1, 1, repeat('e5', 32), 'jti-expired-1', '2026-09-17T10:30:00Z', '2026-09-17T10:00:00Z', '2026-09-17T11:00:00Z', NULL),
+ (2, 1, repeat('f6', 32), 'jti-expired-2', '2026-09-17T12:30:00Z', '2026-09-17T12:00:00Z', '2026-09-17T13:00:00Z', NULL),
+ (3, 1, repeat('a7', 32), 'jti-live', '2026-09-19T10:30:00Z', '2026-09-19T10:00:00Z', '2026-09-19T11:00:00Z', NULL);
+
+ALTER SEQUENCE users_id_seq RESTART WITH 100;
+ALTER SEQUENCE blacklisted_tokens_id_seq RESTART WITH 100;
diff --git a/core/auth/src/test/resources/password-reset-token-dao-dataset.sql b/core/auth/src/test/resources/password-reset-token-dao-dataset.sql
new file mode 100644
index 0000000..895ae6b
--- /dev/null
+++ b/core/auth/src/test/resources/password-reset-token-dao-dataset.sql
@@ -0,0 +1,14 @@
+-- Fixture of PasswordResetTokenDaoImplTest only. Wipes the identity tables, then loads two users and
+-- one reset token for each ('c3...' for user 1, 'd4...' for user 2).
+TRUNCATE TABLE audit_logs, users, roles, permissions RESTART IDENTITY CASCADE;
+
+INSERT INTO users (id, first_name, last_name, email, password, enabled, account_locked) VALUES
+ (1, 'Alice', 'Martin', 'alice@example.com', 'hash-1', TRUE, FALSE),
+ (2, 'Bob', 'Durand', 'bob@example.com', 'hash-2', TRUE, FALSE);
+
+INSERT INTO password_reset_tokens (id, user_id, token, type, expiry_date) VALUES
+ (1, 1, repeat('c3', 32), 'PASSWORD_RESET', '2026-09-19T11:00:00Z'),
+ (2, 2, repeat('d4', 32), 'PASSWORD_RESET', '2026-09-19T12:00:00Z');
+
+ALTER SEQUENCE users_id_seq RESTART WITH 100;
+ALTER SEQUENCE password_reset_tokens_id_seq RESTART WITH 100;
diff --git a/core/auth/src/test/resources/user-dao-dataset.sql b/core/auth/src/test/resources/user-dao-dataset.sql
new file mode 100644
index 0000000..854dd4c
--- /dev/null
+++ b/core/auth/src/test/resources/user-dao-dataset.sql
@@ -0,0 +1,22 @@
+-- Fixture of UserDaoImplTest only. Wipes the identity tables, then loads three users, three roles and
+-- three permissions. alice (id 1) holds ADMIN and EDITOR, which both grant USER:READ (tests DISTINCT).
+-- bob (id 2) is disabled with no role, carol (id 3) is enabled but locked.
+TRUNCATE TABLE audit_logs, users, roles, permissions RESTART IDENTITY CASCADE;
+
+INSERT INTO users (id, first_name, last_name, email, password, enabled, account_locked) VALUES
+ (1, 'Alice', 'Martin', 'alice@example.com', '$2a$04$alicealicealicealiceaeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', TRUE, FALSE),
+ (2, 'Bob', 'Durand', 'bob@example.com', '$2a$04$bobbobbobbobbobbobbobeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', FALSE, FALSE),
+ (3, 'Carol', 'Petit', 'carol@example.com', '$2a$04$carolcarolcarolcarolceAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', TRUE, TRUE);
+
+INSERT INTO roles (id, role_name) VALUES (1, 'ADMIN'), (2, 'EDITOR'), (3, 'VIEWER');
+
+INSERT INTO permissions (id, resource, action) VALUES (1, 'USER', 'READ'), (2, 'USER', 'WRITE'), (3, 'ROLE', 'READ');
+
+INSERT INTO role_permission (role_id, permission_id) VALUES (1, 1), (1, 2), (1, 3), (2, 1), (3, 3);
+
+INSERT INTO role_user (role_id, user_id) VALUES (1, 1), (2, 1);
+
+-- Explicit ids do not advance the sequences: move them high so generated ids never collide
+ALTER SEQUENCE users_id_seq RESTART WITH 100;
+ALTER SEQUENCE roles_id_seq RESTART WITH 100;
+ALTER SEQUENCE permissions_id_seq RESTART WITH 100;
diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java
index 1034a89..2b95c2e 100644
--- a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java
+++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java
@@ -9,6 +9,8 @@
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
+import java.time.Clock;
+
/**
* Wiring of the common module: imports the infrastructure and declares the health DAO and service.
*
@@ -33,4 +35,12 @@ public HealthDao healthDao(JdbcTemplate jdbcTemplate) {
public HealthService healthService(HealthDao healthDao) {
return new HealthServiceImpl(healthDao);
}
+
+ // A Clock bean is the injectable source of "now": services call clock.instant() instead of
+ // Instant.now(), so tests can supply Clock.fixed(...) and assert expiry logic deterministically.
+ // UTC because timestamps are stored as timestamptz and never depend on the server zone.
+ @Bean
+ public Clock clock() {
+ return Clock.systemUTC();
+ }
}
diff --git a/dev-keys/README.md b/dev-keys/README.md
new file mode 100644
index 0000000..eaedd25
--- /dev/null
+++ b/dev-keys/README.md
@@ -0,0 +1,13 @@
+# Development JWT keys
+
+RSA 2048 key pair used ONLY for local development and for the automated tests
+(RS256, PKCS#8 private key, X.509 public key, PEM). They are public on purpose:
+never use them anywhere real.
+
+They live here, at the repository root, and never under `src/main/resources`,
+so they are never packaged inside the WAR.
+
+Production keys are generated separately into `./keys` (ignored by git) and
+mounted into the container, see `docker-compose.yml`. There is no default key:
+the application refuses to start without `app.jwt.private-key-location` and
+`app.jwt.public-key-location`.
diff --git a/dev-keys/privateKey.pem b/dev-keys/privateKey.pem
new file mode 100644
index 0000000..088bc9b
--- /dev/null
+++ b/dev-keys/privateKey.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3Oz4mE8oQnmm7
+Zwm+1ZEAmnfJH5C4vRNHQ1Julh9NYNg1Di/QKs+g9skr7DKnQ7uCjAc5t+wE68fU
+CAkhvx9lPYQu6Ewu8OTbJEOUZ4nT9bZxqXxXxXyoAvjnASZPfBwISldkOT/boOtZ
+Gomp24GHjxJrBM1OWWGFx1TxkXH03m+Z/IwEEEETo4HCcpRUvXwLPIiptYA0U+wx
+fx4MF8QafdMOJGNHuigQHu3KGUhJP+pxpRGLVEJLz83igeAvP/BGu1OO3EGPoKkT
+rOSQEOnBZWBCFGqNm14oRFai6j24EFuXQP60xRzyd9H49RhuiX0q2u5IaqYyT2hU
+0Q29YdarAgMBAAECggEAHIhzsZ9ESJk6ks3FVTvRpDvpql6pfTMoVOEVbZYgEr8h
+fAWVCYAZLBIyGo5iBu0Dvp0KpQDQlqWWzRxhO6mU/IF7W4ieEHM/2CZKBcqnymOJ
+gSKo88wTkOBBnUtvCVKk1N+qwzctDAj/jTUWOeauQYtUjWjGhcxiZOA5CJMjIUPb
+6o7ZMQ5ia7K1gnwNucbG2S7Vvwc6AB9gR712wkN0ImxlNSa2QCq5KmG7QVwY7Qc3
+v7b01KL/vRjc4wmQjfpZHhxzmruhE22sx+mv62fSiouzsc2kG9NoxJoQV/Thp33O
+GBFElNQFCgipUjmUAkUNrEdOODcutgt3MNWglrb+JQKBgQC5OzgwCbmmM5xqYU23
+zzXA4y2xitTSmBV1sjYNdZ6K8bqOP+ZlQRU7OzklR09DXBG4Yzy5WCFyiZIWUT8l
+5FGqB2A/zNhPWLdBkogsyCxkLuKtvaKUKorusyxl7VPmnUWlruSRE3SEly+5d4fb
+dDM74iKmslmToJiW7TCUMylw1QKBgQD9PGtw42JR7WHHd8f7EPZgMEGJs2DvP2v+
+Y7/Aj/8TqTUPto1GPBmY7xA107C9S2TvfwgrW+1uUMgCJWxtq8WfX7Ae7atNYfm9
+2w9GW99n5pAJSNkWZ0pdnz1eI9cxh/+PvqlYnpP87nwswGqcamzuz3Y7bqYZ+dka
+RIz8bB3pfwKBgQCfx6mMJpdx+osuaIj1yVQA8vZzc+JpVq8MO5HMwmzkReg61HwV
+U7LQySFeOsjwvfa7Kz8yUgdoGatefO8P7IdVN8+auUTODSTm91Nn7bP9J0R5wl7/
+mEMH7J6bvcDl6Twu01Juom2cAh40yhry/qVDPqnNSYNjiOCXMDr7W7afkQKBgQDo
+h1xY0lJrzD+mu1REM/UnqefkG3R8+42cj9d5aD/eu9bfUM80mOcgSRRneLSnfN14
+FPtZZRYF3+NoJRkFM19J0sGUBMTxT2/VZ5sFDkN70nicLeo46iudACWHlf15m9mN
+EpPJrgNRk2MubJj4e7j/KDz2ocmfvI4f0qZq+pQU3QKBgE8Gg5Om86xWMogd/J4v
+ZpDGT7/6Xhx3/TfR7ivG18j2YYs9CWb5MRiiQnaIomSEE1hzk9d3CFtoMt8qiLij
+9pp6wzo+WxAyE4OaVf5ZctKWWRNvVhcXwB0YrmVW+ibAr9vjfY3vpMHpi3isB34c
+1vwLf8kaiglQg9iNITXB7n1b
+-----END PRIVATE KEY-----
diff --git a/dev-keys/publicKey.pem b/dev-keys/publicKey.pem
new file mode 100644
index 0000000..9445c3d
--- /dev/null
+++ b/dev-keys/publicKey.pem
@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtzs+JhPKEJ5pu2cJvtWR
+AJp3yR+QuL0TR0NSbpYfTWDYNQ4v0CrPoPbJK+wyp0O7gowHObfsBOvH1AgJIb8f
+ZT2ELuhMLvDk2yRDlGeJ0/W2cal8V8V8qAL45wEmT3wcCEpXZDk/26DrWRqJqduB
+h48SawTNTllhhcdU8ZFx9N5vmfyMBBBBE6OBwnKUVL18CzyIqbWANFPsMX8eDBfE
+Gn3TDiRjR7ooEB7tyhlIST/qcaURi1RCS8/N4oHgLz/wRrtTjtxBj6CpE6zkkBDp
+wWVgQhRqjZteKERWouo9uBBbl0D+tMUc8nfR+PUYbol9KtruSGqmMk9oVNENvWHW
+qwIDAQAB
+-----END PUBLIC KEY-----
diff --git a/docker-compose.yml b/docker-compose.yml
index d19c854..a845601 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,7 +3,7 @@
#
# Before the first "docker compose up --build":
# 1. cp .env.example .env and set DB_PASSWORD (there is no default password on purpose)
-# 2. from the feature/auth branch on, create a production RSA key pair in ./keys (the keys under
+# 2. create a production RSA key pair in ./keys (the keys under
# dev-keys/ are for development only):
# mkdir -p keys
# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out keys/privateKey.pem
@@ -40,6 +40,11 @@ services:
APP_DATASOURCE_URL: jdbc:postgresql://db:5432/${DB_NAME:-spring_jdbc_tutorial}
APP_DATASOURCE_USERNAME: ${DB_USER:-spring}
APP_DATASOURCE_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env}
+ # No default key: the application refuses to start without these two locations
+ APP_JWT_PRIVATE_KEY_LOCATION: file:/keys/privateKey.pem
+ APP_JWT_PUBLIC_KEY_LOCATION: file:/keys/publicKey.pem
+ volumes:
+ - ./keys:/keys:ro
ports:
- "${APP_PORT:-8080}:8080"
diff --git a/ws/pom.xml b/ws/pom.xml
index 2136e9f..0e6e161 100644
--- a/ws/pom.xml
+++ b/ws/pom.xml
@@ -68,6 +68,30 @@
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+// @EnableWebSecurity registers the Spring Security infrastructure and the servlet filter named
+// "springSecurityFilterChain" (a FilterChainProxy) that the DelegatingFilterProxy of WebAppInitializer
+// delegates to. Every SecurityFilterChain bean declared here becomes one ordered list of security filters.
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+ // Read from the Environment: the two key locations have no default (startup fails clearly when
+ // missing), expiration and issuer have harmless defaults.
+ @Bean
+ public JwtService jwtService(Environment environment, ResourceLoader resourceLoader, Clock clock) {
+ return new JwtService(resourceLoader,
+ "app.jwt.private-key-location", environment.getProperty("app.jwt.private-key-location"),
+ "app.jwt.public-key-location", environment.getProperty("app.jwt.public-key-location"),
+ environment.getProperty("app.jwt.expiration-seconds", Long.class, 3600L),
+ environment.getProperty("app.jwt.issuer", "spring-jdbc-tutorial"),
+ clock);
+ }
+
+ @Bean
+ public AuthenticationEntryPoint apiAuthenticationEntryPoint(ObjectMapper objectMapper) {
+ return new ApiAuthenticationEntryPoint(objectMapper);
+ }
+
+ @Bean
+ public AccessDeniedHandler apiAccessDeniedHandler(ObjectMapper objectMapper) {
+ return new ApiAccessDeniedHandler(objectMapper);
+ }
+
+ // The SecurityFilterChain describes how requests are protected. This is a token API: CSRF protection
+ // guards browser sessions and cookies, so it is disabled here, and the chain is STATELESS (no
+ // HttpSession is created or read, every request must carry its own Bearer JWT). JwtAuthFilter runs
+ // before UsernamePasswordAuthenticationFilter so the SecurityContext is filled before authorization.
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtService jwtService,
+ AuthService authService,
+ AuthenticationEntryPoint apiAuthenticationEntryPoint,
+ AccessDeniedHandler apiAccessDeniedHandler) throws Exception {
+ http.csrf(AbstractHttpConfigurer::disable)
+ .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers(HttpMethod.POST, "/api/v1/auth/register").permitAll()
+ .requestMatchers(HttpMethod.GET, "/api/v1/auth/activate-account").permitAll()
+ .requestMatchers(HttpMethod.POST, "/api/v1/auth/login").permitAll()
+ .requestMatchers(HttpMethod.POST, "/api/v1/auth/forgot-password").permitAll()
+ .requestMatchers(HttpMethod.POST, "/api/v1/auth/reset-password").permitAll()
+ .requestMatchers(HttpMethod.GET, "/api/v1/health").permitAll()
+ .anyRequest().authenticated())
+ .exceptionHandling(e -> e
+ .authenticationEntryPoint(apiAuthenticationEntryPoint)
+ .accessDeniedHandler(apiAccessDeniedHandler))
+ .addFilterBefore(new JwtAuthFilter(jwtService, authService), UsernamePasswordAuthenticationFilter.class);
+ return http.build();
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java
index 9bec963..502823d 100644
--- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java
@@ -7,6 +7,7 @@
import org.springframework.web.context.ContextLoaderListener;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.filter.CharacterEncodingFilter;
+import org.springframework.web.filter.DelegatingFilterProxy;
import org.springframework.web.servlet.DispatcherServlet;
import java.util.EnumSet;
@@ -45,5 +46,16 @@ public void onStartup(ServletContext servletContext) {
servletContext.addFilter("characterEncodingFilter", new CharacterEncodingFilter("UTF-8", true));
encoding.setAsyncSupported(true);
encoding.addMappingForUrlPatterns(EnumSet.of(jakarta.servlet.DispatcherType.REQUEST), false, "/*");
+
+ // DelegatingFilterProxy is a thin servlet filter that looks up the Spring bean named
+ // "springSecurityFilterChain" (created by @EnableWebSecurity) in the ROOT context and delegates
+ // to it. That is why the one context must be the root context too (ContextLoaderListener): the
+ // servlet container instantiates the filter, only Spring can build the security chain. It is
+ // registered after the encoding filter, on every dispatch type incl. ERROR and ASYNC.
+ FilterRegistration.Dynamic security =
+ servletContext.addFilter("springSecurityFilterChain", new DelegatingFilterProxy("springSecurityFilterChain"));
+ security.setAsyncSupported(true);
+ security.addMappingForUrlPatterns(EnumSet.of(jakarta.servlet.DispatcherType.REQUEST,
+ jakarta.servlet.DispatcherType.ERROR, jakarta.servlet.DispatcherType.ASYNC), false, "/*");
}
}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java
index 1c93d58..295e659 100644
--- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java
@@ -29,28 +29,35 @@
// plain Spring, without Boot. The WebMvcConfigurer callbacks below customise it.
//
// Core module configs are imported here in the SAME context (one shared context). CommonConfig comes
-// first; each later branch adds its module's DaoConfig and ServiceConfig to the @Import list.
+// first; each later branch adds its module's ServiceConfig (which imports its own DaoConfig) to the @Import
+// list, fully qualified because every module uses the same simple names. SecurityConfig is imported too.
//
// The component scan is limited to the web layer (controllers and the advice): those are the only
// stereotypes of the project. DAO and service implementations are never scanned, they are explicit
// @Bean methods, so nothing gets wired by accident.
@Configuration
@EnableWebMvc
-@Import(CommonConfig.class)
+@Import({CommonConfig.class, com.edgareldy.springjdbctutorial.core.auth.config.ServiceConfig.class, SecurityConfig.class})
@ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"})
public class WebMvcConfig implements WebMvcConfigurer {
- // Swaps the default Jackson converter for one built on our ObjectMapper and keeps every other
- // default converter (String, byte[], Resource...). The builder registers the JSR-310 module
- // (jackson-datatype-jsr310) found on the classpath; disabling WRITE_DATES_AS_TIMESTAMPS makes
- // Instant serialise as ISO-8601 text instead of a number. Null fields stay in the output.
- @Override
- public void extendMessageConverters(List
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+// @AuthenticationPrincipal injects the principal the JwtAuthFilter put in the SecurityContext.
+@RestController
+@RequestMapping("/api/v1/auth")
+public class AuthController {
+
+ private final AuthService authService;
+ private final JwtService jwtService;
+
+ public AuthController(AuthService authService, JwtService jwtService) {
+ this.authService = authService;
+ this.jwtService = jwtService;
+ }
+
+ @PostMapping("/register")
+ @ResponseStatus(HttpStatus.CREATED)
+ public ApiResponse
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public final class UserConverter {
+
+ private UserConverter() {
+ }
+
+ public static UserDto toDto(RegisterRequest request) {
+ UserDto dto = new UserDto();
+ dto.setFirstName(request.getFirstName());
+ dto.setLastName(request.getLastName());
+ dto.setEmail(request.getEmail());
+ dto.setPassword(request.getPassword());
+ return dto;
+ }
+
+ public static UserResponse toResponse(UserDto dto) {
+ return new UserResponse(dto.getId(), dto.getFirstName(), dto.getLastName(), dto.getEmail(),
+ dto.isEnabled(), dto.getRoles(), dto.getPermissions());
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java
index 47ddacc..393f832 100644
--- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java
@@ -1,5 +1,6 @@
package com.edgareldy.springjdbctutorial.ws.exception;
+import com.edgareldy.springjdbctutorial.core.auth.exception.AuthenticationFailedException;
import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException;
import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException;
import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse;
@@ -11,6 +12,8 @@
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.FieldError;
import org.springframework.web.ErrorResponse;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
@@ -126,8 +129,24 @@ public ResponseEntity
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ForgotPasswordRequest {
+
+ @NotBlank
+ private String email;
+
+ public ForgotPasswordRequest() {
+ }
+
+ public ForgotPasswordRequest(String email) {
+ this.email = email;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ ForgotPasswordRequest that = (ForgotPasswordRequest) o;
+ return Objects.equals(email, that.email);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(email);
+ }
+
+ @Override
+ public String toString() {
+ return "ForgotPasswordRequest{" + "email=" + email + "}";
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginRequest.java
new file mode 100644
index 0000000..e421172
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginRequest.java
@@ -0,0 +1,68 @@
+package com.edgareldy.springjdbctutorial.ws.payload.auth;
+
+import jakarta.validation.constraints.NotBlank;
+import java.util.Objects;
+
+/**
+ * Body of POST /api/v1/auth/login. The password is masked in toString.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class LoginRequest {
+
+ @NotBlank
+ private String email;
+
+ @NotBlank
+ private String password;
+
+ public LoginRequest() {
+ }
+
+ public LoginRequest(String email, String password) {
+ this.email = email;
+ this.password = password;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ LoginRequest that = (LoginRequest) o;
+ return Objects.equals(email, that.email)
+ && Objects.equals(password, that.password);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(email, password);
+ }
+
+ @Override
+ public String toString() {
+ return "LoginRequest{" + "email=" + email + ", password=" + (password == null ? "null" : "****") + "}";
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginResponse.java
new file mode 100644
index 0000000..b8810d2
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginResponse.java
@@ -0,0 +1,78 @@
+package com.edgareldy.springjdbctutorial.ws.payload.auth;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Result of a successful login: the Bearer JWT (masked in toString), its type and its expiry.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class LoginResponse {
+
+ private String token;
+
+ private String tokenType;
+
+ private Instant expiresAt;
+
+ public LoginResponse() {
+ }
+
+ public LoginResponse(String token, String tokenType, Instant expiresAt) {
+ this.token = token;
+ this.tokenType = tokenType;
+ this.expiresAt = expiresAt;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public void setToken(String token) {
+ this.token = token;
+ }
+
+ public String getTokenType() {
+ return tokenType;
+ }
+
+ public void setTokenType(String tokenType) {
+ this.tokenType = tokenType;
+ }
+
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ public void setExpiresAt(Instant expiresAt) {
+ this.expiresAt = expiresAt;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ LoginResponse that = (LoginResponse) o;
+ return Objects.equals(token, that.token)
+ && Objects.equals(tokenType, that.tokenType)
+ && Objects.equals(expiresAt, that.expiresAt);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(token, tokenType, expiresAt);
+ }
+
+ @Override
+ public String toString() {
+ return "LoginResponse{" + "token=" + (token == null ? "null" : "****") + ", tokenType=" + tokenType + ", expiresAt=" + expiresAt + "}";
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/RegisterRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/RegisterRequest.java
new file mode 100644
index 0000000..43b6e9e
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/RegisterRequest.java
@@ -0,0 +1,101 @@
+package com.edgareldy.springjdbctutorial.ws.payload.auth;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import java.util.Objects;
+
+/**
+ * Body of POST /api/v1/auth/register. The password is masked in toString.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class RegisterRequest {
+
+ @NotBlank
+ @Size(max = 100)
+ private String firstName;
+
+ @NotBlank
+ @Size(max = 100)
+ private String lastName;
+
+ @NotBlank
+ @Email
+ @Size(max = 255)
+ private String email;
+
+ @NotBlank
+ @Size(min = 8, max = 72)
+ private String password;
+
+ public RegisterRequest() {
+ }
+
+ public RegisterRequest(String firstName, String lastName, String email, String password) {
+ this.firstName = firstName;
+ this.lastName = lastName;
+ this.email = email;
+ this.password = password;
+ }
+
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+
+ public String getLastName() {
+ return lastName;
+ }
+
+ public void setLastName(String lastName) {
+ this.lastName = lastName;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ RegisterRequest that = (RegisterRequest) o;
+ return Objects.equals(firstName, that.firstName)
+ && Objects.equals(lastName, that.lastName)
+ && Objects.equals(email, that.email)
+ && Objects.equals(password, that.password);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(firstName, lastName, email, password);
+ }
+
+ @Override
+ public String toString() {
+ return "RegisterRequest{" + "firstName=" + firstName + ", lastName=" + lastName + ", email=" + email + ", password=" + (password == null ? "null" : "****") + "}";
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ResetPasswordRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ResetPasswordRequest.java
new file mode 100644
index 0000000..596794a
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ResetPasswordRequest.java
@@ -0,0 +1,70 @@
+package com.edgareldy.springjdbctutorial.ws.payload.auth;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import java.util.Objects;
+
+/**
+ * Body of POST /api/v1/auth/reset-password. Token and new password are masked in toString.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ResetPasswordRequest {
+
+ @NotBlank
+ private String token;
+
+ @NotBlank
+ @Size(min = 8, max = 72)
+ private String newPassword;
+
+ public ResetPasswordRequest() {
+ }
+
+ public ResetPasswordRequest(String token, String newPassword) {
+ this.token = token;
+ this.newPassword = newPassword;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public void setToken(String token) {
+ this.token = token;
+ }
+
+ public String getNewPassword() {
+ return newPassword;
+ }
+
+ public void setNewPassword(String newPassword) {
+ this.newPassword = newPassword;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ ResetPasswordRequest that = (ResetPasswordRequest) o;
+ return Objects.equals(token, that.token)
+ && Objects.equals(newPassword, that.newPassword);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(token, newPassword);
+ }
+
+ @Override
+ public String toString() {
+ return "ResetPasswordRequest{" + "token=" + (token == null ? "null" : "****") + ", newPassword=" + (newPassword == null ? "null" : "****") + "}";
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/UserResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/UserResponse.java
new file mode 100644
index 0000000..997a9a1
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/UserResponse.java
@@ -0,0 +1,126 @@
+package com.edgareldy.springjdbctutorial.ws.payload.auth;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Public view of a user account (never the password).
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class UserResponse {
+
+ private Long id;
+
+ private String firstName;
+
+ private String lastName;
+
+ private String email;
+
+ private boolean enabled;
+
+ private List
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+// AccessDeniedHandler is the counterpart for "authenticated but not allowed": it writes the JSON 403.
+public class ApiAccessDeniedHandler implements AccessDeniedHandler {
+
+ private final ObjectMapper objectMapper;
+
+ public ApiAccessDeniedHandler(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ @Override
+ public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
+ throws IOException {
+ response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ response.setContentType("application/json");
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ objectMapper.writeValue(response.getOutputStream(), ApiResponse.error("Access denied"));
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAuthenticationEntryPoint.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAuthenticationEntryPoint.java
new file mode 100644
index 0000000..6fce088
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAuthenticationEntryPoint.java
@@ -0,0 +1,38 @@
+package com.edgareldy.springjdbctutorial.ws.security;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Answers 401 with an ApiResponse body when a protected route is reached without a valid authentication.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+// AuthenticationEntryPoint is Spring Security's hook for "the caller is not authenticated": it turnsn// that situation into the HTTP answer (here a JSON 401 in the API error format, not a login redirect).
+public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
+
+ private final ObjectMapper objectMapper;
+
+ public ApiAuthenticationEntryPoint(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ @Override
+ public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
+ throws IOException {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setContentType("application/json");
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ objectMapper.writeValue(response.getOutputStream(), ApiResponse.error("Authentication required"));
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/AuthenticatedUser.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/AuthenticatedUser.java
new file mode 100644
index 0000000..c1d6669
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/AuthenticatedUser.java
@@ -0,0 +1,81 @@
+package com.edgareldy.springjdbctutorial.ws.security;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Principal placed in the SecurityContext for an authenticated request: who, and which token (hash, jti, validity) so logout can revoke it.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class AuthenticatedUser {
+
+ private final Long userId;
+ private final String email;
+ private final String jti;
+ private final String tokenHash;
+ private final Instant issuedAt;
+ private final Instant expiresAt;
+
+ public AuthenticatedUser(Long userId, String email, String jti, String tokenHash, Instant issuedAt,
+ Instant expiresAt) {
+ this.userId = userId;
+ this.email = email;
+ this.jti = jti;
+ this.tokenHash = tokenHash;
+ this.issuedAt = issuedAt;
+ this.expiresAt = expiresAt;
+ }
+
+ public Long getUserId() {
+ return userId;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public String getJti() {
+ return jti;
+ }
+
+ public String getTokenHash() {
+ return tokenHash;
+ }
+
+ public Instant getIssuedAt() {
+ return issuedAt;
+ }
+
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ AuthenticatedUser that = (AuthenticatedUser) o;
+ return Objects.equals(userId, that.userId) && Objects.equals(email, that.email)
+ && Objects.equals(jti, that.jti) && Objects.equals(tokenHash, that.tokenHash)
+ && Objects.equals(issuedAt, that.issuedAt) && Objects.equals(expiresAt, that.expiresAt);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(userId, email, jti, tokenHash, issuedAt, expiresAt);
+ }
+
+ @Override
+ public String toString() {
+ return "AuthenticatedUser{userId=" + userId + ", email=" + email + ", jti=" + jti
+ + ", tokenHash=****, issuedAt=" + issuedAt + ", expiresAt=" + expiresAt + "}";
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/IssuedToken.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/IssuedToken.java
new file mode 100644
index 0000000..86c3425
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/IssuedToken.java
@@ -0,0 +1,60 @@
+package com.edgareldy.springjdbctutorial.ws.security;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * A freshly issued JWT with its issue and expiry instants.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class IssuedToken {
+
+ private final String token;
+ private final Instant issuedAt;
+ private final Instant expiresAt;
+
+ public IssuedToken(String token, Instant issuedAt, Instant expiresAt) {
+ this.token = token;
+ this.issuedAt = issuedAt;
+ this.expiresAt = expiresAt;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public Instant getIssuedAt() {
+ return issuedAt;
+ }
+
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ IssuedToken that = (IssuedToken) o;
+ return Objects.equals(token, that.token) && Objects.equals(issuedAt, that.issuedAt)
+ && Objects.equals(expiresAt, that.expiresAt);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(token, issuedAt, expiresAt);
+ }
+
+ @Override
+ public String toString() {
+ return "IssuedToken{token=****, issuedAt=" + issuedAt + ", expiresAt=" + expiresAt + "}";
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtAuthFilter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtAuthFilter.java
new file mode 100644
index 0000000..01a7cb9
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtAuthFilter.java
@@ -0,0 +1,65 @@
+package com.edgareldy.springjdbctutorial.ws.security;
+
+import com.edgareldy.springjdbctutorial.core.auth.service.AuthService;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Authenticates a request from its Bearer JWT: verifies it, rejects revoked tokens, and fills the SecurityContext. Never throws for a bad token.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+// OncePerRequestFilter guarantees one execution per request even with forwards or error dispatches.
+// It is created inside the SecurityFilterChain (not a bean, not registered in the servlet container),
+// so it runs only inside the Spring Security chain. When the token is missing, invalid or revoked the
+// context is left empty and the AuthenticationEntryPoint answers 401 for protected routes.
+public class JwtAuthFilter extends OncePerRequestFilter {
+
+ private static final String BEARER_PREFIX = "Bearer ";
+
+ private final JwtService jwtService;
+ private final AuthService authService;
+
+ public JwtAuthFilter(JwtService jwtService, AuthService authService) {
+ this.jwtService = jwtService;
+ this.authService = authService;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ String header = request.getHeader("Authorization");
+ if (header != null && header.startsWith(BEARER_PREFIX)) {
+ String jwt = header.substring(BEARER_PREFIX.length()).trim();
+ Optional
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+// Only java.security is used (KeyFactory + the standard key specs), no extra library. The locations are
+// Spring resource URLs (file:, classpath:...) resolved by the ResourceLoader.
+public final class JwtKeyLoader {
+
+ private JwtKeyLoader() {
+ }
+
+ public static PrivateKey loadPrivateKey(ResourceLoader loader, String property, String location) {
+ byte[] der = readPem(loader, property, location, "PRIVATE KEY");
+ try {
+ return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der));
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("Invalid RSA private key for " + property + " (PKCS#8 expected)", e);
+ }
+ }
+
+ public static PublicKey loadPublicKey(ResourceLoader loader, String property, String location) {
+ byte[] der = readPem(loader, property, location, "PUBLIC KEY");
+ try {
+ return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(der));
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("Invalid RSA public key for " + property + " (X.509 expected)", e);
+ }
+ }
+
+ private static byte[] readPem(ResourceLoader loader, String property, String location, String type) {
+ if (location == null || location.isBlank()) {
+ throw new IllegalStateException("Missing required property " + property);
+ }
+ Resource resource = loader.getResource(location);
+ String pem;
+ try (InputStream in = resource.getInputStream()) {
+ pem = new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new IllegalStateException("Cannot read the key of " + property + " at " + location, e);
+ }
+ String begin = "-----BEGIN " + type + "-----";
+ String end = "-----END " + type + "-----";
+ if (!pem.contains(begin) || !pem.contains(end)) {
+ throw new IllegalStateException("The key of " + property + " must be a PEM '" + type + "' block");
+ }
+ String body = pem.substring(pem.indexOf(begin) + begin.length(), pem.indexOf(end)).replaceAll("\\s", "");
+ try {
+ return Base64.getDecoder().decode(body);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalStateException("The key of " + property + " is not valid Base64 PEM", e);
+ }
+ }
+}
diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtService.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtService.java
new file mode 100644
index 0000000..f754a31
--- /dev/null
+++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtService.java
@@ -0,0 +1,119 @@
+package com.edgareldy.springjdbctutorial.ws.security;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jws;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import org.springframework.core.io.ResourceLoader;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Issues and verifies the RS256 JWTs of the API. parse never throws: any invalid token gives an empty Optional.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+// A JWT (JSON Web Token) is a signed, self-contained bearer credential: header.claims.signature. The
+// server keeps no session, it just verifies the signature and reads the claims (here the user id, email
+// and permissions), which is what makes the API stateless.
+//
+// RS256 (RSA + SHA-256) is a deliberate choice over HS256: it is asymmetric, so only the holder of the
+// PRIVATE key can issue tokens while verification needs only the PUBLIC key. A service that merely
+// verifies tokens never has to hold a signing secret. Keys are read once, at construction.
+public class JwtService {
+
+ private static final String PERMISSIONS_CLAIM = "permissions";
+ private static final String EMAIL_CLAIM = "email";
+ private static final String ALGORITHM = "RS256";
+
+ private final PrivateKey privateKey;
+ private final PublicKey publicKey;
+ private final Duration validity;
+ private final String issuer;
+ private final Clock clock;
+
+ public JwtService(ResourceLoader loader, String privateKeyProperty, String privateKeyLocation,
+ String publicKeyProperty, String publicKeyLocation, long expirationSeconds,
+ String issuer, Clock clock) {
+ this.privateKey = JwtKeyLoader.loadPrivateKey(loader, privateKeyProperty, privateKeyLocation);
+ this.publicKey = JwtKeyLoader.loadPublicKey(loader, publicKeyProperty, publicKeyLocation);
+ this.validity = Duration.ofSeconds(expirationSeconds);
+ this.issuer = issuer;
+ this.clock = clock;
+ }
+
+ public IssuedToken issue(com.edgareldy.springjdbctutorial.core.auth.dto.UserDto user) {
+ // JWT times have second precision, so truncate to keep the returned instants equal to the claims
+ Instant issuedAt = clock.instant().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
+ Instant expiresAt = issuedAt.plus(validity);
+ List
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public class ParsedToken {
+
+ private final Long userId;
+ private final String email;
+ private final String jti;
+ private final Instant issuedAt;
+ private final Instant expiresAt;
+ private final List
* Created edgar.muhamyangabo on 9/19/26
* Author : edgar.muhamyangabo
@@ -45,11 +48,13 @@ static void datasource(DynamicPropertyRegistry registry) {
@BeforeEach
void setUp() {
- mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
+ // springSecurity() adds the springSecurityFilterChain bean in front of MVC, as the DelegatingFilterProxy
+ // does in Tomcat, so the routes are really protected in these tests
+ mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
}
@Test
- void _01_ShouldReturn200WithStatusUp_WhenHealthRouteIsCalledOnARealDatabase() throws Exception {
+ void _01_ShouldReturn200WithStatusUpWithoutToken_WhenHealthRouteIsCalledOnARealDatabase() throws Exception {
MvcResult result = mockMvc.perform(get("/api/v1/health")).andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(200);
@@ -60,14 +65,16 @@ void _01_ShouldReturn200WithStatusUp_WhenHealthRouteIsCalledOnARealDatabase() th
}
@Test
- void _02_ShouldReturn404ApiResponse_WhenUrlIsUnknown() throws Exception {
- MvcResult result = mockMvc.perform(get("/api/v1/nothing-here")).andReturn();
+ void _02_ShouldReturn401ApiResponse_WhenUrlIsUnknownAndNoTokenIsSent() throws Exception {
+ assertError(mockMvc.perform(get("/api/v1/nothing-here")).andReturn(), 401);
+ }
- assertThat(result.getResponse().getStatus()).isEqualTo(404);
- JsonNode json = mapper.readTree(result.getResponse().getContentAsString());
- assertThat(json.get("success").asBoolean()).isFalse();
- assertThat(json.get("message").asText()).isNotBlank();
- assertThat(json.get("data") == null || json.get("data").isNull()).isTrue();
- assertThat(json.get("timestamp").asText()).isNotBlank();
+ @Test
+ void _03_ShouldReturn404ApiResponse_WhenUrlIsUnknownAndTokenIsValid() throws Exception {
+ try (AuthTestSupport support = new AuthTestSupport(mockMvc)) {
+ String token = support.registerActivateAndLogin(AuthTestSupport.uniqueEmail("config"), AuthTestSupport.PASSWORD);
+
+ assertError(support.getAs(token, "/api/v1/nothing-here"), 404);
+ }
}
}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AuthFlowIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AuthFlowIntegrationTest.java
new file mode 100644
index 0000000..15b8c08
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AuthFlowIntegrationTest.java
@@ -0,0 +1,266 @@
+package com.edgareldy.springjdbctutorial.ws.integration;
+
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError;
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess;
+import static com.edgareldy.springjdbctutorial.ws.support.AuthTestSupport.PASSWORD;
+import static com.edgareldy.springjdbctutorial.ws.support.AuthTestSupport.uniqueEmail;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+
+import com.edgareldy.springjdbctutorial.core.auth.service.AuthService;
+import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer;
+import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig;
+import com.edgareldy.springjdbctutorial.ws.security.JwtService;
+import com.edgareldy.springjdbctutorial.ws.support.AuthTestSupport;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.util.UUID;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+
+/**
+ * Full-stack flows of feature/auth: the real WebMvcConfig (real services, DAOs, Flyway, security chain)
+ * against PostgreSQL in Testcontainers, driven through MockMvc. Every test uses its own random email, so
+ * tests sharing the one database never collide.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+@SpringJUnitWebConfig(WebMvcConfig.class)
+class AuthFlowIntegrationTest {
+
+ @DynamicPropertySource
+ static void datasource(DynamicPropertyRegistry registry) {
+ PostgresTestContainer.register(registry);
+ }
+
+ @Autowired
+ private WebApplicationContext context;
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+ @Autowired
+ private AuthService authService;
+
+ private AuthTestSupport support;
+ private MockMvc mockMvc;
+
+ @BeforeEach
+ void setUp() {
+ // Same wiring as production: springSecurity() puts the springSecurityFilterChain in front of MVC
+ mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
+ support = new AuthTestSupport(mockMvc);
+ }
+
+ @AfterEach
+ void tearDown() {
+ support.close();
+ }
+
+ private long count(String sql, Object... args) {
+ return jdbcTemplate.queryForObject(sql, Long.class, args);
+ }
+
+ // ---------------------------------------------------------------- the README flow
+
+ @Test
+ void _01_ShouldCompleteRegisterActivateLoginMeLogout_WhenFollowingTheHappyPath() throws Exception {
+ String email = uniqueEmail("flow");
+
+ JsonNode registered = assertSuccess(support.register(email, PASSWORD), 201);
+ assertThat(registered.get("data").get("enabled").asBoolean()).isFalse();
+ assertThat(registered.get("data").has("password")).isFalse();
+
+ // The raw token exists only in the log: the database holds its SHA-256 digest, never the raw value
+ String rawActivation = support.activationToken(email);
+ assertThat(count("SELECT COUNT(*) FROM activation_tokens WHERE token = ?", rawActivation)).isZero();
+ assertThat(count("SELECT COUNT(*) FROM activation_tokens WHERE token = ?",
+ JwtService.sha256Hex(rawActivation))).isOne();
+ assertThat(count("SELECT COUNT(*) FROM users WHERE email = ? AND password = ?", email, PASSWORD)).isZero();
+
+ assertSuccess(support.activate(rawActivation), 200);
+
+ String jwt = support.loginToken(email, PASSWORD);
+ JsonNode me = assertSuccess(support.getAs(jwt, "/api/v1/auth/me"), 200);
+ assertThat(me.get("data").get("email").asText()).isEqualTo(email);
+ assertThat(me.get("data").get("enabled").asBoolean()).isTrue();
+ assertThat(me.get("data").has("password")).isFalse();
+
+ assertSuccess(support.logout(jwt), 200);
+ assertError(support.getAs(jwt, "/api/v1/auth/me"), 401);
+ }
+
+ @Test
+ void _02_ShouldRejectOnlyTheLoggedOutToken_WhenTheSameUserHoldsTwoTokens() throws Exception {
+ String email = uniqueEmail("blacklist");
+ String tokenA = support.registerActivateAndLogin(email, PASSWORD);
+ String tokenB = support.loginToken(email, PASSWORD);
+ assertThat(tokenB).isNotEqualTo(tokenA);
+
+ assertSuccess(support.logout(tokenA), 200);
+
+ assertError(support.getAs(tokenA, "/api/v1/auth/me"), 401);
+ assertSuccess(support.getAs(tokenB, "/api/v1/auth/me"), 200);
+ assertThat(count("SELECT COUNT(*) FROM blacklisted_tokens WHERE token = ?", JwtService.sha256Hex(tokenA))).isOne();
+ assertThat(count("SELECT COUNT(*) FROM blacklisted_tokens WHERE token = ?", tokenA)).isZero();
+ }
+
+ @Test
+ void _03_ShouldReturn401AndKeepOneBlacklistRow_WhenTheRevokedTokenLogsOutAgain() throws Exception {
+ String email = uniqueEmail("logout-twice");
+ String token = support.registerActivateAndLogin(email, PASSWORD);
+
+ assertSuccess(support.logout(token), 200);
+ // The revoked token is refused by the filter before the controller runs: no second row, no 500
+ assertError(support.logout(token), 401);
+
+ assertThat(count("SELECT COUNT(*) FROM blacklisted_tokens WHERE user_id = "
+ + "(SELECT id FROM users WHERE email = ?)", email)).isOne();
+ }
+
+ @Test
+ void _04_ShouldResetPasswordAndRefuseTheOldOne_WhenForgotAndResetAreFollowed() throws Exception {
+ String email = uniqueEmail("reset");
+ support.registerActivateAndLogin(email, PASSWORD);
+
+ assertSuccess(support.forgotPassword(email), 200);
+ String rawReset = support.resetToken(email);
+ assertThat(count("SELECT COUNT(*) FROM password_reset_tokens WHERE token = ?", rawReset)).isZero();
+ assertThat(count("SELECT COUNT(*) FROM password_reset_tokens WHERE token = ?",
+ JwtService.sha256Hex(rawReset))).isOne();
+
+ assertSuccess(support.resetPassword(rawReset, "Brand-new-pass1"), 200);
+
+ assertError(support.login(email, PASSWORD), 401);
+ assertSuccess(support.login(email, "Brand-new-pass1"), 200);
+ // A reset token is single use
+ assertError(support.resetPassword(rawReset, "Another-pass-2"), 422);
+ }
+
+ // ---------------------------------------------------------------- rejections
+
+ @Test
+ void _05_ShouldReturn401AndNoToken_WhenAccountIsNotActivatedYet() throws Exception {
+ String email = uniqueEmail("inactive");
+ support.register(email, PASSWORD);
+
+ JsonNode json = assertError(support.login(email, PASSWORD), 401);
+
+ assertThat(json.get("message").asText()).isEqualTo("Account is not activated or is locked");
+ }
+
+ @Test
+ void _06_ShouldReturn422_WhenEmailIsAlreadyRegisteredWhateverItsCase() throws Exception {
+ String email = uniqueEmail("dup");
+ assertSuccess(support.register(email, PASSWORD), 201);
+
+ assertError(support.register(email, PASSWORD), 422);
+ assertError(support.register(email.toUpperCase(), PASSWORD), 422);
+ }
+
+ @Test
+ void _07_ShouldReturn400_WhenPasswordHas73AsciiBytes() throws Exception {
+ MvcResult result = support.register(uniqueEmail("long"), "a".repeat(73));
+
+ assertError(result, 400);
+ }
+
+ @Test
+ void _08_ShouldReturn422NotAServerError_WhenPasswordIsUnder72CharactersButOver72Bytes() throws Exception {
+ // 40 x "e acute" passes the 72 character validation but is 80 bytes: the service must refuse it cleanly
+ MvcResult result = support.register(uniqueEmail("multibyte"), "é".repeat(40));
+
+ JsonNode json = assertError(result, 422);
+ assertThat(json.get("message").asText()).contains("72 bytes");
+ }
+
+ @Test
+ void _09_ShouldGiveTheSameAnswer_WhenEmailIsUnknownOrPasswordIsWrong() throws Exception {
+ String email = uniqueEmail("creds");
+ support.registerActivateAndLogin(email, PASSWORD);
+
+ JsonNode wrongPassword = assertError(support.login(email, "Wrong-password-1"), 401);
+ JsonNode unknownEmail = assertError(support.login(uniqueEmail("ghost"), PASSWORD), 401);
+ JsonNode tooLong = assertError(support.login(email, "x".repeat(200)), 401);
+
+ assertThat(wrongPassword.get("message").asText()).isEqualTo(unknownEmail.get("message").asText());
+ assertThat(tooLong.get("message").asText()).isEqualTo(unknownEmail.get("message").asText());
+ }
+
+ @Test
+ void _10_ShouldGiveTheSameAnswer_WhenForgotPasswordIsCalledForKnownAndUnknownEmails() throws Exception {
+ String email = uniqueEmail("forgot");
+ support.registerActivateAndLogin(email, PASSWORD);
+
+ MvcResult known = support.forgotPassword(email);
+ MvcResult unknown = support.forgotPassword(uniqueEmail("nobody"));
+
+ assertThat(known.getResponse().getStatus()).isEqualTo(200).isEqualTo(unknown.getResponse().getStatus());
+ assertThat(assertSuccess(known, 200).get("message").asText())
+ .isEqualTo(assertSuccess(unknown, 200).get("message").asText());
+ }
+
+ @Test
+ void _11_ShouldReturn422_WhenActivationTokenIsUnknownReusedOrExpired() throws Exception {
+ String email = uniqueEmail("activation");
+ support.register(email, PASSWORD);
+ String raw = support.activationToken(email);
+
+ assertError(support.activate("unknown-token"), 422);
+ assertSuccess(support.activate(raw), 200);
+ assertError(support.activate(raw), 422);
+
+ String expiredEmail = uniqueEmail("expired");
+ support.register(expiredEmail, PASSWORD);
+ String expiredRaw = support.activationToken(expiredEmail);
+ jdbcTemplate.update("UPDATE activation_tokens SET expires_at = now() - interval '1 hour' WHERE token = ?",
+ JwtService.sha256Hex(expiredRaw));
+ assertError(support.activate(expiredRaw), 422);
+ assertError(support.login(expiredEmail, PASSWORD), 401);
+ }
+
+ @Test
+ void _12_ShouldReturn401WhateverThePath_WhenNoTokenIsSent() throws Exception {
+ assertError(mockMvc.perform(get("/api/v1/nothing-here")).andReturn(), 401);
+ assertError(mockMvc.perform(get("/api/v1/auth/me")).andReturn(), 401);
+ }
+
+ @Test
+ void _13_ShouldReturn404_WhenUnknownUrlIsCalledWithAValidToken() throws Exception {
+ String token = support.registerActivateAndLogin(uniqueEmail("notfound"), PASSWORD);
+
+ assertError(support.getAs(token, "/api/v1/nothing-here"), 404);
+ }
+
+ // ---------------------------------------------------------------- scheduled cleanup
+
+ @Test
+ void _14_ShouldDeleteOnlyExpiredBlacklistRows_WhenTheCleanupRuns() throws Exception {
+ String email = uniqueEmail("purge");
+ JsonNode registered = assertSuccess(support.register(email, PASSWORD), 201);
+ long userId = registered.get("data").get("id").asLong();
+ String expiredHash = JwtService.sha256Hex(UUID.randomUUID().toString());
+ String liveHash = JwtService.sha256Hex(UUID.randomUUID().toString());
+ String insert = "INSERT INTO blacklisted_tokens (user_id, token, jti, blacklisted_at, created_at, expires_at) "
+ + "VALUES (?, ?, ?, now(), now() - interval '2 days', now() + (?::interval))";
+ jdbcTemplate.update(insert, userId, expiredHash, UUID.randomUUID().toString(), "-1 day");
+ jdbcTemplate.update(insert, userId, liveHash, UUID.randomUUID().toString(), "1 day");
+
+ int removed = authService.purgeExpiredTokens();
+
+ assertThat(removed).isGreaterThanOrEqualTo(1);
+ assertThat(count("SELECT COUNT(*) FROM blacklisted_tokens WHERE token = ?", expiredHash)).isZero();
+ assertThat(count("SELECT COUNT(*) FROM blacklisted_tokens WHERE token = ?", liveHash)).isOne();
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AuthControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AuthControllerTest.java
new file mode 100644
index 0000000..d1a6848
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AuthControllerTest.java
@@ -0,0 +1,455 @@
+package com.edgareldy.springjdbctutorial.ws.mvc;
+
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError;
+import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertSuccess;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+
+import com.edgareldy.springjdbctutorial.core.auth.dto.BlacklistedTokenDto;
+import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto;
+import com.edgareldy.springjdbctutorial.core.auth.exception.AccountNotActiveException;
+import com.edgareldy.springjdbctutorial.core.auth.exception.InvalidCredentialsException;
+import com.edgareldy.springjdbctutorial.core.auth.service.AuthService;
+import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException;
+import com.edgareldy.springjdbctutorial.ws.config.SecurityConfig;
+import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig;
+import com.edgareldy.springjdbctutorial.ws.controller.AuthController;
+import com.edgareldy.springjdbctutorial.ws.exception.GlobalExceptionHandler;
+import com.edgareldy.springjdbctutorial.ws.security.IssuedToken;
+import com.edgareldy.springjdbctutorial.ws.security.JwtService;
+import com.edgareldy.springjdbctutorial.ws.support.JwtTestSupport;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.security.KeyPair;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.validation.Validator;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * MockMvc tests of AuthController with AuthService mocked (no database) but the REAL SecurityConfig: JWT
+ * filter, entry point, advice and validation all run, so status codes and the ApiResponse shape are the
+ * production ones.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+// Kept in ws.mvc, never in ws.controller: WebMvcConfig component-scans ws.controller and ws.exception and
+// would pick up this test configuration. The JWT key locations come from the system properties surefire
+// sets (see ws/pom.xml), read by SecurityConfig through the Spring Environment.
+@SpringJUnitWebConfig(AuthControllerTest.TestConfig.class)
+class AuthControllerTest {
+
+ /**
+ * Test context: MVC, the real SecurityConfig (JwtService, filter chain, 401/403 handlers), the real
+ * controller and advice, and a Mockito mock in place of the AuthService.
+ */
+ @Configuration
+ @EnableWebMvc
+ @Import(SecurityConfig.class)
+ static class TestConfig implements WebMvcConfigurer {
+
+ private final WebMvcConfig real = new WebMvcConfig();
+
+ @Bean
+ Clock clock() {
+ return Clock.systemUTC();
+ }
+
+ @Bean
+ ObjectMapper objectMapper() {
+ return real.objectMapper();
+ }
+
+ @Bean
+ AuthService authService() {
+ return mock(AuthService.class);
+ }
+
+ @Bean
+ AuthController authController(AuthService authService, JwtService jwtService) {
+ return new AuthController(authService, jwtService);
+ }
+
+ @Bean
+ GlobalExceptionHandler globalExceptionHandler() {
+ return new GlobalExceptionHandler();
+ }
+
+ @Override
+ public void extendMessageConverters(List
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+class JwtKeyLoaderTest {
+
+ private static final String PRIVATE_PROPERTY = "app.jwt.private-key-location";
+ private static final String PUBLIC_PROPERTY = "app.jwt.public-key-location";
+
+ private final ResourceLoader loader = new DefaultResourceLoader();
+
+ @TempDir
+ Path tempDir;
+
+ private String write(String name, String content) throws Exception {
+ return "file:" + Files.writeString(tempDir.resolve(name), content);
+ }
+
+ @Test
+ void _01_ShouldLoadBothKeys_WhenPemFilesAreValid() throws Exception {
+ KeyPair pair = JwtTestSupport.generateKeyPair();
+ String privateLocation = write("private.pem", JwtTestSupport.privatePem(pair.getPrivate()));
+ String publicLocation = write("public.pem", JwtTestSupport.publicPem(pair.getPublic()));
+
+ assertThat(JwtKeyLoader.loadPrivateKey(loader, PRIVATE_PROPERTY, privateLocation)).isEqualTo(pair.getPrivate());
+ assertThat(JwtKeyLoader.loadPublicKey(loader, PUBLIC_PROPERTY, publicLocation)).isEqualTo(pair.getPublic());
+ }
+
+ @Test
+ void _02_ShouldFailNamingTheProperty_WhenLocationIsMissing() {
+ assertThatThrownBy(() -> JwtKeyLoader.loadPrivateKey(loader, PRIVATE_PROPERTY, null))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("Missing required property " + PRIVATE_PROPERTY);
+ assertThatThrownBy(() -> JwtKeyLoader.loadPublicKey(loader, PUBLIC_PROPERTY, " "))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("Missing required property " + PUBLIC_PROPERTY);
+ }
+
+ @Test
+ void _03_ShouldFailWithWrongTypeMessage_WhenPublicKeyPemIsGivenAsPrivateKey() throws Exception {
+ KeyPair pair = JwtTestSupport.generateKeyPair();
+ String location = write("public.pem", JwtTestSupport.publicPem(pair.getPublic()));
+
+ assertThatThrownBy(() -> JwtKeyLoader.loadPrivateKey(loader, PRIVATE_PROPERTY, location))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("must be a PEM 'PRIVATE KEY' block");
+ }
+
+ @Test
+ void _04_ShouldFailWithWrongTypeMessage_WhenPrivateKeyPemIsGivenAsPublicKey() throws Exception {
+ KeyPair pair = JwtTestSupport.generateKeyPair();
+ String location = write("private.pem", JwtTestSupport.privatePem(pair.getPrivate()));
+
+ assertThatThrownBy(() -> JwtKeyLoader.loadPublicKey(loader, PUBLIC_PROPERTY, location))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("must be a PEM 'PUBLIC KEY' block");
+ }
+
+ @Test
+ void _05_ShouldFailWithReadMessage_WhenFileDoesNotExist() {
+ String location = "file:" + tempDir.resolve("absent.pem");
+
+ assertThatThrownBy(() -> JwtKeyLoader.loadPrivateKey(loader, PRIVATE_PROPERTY, location))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("Cannot read the key of " + PRIVATE_PROPERTY);
+ }
+
+ @Test
+ void _06_ShouldFailWithInvalidKeyMessage_WhenPemBodyIsNotAKey() throws Exception {
+ String garbage = JwtTestSupport.pem("PRIVATE KEY", new byte[]{1, 2, 3, 4});
+ String location = write("garbage.pem", garbage);
+
+ assertThatThrownBy(() -> JwtKeyLoader.loadPrivateKey(loader, PRIVATE_PROPERTY, location))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("Invalid RSA private key");
+ }
+
+ @Test
+ void _07_ShouldFailWithBase64Message_WhenPemBodyIsNotBase64() throws Exception {
+ String location = write("bad.pem", "-----BEGIN PUBLIC KEY-----\n***not base64***\n-----END PUBLIC KEY-----\n");
+
+ assertThatThrownBy(() -> JwtKeyLoader.loadPublicKey(loader, PUBLIC_PROPERTY, location))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("not valid Base64");
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtServiceTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtServiceTest.java
new file mode 100644
index 0000000..bef9e88
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtServiceTest.java
@@ -0,0 +1,171 @@
+package com.edgareldy.springjdbctutorial.ws.security;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto;
+import com.edgareldy.springjdbctutorial.ws.support.JwtTestSupport;
+import java.security.KeyPair;
+import java.security.PublicKey;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.DefaultResourceLoader;
+
+/**
+ * Tests JwtService (RS256 issue and parse) with a fixed clock: round trip, expiry, tampering, wrong issuer,
+ * foreign key, algorithm confusion and unsigned tokens.
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+class JwtServiceTest {
+
+ private static final Instant NOW = Instant.parse("2026-09-19T12:00:00Z");
+
+ private final JwtService service = JwtTestSupport.devJwtService(Clock.fixed(NOW, ZoneOffset.UTC));
+
+ private static UserDto user() {
+ UserDto dto = new UserDto();
+ dto.setId(5L);
+ dto.setEmail("alice@example.com");
+ dto.setPermissions(List.of("USER:READ", "ROLE:WRITE"));
+ return dto;
+ }
+
+ @Test
+ void _01_ShouldParseBackTheSameClaims_WhenTokenIsIssuedThenParsed() {
+ IssuedToken issued = service.issue(user());
+
+ ParsedToken parsed = service.parse(issued.getToken()).orElseThrow();
+
+ assertThat(parsed.getUserId()).isEqualTo(5L);
+ assertThat(parsed.getEmail()).isEqualTo("alice@example.com");
+ assertThat(parsed.getPermissions()).containsExactly("USER:READ", "ROLE:WRITE");
+ assertThat(parsed.getJti()).isNotBlank();
+ assertThat(parsed.getIssuedAt()).isEqualTo(NOW);
+ assertThat(parsed.getExpiresAt()).isEqualTo(NOW.plusSeconds(3600));
+ assertThat(issued.getExpiresAt()).isEqualTo(NOW.plusSeconds(3600));
+ }
+
+ @Test
+ void _02_ShouldIssueDifferentJti_WhenTwoTokensAreIssuedForTheSameUser() {
+ ParsedToken first = service.parse(service.issue(user()).getToken()).orElseThrow();
+ ParsedToken second = service.parse(service.issue(user()).getToken()).orElseThrow();
+
+ assertThat(first.getJti()).isNotEqualTo(second.getJti());
+ }
+
+ @Test
+ void _03_ShouldReturnEmpty_WhenSignatureIsTampered() {
+ String token = service.issue(user()).getToken();
+ int position = token.length() - 20;
+ char original = token.charAt(position);
+ String tampered = token.substring(0, position) + (original == 'A' ? 'B' : 'A') + token.substring(position + 1);
+
+ assertThat(service.parse(tampered)).isEmpty();
+ }
+
+ @Test
+ void _04_ShouldReturnEmpty_WhenPayloadIsReplacedButSignatureKept() {
+ String[] parts = service.issue(user()).getToken().split("\\.");
+ String otherPayload = service.issue(user()).getToken().split("\\.")[1];
+ String swapped = parts[0] + "." + otherPayload + "." + parts[2];
+
+ assertThat(service.parse(swapped)).isEmpty();
+ }
+
+ @Test
+ void _05_ShouldReturnEmpty_WhenTokenIsExpiredAccordingToTheClock() {
+ String token = service.issue(user()).getToken();
+ JwtService later = JwtTestSupport.devJwtService(Clock.fixed(NOW.plusSeconds(3601), ZoneOffset.UTC));
+
+ assertThat(later.parse(token)).isEmpty();
+ assertThat(JwtTestSupport.devJwtService(Clock.fixed(NOW.plusSeconds(3599), ZoneOffset.UTC)).parse(token))
+ .isPresent();
+ }
+
+ @Test
+ void _06_ShouldReturnEmpty_WhenIssuerIsWrong() {
+ // Same dev key pair, so the signature is valid: only the issuer claim differs
+ JwtService otherIssuer = new JwtService(new DefaultResourceLoader(),
+ "app.jwt.private-key-location", System.getProperty("app.jwt.private-key-location"),
+ "app.jwt.public-key-location", System.getProperty("app.jwt.public-key-location"),
+ 3600L, "someone-else", Clock.fixed(NOW, ZoneOffset.UTC));
+ String token = otherIssuer.issue(user()).getToken();
+
+ assertThat(service.parse(token)).isEmpty();
+ assertThat(otherIssuer.parse(token)).isPresent();
+ }
+
+ @Test
+ void _07_ShouldReturnEmpty_WhenTokenIsSignedWithAnotherRsaKey() {
+ KeyPair foreign = JwtTestSupport.generateKeyPair();
+ String forged = JwtTestSupport.rs256Token(foreign.getPrivate(), 5L, NOW, NOW.plusSeconds(3600));
+
+ assertThat(service.parse(forged)).isEmpty();
+ }
+
+ @Test
+ void _08_ShouldReturnEmpty_WhenTokenIsHs256SignedWithThePublicKeyBytes() {
+ PublicKey publicKey = JwtTestSupport.generateKeyPair().getPublic();
+ String forged = JwtTestSupport.hs256TokenSignedWithPublicKey(publicKey, 5L, NOW, NOW.plusSeconds(3600));
+
+ assertThat(service.parse(forged)).isEmpty();
+ }
+
+ @Test
+ void _09_ShouldReturnEmpty_WhenTokenIsUnsignedWithAlgNone() {
+ String unsigned = JwtTestSupport.unsignedToken(5L, NOW, NOW.plusSeconds(3600));
+
+ assertThat(service.parse(unsigned)).isEmpty();
+ }
+
+ @Test
+ void _10_ShouldReturnEmpty_WhenAlgorithmIsRs384EvenWithAValidSignature() throws Exception {
+ // Only RS256 is ever issued: a token with another RSA algorithm is refused, whoever signed it.
+ // Signed here with the dev private key, which is the only key whose signature would be accepted.
+ java.security.PrivateKey devPrivate = JwtKeyLoader.loadPrivateKey(new DefaultResourceLoader(),
+ "app.jwt.private-key-location", System.getProperty("app.jwt.private-key-location"));
+ String rs384 = JwtTestSupport.rs384Token(devPrivate, 5L, NOW, NOW.plusSeconds(3600));
+
+ assertThat(service.parse(rs384)).isEmpty();
+ }
+
+ @Test
+ void _11_ShouldReturnEmpty_WhenTokenHasNoJti() {
+ java.security.PrivateKey devPrivate = JwtKeyLoader.loadPrivateKey(new DefaultResourceLoader(),
+ "app.jwt.private-key-location", System.getProperty("app.jwt.private-key-location"));
+ String noJti = JwtTestSupport.rs256TokenWithoutJti(devPrivate, 5L, NOW, NOW.plusSeconds(3600));
+
+ assertThat(service.parse(noJti)).isEmpty();
+ }
+
+ @Test
+ void _12_ShouldReturnEmpty_WhenTokenIsNullBlankOrGarbage() {
+ assertThat(service.parse(null)).isEmpty();
+ assertThat(service.parse(" ")).isEmpty();
+ assertThat(service.parse("not-a-jwt")).isEmpty();
+ assertThat(service.parse("a.b.c")).isEmpty();
+ }
+
+ @Test
+ void _13_ShouldIssueEmptyPermissions_WhenUserHasNone() {
+ UserDto noPermissions = user();
+ noPermissions.setPermissions(null);
+
+ Optional
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public final class ApiAssertions {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private ApiAssertions() {
+ }
+
+ /** Parses the response body as JSON. */
+ public static JsonNode json(MvcResult result) throws UnsupportedEncodingException {
+ try {
+ return MAPPER.readTree(result.getResponse().getContentAsString());
+ } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
+ throw new AssertionError("Response body is not JSON: " + result.getResponse().getContentAsString(), e);
+ }
+ }
+
+ /**
+ * Asserts the README error format: the expected status, success=false, a message, no data and a timestamp.
+ * Returns the body for further assertions on the message.
+ */
+ public static JsonNode assertError(MvcResult result, int expectedStatus) throws UnsupportedEncodingException {
+ assertThat(result.getResponse().getStatus()).isEqualTo(expectedStatus);
+ JsonNode json = json(result);
+ assertThat(json.get("success").asBoolean()).isFalse();
+ assertThat(json.get("message").asText()).isNotBlank();
+ assertThat(json.get("data") == null || json.get("data").isNull()).isTrue();
+ assertThat(json.get("timestamp").asText()).isNotBlank();
+ return json;
+ }
+
+ /** Asserts a success envelope with the expected status and returns the body. */
+ public static JsonNode assertSuccess(MvcResult result, int expectedStatus) throws UnsupportedEncodingException {
+ assertThat(result.getResponse().getStatus()).isEqualTo(expectedStatus);
+ JsonNode json = json(result);
+ assertThat(json.get("success").asBoolean()).isTrue();
+ assertThat(json.get("message").asText()).isNotBlank();
+ assertThat(json.get("timestamp").asText()).isNotBlank();
+ return json;
+ }
+}
diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/AuthTestSupport.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/AuthTestSupport.java
new file mode 100644
index 0000000..b524904
--- /dev/null
+++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/AuthTestSupport.java
@@ -0,0 +1,140 @@
+package com.edgareldy.springjdbctutorial.ws.support;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+/**
+ * Shared fixtures for full-stack tests: registers, activates and logs in a user through the real HTTP endpoints
+ * and returns the JWT. There is no mailer, so the raw activation and reset tokens are read from the INFO log
+ * lines of AuthServiceImpl with a logback appender. Reusable by later branches (admin and simple users).
+ *
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public final class AuthTestSupport implements AutoCloseable {
+
+ /** A password accepted by every validation rule. */
+ public static final String PASSWORD = "Str0ng-Password!";
+
+ private static final String SERVICE_LOGGER = "com.edgareldy.springjdbctutorial.core.auth.service.impl.AuthServiceImpl";
+ private static final Pattern ACTIVATION = Pattern.compile("Activation token for (\\S+): (\\S+)");
+ private static final Pattern RESET = Pattern.compile("Password reset token for (\\S+): (\\S+)");
+
+ private final MockMvc mockMvc;
+ private final Logger logger;
+ // logback's ListAppender keeps every logged event in memory, which lets a test read them back
+ private final ListAppender
+ * Created edgar.muhamyangabo on 9/19/26
+ * Author : edgar.muhamyangabo
+ * Date : 9/19/26
+ * Project : spring-jdbc-tutorial
+ */
+public final class JwtTestSupport {
+
+ /** Issuer configured by default in SecurityConfig. */
+ public static final String ISSUER = "spring-jdbc-tutorial";
+
+ private JwtTestSupport() {
+ }
+
+ /** A JwtService on the dev keys, with the given clock and a one hour validity. */
+ public static JwtService devJwtService(Clock clock) {
+ return new JwtService(new DefaultResourceLoader(),
+ "app.jwt.private-key-location", System.getProperty("app.jwt.private-key-location"),
+ "app.jwt.public-key-location", System.getProperty("app.jwt.public-key-location"),
+ 3600L, ISSUER, clock);
+ }
+
+ /** A fresh 2048 bit RSA key pair, never related to the dev keys. */
+ public static KeyPair generateKeyPair() {
+ try {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /** Wraps DER bytes into a PEM block of the given type (for example "PRIVATE KEY"). */
+ public static String pem(String type, byte[] der) {
+ String body = Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(der);
+ return "-----BEGIN " + type + "-----\n" + body + "\n-----END " + type + "-----\n";
+ }
+
+ public static String privatePem(PrivateKey key) {
+ return pem("PRIVATE KEY", key.getEncoded());
+ }
+
+ public static String publicPem(PublicKey key) {
+ return pem("PUBLIC KEY", key.getEncoded());
+ }
+
+ /** A well-formed RS256 token (issuer, jti, iat, exp, email, permissions) signed with the given private key. */
+ public static String rs256Token(PrivateKey key, long userId, Instant issuedAt, Instant expiresAt) {
+ return baseBuilder(userId, issuedAt, expiresAt).signWith(key, Jwts.SIG.RS256).compact();
+ }
+
+ /** Same claims, signed with RS384: a valid signature that the service must still refuse. */
+ public static String rs384Token(PrivateKey key, long userId, Instant issuedAt, Instant expiresAt) {
+ return baseBuilder(userId, issuedAt, expiresAt).signWith(key, Jwts.SIG.RS384).compact();
+ }
+
+ /** Algorithm confusion attack: an HS256 token whose HMAC secret is the (public) RSA public key bytes. */
+ public static String hs256TokenSignedWithPublicKey(PublicKey publicKey, long userId, Instant issuedAt,
+ Instant expiresAt) {
+ SecretKey secret = Keys.hmacShaKeyFor(publicKey.getEncoded());
+ return baseBuilder(userId, issuedAt, expiresAt).signWith(secret, Jwts.SIG.HS256).compact();
+ }
+
+ /** An unsecured JWT (header alg none, no signature). */
+ public static String unsignedToken(long userId, Instant issuedAt, Instant expiresAt) {
+ return baseBuilder(userId, issuedAt, expiresAt).compact();
+ }
+
+ /** A signed token with a different issuer. */
+ public static String rs256TokenWithIssuer(PrivateKey key, String issuer, long userId, Instant issuedAt,
+ Instant expiresAt) {
+ return Jwts.builder().subject(String.valueOf(userId)).id(UUID.randomUUID().toString()).issuer(issuer)
+ .issuedAt(Date.from(issuedAt)).expiration(Date.from(expiresAt))
+ .claim("email", "forged@example.com").claim("permissions", List.of())
+ .signWith(key, Jwts.SIG.RS256).compact();
+ }
+
+ /** A signed token that has no jti claim. */
+ public static String rs256TokenWithoutJti(PrivateKey key, long userId, Instant issuedAt, Instant expiresAt) {
+ return Jwts.builder().subject(String.valueOf(userId)).issuer(ISSUER)
+ .issuedAt(Date.from(issuedAt)).expiration(Date.from(expiresAt))
+ .claim("email", "forged@example.com").claim("permissions", List.of())
+ .signWith(key, Jwts.SIG.RS256).compact();
+ }
+
+ private static io.jsonwebtoken.JwtBuilder baseBuilder(long userId, Instant issuedAt, Instant expiresAt) {
+ return Jwts.builder().subject(String.valueOf(userId)).id(UUID.randomUUID().toString()).issuer(ISSUER)
+ .issuedAt(Date.from(issuedAt)).expiration(Date.from(expiresAt))
+ .claim("email", "forged@example.com").claim("permissions", List.of("USER:READ"));
+ }
+}