From a5f971e9e8d837aa1aee56522976ec4bfbc56eee Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 01/32] build(auth): add spring-security-crypto for password hashing The auth module needs only the password encoders of Spring Security, not the whole security stack, so it depends on the small spring-security-crypto artifact. Its version comes from the Spring Security BOM already imported by the parent pom. Keeping the web-facing Spring Security modules in ws preserves the rule that core knows nothing about HTTP. --- core/auth/pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/auth/pom.xml b/core/auth/pom.xml index def61c8..d1a4560 100644 --- a/core/auth/pom.xml +++ b/core/auth/pom.xml @@ -19,5 +19,10 @@ com.edgareldy.springjdbctutorial common + + + org.springframework.security + spring-security-crypto + From 67daef90f67d99fc860816b5e4e4f6bd93318335 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 02/32] feat(common): declare a Clock bean shared by every service Services that compare or stamp times take a java.time.Clock instead of calling Instant.now(). The production bean is Clock.systemUTC(), and a test can build the service with a fixed clock to check expiry rules exactly. It is declared once in CommonConfig, next to the other infrastructure beans, so every module gets the same time source. --- .../core/common/config/CommonConfig.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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(); + } } From e2667be7991869dbc9ab076b36ebc84755971cd5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 03/32] feat(auth): add the User and token entities Entities are the raw, table-shaped view of a row: one class per table, annotated with jakarta.persistence @Table, @Id and @Column. Those annotations are documentation only, since no JPA provider exists in this project, they simply declare the table and column mapping next to the class. User, ActivationToken, BlacklistedToken and PasswordResetToken are plain classes with explicit accessors and equals/hashCode. Anything carrying a password hash or a token digest masks it in toString so it never reaches a log. --- .../core/auth/entity/ActivationToken.java | 132 +++++++++++++++ .../core/auth/entity/BlacklistedToken.java | 160 ++++++++++++++++++ .../core/auth/entity/PasswordResetToken.java | 118 +++++++++++++ .../core/auth/entity/User.java | 146 ++++++++++++++++ 4 files changed, 556 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/ActivationToken.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/BlacklistedToken.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/PasswordResetToken.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/User.java diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/ActivationToken.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/ActivationToken.java new file mode 100644 index 0000000..382bb53 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/entity/ActivationToken.java @@ -0,0 +1,132 @@ +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 activation_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 = "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 + + "}"; + } +} From df45c511d725bb3ba72086f6de2d472ec040d2cc Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 04/32] feat(auth): add the auth dto classes returned and accepted by services DTOs are the business shape a service exposes, a genuinely separate class from the entity even when the fields match. UserDto carries the raw password on input only and always null on output, plus the role names and the RESOURCE:ACTION permission codes of the user. BlacklistedTokenDto describes a token to revoke at logout. All of them mask passwords and tokens in toString. --- .../core/auth/dto/ActivationTokenDto.java | 121 +++++++++++++ .../core/auth/dto/BlacklistedTokenDto.java | 108 ++++++++++++ .../core/auth/dto/PasswordResetTokenDto.java | 108 ++++++++++++ .../core/auth/dto/UserDto.java | 161 ++++++++++++++++++ 4 files changed, 498 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/ActivationTokenDto.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/BlacklistedTokenDto.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/PasswordResetTokenDto.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/UserDto.java diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/ActivationTokenDto.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/ActivationTokenDto.java new file mode 100644 index 0000000..f835ce5 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dto/ActivationTokenDto.java @@ -0,0 +1,121 @@ +package com.edgareldy.springjdbctutorial.core.auth.dto; + +import java.time.Instant; +import java.util.Objects; + +/** + * Business shape of an activation 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 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 roles; + + private List permissions; + + public UserDto() { + } + + public UserDto(Long id, String firstName, String lastName, String email, String password, boolean enabled, boolean accountLocked, List roles, List permissions) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.password = password; + this.enabled = enabled; + this.accountLocked = accountLocked; + this.roles = roles; + this.permissions = permissions; + } + + 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; + } + + public List getRoles() { + return roles; + } + + public void setRoles(List roles) { + this.roles = roles; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UserDto that = (UserDto) 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 + && Objects.equals(roles, that.roles) + && Objects.equals(permissions, that.permissions); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, email, password, enabled, accountLocked, roles, permissions); + } + + @Override + public String toString() { + return "UserDto{" + + "id=" + id + + ", firstName=" + firstName + + ", lastName=" + lastName + + ", email=" + email + + ", password=" + (password == null ? "null" : "****") + + ", enabled=" + enabled + + ", accountLocked=" + accountLocked + + ", roles=" + roles + + ", permissions=" + permissions + + "}"; + } +} From 3a798ec86a39f7c56ec98d386d8a4f4b58f7dcca Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 05/32] feat(auth): add the RowMappers turning ResultSet rows into entities A RowMapper is the JdbcTemplate callback that converts one ResultSet row into one object. Each mapper reads its columns by name and does nothing else: no query, no business rule. timestamptz columns are read with getTimestamp and converted to Instant through a small null-safe helper, because validated_at is NULL until an activation token is consumed. --- .../auth/mapper/ActivationTokenRowMapper.java | 29 +++++++++++++++++ .../mapper/BlacklistedTokenRowMapper.java | 31 +++++++++++++++++++ .../mapper/PasswordResetTokenRowMapper.java | 28 +++++++++++++++++ .../core/auth/mapper/RowMapperSupport.java | 28 +++++++++++++++++ .../core/auth/mapper/UserRowMapper.java | 30 ++++++++++++++++++ 5 files changed, 146 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapper.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapper.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapper.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RowMapperSupport.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapper.java 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 { + + @Override + public ActivationToken mapRow(ResultSet rs, int rowNum) throws SQLException { + return new ActivationToken( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getString("token"), + RowMapperSupport.instant(rs, "created_at"), + RowMapperSupport.instant(rs, "expires_at"), + RowMapperSupport.instant(rs, "validated_at")); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapper.java new file mode 100644 index 0000000..4f6a71d --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapper.java @@ -0,0 +1,31 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the blacklisted_tokens table into a BlacklistedToken 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 BlacklistedTokenRowMapper implements RowMapper { + + @Override + public BlacklistedToken mapRow(ResultSet rs, int rowNum) throws SQLException { + return new BlacklistedToken( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getString("token"), + rs.getString("jti"), + RowMapperSupport.instant(rs, "blacklisted_at"), + RowMapperSupport.instant(rs, "created_at"), + RowMapperSupport.instant(rs, "expires_at"), + RowMapperSupport.instant(rs, "validated_at")); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapper.java new file mode 100644 index 0000000..efd8651 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapper.java @@ -0,0 +1,28 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the password_reset_tokens table into a PasswordResetToken entity. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PasswordResetTokenRowMapper implements RowMapper { + + @Override + public PasswordResetToken mapRow(ResultSet rs, int rowNum) throws SQLException { + return new PasswordResetToken( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getString("token"), + rs.getString("type"), + RowMapperSupport.instant(rs, "expiry_date")); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RowMapperSupport.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RowMapperSupport.java new file mode 100644 index 0000000..f9decfd --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/RowMapperSupport.java @@ -0,0 +1,28 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; + +/** + * Shared null-safe column readers for the row mappers of this module. + *

