refactor(users): login and registration use JdbcTemplate for users

This commit is contained in:
lobo committed 2026-09-30 13:41:56 +02:00
1 parent b6be1879f0
commit d082f3befd
12 files changed
+145 -262

No files matched your search

+3
View File
@@ -131,6 +131,9 @@ layers:
| Domain | `model`, `service`, `exception` | Plain Java models and business logic. Uses repositories, never API classes, entities or `EntityMapper`. |
| Persistence | `persistence`, `repo` | JPA entities, Spring Data repositories, `EntityMapper`; `repo` holds `JdbcTemplate` queries. Entities never reach the API layer. |
`user` owns the tables `users` and `user_settings` through the one JPA entity `UserEntity`. `login` and
`registration` read and write `users` with `JdbcTemplate` (refresh tokens stay JPA). Every query for "a user who
may log in and is shown to others" uses `shared/account/ActiveUser`.
`profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package.
`version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image.
`account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core
@@ -1,13 +1,11 @@
package auth_service.modules.login.persistence;
import auth_service.shared.account.EmailAddress;
import auth_service.modules.login.model.User;
import auth_service.modules.login.persistence.entities.MinimalUserEntity;
import auth_service.modules.login.persistence.mapper.EntityMapper;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;
import jakarta.transaction.Transactional;
import auth_service.shared.account.EmailAddress;
import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.time.Clock;
@@ -15,39 +13,36 @@ import java.time.LocalDateTime;
import java.util.Optional;
@Repository
@Transactional
@RequiredArgsConstructor
public class MinimalUserRepositoryImpl implements MinimalUserRepository {
@PersistenceContext
private EntityManager entityManager;
private static final String SELECT_USER =
"SELECT id, email, password, steam_id, enabled, deleted_at IS NOT NULL AS deleted, role FROM users ";
private static final RowMapper<User> USER_ROW = (rs, row) -> User.builder()
.id(rs.getLong("id"))
.email(rs.getString("email"))
.password(rs.getString("password"))
.steamId(rs.getObject("steam_id", Long.class))
.enabled(rs.getBoolean("enabled"))
.deleted(rs.getBoolean("deleted"))
.role(Role.parse(rs.getString("role")))
.build();
private final JdbcTemplate jdbc;
@Override
public Optional<User> findByEmail(String email) {
var criteriaBuilder = entityManager.getCriteriaBuilder();
var userEntityCriteriaQuery = criteriaBuilder.createQuery(MinimalUserEntity.class);
var userEntity = userEntityCriteriaQuery.from(MinimalUserEntity.class);
userEntityCriteriaQuery.select(userEntity).where(criteriaBuilder.equal(
userEntity.get("email"), EmailAddress.normalize(email)));
TypedQuery<MinimalUserEntity> typedQuery = entityManager.createQuery(userEntityCriteriaQuery);
return typedQuery.getResultStream()
.findFirst()
.map(EntityMapper::mapToModel);
return jdbc.query(SELECT_USER + "WHERE email = ?", USER_ROW, EmailAddress.normalize(email)).stream().findFirst();
}
@Override
public Optional<User> findById(long userId) {
return Optional.ofNullable(entityManager.find(MinimalUserEntity.class, userId)).map(EntityMapper::mapToModel);
return jdbc.query(SELECT_USER + "WHERE id = ?", USER_ROW, userId).stream().findFirst();
}
@Override
public void updateLoginDate(long userId) {
var userEntity = entityManager.find(MinimalUserEntity.class, userId);
if (userEntity != null) {
userEntity.setLastLoginDate(LocalDateTime.now(Clock.systemUTC()));
entityManager.flush();
}
jdbc.update("UPDATE users SET last_login_date = ? WHERE id = ?", LocalDateTime.now(Clock.systemUTC()), userId);
}
}
@@ -1,46 +0,0 @@
package auth_service.modules.login.persistence.entities;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.time.LocalDateTime;
/**
* Represents a user entity mapped to the 'users' table in PostgreSQL.
*/
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MinimalUserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private boolean enabled;
@Column(name = "last_login_date")
private LocalDateTime lastLoginDate;
@Column(name = "steam_id")
private Long steamId;
@Column(name = "deleted_at", insertable = false, updatable = false)
private Instant deletedAt;
// Set by SQL only (the first admin).
@Column(name = "role", insertable = false, updatable = false)
private String role;
}
@@ -1,8 +1,6 @@
package auth_service.modules.login.persistence.mapper;
import auth_service.modules.login.model.RefreshToken;
import auth_service.modules.login.model.User;
import auth_service.modules.login.persistence.entities.MinimalUserEntity;
import auth_service.modules.login.persistence.entities.RefreshTokenEntity;
import auth_service.shared.security.Role;
@@ -11,22 +9,6 @@ public abstract class EntityMapper {
private EntityMapper() {
}
public static User mapToModel(MinimalUserEntity entity) {
User model = User.builder().build();
if (entity != null) {
model.setId(entity.getId());
model.setEmail(entity.getEmail());
model.setPassword(entity.getPassword());
model.setSteamId(entity.getSteamId());
model.setEnabled(entity.isEnabled());
model.setDeleted(entity.getDeletedAt() != null);
model.setRole(Role.parse(entity.getRole()));
}
return model;
}
public static RefreshToken mapToModel(RefreshTokenEntity entity) {
return new RefreshToken(entity.getId(), entity.getUserId(), entity.getFamilyId(), entity.getExpiresAt(),
entity.getRotatedAt(), entity.getRevokedAt(),
@@ -1,6 +1,7 @@
package auth_service.modules.profile.repo;
import auth_service.modules.profile.model.PublicProfile;
import auth_service.shared.account.ActiveUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
@@ -32,8 +33,8 @@ public class PublicProfileRepository {
SELECT u.id, u.display_name, u.registration_date, s.profile_accent, s.profile_corners,
s.profile_banner_appid, s.profile_background_appid, s.profile_tiles
FROM users u JOIN user_settings s ON s.user_id = u.id
WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND u.deleted_at IS NULL AND s.public_profile
""",
WHERE LOWER(u.display_name) = LOWER(?) AND %s AND s.public_profile
""".formatted(ActiveUser.of("u")),
(rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"),
rs.getTimestamp("registration_date") != null
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null,
@@ -1,44 +1,44 @@
package auth_service.modules.registration.persistence;
import auth_service.shared.account.EmailAddress;
import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.mapper.EntityMapper;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.transaction.Transactional;
import auth_service.shared.account.EmailAddress;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDateTime;
@Repository
@Transactional
@RequiredArgsConstructor
public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
@PersistenceContext
private EntityManager entityManager;
private final JdbcTemplate jdbc;
@Override
public boolean existsByEmail(String email) {
var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE u.email = :email";
var count = entityManager.createQuery(jpql, Long.class)
.setParameter("email", EmailAddress.normalize(email))
.getSingleResult();
return count != null && count > 0;
return Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS (SELECT 1 FROM users WHERE email = ?)",
Boolean.class, EmailAddress.normalize(email)));
}
@Override
public boolean existsByDisplayName(String displayName) {
var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE LOWER(u.displayName) = LOWER(:displayName)";
var count = entityManager.createQuery(jpql, Long.class)
.setParameter("displayName", displayName)
.getSingleResult();
return count != null && count > 0;
return Boolean.TRUE.equals(jdbc.queryForObject(
"SELECT EXISTS (SELECT 1 FROM users WHERE LOWER(display_name) = LOWER(?))", Boolean.class, displayName));
}
@Override
@Transactional
public long save(Registration registration) {
var userToRegisterEntity = EntityMapper.mapToPersistence(registration);
entityManager.persist(userToRegisterEntity);
return userToRegisterEntity.getId();
Long id = jdbc.queryForObject("""
INSERT INTO users (email, password, enabled, display_name, registration_date)
VALUES (?, ?, TRUE, ?, ?) RETURNING id""", Long.class,
registration.getEmail(), registration.getEncryptedPassword(), registration.getDisplayName(),
LocalDateTime.now(Clock.systemUTC()));
if (id == null) {
throw new IllegalStateException("No id for the new user");
}
return id;
}
}
@@ -1,38 +0,0 @@
package auth_service.modules.registration.persistence.entities;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public class RegisteredUserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private boolean enabled;
@Column(name = "display_name", length = 24)
private String displayName;
@Column(name = "registration_date", nullable = false)
private LocalDateTime registrationDate;
@Column(name = "last_login_date")
private LocalDateTime lastLoginDate;
}
@@ -1,24 +0,0 @@
package auth_service.modules.registration.persistence.mapper;
import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.entities.RegisteredUserEntity;
import java.time.Clock;
import java.time.LocalDateTime;
public abstract class EntityMapper {
private EntityMapper() {
}
public static RegisteredUserEntity mapToPersistence(Registration userModel) {
return RegisteredUserEntity.builder().
email(userModel.getEmail()).
registrationDate(LocalDateTime.now(Clock.systemUTC())).
password(userModel.getEncryptedPassword()).
enabled(true).
displayName(userModel.getDisplayName()).
lastLoginDate(null).
build();
}
}
@@ -1,5 +1,6 @@
package auth_service.modules.stats.repo;
import auth_service.shared.account.ActiveUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
@@ -15,13 +16,13 @@ public class StatsRepository {
}
public long countTotalUsers() {
Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE enabled = TRUE AND deleted_at IS NULL", Long.class);
Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u"), Long.class);
return v != null ? v : 0L;
}
public long countUsersWithSteamLinked() {
Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM users WHERE enabled = TRUE AND deleted_at IS NULL AND steam_id IS NOT NULL", Long.class);
"SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u") + " AND u.steam_id IS NOT NULL", Long.class);
return v != null ? v : 0L;
}
}
@@ -0,0 +1,14 @@
package auth_service.shared.account;
/**
* The SQL rule for a user who may log in and is shown to others: not blocked and not deleted.
*/
public final class ActiveUser {
private ActiveUser() {
}
public static String of(String alias) {
return alias + ".enabled AND " + alias + ".deleted_at IS NULL";
}
}
@@ -1,13 +1,19 @@
package auth_service.modules.login.persistence;
import auth_service.modules.login.persistence.entities.MinimalUserEntity;
import auth_service.modules.login.model.User;
import auth_service.shared.security.Role;
import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Import(MinimalUserRepositoryImpl.class)
class MinimalUserRepositoryTest extends AbstractPersistenceTest {
@@ -16,53 +22,47 @@ class MinimalUserRepositoryTest extends AbstractPersistenceTest {
private MinimalUserRepository minimalUserRepository;
@Autowired
private TestEntityManager entityManager;
private JdbcTemplate jdbc;
@Test
void findByEmail_Success() {
// Arrange
var email = "minimal@example.com";
var entity = MinimalUserEntity.builder()
.email(email)
.password("password")
.enabled(true)
.build();
entityManager.persist(entity);
entityManager.flush();
// Act
var result = minimalUserRepository.findByEmail(email);
// Assert
assertTrue(result.isPresent());
assertEquals(email, result.get().getEmail());
private long insertUser(String email) {
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, registration_date) "
+ "VALUES (?, 'password', TRUE, ?) RETURNING id", Long.class, email, LocalDateTime.now());
return id == null ? 0 : id;
}
@Test
void updateLoginDate_Success() {
// Arrange
var entity = MinimalUserEntity.builder()
.email("login@example.com")
.password("password")
.enabled(true)
.build();
entity = entityManager.persist(entity);
entityManager.flush();
void findByEmail_MapsTheUser() {
long id = insertUser("minimal@example.com");
jdbc.update("UPDATE users SET steam_id = 76561198000000001, deleted_at = now() WHERE id = ?", id);
// Act
minimalUserRepository.updateLoginDate(entity.getId());
entityManager.clear(); // Clear cache to force reload from DB
User user = minimalUserRepository.findByEmail("minimal@example.com").orElseThrow();
// Assert
var updated = entityManager.find(MinimalUserEntity.class, entity.getId());
assertNotNull(updated.getLastLoginDate());
assertEquals(id, user.getId());
assertEquals("password", user.getPassword());
assertEquals(76561198000000001L, user.getSteamId());
assertTrue(user.isEnabled());
assertTrue(user.isDeleted());
assertEquals(Role.USER, user.getRole());
}
@Test
void findById_UnknownUser_IsEmpty() {
assertFalse(minimalUserRepository.findById(-1).isPresent());
}
@Test
void updateLoginDate_StoresTheDate() {
long id = insertUser("login@example.com");
minimalUserRepository.updateLoginDate(id);
assertNotNull(jdbc.queryForObject("SELECT last_login_date FROM users WHERE id = ?", LocalDateTime.class, id));
}
// An access token from before V19 still carries the email as it was typed.
@Test
void findByEmail_IgnoresCase() {
entityManager.persist(MinimalUserEntity.builder().email("case@example.com").password("password").enabled(true).build());
entityManager.flush();
insertUser("case@example.com");
assertTrue(minimalUserRepository.findByEmail("Case@Example.com").isPresent());
}
@@ -1,14 +1,19 @@
package auth_service.modules.registration.persistence;
import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.entities.RegisteredUserEntity;
import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Import(RegisteredUserRepositoryImpl.class)
class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
@@ -17,59 +22,49 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
private RegisteredUserRepository registeredUserRepository;
@Autowired
private TestEntityManager entityManager;
private JdbcTemplate jdbc;
private static Registration registration(String email, String displayName) {
return Registration.builder().email(email).encryptedPassword("hashed").displayName(displayName).build();
}
@Test
void existsByEmail_Success() {
// Arrange
var email = "exists@example.com";
var entity = RegisteredUserEntity.builder()
.email(email)
.password("password")
.enabled(true)
.registrationDate(java.time.LocalDateTime.now())
.build();
entityManager.persist(entity);
entityManager.flush();
void existsByEmail_OnlyForAStoredEmail() {
registeredUserRepository.save(registration("exists@example.com", null));
// Act & Assert
assertTrue(registeredUserRepository.existsByEmail(email));
assertTrue(registeredUserRepository.existsByEmail("exists@example.com"));
assertFalse(registeredUserRepository.existsByEmail("other@example.com"));
}
@Test
void save_Success() {
// Arrange
var registration = Registration.builder()
.email("save@example.com")
.encryptedPassword("hashed")
.build();
void existsByDisplayName_IgnoresCase() {
registeredUserRepository.save(registration("named@example.com", "Lobo"));
// Act
long id = registeredUserRepository.save(registration);
assertTrue(registeredUserRepository.existsByDisplayName("lobo"));
assertFalse(registeredUserRepository.existsByDisplayName("Wolf"));
}
// Assert
assertTrue(id > 0);
var saved = entityManager.find(RegisteredUserEntity.class, id);
assertNotNull(saved);
assertEquals("save@example.com", saved.getEmail());
@Test
void save_StoresAnEnabledUser() {
long id = registeredUserRepository.save(registration("save@example.com", "Saver"));
Map<String, Object> saved = jdbc.queryForMap("SELECT * FROM users WHERE id = ?", id);
assertEquals("save@example.com", saved.get("email"));
assertEquals("hashed", saved.get("password"));
assertEquals(true, saved.get("enabled"));
assertEquals("Saver", saved.get("display_name"));
assertEquals("USER", saved.get("role"));
}
// V19: a second account under the same email in other case is refused by the database too.
@Test
void emailsThatDifferOnlyInCase_AreOneAccount() {
entityManager.persist(RegisteredUserEntity.builder().email("dup@example.com").password("password")
.enabled(true).registrationDate(java.time.LocalDateTime.now()).build());
entityManager.flush();
registeredUserRepository.save(registration("dup@example.com", null));
assertTrue(registeredUserRepository.existsByEmail("Dup@Example.com"));
var refused = assertThrows(org.hibernate.exception.ConstraintViolationException.class, () -> {
entityManager.persist(RegisteredUserEntity.builder().email("Dup@example.com").password("password")
.enabled(true).registrationDate(java.time.LocalDateTime.now()).build());
entityManager.flush();
});
// GlobalExceptionController decides on the state; on an English server Hibernate also gives the name.
assertEquals("23505", refused.getSQLState());
assertEquals("users_email_lower", refused.getConstraintName());
DuplicateKeyException refused = assertThrows(DuplicateKeyException.class,
() -> registeredUserRepository.save(registration("Dup@example.com", null)));
// GlobalExceptionController finds the name in the message, in every server language.
assertTrue(refused.getMostSpecificCause().getMessage().contains("users_email_lower"));
}
}