Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a5f971e
build(auth): add spring-security-crypto for password hashing
EdgarEldy Sep 19, 2026
67daef9
feat(common): declare a Clock bean shared by every service
EdgarEldy Sep 19, 2026
e2667be
feat(auth): add the User and token entities
EdgarEldy Sep 19, 2026
df45c51
feat(auth): add the auth dto classes returned and accepted by services
EdgarEldy Sep 19, 2026
3a798ec
feat(auth): add the RowMappers turning ResultSet rows into entities
EdgarEldy Sep 19, 2026
29736cd
feat(auth): add UserMapper between the User entity and UserDto
EdgarEldy Sep 19, 2026
a465d43
feat(auth): add UserDao and its JdbcTemplate implementation
EdgarEldy Sep 19, 2026
d3879d4
feat(auth): add the token DAOs
EdgarEldy Sep 19, 2026
f66c12c
feat(auth): add the authentication exceptions
EdgarEldy Sep 19, 2026
77fad26
feat(auth): implement registration, activation, login, logout and reset
EdgarEldy Sep 19, 2026
caf3984
feat(auth): wire the DAOs, the AuthService and the cleanup job
EdgarEldy Sep 19, 2026
ca7f22d
build(ws): add Spring Security, jjwt and the test key locations
EdgarEldy Sep 19, 2026
8c4dce5
chore(keys): add the development RS256 key pair
EdgarEldy Sep 19, 2026
a4bd52a
feat(ws): issue and verify RS256 JWTs with JwtService
EdgarEldy Sep 19, 2026
2c36767
feat(ws): authenticate requests with JwtAuthFilter and reject revoked…
EdgarEldy Sep 19, 2026
646dcaf
feat(ws): answer 401 and 403 in the ApiResponse format
EdgarEldy Sep 19, 2026
9b76987
feat(ws): declare the stateless SecurityFilterChain explicitly
EdgarEldy Sep 19, 2026
8dd582f
feat(ws): add the auth payloads and UserConverter
EdgarEldy Sep 19, 2026
7855550
feat(ws): expose the auth endpoints in AuthController
EdgarEldy Sep 19, 2026
c92b262
feat(ws): map authentication and access denied exceptions to 401 and 403
EdgarEldy Sep 19, 2026
59a35cd
feat(ws): import the auth module and security into the shared context
EdgarEldy Sep 19, 2026
ec6cfc5
build(auth): add Testcontainers test dependencies to the auth module
EdgarEldy Sep 19, 2026
2f9cb0d
test(auth): cover the user and token DAOs against PostgreSQL
EdgarEldy Sep 19, 2026
56c753c
test(auth): test the RowMappers with a mocked ResultSet and UserMapper
EdgarEldy Sep 19, 2026
19953ba
test(auth): unit test AuthServiceImpl and the cleanup job with mocked…
EdgarEldy Sep 19, 2026
a5a2336
test(ws): add shared fixtures and assertions for HTTP tests
EdgarEldy Sep 19, 2026
2d20d42
test(ws): test JwtService and JwtKeyLoader
EdgarEldy Sep 19, 2026
7caed75
test(ws): test the auth endpoints and the security chain with MockMvc
EdgarEldy Sep 19, 2026
28d3721
test(ws): prove the full auth flow against the real stack
EdgarEldy Sep 19, 2026
c255318
build(docker): pass the JWT key locations and mount the production keys
EdgarEldy Sep 19, 2026
a2c2d0c
fix(auth): treat a blank email as unknown in forgotPassword
EdgarEldy Sep 19, 2026
dfd45f0
docs(readme): tick the feature/auth tasks
EdgarEldy Sep 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions core/auth/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,28 @@
<groupId>com.edgareldy.springjdbctutorial</groupId>
<artifactId>common</artifactId>
</dependency>
<!-- BCryptPasswordEncoder / PasswordEncoder only, without the rest of Spring Security -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>

<!-- Test scope only: the DAO tests reuse the PostgreSQL Testcontainer support published by common -->
<dependency>
<groupId>com.edgareldy.springjdbctutorial</groupId>
<artifactId>common</artifactId>
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -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).
* <p>
* 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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<ActivationToken> findByTokenHash(String tokenHash);

void markValidated(Long id, Instant validatedAt);

void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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);
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<PasswordResetToken> findByTokenHash(String tokenHash);

void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* Created edgar.muhamyangabo on 9/19/26
* Author : edgar.muhamyangabo
* Date : 9/19/26
* Project : spring-jdbc-tutorial
*/
public interface UserDao {

Optional<User> findById(Long id);

/** Case-insensitive lookup. */
Optional<User> 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<String> findRoleNamesByUserId(Long userId);

/** Distinct "RESOURCE:ACTION" codes granted through the user's roles, sorted. */
List<String> findPermissionCodesByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<ActivationToken> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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);
}
}
Loading
Loading