+ * 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/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 { + + @Override + public User mapRow(ResultSet rs, int rowNum) throws SQLException { + return new User( + rs.getLong("id"), + rs.getString("first_name"), + rs.getString("last_name"), + rs.getString("email"), + rs.getString("password"), + rs.getBoolean("enabled"), + rs.getBoolean("account_locked")); + } +} From 29736cda5e26703f089c1b8145c6a30f76c13106 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 06/32] feat(auth): add UserMapper between the User entity and UserDto The entity mapper is the only place where the two shapes are converted, in both directions. toDto never copies the password hash, and toEntity takes the already hashed password as an explicit argument, so a raw password can never be written into an entity by accident. Roles and permissions are not columns of users: the service fills them in from separate queries. --- .../core/auth/mapper/UserMapper.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapper.java 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()); + } +} From a465d4396b730bc88387b61fa37a77997333a135 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 07/32] feat(auth): add UserDao and its JdbcTemplate implementation The DAO owns the SQL and works only in entity types. Lookups by email compare LOWER(email) to match the case-insensitive unique index, insert uses INSERT ... RETURNING id to get the generated key in one round trip, and the permission query joins role_user, role_permission and permissions to return distinct RESOURCE:ACTION codes. The implementation extends AbstractDao and has no stereotype annotation: it is declared by an explicit @Bean in DaoConfig. --- .../core/auth/dao/UserDao.java | 37 +++++++++ .../core/auth/dao/impl/UserDaoImpl.java | 83 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImpl.java diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java new file mode 100644 index 0000000..044f1a0 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/UserDao.java @@ -0,0 +1,37 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao; + +import com.edgareldy.springjdbctutorial.core.auth.entity.User; + +import java.util.List; +import java.util.Optional; + +/** + * Data access for users and for the read-only role/permission lookups of a user. Entity types only. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface UserDao { + + Optional findById(Long id); + + /** Case-insensitive lookup. */ + Optional findByEmail(String email); + + boolean existsByEmail(String email); + + /** Inserts the user and returns it with its generated id. */ + User insert(User user); + + void updatePassword(Long userId, String passwordHash); + + void updateEnabled(Long userId, boolean enabled); + + /** Names of the roles assigned to the user, sorted. */ + List findRoleNamesByUserId(Long userId); + + /** Distinct "RESOURCE:ACTION" codes granted through the user's roles, sorted. */ + List findPermissionCodesByUserId(Long userId); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImpl.java new file mode 100644 index 0000000..16e2bd8 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImpl.java @@ -0,0 +1,83 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import com.edgareldy.springjdbctutorial.core.auth.mapper.UserRowMapper; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Optional; + +/** + * JdbcTemplate implementation of UserDao. 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 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 findById(Long id) { + return findOne("SELECT " + COLUMNS + " FROM users WHERE id = ?", rowMapper, id); + } + + @Override + public Optional findByEmail(String email) { + return findOne("SELECT " + COLUMNS + " FROM users WHERE LOWER(email) = LOWER(?)", rowMapper, email); + } + + @Override + public boolean existsByEmail(String email) { + return count("SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER(?)", email) > 0; + } + + @Override + public User insert(User user) { + long id = insertReturningId( + "INSERT INTO users (first_name, last_name, email, password, enabled, account_locked) " + + "VALUES (?, ?, ?, ?, ?, ?) RETURNING id", + user.getFirstName(), user.getLastName(), user.getEmail(), user.getPassword(), + user.isEnabled(), user.isAccountLocked()); + user.setId(id); + return user; + } + + @Override + public void updatePassword(Long userId, String passwordHash) { + getJdbcTemplate().update("UPDATE users SET password = ? WHERE id = ?", passwordHash, userId); + } + + @Override + public void updateEnabled(Long userId, boolean enabled) { + getJdbcTemplate().update("UPDATE users SET enabled = ? WHERE id = ?", enabled, userId); + } + + @Override + public List findRoleNamesByUserId(Long userId) { + return getJdbcTemplate().queryForList( + "SELECT r.role_name FROM roles r JOIN role_user ru ON ru.role_id = r.id " + + "WHERE ru.user_id = ? ORDER BY r.role_name", + String.class, userId); + } + + @Override + public List findPermissionCodesByUserId(Long userId) { + return getJdbcTemplate().queryForList( + "SELECT DISTINCT p.resource || ':' || p.action AS code FROM permissions p " + + "JOIN role_permission rp ON rp.permission_id = p.id " + + "JOIN role_user ru ON ru.role_id = rp.role_id " + + "WHERE ru.user_id = ? ORDER BY code", + String.class, userId); + } +} From d3879d4c1862368c003c74a0a08a37831f49a1ff Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 08/32] feat(auth): add the token DAOs Activation and password reset tokens are looked up by their SHA-256 digest, which is the only form ever stored. The blacklist DAO only probes, inserts and purges: existsByTokenHash answers the per-request revocation check and deleteExpiredBefore backs the scheduled cleanup. All three run plain JdbcTemplate SQL through AbstractDao, bind Instants with java.sql.Timestamp and work only in entity types. --- .../core/auth/dao/ActivationTokenDao.java | 25 ++++++++ .../core/auth/dao/BlacklistedTokenDao.java | 23 +++++++ .../core/auth/dao/PasswordResetTokenDao.java | 22 +++++++ .../auth/dao/impl/ActivationTokenDaoImpl.java | 60 +++++++++++++++++++ .../dao/impl/BlacklistedTokenDaoImpl.java | 52 ++++++++++++++++ .../dao/impl/PasswordResetTokenDaoImpl.java | 48 +++++++++++++++ 6 files changed, 230 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/ActivationTokenDao.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/BlacklistedTokenDao.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/PasswordResetTokenDao.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImpl.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImpl.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImpl.java 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 findByTokenHash(String tokenHash); + + void markValidated(Long id, Instant validatedAt); + + void deleteByUserId(Long userId); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/BlacklistedTokenDao.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/BlacklistedTokenDao.java new file mode 100644 index 0000000..56b7d3d --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/BlacklistedTokenDao.java @@ -0,0 +1,23 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao; + +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; + +import java.time.Instant; + +/** + * Data access for revoked JWTs (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 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 findByTokenHash(String tokenHash); + + void deleteByUserId(Long userId); +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImpl.java new file mode 100644 index 0000000..141933c --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImpl.java @@ -0,0 +1,60 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; +import com.edgareldy.springjdbctutorial.core.auth.mapper.ActivationTokenRowMapper; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Optional; + +/** + * JdbcTemplate implementation of ActivationTokenDao. 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 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 findByTokenHash(String tokenHash) { + return findOne("SELECT id, user_id, token, created_at, expires_at, validated_at " + + "FROM activation_tokens WHERE token = ?", rowMapper, tokenHash); + } + + @Override + public void markValidated(Long id, Instant validatedAt) { + getJdbcTemplate().update("UPDATE activation_tokens SET validated_at = ? WHERE id = ?", + toTimestamp(validatedAt), id); + } + + @Override + public void deleteByUserId(Long userId) { + getJdbcTemplate().update("DELETE FROM activation_tokens WHERE user_id = ?", userId); + } + + 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/BlacklistedTokenDaoImpl.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImpl.java new file mode 100644 index 0000000..24d90a1 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImpl.java @@ -0,0 +1,52 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Timestamp; +import java.time.Instant; + +/** + * JdbcTemplate implementation of BlacklistedTokenDao. Declared by @Bean in DaoConfig, no stereotype. + * Uses no RowMapper: the blacklist is only ever probed for existence, inserted or purged. + *

+ * 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 findByTokenHash(String tokenHash) { + return findOne("SELECT id, user_id, token, type, expiry_date FROM password_reset_tokens WHERE token = ?", + rowMapper, tokenHash); + } + + @Override + public void deleteByUserId(Long userId) { + getJdbcTemplate().update("DELETE FROM password_reset_tokens WHERE user_id = ?", userId); + } +} From f66c12c15ff44b5b638e5dc911eaa45a81249124 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 09/32] feat(auth): add the authentication exceptions AuthenticationFailedException is the base of every 401 case, so the web layer maps one type. InvalidCredentialsException carries a single message for an unknown email and a wrong password, and AccountNotActiveException is raised only after the password was verified, so it never reveals whether an email exists. They live in core and know nothing about HTTP. --- .../exception/AccountNotActiveException.java | 19 +++++++++++++++++++ .../AuthenticationFailedException.java | 18 ++++++++++++++++++ .../InvalidCredentialsException.java | 18 ++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AccountNotActiveException.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AuthenticationFailedException.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/InvalidCredentialsException.java 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"); + } +} From 77fad269f24c7b692f84c14ef8802b67f0ea7b10 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 10/32] feat(auth): implement registration, activation, login, logout and reset AuthServiceImpl holds the business rules and the transaction boundaries. @Transactional makes a Spring proxy open, commit or roll back a transaction around each write, so a user row and its activation token succeed or fail together. Tokens are 256 bits of SecureRandom output stored only as SHA-256 digests, with the raw value visible only in an INFO log line because no mailer exists. Login gives one error for an unknown email and a wrong password and runs a fake bcrypt check for the unknown one to equalise timing. Passwords are limited to 72 bytes, the bcrypt limit, and forgotPassword behaves the same whether or not the account exists. Logout is idempotent, so a repeated revocation of the same token is accepted. --- .../core/auth/service/AuthService.java | 39 +++ .../auth/service/impl/AuthServiceImpl.java | 238 ++++++++++++++++++ .../core/auth/service/impl/TokenHasher.java | 51 ++++ 3 files changed, 328 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/AuthService.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/TokenHasher.java diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/AuthService.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/AuthService.java new file mode 100644 index 0000000..bea1e15 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/AuthService.java @@ -0,0 +1,39 @@ +package com.edgareldy.springjdbctutorial.core.auth.service; + +import com.edgareldy.springjdbctutorial.core.auth.dto.BlacklistedTokenDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; + +/** + * Registration, activation, login, logout, profile and password reset. Dto types at the boundary. + * Authentication failures throw AuthenticationFailedException subclasses, business refusals BusinessRuleException. + *

+ * 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..99d343e --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java @@ -0,0 +1,238 @@ +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); + String normalized = email == null ? "" : 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/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); + } + } +} From caf3984606e9c89f1954068eb9aae5e40a9c6640 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:54:43 +0400 Subject: [PATCH 11/32] feat(auth): wire the DAOs, the AuthService and the cleanup job DaoConfig and ServiceConfig declare every bean explicitly and return them typed by interface, which is what lets @Transactional apply through a JDK proxy. The BCryptPasswordEncoder is exposed as the PasswordEncoder abstraction. @EnableScheduling registers the post-processor that runs @Scheduled methods: ExpiredTokenCleanupJob calls purgeExpiredTokens every day at 3 am to delete blacklist rows whose token has expired anyway. The method is public, so a test can call it directly. --- .../core/auth/config/DaoConfig.java | 45 +++++++++++++++ .../core/auth/config/ServiceConfig.java | 57 +++++++++++++++++++ .../service/impl/ExpiredTokenCleanupJob.java | 35 ++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/DaoConfig.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/ServiceConfig.java create mode 100644 core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJob.java diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/DaoConfig.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/DaoConfig.java new file mode 100644 index 0000000..ce44e78 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/config/DaoConfig.java @@ -0,0 +1,45 @@ +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.dao.impl.ActivationTokenDaoImpl; +import com.edgareldy.springjdbctutorial.core.auth.dao.impl.BlacklistedTokenDaoImpl; +import com.edgareldy.springjdbctutorial.core.auth.dao.impl.PasswordResetTokenDaoImpl; +import com.edgareldy.springjdbctutorial.core.auth.dao.impl.UserDaoImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Declares the DAO beans of the auth module, typed by interface (no stereotype on the implementations). + *

+ * 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/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); + } +} From ca7f22d203dc9b7b7cbe519cb5693c370799c4a4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:07 +0400 Subject: [PATCH 12/32] build(ws): add Spring Security, jjwt and the test key locations ws now depends on spring-security-web and spring-security-config, the web-facing half of Spring Security, and on jjwt: the API at compile time, the implementation and Jackson binding at runtime only. Surefire receives the two JWT key locations as system properties pointing at the development key pair at the repository root, so the tests never read keys from the classpath. --- ws/pom.xml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) 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 @@ org.glassfish.expressly expressly + + + org.springframework.security + spring-security-web + + + org.springframework.security + spring-security-config + + + + io.jsonwebtoken + jjwt-api + + + io.jsonwebtoken + jjwt-impl + runtime + + + io.jsonwebtoken + jjwt-jackson + runtime + ch.qos.logback logback-classic @@ -79,6 +103,11 @@ test-jar test + + org.springframework.security + spring-security-test + test + org.testcontainers junit-jupiter @@ -95,6 +124,18 @@ ws + + org.apache.maven.plugins + maven-surefire-plugin + + + + file:${project.basedir}/../dev-keys/privateKey.pem + file:${project.basedir}/../dev-keys/publicKey.pem + + + org.apache.maven.plugins maven-war-plugin From 8c4dce571d933b966501b427c05a2705446be415 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:07 +0400 Subject: [PATCH 13/32] chore(keys): add the development RS256 key pair A 2048 bit RSA pair (PKCS#8 private key, X.509 public key) for local development and the automated tests. It sits in dev-keys/ at the repository root, never under src/main/resources, so it can never be packaged inside the WAR. The README of the directory states they are public and dev only. Production keys are generated separately and mounted from a git-ignored directory, and the application has no default key at all. --- dev-keys/README.md | 13 +++++++++++++ dev-keys/privateKey.pem | 28 ++++++++++++++++++++++++++++ dev-keys/publicKey.pem | 9 +++++++++ 3 files changed, 50 insertions(+) create mode 100644 dev-keys/README.md create mode 100644 dev-keys/privateKey.pem create mode 100644 dev-keys/publicKey.pem 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----- From a4bd52a7a9f35ff66bd2570c0cd05696e9a06a2d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:07 +0400 Subject: [PATCH 14/32] feat(ws): issue and verify RS256 JWTs with JwtService A JWT is a signed, self-contained bearer credential, so the API keeps no session. RS256 is asymmetric: only the private key holder can issue tokens and verification needs just the public key. JwtKeyLoader reads both PEM files from configurable locations and fails startup with a clear message when one is missing or of the wrong type. Tokens carry the user id, a random jti, the email and the permission codes. parse never throws: a bad signature, an expired or foreign token, or any algorithm other than RS256 gives an empty result. The raw JWT is only ever handled through its SHA-256 digest. --- .../ws/security/IssuedToken.java | 60 +++++++++ .../ws/security/JwtKeyLoader.java | 73 +++++++++++ .../ws/security/JwtService.java | 119 ++++++++++++++++++ .../ws/security/ParsedToken.java | 82 ++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/IssuedToken.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoader.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtService.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ParsedToken.java 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/JwtKeyLoader.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoader.java new file mode 100644 index 0000000..7f36e76 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoader.java @@ -0,0 +1,73 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +/** + * Loads the RSA key pair of the JWT signer from PEM resources (private PKCS#8, public X.509). Any problem raises an IllegalStateException naming the key, so startup fails clearly. + *

+ * 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 permissions = user.getPermissions() == null ? List.of() : user.getPermissions(); + String token = Jwts.builder() + .subject(String.valueOf(user.getId())) + .id(UUID.randomUUID().toString()) + .issuer(issuer) + .issuedAt(Date.from(issuedAt)) + .expiration(Date.from(expiresAt)) + .claim(EMAIL_CLAIM, user.getEmail()) + .claim(PERMISSIONS_CLAIM, permissions) + .signWith(privateKey, Jwts.SIG.RS256) + .compact(); + return new IssuedToken(token, issuedAt, expiresAt); + } + + public Optional parse(String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + try { + Jws jws = Jwts.parser() + .verifyWith(publicKey) + .requireIssuer(issuer) + .clock(() -> Date.from(clock.instant())) + .build() + .parseSignedClaims(token); + // verifyWith(RSA key) would also accept RS384/RS512: only RS256 is issued, so only RS256 passes + if (!ALGORITHM.equals(jws.getHeader().getAlgorithm())) { + return Optional.empty(); + } + Claims claims = jws.getPayload(); + Object rawPermissions = claims.get(PERMISSIONS_CLAIM); + List permissions = rawPermissions instanceof List list + ? list.stream().map(String::valueOf).toList() + : List.of(); + if (claims.getSubject() == null || claims.getId() == null + || claims.getIssuedAt() == null || claims.getExpiration() == null) { + return Optional.empty(); + } + return Optional.of(new ParsedToken(Long.valueOf(claims.getSubject()), claims.get(EMAIL_CLAIM, String.class), + claims.getId(), claims.getIssuedAt().toInstant(), claims.getExpiration().toInstant(), + permissions)); + } catch (JwtException | IllegalArgumentException e) { + return Optional.empty(); + } + } + + /** SHA-256 hex of the raw JWT: the only form in which a token is ever stored or looked up. */ + public static 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) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ParsedToken.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ParsedToken.java new file mode 100644 index 0000000..e7e8c19 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ParsedToken.java @@ -0,0 +1,82 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** + * Verified content of a JWT: user id, email, jti, validity window and permission codes. + *

