diff --git a/README.md b/README.md index 2268d3e..8bf93af 100644 --- a/README.md +++ b/README.md @@ -334,15 +334,15 @@ No other naming style (`shouldX()`, `testX()`, `givenX_whenY_thenZ()`) is used a ### Tasks -- [ ] `core/auth`: `User`, `ActivationToken`, `BlacklistedToken`, `PasswordResetToken` **entity** classes (`entity/`, `@Table("users")`/`@Table("activation_tokens")`/etc., `@Id`/`@Column` on fields, plain Java otherwise) and their matching `UserDto`, `ActivationTokenDto`, `BlacklistedTokenDto`, `PasswordResetTokenDto` classes (`dto/`) -- [ ] `UserRowMapper`, `ActivationTokenRowMapper`, `BlacklistedTokenRowMapper`, `PasswordResetTokenRowMapper` (`ResultSet` → entity), and `UserMapper` (entity ↔ dto, the only one of these four actually needed yet - the token entities/dtos are used directly by `AuthServiceImpl` without a full bidirectional mapper, since nothing outside the service ever needs a token dto's shape beyond what `AuthController` reads directly off `AuthService`'s own return types) -- [ ] `UserDao` + `UserDaoImpl` (and the token DAOs), all `JdbcTemplate`-based, all unannotated, all working in `entity/` types -- [ ] `AuthService` (interface) + `AuthServiceImpl`: registration, activation, login (password hashing/verification via `org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder`, used standalone), logout, forgot/reset password - every public method takes/returns `dto/` types, converting to/from `entity/` via `UserMapper` around each `UserDao` call -- [ ] `forgotPassword` returns the exact same outcome - same behavior, roughly the same timing - whether or not the submitted email matches an existing account, so the endpoint can't be used to enumerate registered emails -- [ ] `core/auth`'s `DaoConfig`/`ServiceConfig` -- [ ] `ws`: `JwtService` (issuance/validation via `jjwt`, unique `jti` per token), `JwtAuthFilter`, `AuthController`, `UserConverter` (`core.auth.dto.UserDto` ↔ `ws.payload.auth.*`) -- [ ] `WebMvcConfig` updated to import `core/auth`'s `DaoConfig`/`ServiceConfig` -- [ ] Tests at all five layers (see [Testing strategy](#testing-strategy)): `UserDao`/token DAOs against Testcontainers with their own fixture files, each `RowMapper` against a mocked `ResultSet`, `UserMapper` against a hand-built entity/dto pair (pure unit test, no mocks needed), `AuthServiceImpl` with `UserDao` mocked, `AuthController` via `MockMvc` with `AuthService` mocked - plus one full register → activate → login → `/me` flow +- [x] `core/auth`: `User`, `ActivationToken`, `BlacklistedToken`, `PasswordResetToken` **entity** classes (`entity/`, `@Table("users")`/`@Table("activation_tokens")`/etc., `@Id`/`@Column` on fields, plain Java otherwise) and their matching `UserDto`, `ActivationTokenDto`, `BlacklistedTokenDto`, `PasswordResetTokenDto` classes (`dto/`) +- [x] `UserRowMapper`, `ActivationTokenRowMapper`, `BlacklistedTokenRowMapper`, `PasswordResetTokenRowMapper` (`ResultSet` → entity), and `UserMapper` (entity ↔ dto, the only one of these four actually needed yet - the token entities/dtos are used directly by `AuthServiceImpl` without a full bidirectional mapper, since nothing outside the service ever needs a token dto's shape beyond what `AuthController` reads directly off `AuthService`'s own return types) +- [x] `UserDao` + `UserDaoImpl` (and the token DAOs), all `JdbcTemplate`-based, all unannotated, all working in `entity/` types +- [x] `AuthService` (interface) + `AuthServiceImpl`: registration, activation, login (password hashing/verification via `org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder`, used standalone), logout, forgot/reset password - every public method takes/returns `dto/` types, converting to/from `entity/` via `UserMapper` around each `UserDao` call +- [x] `forgotPassword` returns the exact same outcome - same behavior, roughly the same timing - whether or not the submitted email matches an existing account, so the endpoint can't be used to enumerate registered emails +- [x] `core/auth`'s `DaoConfig`/`ServiceConfig` +- [x] `ws`: `JwtService` (issuance/validation via `jjwt`, unique `jti` per token), `JwtAuthFilter`, `AuthController`, `UserConverter` (`core.auth.dto.UserDto` ↔ `ws.payload.auth.*`) +- [x] `WebMvcConfig` updated to import `core/auth`'s `DaoConfig`/`ServiceConfig` +- [x] Tests at all five layers (see [Testing strategy](#testing-strategy)): `UserDao`/token DAOs against Testcontainers with their own fixture files, each `RowMapper` against a mocked `ResultSet`, `UserMapper` against a hand-built entity/dto pair (pure unit test, no mocks needed), `AuthServiceImpl` with `UserDao` mocked, `AuthController` via `MockMvc` with `AuthService` mocked - plus one full register → activate → login → `/me` flow ## feature/rbac diff --git a/core/auth/pom.xml b/core/auth/pom.xml index def61c8..1469698 100644 --- a/core/auth/pom.xml +++ b/core/auth/pom.xml @@ -19,5 +19,28 @@ com.edgareldy.springjdbctutorial common + + + org.springframework.security + spring-security-crypto + + + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + 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/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/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/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); + } +} 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); + } +} 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 + + "}"; + } +} 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 + + "}"; + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AccountNotActiveException.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AccountNotActiveException.java new file mode 100644 index 0000000..505d6a3 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AccountNotActiveException.java @@ -0,0 +1,19 @@ +package com.edgareldy.springjdbctutorial.core.auth.exception; + +/** + * The password was correct but the account is not activated or is locked. Only thrown after the + * password check, so it never reveals whether an email exists. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AccountNotActiveException extends AuthenticationFailedException { + + private static final long serialVersionUID = 1L; + + public AccountNotActiveException() { + super("Account is not activated or is locked"); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AuthenticationFailedException.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AuthenticationFailedException.java new file mode 100644 index 0000000..45dcb82 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/AuthenticationFailedException.java @@ -0,0 +1,18 @@ +package com.edgareldy.springjdbctutorial.core.auth.exception; + +/** + * Base of every authentication failure (mapped to HTTP 401 by the web layer). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AuthenticationFailedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public AuthenticationFailedException(String message) { + super(message); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/InvalidCredentialsException.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/InvalidCredentialsException.java new file mode 100644 index 0000000..a3f77cd --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/exception/InvalidCredentialsException.java @@ -0,0 +1,18 @@ +package com.edgareldy.springjdbctutorial.core.auth.exception; + +/** + * Unknown email or wrong password: one message for both so an email's existence is never revealed. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class InvalidCredentialsException extends AuthenticationFailedException { + + private static final long serialVersionUID = 1L; + + public InvalidCredentialsException() { + super("Invalid email or password"); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapper.java new file mode 100644 index 0000000..a430aa6 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapper.java @@ -0,0 +1,29 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the activation_tokens table into an ActivationToken entity (validated_at may be NULL). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class ActivationTokenRowMapper implements RowMapper { + + @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/UserMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapper.java new file mode 100644 index 0000000..c8d218c --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapper.java @@ -0,0 +1,40 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; + +import java.util.ArrayList; + +/** + * Converts between the User entity and the UserDto. The password hash is never copied to a dto; + * roles and permissions are filled by the service. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class UserMapper { + + /** + * Entity to dto: password is always null, roles and permissions start empty. + */ + public UserDto toDto(User user) { + if (user == null) { + return null; + } + return new UserDto(user.getId(), user.getFirstName(), user.getLastName(), user.getEmail(), null, + user.isEnabled(), user.isAccountLocked(), new ArrayList<>(), new ArrayList<>()); + } + + /** + * Dto to entity: the caller supplies the bcrypt hash, the dto's raw password is never read here. + */ + public User toEntity(UserDto dto, String passwordHash) { + if (dto == null) { + return null; + } + return new User(dto.getId(), dto.getFirstName(), dto.getLastName(), dto.getEmail(), passwordHash, + dto.isEnabled(), dto.isAccountLocked()); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapper.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapper.java new file mode 100644 index 0000000..c4376c7 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapper.java @@ -0,0 +1,30 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Converts one row of the users table into a User entity. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class UserRowMapper implements RowMapper { + + @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")); + } +} 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..5cfd6a3 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImpl.java @@ -0,0 +1,240 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.dto.BlacklistedTokenDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; +import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import com.edgareldy.springjdbctutorial.core.auth.exception.AccountNotActiveException; +import com.edgareldy.springjdbctutorial.core.auth.exception.InvalidCredentialsException; +import com.edgareldy.springjdbctutorial.core.auth.mapper.UserMapper; +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; + +/** + * Implementation of AuthService. Declared by @Bean in ServiceConfig (no stereotype) and returned as + * the interface so the @Transactional advice applies through a JDK proxy. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AuthServiceImpl implements AuthService { + + private static final Log LOG = LogFactory.getLog(AuthServiceImpl.class); + + private static final int MAX_PASSWORD_BYTES = 72; + private static final Duration ACTIVATION_VALIDITY = Duration.ofHours(24); + private static final Duration RESET_VALIDITY = Duration.ofHours(1); + private static final String RESET_TYPE = "PASSWORD_RESET"; + + private final UserDao userDao; + private final ActivationTokenDao activationTokenDao; + private final PasswordResetTokenDao passwordResetTokenDao; + private final BlacklistedTokenDao blacklistedTokenDao; + private final PasswordEncoder passwordEncoder; + private final Clock clock; + private final UserMapper userMapper = new UserMapper(); + private final TokenHasher tokenHasher = new TokenHasher(); + // Verified against when the email is unknown, so a failed login costs one bcrypt check either way + private final String dummyHash; + + public AuthServiceImpl(UserDao userDao, ActivationTokenDao activationTokenDao, + PasswordResetTokenDao passwordResetTokenDao, BlacklistedTokenDao blacklistedTokenDao, + PasswordEncoder passwordEncoder, Clock clock) { + this.userDao = userDao; + this.activationTokenDao = activationTokenDao; + this.passwordResetTokenDao = passwordResetTokenDao; + this.blacklistedTokenDao = blacklistedTokenDao; + this.passwordEncoder = passwordEncoder; + this.clock = clock; + this.dummyHash = passwordEncoder.encode("timing-equalisation-dummy-password"); + } + + // @Transactional (enabled by @EnableTransactionManagement in DataSourceConfig) makes the Spring proxy + // open a transaction before the method and commit it on return, or roll back on a RuntimeException. + // Every write below therefore succeeds or fails as one unit (e.g. user row + activation token). + @Override + @Transactional + public UserDto register(UserDto input) { + if (input == null) { + throw new BusinessRuleException("User is required"); + } + String email = normalizeEmail(input.getEmail()); + requirePasswordWithinLimit(input.getPassword()); + if (userDao.existsByEmail(email)) { + throw new BusinessRuleException("Email is already registered"); + } + UserDto toCreate = new UserDto(null, input.getFirstName(), input.getLastName(), email, null, + false, false, null, null); + User created; + try { + created = userDao.insert(userMapper.toEntity(toCreate, passwordEncoder.encode(input.getPassword()))); + } catch (DuplicateKeyException e) { + // Lost a race against a concurrent registration of the same email: the unique index decided + throw new BusinessRuleException("Email is already registered", e); + } + String rawToken = tokenHasher.generateRawToken(); + Instant now = clock.instant(); + activationTokenDao.insert(new ActivationToken(null, created.getId(), tokenHasher.sha256Hex(rawToken), + now, now.plus(ACTIVATION_VALIDITY), null)); + // No mailer exists in this tutorial: the raw token is only ever visible in this log line + LOG.info("Activation token for " + email + ": " + rawToken); + return userMapper.toDto(created); + } + + @Override + @Transactional + public void activateAccount(String rawToken) { + Instant now = clock.instant(); + ActivationToken token = rawToken == null ? null + : activationTokenDao.findByTokenHash(tokenHasher.sha256Hex(rawToken)).orElse(null); + if (token == null || token.getValidatedAt() != null || !token.getExpiresAt().isAfter(now)) { + throw new BusinessRuleException("Invalid or expired activation token"); + } + userDao.updateEnabled(token.getUserId(), true); + activationTokenDao.markValidated(token.getId(), now); + } + + @Override + @Transactional(readOnly = true) + public UserDto login(String email, String rawPassword) { + User user = email == null ? null : userDao.findByEmail(normalizeEmail(email)).orElse(null); + boolean passwordOk; + if (user == null) { + // Fake verification: same bcrypt cost as a real one, result ignored + passwordEncoder.matches("x", dummyHash); + passwordOk = false; + } else if (rawPassword == null || exceedsLimit(rawPassword)) { + // Over 72 bytes can never match a bcrypt hash: plain invalid credentials, never a 500 + passwordEncoder.matches("x", dummyHash); + passwordOk = false; + } else { + passwordOk = passwordEncoder.matches(rawPassword, user.getPassword()); + } + if (!passwordOk) { + throw new InvalidCredentialsException(); + } + if (!user.isEnabled() || user.isAccountLocked()) { + throw new AccountNotActiveException(); + } + return withRolesAndPermissions(user); + } + + @Override + @Transactional + public void logout(BlacklistedTokenDto token) { + if (blacklistedTokenDao.existsByTokenHash(token.getTokenHash())) { + return; + } + Instant now = clock.instant(); + try { + blacklistedTokenDao.insert(new BlacklistedToken(null, token.getUserId(), token.getTokenHash(), + token.getJti(), now, token.getIssuedAt() == null ? now : token.getIssuedAt(), + token.getExpiresAt(), null)); + } catch (DuplicateKeyException e) { + // Concurrent or repeated logout of the same token (same hash or jti): already revoked, accept + LOG.debug("Token already blacklisted, logout accepted"); + } + } + + @Override + @Transactional(readOnly = true) + public boolean isTokenBlacklisted(String tokenHash) { + return blacklistedTokenDao.existsByTokenHash(tokenHash); + } + + @Override + @Transactional(readOnly = true) + public UserDto getProfile(Long userId) { + User user = userDao.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId)); + return withRolesAndPermissions(user); + } + + @Override + @Transactional + public void forgotPassword(String email) { + // Token and hash are generated whether or not the account exists, so the work is alike + String rawToken = tokenHasher.generateRawToken(); + String tokenHash = tokenHasher.sha256Hex(rawToken); + // A null or blank email is simply an unknown account here: normalizeEmail would throw a 422 and + // reveal a different behaviour than the generic answer + String normalized = email == null || email.isBlank() ? "" : normalizeEmail(email); + User user = userDao.findByEmail(normalized).orElse(null); + // The delete runs for an unknown account too (on an id that matches nothing), so the only work + // skipped when the email is unknown is the final insert and the log line + passwordResetTokenDao.deleteByUserId(user == null ? -1L : user.getId()); + if (user == null) { + return; + } + passwordResetTokenDao.insert(new PasswordResetToken(null, user.getId(), tokenHash, RESET_TYPE, + clock.instant().plus(RESET_VALIDITY))); + LOG.info("Password reset token for " + normalized + ": " + rawToken); + } + + @Override + @Transactional + public void resetPassword(String rawToken, String newPassword) { + PasswordResetToken token = rawToken == null ? null + : passwordResetTokenDao.findByTokenHash(tokenHasher.sha256Hex(rawToken)).orElse(null); + if (token == null || !token.getExpiryDate().isAfter(clock.instant())) { + throw new BusinessRuleException("Invalid or expired reset token"); + } + requirePasswordWithinLimit(newPassword); + userDao.updatePassword(token.getUserId(), passwordEncoder.encode(newPassword)); + passwordResetTokenDao.deleteByUserId(token.getUserId()); + } + + @Override + @Transactional + public int purgeExpiredTokens() { + return blacklistedTokenDao.deleteExpiredBefore(clock.instant()); + } + + private UserDto withRolesAndPermissions(User user) { + UserDto dto = userMapper.toDto(user); + dto.setRoles(userDao.findRoleNamesByUserId(user.getId())); + dto.setPermissions(userDao.findPermissionCodesByUserId(user.getId())); + return dto; + } + + private static String normalizeEmail(String email) { + if (email == null || email.isBlank()) { + throw new BusinessRuleException("Email is required"); + } + return email.trim().toLowerCase(Locale.ROOT); + } + + private static boolean exceedsLimit(String password) { + return password.getBytes(StandardCharsets.UTF_8).length > MAX_PASSWORD_BYTES; + } + + private static void requirePasswordWithinLimit(String password) { + if (password == null || password.isEmpty()) { + throw new BusinessRuleException("Password is required"); + } + // bcrypt only reads the first 72 bytes: refuse longer input instead of silently truncating it + if (exceedsLimit(password)) { + throw new BusinessRuleException("Password must not exceed 72 bytes"); + } + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJob.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJob.java new file mode 100644 index 0000000..6ea28e2 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/ExpiredTokenCleanupJob.java @@ -0,0 +1,35 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.scheduling.annotation.Scheduled; + +/** + * Daily job purging expired blacklisted tokens. run() is public so tests can call it directly. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class ExpiredTokenCleanupJob { + + private static final Log LOG = LogFactory.getLog(ExpiredTokenCleanupJob.class); + + private final AuthService authService; + + public ExpiredTokenCleanupJob(AuthService authService) { + this.authService = authService; + } + + // @Scheduled runs this method by itself at 03:00 every day (Spring cron: second minute hour day + // month weekday). It only fires because @EnableScheduling is on ServiceConfig and this class is a + // bean. Expired rows are useless (an expired JWT is refused anyway), so deleting them keeps the + // blacklist small and its lookups fast. + @Scheduled(cron = "0 0 3 * * *") + public void run() { + int removed = authService.purgeExpiredTokens(); + LOG.info("Expired blacklisted tokens purged: " + removed); + } +} diff --git a/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/TokenHasher.java b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/TokenHasher.java new file mode 100644 index 0000000..348d3e1 --- /dev/null +++ b/core/auth/src/main/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/TokenHasher.java @@ -0,0 +1,51 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; + +/** + * Generates random raw tokens and computes the SHA-256 hex digest that is the only form stored. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +final class TokenHasher { + + private static final int TOKEN_BYTES = 32; + + // Instance field on purpose (never static): one generator per service instance, seeded by the OS. + private final SecureRandom secureRandom = new SecureRandom(); + + /** + * 32 random bytes, URL-safe base64 without padding (43 characters). + */ + String generateRawToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + // Why SHA-256 and not bcrypt for tokens: a token is 256 bits of randomness, so it cannot be + // brute-forced or found in a dictionary and needs no slow, salted hash. It must also be looked up + // BY VALUE (WHERE token = ?), which a per-hash random salt (bcrypt) makes impossible. A database + // leak still yields only digests, which cannot be replayed as a token. Passwords are the opposite + // case (low entropy, chosen by humans), hence bcrypt for those. + /** + * SHA-256 of the UTF-8 bytes of the value, as 64 lowercase hex characters. + */ + String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandatory in every Java platform implementation + throw new IllegalStateException("SHA-256 is not available", e); + } + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImplTest.java new file mode 100644 index 0000000..3afb0f4 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/ActivationTokenDaoImplTest.java @@ -0,0 +1,85 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.auth.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests ActivationTokenDaoImpl against the real PostgreSQL, with its own fixture file + * activation-token-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/activation-token-dao-dataset.sql") +class ActivationTokenDaoImplTest { + + private static final String HASH_USER_1 = "a1".repeat(32); + private static final String HASH_USER_2 = "b2".repeat(32); + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private ActivationTokenDao dao; + + @Test + void _01_ShouldInsertAndGenerateId_WhenTokenIsNew() { + Instant created = Instant.parse("2026-09-19T12:00:00Z"); + ActivationToken inserted = dao.insert(new ActivationToken(null, 1L, "9f".repeat(32), created, + created.plusSeconds(3600), null)); + + assertThat(inserted.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + ActivationToken reloaded = dao.findByTokenHash("9f".repeat(32)).orElseThrow(); + assertThat(reloaded.getUserId()).isEqualTo(1L); + assertThat(reloaded.getCreatedAt()).isEqualTo(created); + assertThat(reloaded.getExpiresAt()).isEqualTo(created.plusSeconds(3600)); + assertThat(reloaded.getValidatedAt()).isNull(); + } + + @Test + void _02_ShouldFindToken_WhenHashExists() { + ActivationToken token = dao.findByTokenHash(HASH_USER_2).orElseThrow(); + + assertThat(token.getUserId()).isEqualTo(2L); + assertThat(token.getValidatedAt()).isEqualTo(Instant.parse("2026-09-18T11:00:00Z")); + } + + @Test + void _03_ShouldReturnEmpty_WhenHashIsUnknown() { + assertThat(dao.findByTokenHash("00".repeat(32))).isEmpty(); + } + + @Test + void _04_ShouldStoreValidationDate_WhenTokenIsMarkedValidated() { + Instant validatedAt = Instant.parse("2026-09-19T12:30:00Z"); + + dao.markValidated(1L, validatedAt); + + assertThat(dao.findByTokenHash(HASH_USER_1).orElseThrow().getValidatedAt()).isEqualTo(validatedAt); + } + + @Test + void _05_ShouldDeleteOnlyThatUsersTokens_WhenDeletedByUserId() { + dao.deleteByUserId(1L); + + assertThat(dao.findByTokenHash(HASH_USER_1)).isEmpty(); + assertThat(dao.findByTokenHash(HASH_USER_2)).isPresent(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImplTest.java new file mode 100644 index 0000000..b6cb7d8 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/BlacklistedTokenDaoImplTest.java @@ -0,0 +1,88 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.auth.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests BlacklistedTokenDaoImpl against the real PostgreSQL, with its own fixture file + * blacklisted-token-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/blacklisted-token-dao-dataset.sql") +class BlacklistedTokenDaoImplTest { + + private static final String HASH_EXPIRED_1 = "e5".repeat(32); + private static final String HASH_EXPIRED_2 = "f6".repeat(32); + private static final String HASH_LIVE = "a7".repeat(32); + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private BlacklistedTokenDao dao; + + @Test + void _01_ShouldInsertAndGenerateId_WhenTokenIsNew() { + Instant now = Instant.parse("2026-09-19T12:00:00Z"); + BlacklistedToken inserted = dao.insert(new BlacklistedToken(null, 1L, "1c".repeat(32), "jti-new", now, + now.minusSeconds(60), now.plusSeconds(3600), null)); + + assertThat(inserted.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + assertThat(dao.existsByTokenHash("1c".repeat(32))).isTrue(); + } + + @Test + void _02_ShouldReturnTrue_WhenHashIsBlacklisted() { + assertThat(dao.existsByTokenHash(HASH_LIVE)).isTrue(); + } + + @Test + void _03_ShouldReturnFalse_WhenHashIsNotBlacklisted() { + assertThat(dao.existsByTokenHash("00".repeat(32))).isFalse(); + } + + @Test + void _04_ShouldThrowDuplicateKeyException_WhenSameJtiIsInsertedTwice() { + Instant now = Instant.parse("2026-09-19T12:00:00Z"); + BlacklistedToken sameJti = new BlacklistedToken(null, 1L, "2d".repeat(32), "jti-live", now, + now, now.plusSeconds(3600), null); + + assertThatThrownBy(() -> dao.insert(sameJti)).isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _05_ShouldDeleteOnlyExpiredRowsAndReturnTheirCount_WhenPurgedBeforeAnInstant() { + int deleted = dao.deleteExpiredBefore(Instant.parse("2026-09-19T00:00:00Z")); + + assertThat(deleted).isEqualTo(2); + assertThat(dao.existsByTokenHash(HASH_EXPIRED_1)).isFalse(); + assertThat(dao.existsByTokenHash(HASH_EXPIRED_2)).isFalse(); + assertThat(dao.existsByTokenHash(HASH_LIVE)).isTrue(); + } + + @Test + void _06_ShouldDeleteNothing_WhenNoRowIsExpiredBeforeTheInstant() { + assertThat(dao.deleteExpiredBefore(Instant.parse("2026-09-01T00:00:00Z"))).isZero(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImplTest.java new file mode 100644 index 0000000..aeba82c --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/PasswordResetTokenDaoImplTest.java @@ -0,0 +1,75 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.auth.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests PasswordResetTokenDaoImpl against the real PostgreSQL, with its own fixture file + * password-reset-token-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/password-reset-token-dao-dataset.sql") +class PasswordResetTokenDaoImplTest { + + private static final String HASH_USER_1 = "c3".repeat(32); + private static final String HASH_USER_2 = "d4".repeat(32); + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private PasswordResetTokenDao dao; + + @Test + void _01_ShouldInsertAndGenerateId_WhenTokenIsNew() { + Instant expiry = Instant.parse("2026-09-19T13:00:00Z"); + PasswordResetToken inserted = dao.insert(new PasswordResetToken(null, 2L, "8e".repeat(32), + "PASSWORD_RESET", expiry)); + + assertThat(inserted.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + PasswordResetToken reloaded = dao.findByTokenHash("8e".repeat(32)).orElseThrow(); + assertThat(reloaded.getUserId()).isEqualTo(2L); + assertThat(reloaded.getType()).isEqualTo("PASSWORD_RESET"); + assertThat(reloaded.getExpiryDate()).isEqualTo(expiry); + } + + @Test + void _02_ShouldFindToken_WhenHashExists() { + PasswordResetToken token = dao.findByTokenHash(HASH_USER_1).orElseThrow(); + + assertThat(token.getUserId()).isEqualTo(1L); + assertThat(token.getExpiryDate()).isEqualTo(Instant.parse("2026-09-19T11:00:00Z")); + } + + @Test + void _03_ShouldReturnEmpty_WhenHashIsUnknown() { + assertThat(dao.findByTokenHash("00".repeat(32))).isEmpty(); + } + + @Test + void _04_ShouldDeleteOnlyThatUsersTokens_WhenDeletedByUserId() { + dao.deleteByUserId(1L); + + assertThat(dao.findByTokenHash(HASH_USER_1)).isEmpty(); + assertThat(dao.findByTokenHash(HASH_USER_2)).isPresent(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImplTest.java new file mode 100644 index 0000000..dacc9e3 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/dao/impl/UserDaoImplTest.java @@ -0,0 +1,128 @@ +package com.edgareldy.springjdbctutorial.core.auth.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.auth.config.DaoConfig; +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests UserDaoImpl against the real PostgreSQL, with its own fixture file user-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// DAO tests run real SQL on a real PostgreSQL started by Testcontainers (Docker), migrated by Flyway +// exactly like production. @SpringJUnitConfig builds a Spring context from the two real configuration +// classes; @DynamicPropertySource injects the container's random JDBC url into the Environment because +// the port is only known once the container runs. @Sql executes this class's own fixture file before +// EACH test, so every test starts from the same known rows. +@SpringJUnitConfig({DataSourceConfig.class, DaoConfig.class}) +@Sql("/user-dao-dataset.sql") +class UserDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private UserDao userDao; + + @Test + void _01_ShouldReturnUser_WhenIdExists() { + User user = userDao.findById(1L).orElseThrow(); + + assertThat(user.getFirstName()).isEqualTo("Alice"); + assertThat(user.getLastName()).isEqualTo("Martin"); + assertThat(user.getEmail()).isEqualTo("alice@example.com"); + assertThat(user.isEnabled()).isTrue(); + assertThat(user.isAccountLocked()).isFalse(); + } + + @Test + void _02_ShouldReturnEmpty_WhenIdDoesNotExist() { + assertThat(userDao.findById(999L)).isEmpty(); + } + + @Test + void _03_ShouldFindUser_WhenEmailDiffersOnlyByCase() { + assertThat(userDao.findByEmail("ALICE@Example.COM")).get().extracting(User::getId).isEqualTo(1L); + } + + @Test + void _04_ShouldReturnEmpty_WhenEmailIsUnknown() { + assertThat(userDao.findByEmail("nobody@example.com")).isEmpty(); + } + + @Test + void _05_ShouldReturnTrue_WhenEmailExistsWhateverItsCase() { + assertThat(userDao.existsByEmail("BOB@EXAMPLE.com")).isTrue(); + } + + @Test + void _06_ShouldReturnFalse_WhenEmailDoesNotExist() { + assertThat(userDao.existsByEmail("nobody@example.com")).isFalse(); + } + + @Test + void _07_ShouldGenerateIdAndPersistRow_WhenUserIsInserted() { + User created = userDao.insert(new User(null, "Dave", "Roux", "dave@example.com", "hash-dave", false, false)); + + assertThat(created.getId()).isNotNull().isGreaterThanOrEqualTo(100L); + User reloaded = userDao.findById(created.getId()).orElseThrow(); + assertThat(reloaded.getEmail()).isEqualTo("dave@example.com"); + assertThat(reloaded.getPassword()).isEqualTo("hash-dave"); + assertThat(reloaded.isEnabled()).isFalse(); + } + + @Test + void _08_ShouldThrowDuplicateKeyException_WhenEmailDiffersOnlyByCaseFromAnExistingOne() { + User duplicate = new User(null, "Alice", "Clone", "ALICE@EXAMPLE.COM", "hash-clone", false, false); + + assertThatThrownBy(() -> userDao.insert(duplicate)).isInstanceOf(DuplicateKeyException.class); + } + + @Test + void _09_ShouldStoreNewHash_WhenPasswordIsUpdated() { + userDao.updatePassword(2L, "new-hash"); + + assertThat(userDao.findById(2L).orElseThrow().getPassword()).isEqualTo("new-hash"); + } + + @Test + void _10_ShouldEnableAccount_WhenEnabledIsUpdated() { + userDao.updateEnabled(2L, true); + + assertThat(userDao.findById(2L).orElseThrow().isEnabled()).isTrue(); + } + + @Test + void _11_ShouldReturnRoleNamesSorted_WhenUserHasRoles() { + assertThat(userDao.findRoleNamesByUserId(1L)).containsExactly("ADMIN", "EDITOR"); + } + + @Test + void _12_ShouldReturnDistinctSortedPermissionCodes_WhenTwoRolesGrantTheSamePermission() { + // ADMIN grants USER:READ, USER:WRITE, ROLE:READ and EDITOR grants USER:READ again + assertThat(userDao.findPermissionCodesByUserId(1L)).containsExactly("ROLE:READ", "USER:READ", "USER:WRITE"); + } + + @Test + void _13_ShouldReturnEmptyLists_WhenUserHasNoRole() { + assertThat(userDao.findRoleNamesByUserId(2L)).isEmpty(); + assertThat(userDao.findPermissionCodesByUserId(2L)).isEmpty(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapperTest.java new file mode 100644 index 0000000..d0e1096 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/ActivationTokenRowMapperTest.java @@ -0,0 +1,62 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Tests ActivationTokenRowMapper against a mocked ResultSet, including a NULL validated_at. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class ActivationTokenRowMapperTest { + + private static final Instant CREATED = Instant.parse("2026-09-19T10:00:00Z"); + private static final Instant EXPIRES = Instant.parse("2026-09-20T10:00:00Z"); + + private final ActivationTokenRowMapper mapper = new ActivationTokenRowMapper(); + + private ResultSet baseRow() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(3L); + when(rs.getLong("user_id")).thenReturn(9L); + when(rs.getString("token")).thenReturn("hash"); + when(rs.getTimestamp("created_at")).thenReturn(Timestamp.from(CREATED)); + when(rs.getTimestamp("expires_at")).thenReturn(Timestamp.from(EXPIRES)); + return rs; + } + + @Test + void _01_ShouldMapEveryColumn_WhenTokenIsValidated() throws SQLException { + ResultSet rs = baseRow(); + Instant validated = Instant.parse("2026-09-19T11:00:00Z"); + when(rs.getTimestamp("validated_at")).thenReturn(Timestamp.from(validated)); + + ActivationToken token = mapper.mapRow(rs, 0); + + assertThat(token.getId()).isEqualTo(3L); + assertThat(token.getUserId()).isEqualTo(9L); + assertThat(token.getToken()).isEqualTo("hash"); + assertThat(token.getCreatedAt()).isEqualTo(CREATED); + assertThat(token.getExpiresAt()).isEqualTo(EXPIRES); + assertThat(token.getValidatedAt()).isEqualTo(validated); + } + + @Test + void _02_ShouldMapNullValidationDate_WhenColumnIsNull() throws SQLException { + ResultSet rs = baseRow(); + when(rs.getTimestamp("validated_at")).thenReturn(null); + + assertThat(mapper.mapRow(rs, 0).getValidatedAt()).isNull(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapperTest.java new file mode 100644 index 0000000..627d8fa --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/BlacklistedTokenRowMapperTest.java @@ -0,0 +1,67 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Tests BlacklistedTokenRowMapper against a mocked ResultSet, including a NULL validated_at. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class BlacklistedTokenRowMapperTest { + + private static final Instant BLACKLISTED = Instant.parse("2026-09-19T10:30:00Z"); + private static final Instant CREATED = Instant.parse("2026-09-19T10:00:00Z"); + private static final Instant EXPIRES = Instant.parse("2026-09-19T11:00:00Z"); + + private final BlacklistedTokenRowMapper mapper = new BlacklistedTokenRowMapper(); + + private ResultSet baseRow() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(4L); + when(rs.getLong("user_id")).thenReturn(9L); + when(rs.getString("token")).thenReturn("hash"); + when(rs.getString("jti")).thenReturn("jti-1"); + when(rs.getTimestamp("blacklisted_at")).thenReturn(Timestamp.from(BLACKLISTED)); + when(rs.getTimestamp("created_at")).thenReturn(Timestamp.from(CREATED)); + when(rs.getTimestamp("expires_at")).thenReturn(Timestamp.from(EXPIRES)); + return rs; + } + + @Test + void _01_ShouldMapEveryColumn_WhenValidationDateIsSet() throws SQLException { + ResultSet rs = baseRow(); + Instant validated = Instant.parse("2026-09-19T10:45:00Z"); + when(rs.getTimestamp("validated_at")).thenReturn(Timestamp.from(validated)); + + BlacklistedToken token = mapper.mapRow(rs, 0); + + assertThat(token.getId()).isEqualTo(4L); + assertThat(token.getUserId()).isEqualTo(9L); + assertThat(token.getToken()).isEqualTo("hash"); + assertThat(token.getJti()).isEqualTo("jti-1"); + assertThat(token.getBlacklistedAt()).isEqualTo(BLACKLISTED); + assertThat(token.getCreatedAt()).isEqualTo(CREATED); + assertThat(token.getExpiresAt()).isEqualTo(EXPIRES); + assertThat(token.getValidatedAt()).isEqualTo(validated); + } + + @Test + void _02_ShouldMapNullValidationDate_WhenColumnIsNull() throws SQLException { + ResultSet rs = baseRow(); + when(rs.getTimestamp("validated_at")).thenReturn(null); + + assertThat(mapper.mapRow(rs, 0).getValidatedAt()).isNull(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapperTest.java new file mode 100644 index 0000000..9a9d1f7 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/PasswordResetTokenRowMapperTest.java @@ -0,0 +1,52 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Tests PasswordResetTokenRowMapper against a mocked ResultSet, including a NULL expiry_date. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class PasswordResetTokenRowMapperTest { + + private final PasswordResetTokenRowMapper mapper = new PasswordResetTokenRowMapper(); + + @Test + void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException { + Instant expiry = Instant.parse("2026-09-19T11:00:00Z"); + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(5L); + when(rs.getLong("user_id")).thenReturn(9L); + when(rs.getString("token")).thenReturn("hash"); + when(rs.getString("type")).thenReturn("PASSWORD_RESET"); + when(rs.getTimestamp("expiry_date")).thenReturn(Timestamp.from(expiry)); + + PasswordResetToken token = mapper.mapRow(rs, 0); + + assertThat(token.getId()).isEqualTo(5L); + assertThat(token.getUserId()).isEqualTo(9L); + assertThat(token.getToken()).isEqualTo("hash"); + assertThat(token.getType()).isEqualTo("PASSWORD_RESET"); + assertThat(token.getExpiryDate()).isEqualTo(expiry); + } + + @Test + void _02_ShouldMapNullExpiry_WhenColumnIsNull() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getTimestamp("expiry_date")).thenReturn(null); + + assertThat(mapper.mapRow(rs, 0).getExpiryDate()).isNull(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapperTest.java new file mode 100644 index 0000000..5a0cc14 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserMapperTest.java @@ -0,0 +1,64 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests UserMapper (entity to dto and back) on hand-built objects, no mocks. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class UserMapperTest { + + private final UserMapper mapper = new UserMapper(); + + @Test + void _01_ShouldCopyProfileFieldsButNeverThePassword_WhenConvertingEntityToDto() { + User user = new User(1L, "Alice", "Martin", "alice@example.com", "secret-hash", true, false); + + UserDto dto = mapper.toDto(user); + + assertThat(dto.getId()).isEqualTo(1L); + assertThat(dto.getFirstName()).isEqualTo("Alice"); + assertThat(dto.getLastName()).isEqualTo("Martin"); + assertThat(dto.getEmail()).isEqualTo("alice@example.com"); + assertThat(dto.isEnabled()).isTrue(); + assertThat(dto.isAccountLocked()).isFalse(); + assertThat(dto.getPassword()).isNull(); + assertThat(dto.getRoles()).isEmpty(); + assertThat(dto.getPermissions()).isEmpty(); + } + + @Test + void _02_ShouldReturnNull_WhenEntityIsNull() { + assertThat(mapper.toDto(null)).isNull(); + } + + @Test + void _03_ShouldUseTheGivenHashAndIgnoreTheDtoPassword_WhenConvertingDtoToEntity() { + UserDto dto = new UserDto(2L, "Bob", "Durand", "bob@example.com", "clear-password", false, true, + List.of("ADMIN"), List.of("USER:READ")); + + User user = mapper.toEntity(dto, "the-bcrypt-hash"); + + assertThat(user.getId()).isEqualTo(2L); + assertThat(user.getFirstName()).isEqualTo("Bob"); + assertThat(user.getLastName()).isEqualTo("Durand"); + assertThat(user.getEmail()).isEqualTo("bob@example.com"); + assertThat(user.getPassword()).isEqualTo("the-bcrypt-hash"); + assertThat(user.isEnabled()).isFalse(); + assertThat(user.isAccountLocked()).isTrue(); + } + + @Test + void _04_ShouldReturnNull_WhenDtoIsNull() { + assertThat(mapper.toEntity(null, "hash")).isNull(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapperTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapperTest.java new file mode 100644 index 0000000..90087f9 --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/mapper/UserRowMapperTest.java @@ -0,0 +1,60 @@ +package com.edgareldy.springjdbctutorial.core.auth.mapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** + * Tests UserRowMapper against a mocked ResultSet, without any database. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class UserRowMapperTest { + + private final UserRowMapper mapper = new UserRowMapper(); + + // A RowMapper only reads columns from the ResultSet it is given. Mockito.mock(ResultSet.class) + // creates a fake one whose getters return what the test stubs, column by column: no database, no + // driver, so the test only checks the column-to-field mapping. + @Test + void _01_ShouldMapEveryColumn_WhenRowIsComplete() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(7L); + when(rs.getString("first_name")).thenReturn("Alice"); + when(rs.getString("last_name")).thenReturn("Martin"); + when(rs.getString("email")).thenReturn("alice@example.com"); + when(rs.getString("password")).thenReturn("hash"); + when(rs.getBoolean("enabled")).thenReturn(true); + when(rs.getBoolean("account_locked")).thenReturn(false); + + User user = mapper.mapRow(rs, 0); + + assertThat(user.getId()).isEqualTo(7L); + assertThat(user.getFirstName()).isEqualTo("Alice"); + assertThat(user.getLastName()).isEqualTo("Martin"); + assertThat(user.getEmail()).isEqualTo("alice@example.com"); + assertThat(user.getPassword()).isEqualTo("hash"); + assertThat(user.isEnabled()).isTrue(); + assertThat(user.isAccountLocked()).isFalse(); + } + + @Test + void _02_ShouldMapLockedAndDisabledFlags_WhenColumnsAreInverted() throws SQLException { + ResultSet rs = mock(ResultSet.class); + when(rs.getBoolean("enabled")).thenReturn(false); + when(rs.getBoolean("account_locked")).thenReturn(true); + + User user = mapper.mapRow(rs, 0); + + assertThat(user.isEnabled()).isFalse(); + assertThat(user.isAccountLocked()).isTrue(); + } +} diff --git a/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java new file mode 100644 index 0000000..493768a --- /dev/null +++ b/core/auth/src/test/java/com/edgareldy/springjdbctutorial/core/auth/service/impl/AuthServiceImplTest.java @@ -0,0 +1,525 @@ +package com.edgareldy.springjdbctutorial.core.auth.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.auth.dao.ActivationTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.BlacklistedTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.PasswordResetTokenDao; +import com.edgareldy.springjdbctutorial.core.auth.dao.UserDao; +import com.edgareldy.springjdbctutorial.core.auth.dto.BlacklistedTokenDto; +import com.edgareldy.springjdbctutorial.core.auth.dto.UserDto; +import com.edgareldy.springjdbctutorial.core.auth.entity.ActivationToken; +import com.edgareldy.springjdbctutorial.core.auth.entity.BlacklistedToken; +import com.edgareldy.springjdbctutorial.core.auth.entity.PasswordResetToken; +import com.edgareldy.springjdbctutorial.core.auth.entity.User; +import com.edgareldy.springjdbctutorial.core.auth.exception.AccountNotActiveException; +import com.edgareldy.springjdbctutorial.core.auth.exception.InvalidCredentialsException; +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * Unit tests of AuthServiceImpl: every DAO is a Mockito mock, the password encoder is a real + * low-cost BCryptPasswordEncoder and the clock is fixed, so expiry rules are deterministic. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// MockitoExtension creates the @Mock fields before each test and fails a test whose stubbing is never +// used (strict stubs), which keeps the tests free of dead set-up. No Spring context is started. +@ExtendWith(MockitoExtension.class) +class AuthServiceImplTest { + + private static final Instant NOW = Instant.parse("2026-09-19T12:00:00Z"); + private static final String PASSWORD = "Secret123!"; + private static final String HEX_64 = "[0-9a-f]{64}"; + + @Mock + private UserDao userDao; + @Mock + private ActivationTokenDao activationTokenDao; + @Mock + private PasswordResetTokenDao passwordResetTokenDao; + @Mock + private BlacklistedTokenDao blacklistedTokenDao; + + // A spy wraps the real encoder (cost 4 keeps the tests fast) and records the calls made on it + private final PasswordEncoder encoder = spy(new BCryptPasswordEncoder(4)); + + // Computed by a separate encoder, so building a User inside a when(...) never calls the spy + private final String passwordHash = new BCryptPasswordEncoder(4).encode(PASSWORD); + + private AuthServiceImpl service; + + @BeforeEach + void setUp() { + service = new AuthServiceImpl(userDao, activationTokenDao, passwordResetTokenDao, blacklistedTokenDao, + encoder, Clock.fixed(NOW, ZoneOffset.UTC)); + } + + private static String sha256(String value) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } + + private static UserDto registration(String email, String password) { + UserDto dto = new UserDto(); + dto.setFirstName("Alice"); + dto.setLastName("Martin"); + dto.setEmail(email); + dto.setPassword(password); + return dto; + } + + private User storedUser(boolean enabled, boolean locked) { + return new User(5L, "Alice", "Martin", "alice@example.com", passwordHash, enabled, locked); + } + + private void stubInsertAssigningId() { + when(userDao.insert(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + user.setId(42L); + return user; + }); + } + + // ---------------------------------------------------------------- register + + @Test + void _01_ShouldCreateDisabledUserWithHashedPassword_WhenRegistrationIsValid() { + stubInsertAssigningId(); + + UserDto created = service.register(registration("alice@example.com", PASSWORD)); + + assertThat(created.getId()).isEqualTo(42L); + assertThat(created.getPassword()).isNull(); + assertThat(created.isEnabled()).isFalse(); + ArgumentCaptor 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()); + } + + // 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 _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)); + + 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 _32_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 _33_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 _34_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 _35_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(); + } +} diff --git a/core/auth/src/test/resources/activation-token-dao-dataset.sql b/core/auth/src/test/resources/activation-token-dao-dataset.sql new file mode 100644 index 0000000..304a4ab --- /dev/null +++ b/core/auth/src/test/resources/activation-token-dao-dataset.sql @@ -0,0 +1,14 @@ +-- Fixture of ActivationTokenDaoImplTest only. Wipes the identity tables, then loads two users (1 and 2) +-- and two activation tokens: 'a1...' for user 1 (not validated), 'b2...' for user 2 (already validated). +TRUNCATE TABLE audit_logs, users, roles, permissions RESTART IDENTITY CASCADE; + +INSERT INTO users (id, first_name, last_name, email, password, enabled, account_locked) VALUES + (1, 'Alice', 'Martin', 'alice@example.com', 'hash-1', FALSE, FALSE), + (2, 'Bob', 'Durand', 'bob@example.com', 'hash-2', TRUE, FALSE); + +INSERT INTO activation_tokens (id, user_id, token, created_at, expires_at, validated_at) VALUES + (1, 1, repeat('a1', 32), '2026-09-19T10:00:00Z', '2026-09-20T10:00:00Z', NULL), + (2, 2, repeat('b2', 32), '2026-09-18T10:00:00Z', '2026-09-19T10:00:00Z', '2026-09-18T11:00:00Z'); + +ALTER SEQUENCE users_id_seq RESTART WITH 100; +ALTER SEQUENCE activation_tokens_id_seq RESTART WITH 100; diff --git a/core/auth/src/test/resources/blacklisted-token-dao-dataset.sql b/core/auth/src/test/resources/blacklisted-token-dao-dataset.sql new file mode 100644 index 0000000..d3cc860 --- /dev/null +++ b/core/auth/src/test/resources/blacklisted-token-dao-dataset.sql @@ -0,0 +1,14 @@ +-- Fixture of BlacklistedTokenDaoImplTest only. Wipes the identity tables, then loads one user and three +-- blacklisted tokens: two expired before 2026-09-19T00:00:00Z ('e5...', 'f6...') and one still live ('a7...'). +TRUNCATE TABLE audit_logs, users, roles, permissions RESTART IDENTITY CASCADE; + +INSERT INTO users (id, first_name, last_name, email, password, enabled, account_locked) VALUES + (1, 'Alice', 'Martin', 'alice@example.com', 'hash-1', TRUE, FALSE); + +INSERT INTO blacklisted_tokens (id, user_id, token, jti, blacklisted_at, created_at, expires_at, validated_at) VALUES + (1, 1, repeat('e5', 32), 'jti-expired-1', '2026-09-17T10:30:00Z', '2026-09-17T10:00:00Z', '2026-09-17T11:00:00Z', NULL), + (2, 1, repeat('f6', 32), 'jti-expired-2', '2026-09-17T12:30:00Z', '2026-09-17T12:00:00Z', '2026-09-17T13:00:00Z', NULL), + (3, 1, repeat('a7', 32), 'jti-live', '2026-09-19T10:30:00Z', '2026-09-19T10:00:00Z', '2026-09-19T11:00:00Z', NULL); + +ALTER SEQUENCE users_id_seq RESTART WITH 100; +ALTER SEQUENCE blacklisted_tokens_id_seq RESTART WITH 100; diff --git a/core/auth/src/test/resources/password-reset-token-dao-dataset.sql b/core/auth/src/test/resources/password-reset-token-dao-dataset.sql new file mode 100644 index 0000000..895ae6b --- /dev/null +++ b/core/auth/src/test/resources/password-reset-token-dao-dataset.sql @@ -0,0 +1,14 @@ +-- Fixture of PasswordResetTokenDaoImplTest only. Wipes the identity tables, then loads two users and +-- one reset token for each ('c3...' for user 1, 'd4...' for user 2). +TRUNCATE TABLE audit_logs, users, roles, permissions RESTART IDENTITY CASCADE; + +INSERT INTO users (id, first_name, last_name, email, password, enabled, account_locked) VALUES + (1, 'Alice', 'Martin', 'alice@example.com', 'hash-1', TRUE, FALSE), + (2, 'Bob', 'Durand', 'bob@example.com', 'hash-2', TRUE, FALSE); + +INSERT INTO password_reset_tokens (id, user_id, token, type, expiry_date) VALUES + (1, 1, repeat('c3', 32), 'PASSWORD_RESET', '2026-09-19T11:00:00Z'), + (2, 2, repeat('d4', 32), 'PASSWORD_RESET', '2026-09-19T12:00:00Z'); + +ALTER SEQUENCE users_id_seq RESTART WITH 100; +ALTER SEQUENCE password_reset_tokens_id_seq RESTART WITH 100; diff --git a/core/auth/src/test/resources/user-dao-dataset.sql b/core/auth/src/test/resources/user-dao-dataset.sql new file mode 100644 index 0000000..854dd4c --- /dev/null +++ b/core/auth/src/test/resources/user-dao-dataset.sql @@ -0,0 +1,22 @@ +-- Fixture of UserDaoImplTest only. Wipes the identity tables, then loads three users, three roles and +-- three permissions. alice (id 1) holds ADMIN and EDITOR, which both grant USER:READ (tests DISTINCT). +-- bob (id 2) is disabled with no role, carol (id 3) is enabled but locked. +TRUNCATE TABLE audit_logs, users, roles, permissions RESTART IDENTITY CASCADE; + +INSERT INTO users (id, first_name, last_name, email, password, enabled, account_locked) VALUES + (1, 'Alice', 'Martin', 'alice@example.com', '$2a$04$alicealicealicealiceaeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', TRUE, FALSE), + (2, 'Bob', 'Durand', 'bob@example.com', '$2a$04$bobbobbobbobbobbobbobeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', FALSE, FALSE), + (3, 'Carol', 'Petit', 'carol@example.com', '$2a$04$carolcarolcarolcarolceAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', TRUE, TRUE); + +INSERT INTO roles (id, role_name) VALUES (1, 'ADMIN'), (2, 'EDITOR'), (3, 'VIEWER'); + +INSERT INTO permissions (id, resource, action) VALUES (1, 'USER', 'READ'), (2, 'USER', 'WRITE'), (3, 'ROLE', 'READ'); + +INSERT INTO role_permission (role_id, permission_id) VALUES (1, 1), (1, 2), (1, 3), (2, 1), (3, 3); + +INSERT INTO role_user (role_id, user_id) VALUES (1, 1), (2, 1); + +-- Explicit ids do not advance the sequences: move them high so generated ids never collide +ALTER SEQUENCE users_id_seq RESTART WITH 100; +ALTER SEQUENCE roles_id_seq RESTART WITH 100; +ALTER SEQUENCE permissions_id_seq RESTART WITH 100; diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java index 1034a89..2b95c2e 100644 --- a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java @@ -9,6 +9,8 @@ import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate; +import java.time.Clock; + /** * Wiring of the common module: imports the infrastructure and declares the health DAO and service. *