+ * 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 permissions; + + public ParsedToken(Long userId, String email, String jti, Instant issuedAt, Instant expiresAt, + List permissions) { + this.userId = userId; + this.email = email; + this.jti = jti; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + this.permissions = permissions; + } + + public Long getUserId() { + return userId; + } + + public String getEmail() { + return email; + } + + public String getJti() { + return jti; + } + + public Instant getIssuedAt() { + return issuedAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public List getPermissions() { + return permissions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ParsedToken that = (ParsedToken) o; + return Objects.equals(userId, that.userId) && Objects.equals(email, that.email) + && Objects.equals(jti, that.jti) && Objects.equals(issuedAt, that.issuedAt) + && Objects.equals(expiresAt, that.expiresAt) && Objects.equals(permissions, that.permissions); + } + + @Override + public int hashCode() { + return Objects.hash(userId, email, jti, issuedAt, expiresAt, permissions); + } + + @Override + public String toString() { + return "ParsedToken{userId=" + userId + ", email=" + email + ", jti=" + jti + ", issuedAt=" + issuedAt + + ", expiresAt=" + expiresAt + ", permissions=" + permissions + "}"; + } +} From 2c36767e512244ef60ca6bf6b1c38671ea833b21 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:07 +0400 Subject: [PATCH 15/32] feat(ws): authenticate requests with JwtAuthFilter and reject revoked tokens A OncePerRequestFilter reads the Bearer token, verifies it and then asks AuthService whether its SHA-256 digest is blacklisted. A valid, non revoked token fills the SecurityContext with an AuthenticatedUser principal and one authority per permission code taken from the token claims, so no database call is needed to authorise a request. For a missing, invalid or revoked token the context stays empty and the entry point answers 401. The filter is created inside the SecurityFilterChain and is not registered as a servlet filter. --- .../ws/security/AuthenticatedUser.java | 81 +++++++++++++++++++ .../ws/security/JwtAuthFilter.java | 65 +++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/AuthenticatedUser.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtAuthFilter.java 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/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 parsed = jwtService.parse(jwt); + if (parsed.isPresent()) { + String tokenHash = JwtService.sha256Hex(jwt); + if (!authService.isTokenBlacklisted(tokenHash)) { + ParsedToken token = parsed.get(); + AuthenticatedUser principal = new AuthenticatedUser(token.getUserId(), token.getEmail(), + token.getJti(), tokenHash, token.getIssuedAt(), token.getExpiresAt()); + List authorities = token.getPermissions().stream() + .map(SimpleGrantedAuthority::new) + .toList(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, authorities)); + } + } + } + filterChain.doFilter(request, response); + } +} From 646dcaf7d904fc5f12050098dfa9740bb52cacf7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:08 +0400 Subject: [PATCH 16/32] feat(ws): answer 401 and 403 in the ApiResponse format The filters of Spring Security run before the DispatcherServlet, so GlobalExceptionHandler never sees their failures. An AuthenticationEntryPoint handles unauthenticated requests and an AccessDeniedHandler handles authenticated requests lacking a right; both write an ApiResponse error as JSON with the shared ObjectMapper. Every error of the API therefore has the same shape, including those raised by the security layer. --- .../ws/security/ApiAccessDeniedHandler.java | 38 +++++++++++++++++++ .../security/ApiAuthenticationEntryPoint.java | 38 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAccessDeniedHandler.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAuthenticationEntryPoint.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAccessDeniedHandler.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAccessDeniedHandler.java new file mode 100644 index 0000000..9ca4f22 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/ApiAccessDeniedHandler.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.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * Answers 403 with an ApiResponse body when an authenticated caller lacks the required permission. + *

+ * 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")); + } +} From 9b7698759e5c8ac62211bf59e0987d5766b9e430 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:08 +0400 Subject: [PATCH 17/32] feat(ws): declare the stateless SecurityFilterChain explicitly @EnableWebSecurity registers the infrastructure and the springSecurityFilterChain filter. The chain is a plain @Bean: CSRF is disabled because this is a token API without cookies, sessions are STATELESS, the register, activation, login, forgot and reset routes and the health check are public, everything else needs authentication, and JwtAuthFilter runs before UsernamePasswordAuthenticationFilter. The JwtService bean reads the key locations from the Environment with no default, so startup fails clearly without them. Method security is not enabled yet, the rbac branch adds it with the permission evaluator. --- .../ws/config/SecurityConfig.java | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/SecurityConfig.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/SecurityConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/SecurityConfig.java new file mode 100644 index 0000000..64a4bcf --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/SecurityConfig.java @@ -0,0 +1,87 @@ +package com.edgareldy.springjdbctutorial.ws.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.ws.security.ApiAccessDeniedHandler; +import com.edgareldy.springjdbctutorial.ws.security.ApiAuthenticationEntryPoint; +import com.edgareldy.springjdbctutorial.ws.security.JwtAuthFilter; +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.io.ResourceLoader; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.time.Clock; + +/** + * Spring Security wiring: the JWT service, the JSON 401/403 handlers and the stateless SecurityFilterChain. + *

+ * 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(); + } +} From 8dd582fd31d3999bb300127b2b2d28c86c885735 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:08 +0400 Subject: [PATCH 18/32] feat(ws): add the auth payloads and UserConverter Payload classes are the HTTP request and response shapes, named differently from core dto classes on purpose. Requests carry Bean Validation constraints: an email format, and a password between 8 and 72 characters. The byte limit of bcrypt is still enforced by the service for multibyte input. UserConverter maps between the core UserDto and these payloads with static methods, so the controller never touches a dto directly. Passwords and tokens are masked in toString. --- .../ws/converter/UserConverter.java | 33 +++++ .../payload/auth/ForgotPasswordRequest.java | 55 ++++++++ .../ws/payload/auth/LoginRequest.java | 68 ++++++++++ .../ws/payload/auth/LoginResponse.java | 78 +++++++++++ .../ws/payload/auth/RegisterRequest.java | 101 ++++++++++++++ .../ws/payload/auth/ResetPasswordRequest.java | 70 ++++++++++ .../ws/payload/auth/UserResponse.java | 126 ++++++++++++++++++ 7 files changed, 531 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/UserConverter.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ForgotPasswordRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/LoginResponse.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/RegisterRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ResetPasswordRequest.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/UserResponse.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/UserConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/UserConverter.java new file mode 100644 index 0000000..9dbf970 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/UserConverter.java @@ -0,0 +1,33 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.ws.payload.auth.RegisterRequest; +import com.edgareldy.springjdbctutorial.ws.payload.auth.UserResponse; + +/** + * Converts between the core UserDto and the auth HTTP payloads. + *

+ * 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/payload/auth/ForgotPasswordRequest.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ForgotPasswordRequest.java new file mode 100644 index 0000000..9c9ccf1 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/auth/ForgotPasswordRequest.java @@ -0,0 +1,55 @@ +package com.edgareldy.springjdbctutorial.ws.payload.auth; + +import jakarta.validation.constraints.NotBlank; +import java.util.Objects; + +/** + * Body of POST /api/v1/auth/forgot-password. + *

+ * 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 roles; + + private List permissions; + + public UserResponse() { + } + + public UserResponse(Long id, String firstName, String lastName, String email, boolean enabled, List roles, List permissions) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.enabled = enabled; + this.roles = roles; + this.permissions = permissions; + } + + 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 boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public List getRoles() { + return roles; + } + + public void setRoles(List roles) { + this.roles = roles; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UserResponse that = (UserResponse) o; + return Objects.equals(id, that.id) + && Objects.equals(firstName, that.firstName) + && Objects.equals(lastName, that.lastName) + && Objects.equals(email, that.email) + && enabled == that.enabled + && Objects.equals(roles, that.roles) + && Objects.equals(permissions, that.permissions); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, email, enabled, roles, permissions); + } + + @Override + public String toString() { + return "UserResponse{" + "id=" + id + ", firstName=" + firstName + ", lastName=" + lastName + ", email=" + email + ", enabled=" + enabled + ", roles=" + roles + ", permissions=" + permissions + "}"; + } +} From 78555502170577ef543505a56769871bc9c4bb26 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:08 +0400 Subject: [PATCH 19/32] feat(ws): expose the auth endpoints in AuthController The controller depends only on AuthService and JwtService and holds no business rule: register, activate, login (authenticate then issue a JWT), logout (revoke the token of the authenticated principal), me, forgot and reset password. Every body is validated and every answer is an ApiResponse. forgot-password always answers with the same generic message, so the endpoint cannot be used to discover which emails are registered. @AuthenticationPrincipal injects the AuthenticatedUser built by the filter. --- .../ws/controller/AuthController.java | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/AuthController.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/AuthController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/AuthController.java new file mode 100644 index 0000000..948b553 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/AuthController.java @@ -0,0 +1,92 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.auth.dto.BlacklistedTokenDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.ws.converter.UserConverter; +import com.edgareldy.springjdbctutorial.ws.payload.auth.ForgotPasswordRequest; +import com.edgareldy.springjdbctutorial.ws.payload.auth.LoginRequest; +import com.edgareldy.springjdbctutorial.ws.payload.auth.LoginResponse; +import com.edgareldy.springjdbctutorial.ws.payload.auth.RegisterRequest; +import com.edgareldy.springjdbctutorial.ws.payload.auth.ResetPasswordRequest; +import com.edgareldy.springjdbctutorial.ws.payload.auth.UserResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import com.edgareldy.springjdbctutorial.ws.security.AuthenticatedUser; +import com.edgareldy.springjdbctutorial.ws.security.IssuedToken; +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry points of registration, activation, login, logout, profile and password reset. No business logic. + *

+ * 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 register(@Valid @RequestBody RegisterRequest request) { + UserDto created = authService.register(UserConverter.toDto(request)); + return ApiResponse.success(UserConverter.toResponse(created), "Account created, activation required"); + } + + @GetMapping("/activate-account") + public ApiResponse activateAccount(@RequestParam("token") String token) { + authService.activateAccount(token); + return ApiResponse.success(null, "Account activated"); + } + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginRequest request) { + UserDto user = authService.login(request.getEmail(), request.getPassword()); + IssuedToken issued = jwtService.issue(user); + return ApiResponse.success(new LoginResponse(issued.getToken(), "Bearer", issued.getExpiresAt()), "Login successful"); + } + + @PostMapping("/logout") + public ApiResponse logout(@AuthenticationPrincipal AuthenticatedUser principal) { + authService.logout(new BlacklistedTokenDto(principal.getUserId(), principal.getTokenHash(), + principal.getJti(), principal.getIssuedAt(), principal.getExpiresAt())); + return ApiResponse.success(null, "Logged out"); + } + + @GetMapping("/me") + public ApiResponse me(@AuthenticationPrincipal AuthenticatedUser principal) { + return ApiResponse.success(UserConverter.toResponse(authService.getProfile(principal.getUserId())), "Profile"); + } + + @PostMapping("/forgot-password") + public ApiResponse forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) { + authService.forgotPassword(request.getEmail()); + return ApiResponse.success(null, "If the email is registered, a reset link has been generated"); + } + + @PostMapping("/reset-password") + public ApiResponse resetPassword(@Valid @RequestBody ResetPasswordRequest request) { + authService.resetPassword(request.getToken(), request.getNewPassword()); + return ApiResponse.success(null, "Password reset"); + } +} From c92b262f06979cb810841c3c59776b5bb559d1d5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:08 +0400 Subject: [PATCH 20/32] feat(ws): map authentication and access denied exceptions to 401 and 403 Exceptions thrown inside a controller reach the advice before the security filters, so an AccessDeniedException raised by method security would fall into the catch-all and become a 500. The advice now maps AuthenticationFailedException and Spring's AuthenticationException to 401 and AccessDeniedException to 403, all as ApiResponse. This fills the spot the core-architecture branch left open for it. --- .../ws/exception/GlobalExceptionHandler.java | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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> handleNotAcceptable(HttpMediaTypeNotAcc return build(HttpStatus.NOT_ACCEPTABLE, "Not acceptable"); } - // AUTH BRANCH: add handlers here for AccessDeniedException (403) and AuthenticationException (401). - // They are intentionally absent now: no Spring Security dependency exists yet. + // Authentication failures raised by the auth service (bad credentials, inactive account) are 401 + // with the core message. Spring Security's own AuthenticationException gets a generic message. + @ExceptionHandler(AuthenticationFailedException.class) + public ResponseEntity> handleAuthenticationFailed(AuthenticationFailedException e) { + return build(HttpStatus.UNAUTHORIZED, e.getMessage()); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity> handleSpringAuthentication(AuthenticationException e) { + return build(HttpStatus.UNAUTHORIZED, "Authentication required"); + } + + // A denied @PreAuthorize inside a controller surfaces as AccessDeniedException: without this handler + // the catch-all would turn it into a 500. + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException e) { + return build(HttpStatus.FORBIDDEN, "Access denied"); + } // Catch-all. Other Spring MVC exceptions implementing ErrorResponse keep the status they carry but // never their detail text (framework wording): the client gets the standard reason phrase of the From 59a35cda8ec543d1a38792fa54f5ef0426cbd80e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 22:55:08 +0400 Subject: [PATCH 21/32] feat(ws): import the auth module and security into the shared context WebMvcConfig now imports the auth ServiceConfig, by fully qualified name since every core module has a ServiceConfig, and SecurityConfig. One ObjectMapper bean is shared by the MVC converter and the security handlers so all JSON has the same format. WebAppInitializer registers a DelegatingFilterProxy that looks up the springSecurityFilterChain bean in the root context, which is why a single shared context matters. It is registered after the character encoding filter. --- .../ws/config/WebAppInitializer.java | 12 +++++++++ .../ws/config/WebMvcConfig.java | 27 ++++++++++++------- 2 files changed, 29 insertions(+), 10 deletions(-) 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> converters) { - ObjectMapper mapper = Jackson2ObjectMapperBuilder.json() + // ONE shared ObjectMapper bean: the MVC converter below and the security handlers (401/403 bodies, + // written outside MVC) must serialise ApiResponse identically. The builder registers the JSR-310 + // module 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. + @Bean + public ObjectMapper objectMapper() { + return Jackson2ObjectMapperBuilder.json() .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build(); + } + + // Swaps the default Jackson converter for one built on the shared ObjectMapper and keeps every + // other default converter (String, byte[], Resource...). + @Override + public void extendMessageConverters(List> converters) { converters.removeIf(MappingJackson2HttpMessageConverter.class::isInstance); - converters.add(0, new MappingJackson2HttpMessageConverter(mapper)); + converters.add(0, new MappingJackson2HttpMessageConverter(objectMapper())); } // LocalValidatorFactoryBean bootstraps Bean Validation (Hibernate Validator on the classpath). From ec6cfc5f10dc10ea05be5b8d42d86130c083b3bb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 22/32] build(auth): add Testcontainers test dependencies to the auth module The DAO tests of the auth module run against a real PostgreSQL, so the module declares the Testcontainers JUnit and PostgreSQL artifacts, and the common test-jar that holds the shared singleton container, all with test scope. They stay in this module rather than the parent pom because only modules with DAO tests need them. --- core/auth/pom.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/core/auth/pom.xml b/core/auth/pom.xml index d1a4560..1469698 100644 --- a/core/auth/pom.xml +++ b/core/auth/pom.xml @@ -24,5 +24,23 @@ org.springframework.security spring-security-crypto + + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + From 2f9cb0d90e304fb0b790629928b0936b7aa99e0a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 23/32] test(auth): cover the user and token DAOs against PostgreSQL Each DAO test class loads its own dedicated SQL fixture through @Sql before every test, which truncates the tables it touches with RESTART IDENTITY and inserts its own rows, so no dataset is shared between classes. UserDao tests cover case-insensitive lookups, generated keys, a duplicate email differing only by case, and the joins returning distinct sorted permission codes. The token DAO tests cover insert, lookup by digest, marking a token validated, deletion by user, and the purge counts of the blacklist. --- .../dao/impl/ActivationTokenDaoImplTest.java | 85 ++++++++++++ .../dao/impl/BlacklistedTokenDaoImplTest.java | 88 ++++++++++++ .../impl/PasswordResetTokenDaoImplTest.java | 75 ++++++++++ .../core/auth/dao/impl/UserDaoImplTest.java | 128 ++++++++++++++++++ .../activation-token-dao-dataset.sql | 14 ++ .../blacklisted-token-dao-dataset.sql | 14 ++ .../password-reset-token-dao-dataset.sql | 14 ++ .../src/test/resources/user-dao-dataset.sql | 22 +++ 8 files changed, 440 insertions(+) create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImplTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImplTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImplTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImplTest.java create mode 100644 core/auth/src/test/resources/activation-token-dao-dataset.sql create mode 100644 core/auth/src/test/resources/blacklisted-token-dao-dataset.sql create mode 100644 core/auth/src/test/resources/password-reset-token-dao-dataset.sql create mode 100644 core/auth/src/test/resources/user-dao-dataset.sql 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/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; From 56c753cda34e5e1b7236ab31a6ded4127bf38039 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 24/32] test(auth): test the RowMappers with a mocked ResultSet and UserMapper Each RowMapper is fed a Mockito mock of java.sql.ResultSet stubbed column by column, so no database is involved, including the case of a NULL validated_at. UserMapper is tested with hand-built objects and no mocks: toDto never copies the password hash and toEntity uses the hash it is given. These are the fastest tests of the strategy and pin down the mapping contracts the DAO and service layers rely on. --- .../mapper/ActivationTokenRowMapperTest.java | 62 +++++++++++++++++ .../mapper/BlacklistedTokenRowMapperTest.java | 67 +++++++++++++++++++ .../PasswordResetTokenRowMapperTest.java | 52 ++++++++++++++ .../core/auth/mapper/UserMapperTest.java | 64 ++++++++++++++++++ .../core/auth/mapper/UserRowMapperTest.java | 60 +++++++++++++++++ 5 files changed, 305 insertions(+) create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapperTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapperTest.java 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(); + } +} From 19953bab8d2a0ee109441f65c2895a80bc3bbec3 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 25/32] test(auth): unit test AuthServiceImpl and the cleanup job with mocked DAOs The service is tested with Mockito DAOs, a real low-cost bcrypt encoder and a fixed Clock so expiry rules are exact. The tests cover the 72-byte limit with multibyte characters, the digest-only storage of tokens, one identical error for an unknown email and a wrong password, a password over 72 bytes treated as invalid credentials, the inactive-account error only after a correct password, an idempotent logout and a forgotPassword that never throws. ExpiredTokenCleanupJobTest calls run() directly, which is why the scheduled method is public. --- .../service/impl/AuthServiceImplTest.java | 517 ++++++++++++++++++ .../impl/ExpiredTokenCleanupJobTest.java | 29 + 2 files changed, 546 insertions(+) create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java create mode 100644 core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJobTest.java 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..aa3fa23 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java @@ -0,0 +1,517 @@ +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 stored = ArgumentCaptor.forClass(User.class); + verify(userDao).insert(stored.capture()); + assertThat(stored.getValue().getPassword()).isNotEqualTo(PASSWORD); + assertThat(encoder.matches(PASSWORD, stored.getValue().getPassword())).isTrue(); + assertThat(stored.getValue().isEnabled()).isFalse(); + assertThat(stored.getValue().isAccountLocked()).isFalse(); + } + + @Test + void _02_ShouldTrimAndLowerCaseEmail_WhenRegistering() { + stubInsertAssigningId(); + + UserDto created = service.register(registration(" Alice@Example.COM ", PASSWORD)); + + assertThat(created.getEmail()).isEqualTo("alice@example.com"); + verify(userDao).existsByEmail("alice@example.com"); + } + + @Test + void _03_ShouldStoreOnlyTheSha256OfTheActivationToken_WhenRegistering() { + stubInsertAssigningId(); + + service.register(registration("alice@example.com", PASSWORD)); + + ArgumentCaptor stored = ArgumentCaptor.forClass(ActivationToken.class); + verify(activationTokenDao).insert(stored.capture()); + // A raw token is 43 base64url characters: a 64 lowercase hex value can only be a SHA-256 digest + assertThat(stored.getValue().getToken()).matches(HEX_64); + assertThat(stored.getValue().getUserId()).isEqualTo(42L); + assertThat(stored.getValue().getCreatedAt()).isEqualTo(NOW); + assertThat(stored.getValue().getExpiresAt()).isEqualTo(NOW.plusSeconds(24 * 3600)); + assertThat(stored.getValue().getValidatedAt()).isNull(); + } + + @Test + void _04_ShouldThrowBusinessRuleException_WhenEmailIsAlreadyRegistered() { + when(userDao.existsByEmail("alice@example.com")).thenReturn(true); + + assertThatThrownBy(() -> service.register(registration("Alice@example.com", PASSWORD))) + .isInstanceOf(BusinessRuleException.class) + .hasMessage("Email is already registered"); + + verify(userDao, never()).insert(any()); + verifyNoInteractions(activationTokenDao); + } + + @Test + void _05_ShouldThrowBusinessRuleException_WhenInsertLosesARaceOnTheUniqueEmailIndex() { + when(userDao.insert(any(User.class))).thenThrow(new DuplicateKeyException("uq_users_email_lower")); + + assertThatThrownBy(() -> service.register(registration("alice@example.com", PASSWORD))) + .isInstanceOf(BusinessRuleException.class) + .hasMessage("Email is already registered"); + + verifyNoInteractions(activationTokenDao); + } + + @Test + void _06_ShouldThrowBusinessRuleException_WhenPasswordExceeds72BytesWithMultibyteCharacters() { + // 37 x "e acute" is 37 characters but 74 bytes in UTF-8: a character count would let it through + String tooLong = "é".repeat(37); + + assertThatThrownBy(() -> service.register(registration("alice@example.com", tooLong))) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("72 bytes"); + + verify(userDao, never()).insert(any()); + } + + @Test + void _07_ShouldAcceptPassword_WhenItIsExactly72BytesWithMultibyteCharacters() { + stubInsertAssigningId(); + String atLimit = "é".repeat(36); + + UserDto created = service.register(registration("alice@example.com", atLimit)); + + assertThat(created.getId()).isEqualTo(42L); + } + + @Test + void _08_ShouldThrowBusinessRuleException_WhenPasswordIsEmpty() { + assertThatThrownBy(() -> service.register(registration("alice@example.com", ""))) + .isInstanceOf(BusinessRuleException.class); + } + + // ---------------------------------------------------------------- activateAccount + + @Test + void _09_ShouldEnableUserAndMarkTokenValidated_WhenTokenIsValid() throws Exception { + ActivationToken token = new ActivationToken(8L, 5L, sha256("raw-activation"), NOW.minusSeconds(60), + NOW.plusSeconds(3600), null); + // The stub is keyed on the SHA-256 of the raw value: the service must look up by hash, never by raw + when(activationTokenDao.findByTokenHash(sha256("raw-activation"))).thenReturn(Optional.of(token)); + + service.activateAccount("raw-activation"); + + verify(userDao).updateEnabled(5L, true); + verify(activationTokenDao).markValidated(8L, NOW); + } + + @Test + void _10_ShouldThrowBusinessRuleException_WhenActivationTokenIsUnknown() { + when(activationTokenDao.findByTokenHash(anyString())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.activateAccount("unknown")) + .isInstanceOf(BusinessRuleException.class); + + verify(userDao, never()).updateEnabled(anyLong(), anyBoolean()); + } + + @Test + void _11_ShouldThrowBusinessRuleException_WhenActivationTokenIsExpired() { + ActivationToken expired = new ActivationToken(8L, 5L, "h", NOW.minusSeconds(7200), NOW, null); + when(activationTokenDao.findByTokenHash(anyString())).thenReturn(Optional.of(expired)); + + assertThatThrownBy(() -> service.activateAccount("raw")) + .isInstanceOf(BusinessRuleException.class); + + verify(userDao, never()).updateEnabled(anyLong(), anyBoolean()); + verify(activationTokenDao, never()).markValidated(anyLong(), any()); + } + + @Test + void _12_ShouldThrowBusinessRuleException_WhenActivationTokenIsAlreadyValidated() { + ActivationToken used = new ActivationToken(8L, 5L, "h", NOW.minusSeconds(60), NOW.plusSeconds(3600), + NOW.minusSeconds(30)); + when(activationTokenDao.findByTokenHash(anyString())).thenReturn(Optional.of(used)); + + assertThatThrownBy(() -> service.activateAccount("raw")) + .isInstanceOf(BusinessRuleException.class); + + verify(userDao, never()).updateEnabled(anyLong(), anyBoolean()); + } + + // ---------------------------------------------------------------- login + + @Test + void _13_ShouldReturnUserWithRolesAndPermissionsAndNoPassword_WhenCredentialsAreValid() { + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(storedUser(true, false))); + when(userDao.findRoleNamesByUserId(5L)).thenReturn(List.of("ADMIN")); + when(userDao.findPermissionCodesByUserId(5L)).thenReturn(List.of("USER:READ", "USER:WRITE")); + + UserDto user = service.login(" Alice@Example.com ", PASSWORD); + + assertThat(user.getId()).isEqualTo(5L); + assertThat(user.getPassword()).isNull(); + assertThat(user.getRoles()).containsExactly("ADMIN"); + assertThat(user.getPermissions()).containsExactly("USER:READ", "USER:WRITE"); + } + + @Test + void _14_ShouldThrowInvalidCredentials_WhenEmailIsUnknown() { + when(userDao.findByEmail("nobody@example.com")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.login("nobody@example.com", PASSWORD)) + .isInstanceOf(InvalidCredentialsException.class); + + // The fake verification runs (one bcrypt check) but never against the submitted password + verify(encoder, times(1)).matches(eq("x"), anyString()); + verify(encoder, never()).matches(eq(PASSWORD), anyString()); + } + + @Test + void _15_ShouldThrowTheSameException_WhenPasswordIsWrongAsWhenEmailIsUnknown() { + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(storedUser(true, false))); + when(userDao.findByEmail("nobody@example.com")).thenReturn(Optional.empty()); + + InvalidCredentialsException wrongPassword = catchInvalid(() -> service.login("alice@example.com", "Wrong-pass1")); + InvalidCredentialsException unknownEmail = catchInvalid(() -> service.login("nobody@example.com", PASSWORD)); + + assertThat(wrongPassword).isNotNull(); + assertThat(unknownEmail).isNotNull(); + assertThat(wrongPassword.getMessage()).isEqualTo(unknownEmail.getMessage()); + assertThat(wrongPassword.getClass()).isEqualTo(unknownEmail.getClass()); + } + + private static InvalidCredentialsException catchInvalid(Runnable call) { + try { + call.run(); + } catch (InvalidCredentialsException e) { + return e; + } + return null; + } + + @Test + void _16_ShouldThrowInvalidCredentialsAndNeverReachTheHash_WhenPasswordExceeds72Bytes() { + User user = storedUser(true, false); + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(user)); + String tooLong = "é".repeat(37); + + assertThatThrownBy(() -> service.login("alice@example.com", tooLong)) + .isInstanceOf(InvalidCredentialsException.class); + + verify(encoder, never()).matches(eq(tooLong), anyString()); + } + + @Test + void _17_ShouldThrowAccountNotActive_WhenPasswordIsCorrectButAccountIsDisabled() { + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(storedUser(false, false))); + + assertThatThrownBy(() -> service.login("alice@example.com", PASSWORD)) + .isInstanceOf(AccountNotActiveException.class); + } + + @Test + void _18_ShouldThrowAccountNotActive_WhenPasswordIsCorrectButAccountIsLocked() { + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(storedUser(true, true))); + + assertThatThrownBy(() -> service.login("alice@example.com", PASSWORD)) + .isInstanceOf(AccountNotActiveException.class); + } + + @Test + void _19_ShouldThrowInvalidCredentialsNotAccountNotActive_WhenPasswordIsWrongOnADisabledAccount() { + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(storedUser(false, false))); + + assertThatThrownBy(() -> service.login("alice@example.com", "Wrong-pass1")) + .isInstanceOf(InvalidCredentialsException.class); + } + + @Test + void _20_ShouldThrowInvalidCredentials_WhenEmailIsNull() { + assertThatThrownBy(() -> service.login(null, PASSWORD)).isInstanceOf(InvalidCredentialsException.class); + } + + // ---------------------------------------------------------------- logout / blacklist + + @Test + void _21_ShouldStoreTokenHashAndJti_WhenLoggingOut() { + when(blacklistedTokenDao.existsByTokenHash("hash")).thenReturn(false); + Instant issued = NOW.minusSeconds(600); + Instant expires = NOW.plusSeconds(3000); + + service.logout(new BlacklistedTokenDto(5L, "hash", "jti-1", issued, expires)); + + ArgumentCaptor stored = ArgumentCaptor.forClass(BlacklistedToken.class); + verify(blacklistedTokenDao).insert(stored.capture()); + assertThat(stored.getValue().getToken()).isEqualTo("hash"); + assertThat(stored.getValue().getJti()).isEqualTo("jti-1"); + assertThat(stored.getValue().getUserId()).isEqualTo(5L); + assertThat(stored.getValue().getBlacklistedAt()).isEqualTo(NOW); + assertThat(stored.getValue().getCreatedAt()).isEqualTo(issued); + assertThat(stored.getValue().getExpiresAt()).isEqualTo(expires); + } + + @Test + void _22_ShouldInsertNothing_WhenTokenIsAlreadyBlacklisted() { + when(blacklistedTokenDao.existsByTokenHash("hash")).thenReturn(true); + + service.logout(new BlacklistedTokenDto(5L, "hash", "jti-1", NOW, NOW.plusSeconds(60))); + + verify(blacklistedTokenDao, never()).insert(any()); + } + + @Test + void _23_ShouldNotThrow_WhenConcurrentLogoutHitsTheUniqueIndex() { + when(blacklistedTokenDao.existsByTokenHash("hash")).thenReturn(false); + when(blacklistedTokenDao.insert(any())).thenThrow(new DuplicateKeyException("uq_blacklisted_tokens_jti")); + + assertThatCode(() -> service.logout(new BlacklistedTokenDto(5L, "hash", "jti-1", NOW, NOW.plusSeconds(60)))) + .doesNotThrowAnyException(); + } + + @Test + void _24_ShouldReturnDaoAnswer_WhenAskingIfTokenIsBlacklisted() { + when(blacklistedTokenDao.existsByTokenHash("revoked")).thenReturn(true); + when(blacklistedTokenDao.existsByTokenHash("fresh")).thenReturn(false); + + assertThat(service.isTokenBlacklisted("revoked")).isTrue(); + assertThat(service.isTokenBlacklisted("fresh")).isFalse(); + } + + // ---------------------------------------------------------------- getProfile + + @Test + void _25_ShouldReturnProfileWithRolesAndPermissions_WhenUserExists() { + when(userDao.findById(5L)).thenReturn(Optional.of(storedUser(true, false))); + when(userDao.findRoleNamesByUserId(5L)).thenReturn(List.of("ADMIN")); + when(userDao.findPermissionCodesByUserId(5L)).thenReturn(List.of("USER:READ")); + + UserDto profile = service.getProfile(5L); + + assertThat(profile.getEmail()).isEqualTo("alice@example.com"); + assertThat(profile.getPassword()).isNull(); + assertThat(profile.getRoles()).containsExactly("ADMIN"); + assertThat(profile.getPermissions()).containsExactly("USER:READ"); + } + + @Test + void _26_ShouldThrowResourceNotFound_WhenProfileUserDoesNotExist() { + when(userDao.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getProfile(99L)).isInstanceOf(ResourceNotFoundException.class); + } + + // ---------------------------------------------------------------- forgotPassword + + @Test + void _27_ShouldReplaceTokensAndStoreOnlyTheHash_WhenEmailIsKnown() { + when(userDao.findByEmail("alice@example.com")).thenReturn(Optional.of(storedUser(true, false))); + + service.forgotPassword("Alice@Example.com"); + + verify(passwordResetTokenDao).deleteByUserId(5L); + ArgumentCaptor stored = ArgumentCaptor.forClass(PasswordResetToken.class); + verify(passwordResetTokenDao).insert(stored.capture()); + assertThat(stored.getValue().getToken()).matches(HEX_64); + assertThat(stored.getValue().getUserId()).isEqualTo(5L); + assertThat(stored.getValue().getType()).isEqualTo("PASSWORD_RESET"); + assertThat(stored.getValue().getExpiryDate()).isEqualTo(NOW.plusSeconds(3600)); + } + + @Test + void _28_ShouldNotThrowAndStillRunTheDelete_WhenEmailIsUnknown() { + when(userDao.findByEmail("nobody@example.com")).thenReturn(Optional.empty()); + + assertThatCode(() -> service.forgotPassword("nobody@example.com")).doesNotThrowAnyException(); + + // Same lookup and same delete as for a known email: only the final insert is skipped + verify(passwordResetTokenDao).deleteByUserId(anyLong()); + verify(passwordResetTokenDao, never()).insert(any()); + } + + @Test + void _29_ShouldNotThrow_WhenEmailIsNull() { + assertThatCode(() -> service.forgotPassword(null)).doesNotThrowAnyException(); + + verify(passwordResetTokenDao, never()).insert(any()); + } + + // ---------------------------------------------------------------- resetPassword + + @Test + void _30_ShouldStoreNewHashAndDeleteResetTokens_WhenTokenIsValid() throws Exception { + PasswordResetToken token = new PasswordResetToken(3L, 5L, sha256("raw-reset"), "PASSWORD_RESET", + NOW.plusSeconds(600)); + when(passwordResetTokenDao.findByTokenHash(sha256("raw-reset"))).thenReturn(Optional.of(token)); + + service.resetPassword("raw-reset", "Brand-new-pass1"); + + ArgumentCaptor hash = ArgumentCaptor.forClass(String.class); + verify(userDao).updatePassword(eq(5L), hash.capture()); + assertThat(encoder.matches("Brand-new-pass1", hash.getValue())).isTrue(); + verify(passwordResetTokenDao).deleteByUserId(5L); + } + + @Test + void _31_ShouldThrowBusinessRuleException_WhenResetTokenIsExpired() { + PasswordResetToken expired = new PasswordResetToken(3L, 5L, "h", "PASSWORD_RESET", NOW); + when(passwordResetTokenDao.findByTokenHash(anyString())).thenReturn(Optional.of(expired)); + + assertThatThrownBy(() -> service.resetPassword("raw", "Brand-new-pass1")) + .isInstanceOf(BusinessRuleException.class); + + verify(userDao, never()).updatePassword(anyLong(), anyString()); + } + + @Test + void _32_ShouldThrowBusinessRuleException_WhenResetTokenIsUnknown() { + when(passwordResetTokenDao.findByTokenHash(anyString())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.resetPassword("raw", "Brand-new-pass1")) + .isInstanceOf(BusinessRuleException.class); + + verify(userDao, never()).updatePassword(anyLong(), anyString()); + } + + @Test + void _33_ShouldThrowBusinessRuleException_WhenNewPasswordExceeds72Bytes() { + PasswordResetToken token = new PasswordResetToken(3L, 5L, "h", "PASSWORD_RESET", NOW.plusSeconds(600)); + when(passwordResetTokenDao.findByTokenHash(anyString())).thenReturn(Optional.of(token)); + + assertThatThrownBy(() -> service.resetPassword("raw", "é".repeat(37))) + .isInstanceOf(BusinessRuleException.class) + .hasMessageContaining("72 bytes"); + + verify(userDao, never()).updatePassword(anyLong(), anyString()); + verify(passwordResetTokenDao, never()).deleteByUserId(anyLong()); + } + + // ---------------------------------------------------------------- purgeExpiredTokens + + @Test + void _34_ShouldDeleteRowsExpiredBeforeNowAndReturnTheCount_WhenPurging() { + when(blacklistedTokenDao.deleteExpiredBefore(NOW)).thenReturn(3); + + assertThat(service.purgeExpiredTokens()).isEqualTo(3); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJobTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJobTest.java new file mode 100644 index 0000000..fbc91ca --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJobTest.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import org.junit.jupiter.api.Test; + +/** + * Tests ExpiredTokenCleanupJob: run() is public, so it is called directly instead of waiting for the cron. + *

+ * 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(); + } +} From a5a2336ae98f038c80f11d2f7ea93dab5cdc3135 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 26/32] test(ws): add shared fixtures and assertions for HTTP tests AuthTestSupport registers, activates and logs in a user through the real endpoints and returns the JWT, reading raw tokens from the INFO log with a logback ListAppender since no mailer exists. ApiAssertions checks the ApiResponse envelope of successes and errors, and JwtTestSupport builds the service on the development keys and forges tokens with other keys. They are meant to be reused by the controller tests of the later branches. --- .../ws/support/ApiAssertions.java | 57 +++++++ .../ws/support/AuthTestSupport.java | 140 ++++++++++++++++++ .../ws/support/JwtTestSupport.java | 114 ++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/ApiAssertions.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/AuthTestSupport.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/JwtTestSupport.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/ApiAssertions.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/ApiAssertions.java new file mode 100644 index 0000000..93ebd64 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/ApiAssertions.java @@ -0,0 +1,57 @@ +package com.edgareldy.springjdbctutorial.ws.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.UnsupportedEncodingException; +import org.springframework.test.web.servlet.MvcResult; + +/** + * Shared assertions on the ApiResponse envelope, so every test checks the README error format the same way. + *

+ * 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 appender = new ListAppender<>(); + + public AuthTestSupport(MockMvc mockMvc) { + this.mockMvc = mockMvc; + this.logger = (Logger) LoggerFactory.getLogger(SERVICE_LOGGER); + logger.setLevel(Level.INFO); + appender.start(); + logger.addAppender(appender); + } + + /** Detaches the appender: call it from an @AfterEach so log capture never leaks between test classes. */ + @Override + public void close() { + logger.detachAppender(appender); + appender.stop(); + } + + /** An email never used before, so tests sharing one database never collide. */ + public static String uniqueEmail(String prefix) { + return prefix + "-" + UUID.randomUUID() + "@example.com"; + } + + public static String registerBody(String email, String password) { + return "{\"firstName\":\"Test\",\"lastName\":\"User\",\"email\":\"" + email + "\",\"password\":\"" + password + "\"}"; + } + + public MvcResult register(String email, String password) throws Exception { + return mockMvc.perform(post("/api/v1/auth/register").contentType(MediaType.APPLICATION_JSON) + .content(registerBody(email, password))).andReturn(); + } + + public MvcResult activate(String rawToken) throws Exception { + return mockMvc.perform(get("/api/v1/auth/activate-account").param("token", rawToken)).andReturn(); + } + + public MvcResult login(String email, String password) throws Exception { + return mockMvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}")).andReturn(); + } + + public MvcResult forgotPassword(String email) throws Exception { + return mockMvc.perform(post("/api/v1/auth/forgot-password").contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + email + "\"}")).andReturn(); + } + + public MvcResult resetPassword(String rawToken, String newPassword) throws Exception { + return mockMvc.perform(post("/api/v1/auth/reset-password").contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"" + rawToken + "\",\"newPassword\":\"" + newPassword + "\"}")).andReturn(); + } + + public MvcResult logout(String jwt) throws Exception { + return mockMvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + jwt)).andReturn(); + } + + /** GET on a route with the Bearer header, for the common "call as this user" case. */ + public MvcResult getAs(String jwt, String url) throws Exception { + return mockMvc.perform(get(url).header("Authorization", "Bearer " + jwt)).andReturn(); + } + + /** The most recent raw activation token logged for the email. */ + public String activationToken(String email) { + return lastToken(ACTIVATION, email); + } + + /** The most recent raw password reset token logged for the email. */ + public String resetToken(String email) { + return lastToken(RESET, email); + } + + /** Registers, activates and logs in a brand new user, then returns its JWT. */ + public String registerActivateAndLogin(String email, String password) throws Exception { + register(email, password); + activate(activationToken(email)); + return loginToken(email, password); + } + + /** Logs in (expects success) and returns the JWT of the response. */ + public String loginToken(String email, String password) throws Exception { + MvcResult result = login(email, password); + JsonNode json = ApiAssertions.assertSuccess(result, 200); + return json.get("data").get("token").asText(); + } + + private String lastToken(Pattern pattern, String email) { + List events = new ArrayList<>(appender.list); + String found = null; + for (ILoggingEvent event : events) { + Matcher matcher = pattern.matcher(event.getFormattedMessage()); + if (matcher.find() && matcher.group(1).equalsIgnoreCase(email)) { + found = matcher.group(2); + } + } + if (found == null) { + throw new AssertionError("No token logged for " + email); + } + return found; + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/JwtTestSupport.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/JwtTestSupport.java new file mode 100644 index 0000000..3c12ef6 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/support/JwtTestSupport.java @@ -0,0 +1,114 @@ +package com.edgareldy.springjdbctutorial.ws.support; + +import com.edgareldy.springjdbctutorial.ws.security.JwtService; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import javax.crypto.SecretKey; +import org.springframework.core.io.DefaultResourceLoader; + +/** + * Test helpers around JWTs: a JwtService built on the development key pair (the locations surefire passes as + * system properties), throw-away RSA keys, PEM encoding and forged tokens (foreign key, HS256, alg none...). + *

+ * 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")); + } +} From 2d20d42a89672ca38a73c061be7a53441397d9ce Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 27/32] test(ws): test JwtService and JwtKeyLoader The tests cover the issue and parse round trip, unique jti values, a tampered signature or payload, expiry through a fixed Clock including the boundary, a wrong issuer, a foreign key, and the classic algorithm confusion attacks: HS256 signed with the public key, alg none and RS384. JwtKeyLoader is checked for a missing property, a PEM of the wrong type in both directions, an unreadable file and invalid Base64, each with a clear failure message. --- .../ws/security/JwtKeyLoaderTest.java | 104 +++++++++++ .../ws/security/JwtServiceTest.java | 171 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoaderTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtServiceTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoaderTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoaderTest.java new file mode 100644 index 0000000..a9f6665 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/security/JwtKeyLoaderTest.java @@ -0,0 +1,104 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.ws.support.JwtTestSupport; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.ResourceLoader; + +/** + * Tests JwtKeyLoader: valid PEM keys load, and every misconfiguration fails with a clear message. + *

+ * 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 parsed = service.parse(service.issue(noPermissions).getToken()); + + assertThat(parsed.orElseThrow().getPermissions()).isEmpty(); + } + + @Test + void _14_ShouldComputeKnownSha256_WhenHashingAValue() { + assertThat(JwtService.sha256Hex("abc")) + .isEqualTo("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + } +} From 7caed75ed4b72cf7484e8cff13279a6bfdcb82c2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 28/32] test(ws): test the auth endpoints and the security chain with MockMvc The real SecurityConfig and a mocked AuthService are driven through MockMvc with springSecurity() applied. Every route is checked for status and ApiResponse shape, validation errors are checked per field, and the chain must answer 401 in the ApiResponse format for a missing, garbage, expired, wrongly signed, HS256, alg none or blacklisted token. forgot-password is asserted to return the same status and body for a known and an unknown email, and logout to build its BlacklistedTokenDto from the authenticated principal. --- .../ws/mvc/AuthControllerTest.java | 455 ++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/AuthControllerTest.java 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> converters) { + real.extendMessageConverters(converters); + } + + @Override + public Validator getValidator() { + return real.getValidator(); + } + } + + @Autowired + private WebApplicationContext context; + @Autowired + private AuthService authService; + @Autowired + private JwtService jwtService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + reset(authService); + // springSecurity() adds the springSecurityFilterChain bean of the context in front of the + // DispatcherServlet, exactly like the DelegatingFilterProxy does in Tomcat. Without it MockMvc + // would bypass the JWT filter and every request would look unauthenticated-but-allowed. + mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + } + + private static UserDto profile() { + return new UserDto(5L, "Alice", "Martin", "alice@example.com", null, true, false, + List.of("ADMIN"), List.of("USER:READ")); + } + + private IssuedToken issueToken() { + return jwtService.issue(profile()); + } + + private MvcResult call(MockHttpServletRequestBuilder request) throws Exception { + return mockMvc.perform(request).andReturn(); + } + + private MockHttpServletRequestBuilder me(String jwt) { + return get("/api/v1/auth/me").header("Authorization", "Bearer " + jwt); + } + + private static MockHttpServletRequestBuilder postJson(String url, String body) { + return post(url).contentType(MediaType.APPLICATION_JSON).content(body); + } + + // ---------------------------------------------------------------- register + + @Test + void _01_ShouldReturn201WithProfileAndNoPassword_WhenRegistrationIsValid() throws Exception { + when(authService.register(any(UserDto.class))) + .thenReturn(new UserDto(42L, "Alice", "Martin", "alice@example.com", null, false, false, + List.of(), List.of())); + + MvcResult result = call(postJson("/api/v1/auth/register", + "{\"firstName\":\"Alice\",\"lastName\":\"Martin\",\"email\":\"alice@example.com\",\"password\":\"Str0ng-Password!\"}")); + + JsonNode json = assertSuccess(result, 201); + assertThat(json.get("data").get("id").asLong()).isEqualTo(42L); + assertThat(json.get("data").get("email").asText()).isEqualTo("alice@example.com"); + assertThat(json.get("data").get("enabled").asBoolean()).isFalse(); + assertThat(json.get("data").has("password")).isFalse(); + ArgumentCaptor sent = ArgumentCaptor.forClass(UserDto.class); + verify(authService).register(sent.capture()); + assertThat(sent.getValue().getFirstName()).isEqualTo("Alice"); + assertThat(sent.getValue().getPassword()).isEqualTo("Str0ng-Password!"); + } + + @Test + void _02_ShouldReturn400WithPerFieldMessages_WhenRegistrationBodyIsInvalid() throws Exception { + MvcResult result = call(postJson("/api/v1/auth/register", + "{\"firstName\":\"\",\"lastName\":\"Martin\",\"email\":\"not-an-email\",\"password\":\"short\"}")); + + JsonNode json = assertError(result, 400); + String message = json.get("message").asText(); + assertThat(message).contains("firstName: ").contains("email: ").contains("password: "); + assertThat(message).doesNotContain("lastName"); + verifyNoInteractions(authService); + } + + @Test + void _03_ShouldReturn422_WhenServiceRefusesADuplicateEmail() throws Exception { + when(authService.register(any(UserDto.class))).thenThrow(new BusinessRuleException("Email is already registered")); + + MvcResult result = call(postJson("/api/v1/auth/register", + "{\"firstName\":\"Alice\",\"lastName\":\"Martin\",\"email\":\"alice@example.com\",\"password\":\"Str0ng-Password!\"}")); + + JsonNode json = assertError(result, 422); + assertThat(json.get("message").asText()).isEqualTo("Email is already registered"); + } + + @Test + void _04_ShouldReturn400_WhenRegistrationJsonIsMalformed() throws Exception { + MvcResult result = call(postJson("/api/v1/auth/register", "{\"firstName\":")); + + assertError(result, 400); + } + + @Test + void _05_ShouldReturn415_WhenRegistrationContentTypeIsNotJson() throws Exception { + MvcResult result = call(post("/api/v1/auth/register").contentType(MediaType.TEXT_PLAIN).content("hello")); + + assertError(result, 415); + } + + @Test + void _06_ShouldReturn405_WhenAuthenticatedUserCallsRegisterWithGet() throws Exception { + MvcResult result = call(get("/api/v1/auth/register") + .header("Authorization", "Bearer " + issueToken().getToken())); + + assertError(result, 405); + } + + // ---------------------------------------------------------------- activate-account + + @Test + void _07_ShouldReturn200_WhenActivationTokenIsAccepted() throws Exception { + MvcResult result = call(get("/api/v1/auth/activate-account").param("token", "raw-token")); + + assertSuccess(result, 200); + verify(authService).activateAccount("raw-token"); + } + + @Test + void _08_ShouldReturn400_WhenActivationTokenParameterIsMissing() throws Exception { + MvcResult result = call(get("/api/v1/auth/activate-account")); + + JsonNode json = assertError(result, 400); + assertThat(json.get("message").asText()).isEqualTo("Missing parameter: token"); + } + + @Test + void _09_ShouldReturn422_WhenActivationTokenIsRefusedByTheService() throws Exception { + doThrow(new BusinessRuleException("Invalid or expired activation token")) + .when(authService).activateAccount("bad"); + + assertError(call(get("/api/v1/auth/activate-account").param("token", "bad")), 422); + } + + // ---------------------------------------------------------------- login + + @Test + void _10_ShouldReturn200WithAValidJwt_WhenCredentialsAreCorrect() throws Exception { + when(authService.login("alice@example.com", "Str0ng-Password!")).thenReturn(profile()); + + MvcResult result = call(postJson("/api/v1/auth/login", + "{\"email\":\"alice@example.com\",\"password\":\"Str0ng-Password!\"}")); + + JsonNode json = assertSuccess(result, 200); + assertThat(json.get("data").get("tokenType").asText()).isEqualTo("Bearer"); + assertThat(Instant.parse(json.get("data").get("expiresAt").asText())).isAfter(Instant.now()); + String token = json.get("data").get("token").asText(); + assertThat(jwtService.parse(token).orElseThrow().getUserId()).isEqualTo(5L); + assertThat(jwtService.parse(token).orElseThrow().getPermissions()).containsExactly("USER:READ"); + } + + @Test + void _11_ShouldReturn401WithErrorShape_WhenCredentialsAreInvalid() throws Exception { + when(authService.login(anyString(), anyString())).thenThrow(new InvalidCredentialsException()); + + JsonNode json = assertError(call(postJson("/api/v1/auth/login", + "{\"email\":\"alice@example.com\",\"password\":\"wrong\"}")), 401); + + assertThat(json.get("message").asText()).isEqualTo("Invalid email or password"); + } + + @Test + void _12_ShouldReturn401_WhenAccountIsNotActive() throws Exception { + when(authService.login(anyString(), anyString())).thenThrow(new AccountNotActiveException()); + + JsonNode json = assertError(call(postJson("/api/v1/auth/login", + "{\"email\":\"alice@example.com\",\"password\":\"Str0ng-Password!\"}")), 401); + + assertThat(json.get("message").asText()).isEqualTo("Account is not activated or is locked"); + } + + @Test + void _13_ShouldReturn400WithPerFieldMessages_WhenLoginBodyIsBlank() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/auth/login", "{\"email\":\"\",\"password\":\"\"}")), 400); + + assertThat(json.get("message").asText()).contains("email: ").contains("password: "); + verifyNoInteractions(authService); + } + + // ---------------------------------------------------------------- /me and the JWT filter + + @Test + void _14_ShouldReturn200WithProfile_WhenBearerTokenIsValid() throws Exception { + when(authService.getProfile(5L)).thenReturn(profile()); + + JsonNode json = assertSuccess(call(me(issueToken().getToken())), 200); + + assertThat(json.get("data").get("email").asText()).isEqualTo("alice@example.com"); + assertThat(json.get("data").get("roles").get(0).asText()).isEqualTo("ADMIN"); + assertThat(json.get("data").get("permissions").get(0).asText()).isEqualTo("USER:READ"); + assertThat(json.get("data").has("password")).isFalse(); + } + + @Test + void _15_ShouldReturn401ErrorShape_WhenNoTokenIsSent() throws Exception { + assertError(call(get("/api/v1/auth/me")), 401); + + verifyNoInteractions(authService); + } + + @Test + void _16_ShouldReturn401_WhenTokenIsGarbage() throws Exception { + assertError(call(me("this-is-not-a-jwt")), 401); + } + + @Test + void _17_ShouldReturn401_WhenTokenIsExpired() throws Exception { + JwtService twoHoursAgo = JwtTestSupport.devJwtService( + Clock.fixed(Instant.now().minusSeconds(7200), ZoneOffset.UTC)); + + assertError(call(me(twoHoursAgo.issue(profile()).getToken())), 401); + } + + @Test + void _18_ShouldReturn401_WhenTokenIsSignedWithAnotherRsaKey() throws Exception { + KeyPair foreign = JwtTestSupport.generateKeyPair(); + String forged = JwtTestSupport.rs256Token(foreign.getPrivate(), 5L, Instant.now(), + Instant.now().plusSeconds(3600)); + + assertError(call(me(forged)), 401); + } + + @Test + void _19_ShouldReturn401_WhenTokenIsHs256SignedWithThePublicKey() throws Exception { + KeyPair pair = JwtTestSupport.generateKeyPair(); + String forged = JwtTestSupport.hs256TokenSignedWithPublicKey(pair.getPublic(), 5L, Instant.now(), + Instant.now().plusSeconds(3600)); + + assertError(call(me(forged)), 401); + } + + @Test + void _20_ShouldReturn401_WhenTokenIsUnsignedWithAlgNone() throws Exception { + String unsigned = JwtTestSupport.unsignedToken(5L, Instant.now(), Instant.now().plusSeconds(3600)); + + assertError(call(me(unsigned)), 401); + } + + @Test + void _21_ShouldReturn401AndNeverLoadTheProfile_WhenTokenIsBlacklisted() throws Exception { + String token = issueToken().getToken(); + when(authService.isTokenBlacklisted(JwtService.sha256Hex(token))).thenReturn(true); + + assertError(call(me(token)), 401); + + verify(authService, never()).getProfile(anyLong()); + } + + // ---------------------------------------------------------------- logout + + @Test + void _22_ShouldBlacklistTheTokenBuiltFromThePrincipal_WhenLoggingOut() throws Exception { + IssuedToken issued = issueToken(); + + assertSuccess(call(post("/api/v1/auth/logout").header("Authorization", "Bearer " + issued.getToken())), 200); + + ArgumentCaptor sent = ArgumentCaptor.forClass(BlacklistedTokenDto.class); + verify(authService).logout(sent.capture()); + BlacklistedTokenDto dto = sent.getValue(); + assertThat(dto.getUserId()).isEqualTo(5L); + assertThat(dto.getTokenHash()).isEqualTo(JwtService.sha256Hex(issued.getToken())); + assertThat(dto.getTokenHash()).isNotEqualTo(issued.getToken()); + assertThat(dto.getJti()).isEqualTo(jwtService.parse(issued.getToken()).orElseThrow().getJti()); + assertThat(dto.getIssuedAt()).isEqualTo(issued.getIssuedAt()); + assertThat(dto.getExpiresAt()).isEqualTo(issued.getExpiresAt()); + } + + @Test + void _23_ShouldReturn401AndNotTouchTheService_WhenLoggingOutWithoutToken() throws Exception { + assertError(call(post("/api/v1/auth/logout")), 401); + + verify(authService, never()).logout(any()); + } + + // ---------------------------------------------------------------- forgot / reset password + + @Test + void _24_ShouldReturnTheSameStatusAndMessage_WhenEmailIsKnownOrUnknown() throws Exception { + JsonNode known = assertSuccess(call(postJson("/api/v1/auth/forgot-password", + "{\"email\":\"alice@example.com\"}")), 200); + JsonNode unknown = assertSuccess(call(postJson("/api/v1/auth/forgot-password", + "{\"email\":\"nobody@example.com\"}")), 200); + + assertThat(known.get("message").asText()).isEqualTo(unknown.get("message").asText()); + assertThat(known.has("data") && !known.get("data").isNull()).isFalse(); + assertThat(unknown.has("data") && !unknown.get("data").isNull()).isFalse(); + verify(authService).forgotPassword("alice@example.com"); + verify(authService).forgotPassword("nobody@example.com"); + } + + @Test + void _25_ShouldReturn400_WhenForgotPasswordEmailIsBlank() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/auth/forgot-password", "{\"email\":\"\"}")), 400); + + assertThat(json.get("message").asText()).contains("email: "); + } + + @Test + void _26_ShouldReturn200_WhenResetPasswordIsAccepted() throws Exception { + assertSuccess(call(postJson("/api/v1/auth/reset-password", + "{\"token\":\"raw-reset\",\"newPassword\":\"Brand-new-pass1\"}")), 200); + + verify(authService).resetPassword("raw-reset", "Brand-new-pass1"); + } + + @Test + void _27_ShouldReturn422_WhenResetTokenIsRefusedByTheService() throws Exception { + doThrow(new BusinessRuleException("Invalid or expired reset token")) + .when(authService).resetPassword(anyString(), anyString()); + + assertError(call(postJson("/api/v1/auth/reset-password", + "{\"token\":\"bad\",\"newPassword\":\"Brand-new-pass1\"}")), 422); + } + + @Test + void _28_ShouldReturn400WithPerFieldMessages_WhenNewPasswordIsTooShort() throws Exception { + JsonNode json = assertError(call(postJson("/api/v1/auth/reset-password", + "{\"token\":\"raw\",\"newPassword\":\"short\"}")), 400); + + assertThat(json.get("message").asText()).contains("newPassword: "); + verifyNoInteractions(authService); + } + + // ---------------------------------------------------------------- unknown URL + + @Test + void _29_ShouldReturn401NotSpecific404_WhenUnknownUrlIsCalledWithoutToken() throws Exception { + assertError(call(get("/api/v1/does-not-exist")), 401); + } + + @Test + void _30_ShouldReturn404ErrorShape_WhenUnknownUrlIsCalledWithAValidToken() throws Exception { + assertError(call(get("/api/v1/does-not-exist") + .header("Authorization", "Bearer " + issueToken().getToken())), 404); + } +} From 28d372167f73e47dea5d9d27e33d386ea48c68f3 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:19:27 +0400 Subject: [PATCH 29/32] test(ws): prove the full auth flow against the real stack The integration test boots the real WebMvcConfig on a Testcontainers database and walks register, activate, login, /me, logout. The same token then gets 401 while a second token of the same user still gets 200, which proves the blacklist really rejects a revoked token. It also checks that only SHA-256 digests are stored, the reset flow, single-use tokens, identical answers for wrong password, unknown email and over-long password, and the cleanup of expired blacklist rows. The existing initializer and context tests are adapted to the new security filter and to the fact that an unknown URL is 401 without a token. --- .../ws/config/WebAppInitializerTest.java | 35 +++ .../config/WebMvcConfigIntegrationTest.java | 31 +- .../integration/AuthFlowIntegrationTest.java | 266 ++++++++++++++++++ 3 files changed, 320 insertions(+), 12 deletions(-) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/integration/AuthFlowIntegrationTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java index fa1304d..26388ca 100644 --- a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java @@ -3,19 +3,24 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import jakarta.servlet.DispatcherType; import jakarta.servlet.Filter; import jakarta.servlet.FilterRegistration; import jakarta.servlet.Servlet; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletRegistration; +import java.util.EnumSet; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.springframework.web.context.ContextLoaderListener; import org.springframework.web.filter.CharacterEncodingFilter; +import org.springframework.web.filter.DelegatingFilterProxy; import org.springframework.web.servlet.DispatcherServlet; /** @@ -37,6 +42,8 @@ void _01_ShouldRegisterDispatcherServletOnRoot_WhenApplicationStarts() { when(servletContext.addServlet(eq("dispatcher"), any(Servlet.class))).thenReturn(servletRegistration); when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))) .thenReturn(filterRegistration); + when(servletContext.addFilter(eq("springSecurityFilterChain"), any(Filter.class))) + .thenReturn(mock(FilterRegistration.Dynamic.class)); new WebAppInitializer().onStartup(servletContext); @@ -54,6 +61,8 @@ void _02_ShouldRegisterContextLoaderListener_WhenApplicationStarts() { .thenReturn(mock(ServletRegistration.Dynamic.class)); when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))) .thenReturn(mock(FilterRegistration.Dynamic.class)); + when(servletContext.addFilter(eq("springSecurityFilterChain"), any(Filter.class))) + .thenReturn(mock(FilterRegistration.Dynamic.class)); new WebAppInitializer().onStartup(servletContext); @@ -70,6 +79,8 @@ void _03_ShouldRegisterUtf8EncodingFilterOnEveryUrl_WhenApplicationStarts() { .thenReturn(mock(ServletRegistration.Dynamic.class)); when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))) .thenReturn(filterRegistration); + when(servletContext.addFilter(eq("springSecurityFilterChain"), any(Filter.class))) + .thenReturn(mock(FilterRegistration.Dynamic.class)); new WebAppInitializer().onStartup(servletContext); @@ -78,4 +89,28 @@ void _03_ShouldRegisterUtf8EncodingFilterOnEveryUrl_WhenApplicationStarts() { assertThat(filter.getValue()).isInstanceOf(CharacterEncodingFilter.class); verify(filterRegistration).addMappingForUrlPatterns(any(), eq(false), eq("/*")); } + + @Test + void _04_ShouldRegisterSecurityFilterChainProxyAfterEncodingFilter_WhenApplicationStarts() { + ServletContext servletContext = mock(ServletContext.class); + FilterRegistration.Dynamic encoding = mock(FilterRegistration.Dynamic.class); + FilterRegistration.Dynamic security = mock(FilterRegistration.Dynamic.class); + when(servletContext.addServlet(eq("dispatcher"), any(Servlet.class))) + .thenReturn(mock(ServletRegistration.Dynamic.class)); + when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))).thenReturn(encoding); + when(servletContext.addFilter(eq("springSecurityFilterChain"), any(Filter.class))).thenReturn(security); + + new WebAppInitializer().onStartup(servletContext); + + // Registration order is the servlet filter order: encoding first, then security + InOrder order = inOrder(servletContext); + order.verify(servletContext).addFilter(eq("characterEncodingFilter"), any(Filter.class)); + order.verify(servletContext).addFilter(eq("springSecurityFilterChain"), any(Filter.class)); + ArgumentCaptor filter = ArgumentCaptor.forClass(Filter.class); + verify(servletContext).addFilter(eq("springSecurityFilterChain"), filter.capture()); + assertThat(filter.getValue()).isInstanceOf(DelegatingFilterProxy.class); + verify(security).addMappingForUrlPatterns( + eq(EnumSet.of(DispatcherType.REQUEST, DispatcherType.ERROR, DispatcherType.ASYNC)), eq(false), + eq("/*")); + } } diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java index a9a4333..056342c 100644 --- a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java @@ -1,9 +1,12 @@ package com.edgareldy.springjdbctutorial.ws.config; import static org.assertj.core.api.Assertions.assertThat; +import static com.edgareldy.springjdbctutorial.ws.support.ApiAssertions.assertError; +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.common.support.PostgresTestContainer; +import com.edgareldy.springjdbctutorial.ws.support.AuthTestSupport; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -18,8 +21,8 @@ import org.springframework.web.context.WebApplicationContext; /** - * Full-stack integration test: the REAL WebMvcConfig (CommonConfig, DataSourceConfig, Flyway, real - * service and DAO) against the Testcontainers PostgreSQL, driven through MockMvc. + * Full-stack integration test: the REAL WebMvcConfig (CommonConfig, DataSourceConfig, Flyway, auth services, + * SecurityConfig, real service and DAO) against the Testcontainers PostgreSQL, driven through MockMvc. *

* 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(); + } +} From c25531885cc2b18bf7b2290d9e6966777280ecf2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:22:46 +0400 Subject: [PATCH 30/32] build(docker): pass the JWT key locations and mount the production keys The app service now receives APP_JWT_PRIVATE_KEY_LOCATION and APP_JWT_PUBLIC_KEY_LOCATION, which Spring maps to app.jwt.* properties, and mounts ./keys read-only at /keys. There is no default key: without the mounted pair the application refuses to start. The README of the compose file now applies to the whole project since authentication exists. The keys directory is ignored by git. --- docker-compose.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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" From a2c2d0cfb494f38387f3ecc925c6b735344e2c8c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:25:16 +0400 Subject: [PATCH 31/32] fix(auth): treat a blank email as unknown in forgotPassword normalizeEmail throws a BusinessRuleException for a blank value, so a whitespace-only email produced a 422 while every other input, known or unknown, gave the generic answer. forgotPassword now handles a null or blank email like an unknown account and never throws. The unit test suite gets a non-regression test for it, and the following test methods are renumbered to keep the sequence. --- .../auth/service/impl/AuthServiceImpl.java | 4 +++- .../auth/service/impl/AuthServiceImplTest.java | 18 +++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) 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 index 99d343e..5cfd6a3 100644 --- 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 @@ -176,7 +176,9 @@ 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); - String normalized = email == null ? "" : normalizeEmail(email); + // 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 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 index aa3fa23..493768a 100644 --- 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 @@ -456,10 +456,18 @@ void _29_ShouldNotThrow_WhenEmailIsNull() { verify(passwordResetTokenDao, never()).insert(any()); } + // Non-regression: a blank email used to throw a 422 here, unlike the generic answer for an unknown one + @Test + void _30_ShouldNotThrow_WhenEmailIsBlank() { + assertThatCode(() -> service.forgotPassword(" ")).doesNotThrowAnyException(); + + verify(passwordResetTokenDao, never()).insert(any()); + } + // ---------------------------------------------------------------- resetPassword @Test - void _30_ShouldStoreNewHashAndDeleteResetTokens_WhenTokenIsValid() throws Exception { + void _31_ShouldStoreNewHashAndDeleteResetTokens_WhenTokenIsValid() throws Exception { PasswordResetToken token = new PasswordResetToken(3L, 5L, sha256("raw-reset"), "PASSWORD_RESET", NOW.plusSeconds(600)); when(passwordResetTokenDao.findByTokenHash(sha256("raw-reset"))).thenReturn(Optional.of(token)); @@ -473,7 +481,7 @@ void _30_ShouldStoreNewHashAndDeleteResetTokens_WhenTokenIsValid() throws Except } @Test - void _31_ShouldThrowBusinessRuleException_WhenResetTokenIsExpired() { + void _32_ShouldThrowBusinessRuleException_WhenResetTokenIsExpired() { PasswordResetToken expired = new PasswordResetToken(3L, 5L, "h", "PASSWORD_RESET", NOW); when(passwordResetTokenDao.findByTokenHash(anyString())).thenReturn(Optional.of(expired)); @@ -484,7 +492,7 @@ void _31_ShouldThrowBusinessRuleException_WhenResetTokenIsExpired() { } @Test - void _32_ShouldThrowBusinessRuleException_WhenResetTokenIsUnknown() { + void _33_ShouldThrowBusinessRuleException_WhenResetTokenIsUnknown() { when(passwordResetTokenDao.findByTokenHash(anyString())).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.resetPassword("raw", "Brand-new-pass1")) @@ -494,7 +502,7 @@ void _32_ShouldThrowBusinessRuleException_WhenResetTokenIsUnknown() { } @Test - void _33_ShouldThrowBusinessRuleException_WhenNewPasswordExceeds72Bytes() { + void _34_ShouldThrowBusinessRuleException_WhenNewPasswordExceeds72Bytes() { PasswordResetToken token = new PasswordResetToken(3L, 5L, "h", "PASSWORD_RESET", NOW.plusSeconds(600)); when(passwordResetTokenDao.findByTokenHash(anyString())).thenReturn(Optional.of(token)); @@ -509,7 +517,7 @@ void _33_ShouldThrowBusinessRuleException_WhenNewPasswordExceeds72Bytes() { // ---------------------------------------------------------------- purgeExpiredTokens @Test - void _34_ShouldDeleteRowsExpiredBeforeNowAndReturnTheCount_WhenPurging() { + void _35_ShouldDeleteRowsExpiredBeforeNowAndReturnTheCount_WhenPurging() { when(blacklistedTokenDao.deleteExpiredBefore(NOW)).thenReturn(3); assertThat(service.purgeExpiredTokens()).isEqualTo(3); From dfd45f08ab8ca62e7892356ae0e388faa76c0775 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 23:25:16 +0400 Subject: [PATCH 32/32] docs(readme): tick the feature/auth tasks All nine tasks of the auth branch are checked. They cover the entities and dto classes, the RowMappers and UserMapper, the DAOs, AuthService with its constant-outcome forgotPassword, the module wiring, the JWT service and filter, the controller, the WebMvcConfig imports and the tests at all five layers plus the full register to login flow. --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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