@@ -33,4 +35,12 @@ public HealthDao healthDao(JdbcTemplate jdbcTemplate) { public HealthService healthService(HealthDao healthDao) { return new HealthServiceImpl(healthDao); } + + // A Clock bean is the injectable source of "now": services call clock.instant() instead of + // Instant.now(), so tests can supply Clock.fixed(...) and assert expiry logic deterministically. + // UTC because timestamps are stored as timestamptz and never depend on the server zone. + @Bean + public Clock clock() { + return Clock.systemUTC(); + } } diff --git a/dev-keys/README.md b/dev-keys/README.md new file mode 100644 index 0000000..eaedd25 --- /dev/null +++ b/dev-keys/README.md @@ -0,0 +1,13 @@ +# Development JWT keys + +RSA 2048 key pair used ONLY for local development and for the automated tests +(RS256, PKCS#8 private key, X.509 public key, PEM). They are public on purpose: +never use them anywhere real. + +They live here, at the repository root, and never under `src/main/resources`, +so they are never packaged inside the WAR. + +Production keys are generated separately into `./keys` (ignored by git) and +mounted into the container, see `docker-compose.yml`. There is no default key: +the application refuses to start without `app.jwt.private-key-location` and +`app.jwt.public-key-location`. diff --git a/dev-keys/privateKey.pem b/dev-keys/privateKey.pem new file mode 100644 index 0000000..088bc9b --- /dev/null +++ b/dev-keys/privateKey.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3Oz4mE8oQnmm7 +Zwm+1ZEAmnfJH5C4vRNHQ1Julh9NYNg1Di/QKs+g9skr7DKnQ7uCjAc5t+wE68fU +CAkhvx9lPYQu6Ewu8OTbJEOUZ4nT9bZxqXxXxXyoAvjnASZPfBwISldkOT/boOtZ +Gomp24GHjxJrBM1OWWGFx1TxkXH03m+Z/IwEEEETo4HCcpRUvXwLPIiptYA0U+wx +fx4MF8QafdMOJGNHuigQHu3KGUhJP+pxpRGLVEJLz83igeAvP/BGu1OO3EGPoKkT +rOSQEOnBZWBCFGqNm14oRFai6j24EFuXQP60xRzyd9H49RhuiX0q2u5IaqYyT2hU +0Q29YdarAgMBAAECggEAHIhzsZ9ESJk6ks3FVTvRpDvpql6pfTMoVOEVbZYgEr8h +fAWVCYAZLBIyGo5iBu0Dvp0KpQDQlqWWzRxhO6mU/IF7W4ieEHM/2CZKBcqnymOJ +gSKo88wTkOBBnUtvCVKk1N+qwzctDAj/jTUWOeauQYtUjWjGhcxiZOA5CJMjIUPb +6o7ZMQ5ia7K1gnwNucbG2S7Vvwc6AB9gR712wkN0ImxlNSa2QCq5KmG7QVwY7Qc3 +v7b01KL/vRjc4wmQjfpZHhxzmruhE22sx+mv62fSiouzsc2kG9NoxJoQV/Thp33O +GBFElNQFCgipUjmUAkUNrEdOODcutgt3MNWglrb+JQKBgQC5OzgwCbmmM5xqYU23 +zzXA4y2xitTSmBV1sjYNdZ6K8bqOP+ZlQRU7OzklR09DXBG4Yzy5WCFyiZIWUT8l +5FGqB2A/zNhPWLdBkogsyCxkLuKtvaKUKorusyxl7VPmnUWlruSRE3SEly+5d4fb +dDM74iKmslmToJiW7TCUMylw1QKBgQD9PGtw42JR7WHHd8f7EPZgMEGJs2DvP2v+ +Y7/Aj/8TqTUPto1GPBmY7xA107C9S2TvfwgrW+1uUMgCJWxtq8WfX7Ae7atNYfm9 +2w9GW99n5pAJSNkWZ0pdnz1eI9cxh/+PvqlYnpP87nwswGqcamzuz3Y7bqYZ+dka +RIz8bB3pfwKBgQCfx6mMJpdx+osuaIj1yVQA8vZzc+JpVq8MO5HMwmzkReg61HwV +U7LQySFeOsjwvfa7Kz8yUgdoGatefO8P7IdVN8+auUTODSTm91Nn7bP9J0R5wl7/ +mEMH7J6bvcDl6Twu01Juom2cAh40yhry/qVDPqnNSYNjiOCXMDr7W7afkQKBgQDo +h1xY0lJrzD+mu1REM/UnqefkG3R8+42cj9d5aD/eu9bfUM80mOcgSRRneLSnfN14 +FPtZZRYF3+NoJRkFM19J0sGUBMTxT2/VZ5sFDkN70nicLeo46iudACWHlf15m9mN +EpPJrgNRk2MubJj4e7j/KDz2ocmfvI4f0qZq+pQU3QKBgE8Gg5Om86xWMogd/J4v +ZpDGT7/6Xhx3/TfR7ivG18j2YYs9CWb5MRiiQnaIomSEE1hzk9d3CFtoMt8qiLij +9pp6wzo+WxAyE4OaVf5ZctKWWRNvVhcXwB0YrmVW+ibAr9vjfY3vpMHpi3isB34c +1vwLf8kaiglQg9iNITXB7n1b +-----END PRIVATE KEY----- diff --git a/dev-keys/publicKey.pem b/dev-keys/publicKey.pem new file mode 100644 index 0000000..9445c3d --- /dev/null +++ b/dev-keys/publicKey.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtzs+JhPKEJ5pu2cJvtWR +AJp3yR+QuL0TR0NSbpYfTWDYNQ4v0CrPoPbJK+wyp0O7gowHObfsBOvH1AgJIb8f +ZT2ELuhMLvDk2yRDlGeJ0/W2cal8V8V8qAL45wEmT3wcCEpXZDk/26DrWRqJqduB +h48SawTNTllhhcdU8ZFx9N5vmfyMBBBBE6OBwnKUVL18CzyIqbWANFPsMX8eDBfE +Gn3TDiRjR7ooEB7tyhlIST/qcaURi1RCS8/N4oHgLz/wRrtTjtxBj6CpE6zkkBDp +wWVgQhRqjZteKERWouo9uBBbl0D+tMUc8nfR+PUYbol9KtruSGqmMk9oVNENvWHW +qwIDAQAB +-----END PUBLIC KEY----- diff --git a/docker-compose.yml b/docker-compose.yml index d19c854..a845601 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ # # Before the first "docker compose up --build": # 1. cp .env.example .env and set DB_PASSWORD (there is no default password on purpose) -# 2. from the feature/auth branch on, create a production RSA key pair in ./keys (the keys under +# 2. create a production RSA key pair in ./keys (the keys under # dev-keys/ are for development only): # mkdir -p keys # openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out keys/privateKey.pem @@ -40,6 +40,11 @@ services: APP_DATASOURCE_URL: jdbc:postgresql://db:5432/${DB_NAME:-spring_jdbc_tutorial} APP_DATASOURCE_USERNAME: ${DB_USER:-spring} APP_DATASOURCE_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + # No default key: the application refuses to start without these two locations + APP_JWT_PRIVATE_KEY_LOCATION: file:/keys/privateKey.pem + APP_JWT_PUBLIC_KEY_LOCATION: file:/keys/publicKey.pem + volumes: + - ./keys:/keys:ro ports: - "${APP_PORT:-8080}:8080" diff --git a/ws/pom.xml b/ws/pom.xml index 2136e9f..0e6e161 100644 --- a/ws/pom.xml +++ b/ws/pom.xml @@ -68,6 +68,30 @@ 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 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(); + } +} 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). 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"); + } +} 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/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 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 + "}"; + } +} 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")); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/AuthenticatedUser.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/AuthenticatedUser.java new file mode 100644 index 0000000..c1d6669 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/AuthenticatedUser.java @@ -0,0 +1,81 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import java.time.Instant; +import java.util.Objects; + +/** + * Principal placed in the SecurityContext for an authenticated request: who, and which token (hash, jti, validity) so logout can revoke it. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class AuthenticatedUser { + + private final Long userId; + private final String email; + private final String jti; + private final String tokenHash; + private final Instant issuedAt; + private final Instant expiresAt; + + public AuthenticatedUser(Long userId, String email, String jti, String tokenHash, Instant issuedAt, + Instant expiresAt) { + this.userId = userId; + this.email = email; + this.jti = jti; + this.tokenHash = tokenHash; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public Long getUserId() { + return userId; + } + + public String getEmail() { + return email; + } + + public String getJti() { + return jti; + } + + public String getTokenHash() { + return tokenHash; + } + + public Instant getIssuedAt() { + return issuedAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AuthenticatedUser that = (AuthenticatedUser) o; + return Objects.equals(userId, that.userId) && Objects.equals(email, that.email) + && Objects.equals(jti, that.jti) && Objects.equals(tokenHash, that.tokenHash) + && Objects.equals(issuedAt, that.issuedAt) && Objects.equals(expiresAt, that.expiresAt); + } + + @Override + public int hashCode() { + return Objects.hash(userId, email, jti, tokenHash, issuedAt, expiresAt); + } + + @Override + public String toString() { + return "AuthenticatedUser{userId=" + userId + ", email=" + email + ", jti=" + jti + + ", tokenHash=****, issuedAt=" + issuedAt + ", expiresAt=" + expiresAt + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/IssuedToken.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/IssuedToken.java new file mode 100644 index 0000000..86c3425 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/IssuedToken.java @@ -0,0 +1,60 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import java.time.Instant; +import java.util.Objects; + +/** + * A freshly issued JWT with its issue and expiry instants. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class IssuedToken { + + private final String token; + private final Instant issuedAt; + private final Instant expiresAt; + + public IssuedToken(String token, Instant issuedAt, Instant expiresAt) { + this.token = token; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public String getToken() { + return token; + } + + public Instant getIssuedAt() { + return issuedAt; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + IssuedToken that = (IssuedToken) o; + return Objects.equals(token, that.token) && Objects.equals(issuedAt, that.issuedAt) + && Objects.equals(expiresAt, that.expiresAt); + } + + @Override + public int hashCode() { + return Objects.hash(token, issuedAt, expiresAt); + } + + @Override + public String toString() { + return "IssuedToken{token=****, issuedAt=" + issuedAt + ", expiresAt=" + expiresAt + "}"; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtAuthFilter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtAuthFilter.java new file mode 100644 index 0000000..01a7cb9 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/security/JwtAuthFilter.java @@ -0,0 +1,65 @@ +package com.edgareldy.springjdbctutorial.ws.security; + +import com.edgareldy.springjdbctutorial.core.auth.service.AuthService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +/** + * Authenticates a request from its Bearer JWT: verifies it, rejects revoked tokens, and fills the SecurityContext. Never throws for a bad token. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// OncePerRequestFilter guarantees one execution per request even with forwards or error dispatches. +// It is created inside the SecurityFilterChain (not a bean, not registered in the servlet container), +// so it runs only inside the Spring Security chain. When the token is missing, invalid or revoked the +// context is left empty and the AuthenticationEntryPoint answers 401 for protected routes. +public class JwtAuthFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtService jwtService; + private final AuthService authService; + + public JwtAuthFilter(JwtService jwtService, AuthService authService) { + this.jwtService = jwtService; + this.authService = authService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith(BEARER_PREFIX)) { + String jwt = header.substring(BEARER_PREFIX.length()).trim(); + Optional 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); + } +} 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 + "}"; + } +} 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(); + } +} 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); + } +} 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"); + } +} 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")); + } +}