refactor: adopt feature package boundaries and JPA

This commit is contained in:
sechmachine
2026-08-15 00:14:21 +07:00
parent bc70db1d0d
commit 3fdfbb2bf2
54 changed files with 1137 additions and 466 deletions
@@ -4,7 +4,7 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.lab.labtimesheet.configuration.SecurityProperties;
import com.lab.labtimesheet.config.SecurityProperties;
@SpringBootApplication
@EnableConfigurationProperties(SecurityProperties.class)
@@ -1,82 +0,0 @@
package com.lab.labtimesheet.accounts;
import java.time.Clock;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Locale;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
@Service
public class BootstrapService {
private final JdbcTemplate jdbc;
private final TransactionTemplate transactions;
private final PasswordEncoder passwords;
private final Clock clock;
BootstrapService(JdbcTemplate jdbc, TransactionTemplate transactions, PasswordEncoder passwords, Clock clock) {
this.jdbc = jdbc;
this.transactions = transactions;
this.passwords = passwords;
this.clock = clock;
}
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
String normalizedEmail = normalizeEmail(email);
String normalizedName = requireText(displayName, "Display name");
requirePassword(password);
return transactions.execute(status -> {
Boolean initialized = jdbc.queryForObject(
"select initialized from system_state where singleton_id = 1 for update", Boolean.class);
if (Boolean.TRUE.equals(initialized)) {
return BootstrapOutcome.ALREADY_INITIALIZED;
}
OffsetDateTime now = OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
Long userId = jdbc.queryForObject("""
insert into app_users
(email, display_name, password_hash, global_role, account_status, activated_at, created_at, updated_at)
values (?, ?, ?, 'ADMIN', 'ACTIVE', ?, ?, ?)
returning id
""", Long.class, normalizedEmail, normalizedName, passwords.encode(password), now, now, now);
jdbc.update("""
update system_state
set initialized = true, initialized_at = ?, bootstrap_admin_id = ?, updated_at = ?, version = version + 1
where singleton_id = 1
""", now, userId, now);
return BootstrapOutcome.CREATED;
});
}
public boolean isInitialized() {
return Boolean.TRUE.equals(jdbc.queryForObject(
"select initialized from system_state where singleton_id = 1", Boolean.class));
}
static String normalizeEmail(String email) {
return requireText(email, "Email").toLowerCase(Locale.ROOT);
}
static void requirePassword(String password) {
if (password == null || password.length() < 12 || password.length() > 128) {
throw new IllegalArgumentException("Password must contain 12 through 128 characters");
}
}
private static String requireText(String value, String field) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(field + " is required");
}
return value.trim();
}
public enum BootstrapOutcome {
CREATED,
ALREADY_INITIALIZED
}
}
@@ -1,37 +0,0 @@
package com.lab.labtimesheet.accounts;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
class JdbcUserDetailsService implements UserDetailsService {
private final JdbcTemplate jdbc;
JdbcUserDetailsService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
String email = BootstrapService.normalizeEmail(username);
return jdbc.query("""
select email, password_hash, global_role, account_status
from app_users where lower(btrim(email)) = ?
""", resultSet -> {
if (!resultSet.next()) {
throw new UsernameNotFoundException("Invalid credentials");
}
boolean active = "ACTIVE".equals(resultSet.getString("account_status"));
String hash = resultSet.getString("password_hash");
return User.withUsername(resultSet.getString("email"))
.password(hash == null ? "{noop}unavailable" : hash)
.roles(resultSet.getString("global_role"))
.disabled(!active)
.build();
}, email);
}
}
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.accounts;
/** Accounts and security module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.attendance;
/** Attendance, leave, and corrections module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -1,5 +1,7 @@
package com.lab.labtimesheet.accounts;
package com.lab.labtimesheet.config;
import com.lab.labtimesheet.feature.account.controller.BootstrapAccessFilter;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -10,7 +12,6 @@ import org.springframework.security.web.access.intercept.AuthorizationFilter;
@Configuration(proxyBeanMethods = false)
class SecurityConfiguration {
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.configuration;
package com.lab.labtimesheet.config;
import java.util.Base64;
@@ -16,7 +16,7 @@ public class SecurityProperties {
this.masterKey = masterKey;
}
byte[] decodedMasterKey() {
public byte[] decodedMasterKey() {
if (masterKey == null || masterKey.isBlank()) {
throw new IllegalStateException("lab.security.master-key is required");
}
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.configuration;
package com.lab.labtimesheet.config;
import java.time.Clock;
@@ -7,7 +7,6 @@ import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
class TimeConfiguration {
@Bean
Clock applicationClock() {
return Clock.systemUTC();
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.configuration;
/** Configuration, integrations, and calendar module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -1,183 +0,0 @@
package com.lab.labtimesheet.configuration;
import java.time.Clock;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
@Service
public class SmtpConfigurationService {
private final JdbcTemplate jdbc;
private final TransactionTemplate transactions;
private final SecretCipher secrets;
private final SmtpProbe probe;
private final Environment environment;
private final Clock clock;
SmtpConfigurationService(JdbcTemplate jdbc, TransactionTemplate transactions, SecretCipher secrets,
SmtpProbe probe, Environment environment, Clock clock) {
this.jdbc = jdbc;
this.transactions = transactions;
this.secrets = secrets;
this.probe = probe;
this.environment = environment;
this.clock = clock;
}
public long saveDraft(long adminId, SmtpDraft draft) {
validate(draft);
SecretCipher.EncryptedSecret password = draft.password() == null ? null : secrets.encrypt(draft.password());
OffsetDateTime now = now();
return transactions.execute(status -> {
Long existing = jdbc.query("select id from smtp_configurations where status = 'DRAFT' for update",
resultSet -> resultSet.next() ? resultSet.getLong(1) : null);
Object[] values = values(draft, password, adminId, now);
if (existing == null) {
return jdbc.queryForObject("""
insert into smtp_configurations
(status, host, port, security_mode, username, password_ciphertext, password_nonce,
secret_key_version, from_address, from_name, created_by_user_id, created_at, updated_at)
values ('DRAFT', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
returning id
""", Long.class, values);
}
jdbc.update("""
update smtp_configurations
set host = ?, port = ?, security_mode = ?, username = ?, password_ciphertext = ?,
password_nonce = ?, secret_key_version = ?, from_address = ?, from_name = ?,
tested_at = null, tested_by_user_id = null, updated_at = ?, version = version + 1
where id = ?
""", draft.host().trim(), draft.port(), draft.securityMode().name(), clean(draft.username()),
password == null ? null : password.ciphertext(), password == null ? null : password.nonce(),
password == null ? null : password.keyVersion(), draft.fromAddress().trim(), draft.fromName().trim(),
now, existing);
return existing;
});
}
private static Object[] values(SmtpDraft draft, SecretCipher.EncryptedSecret password, long adminId,
OffsetDateTime now) {
return new Object[] {
draft.host().trim(), draft.port(), draft.securityMode().name(), clean(draft.username()),
password == null ? null : password.ciphertext(), password == null ? null : password.nonce(),
password == null ? null : password.keyVersion(), draft.fromAddress().trim(), draft.fromName().trim(),
adminId, now, now
};
}
public void testDraft(long draftId, long adminId, String recipient) {
SmtpConnection connection = load(draftId, "DRAFT");
probe.send(connection, recipient, "Lab Timesheet SMTP test", "SMTP configuration test succeeded.");
OffsetDateTime now = now();
if (jdbc.update("""
update smtp_configurations
set tested_at = ?, tested_by_user_id = ?, updated_at = ?, version = version + 1
where id = ? and status = 'DRAFT'
""", now, adminId, now, draftId) != 1) {
throw new IllegalStateException("SMTP draft is no longer available");
}
}
public void activate(long draftId, long adminId) {
transactions.executeWithoutResult(status -> {
OffsetDateTime testedAt = jdbc.query("""
select tested_at from smtp_configurations where id = ? and status = 'DRAFT' for update
""", resultSet -> resultSet.next() ? resultSet.getObject(1, OffsetDateTime.class) : null, draftId);
if (testedAt == null) {
throw new IllegalStateException("SMTP draft must pass a test before activation");
}
OffsetDateTime now = now();
jdbc.update("""
update smtp_configurations
set status = 'RETIRED', retired_at = ?, retired_by_user_id = ?, updated_at = ?, version = version + 1
where status = 'ACTIVE'
""", now, adminId, now);
jdbc.update("""
update smtp_configurations
set status = 'ACTIVE', activated_at = ?, activated_by_user_id = ?, updated_at = ?, version = version + 1
where id = ? and status = 'DRAFT'
""", now, adminId, now, draftId);
});
}
public boolean hasActiveConfiguration() {
return jdbc.queryForObject("select exists(select 1 from smtp_configurations where status = 'ACTIVE')",
Boolean.class);
}
public SmtpConnection activeConnection() {
return jdbc.query("select id from smtp_configurations where status = 'ACTIVE'",
resultSet -> {
if (!resultSet.next()) {
throw new IllegalStateException("Active SMTP configuration is required");
}
return load(resultSet.getLong(1), "ACTIVE");
});
}
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
probe.send(activeConnection(), recipient, subject, body);
}
private SmtpConnection load(long id, String requiredStatus) {
return jdbc.query("""
select host, port, security_mode, username, password_ciphertext, password_nonce,
from_address, from_name
from smtp_configurations where id = ? and status = ?
""", resultSet -> {
if (!resultSet.next()) {
throw new IllegalStateException("SMTP configuration is not available");
}
byte[] ciphertext = resultSet.getBytes("password_ciphertext");
return new SmtpConnection(
resultSet.getString("host"), resultSet.getInt("port"),
SecurityMode.valueOf(resultSet.getString("security_mode")),
resultSet.getString("username"),
ciphertext == null ? null : secrets.decrypt(ciphertext, resultSet.getBytes("password_nonce")),
resultSet.getString("from_address"), resultSet.getString("from_name"));
}, id, requiredStatus);
}
private void validate(SmtpDraft draft) {
if (draft.host() == null || draft.host().isBlank() || draft.port() < 1 || draft.port() > 65535
|| draft.securityMode() == null || draft.fromAddress() == null || draft.fromAddress().isBlank()
|| draft.fromName() == null || draft.fromName().isBlank()) {
throw new IllegalArgumentException("Valid SMTP host, port, security mode, From address and name are required");
}
if ((clean(draft.username()) == null) != (draft.password() == null || draft.password().isEmpty())) {
throw new IllegalArgumentException("SMTP username and password must be supplied together");
}
if (draft.securityMode() == SecurityMode.NONE
&& !environment.acceptsProfiles(Profiles.of("dev", "test"))) {
throw new IllegalArgumentException("Plaintext SMTP is allowed only in dev and test");
}
}
private OffsetDateTime now() {
return OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
}
private static String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public enum SecurityMode {
NONE,
STARTTLS,
TLS
}
public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password,
String fromAddress, String fromName) {
}
public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password,
String fromAddress, String fromName) {
}
}
@@ -1,6 +0,0 @@
package com.lab.labtimesheet.configuration;
@FunctionalInterface
public interface SmtpProbe {
void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body);
}
@@ -1,17 +1,18 @@
package com.lab.labtimesheet.accounts;
package com.lab.labtimesheet.feature.account.controller;
import java.io.IOException;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
class BootstrapAccessFilter extends OncePerRequestFilter {
public class BootstrapAccessFilter extends OncePerRequestFilter {
private final BootstrapService bootstrap;
BootstrapAccessFilter(BootstrapService bootstrap) {
public BootstrapAccessFilter(BootstrapService bootstrap) {
this.bootstrap = bootstrap;
}
@@ -1,5 +1,6 @@
package com.lab.labtimesheet.accounts;
package com.lab.labtimesheet.feature.account.controller;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -1,4 +1,4 @@
package com.lab.labtimesheet.accounts;
package com.lab.labtimesheet.feature.account.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.account.model;
public enum AccountStatus {
PENDING_ACTIVATION,
ACTIVE,
LOCKED,
DEACTIVATED
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.account.model;
public enum GlobalRole {
ADMIN,
MENTOR,
INTERN
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.account.model;
public enum InternshipStatus {
NOT_STARTED,
ACTIVE,
COMPLETED,
WITHDRAWN
}
@@ -0,0 +1,12 @@
package com.lab.labtimesheet.feature.account.model.dto;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
public record AccountIdentity(
long id,
String email,
String displayName,
GlobalRole role,
AccountStatus status) {
}
@@ -0,0 +1,103 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "app_users")
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 320)
private String email;
@Column(name = "display_name", nullable = false, length = 120)
private String displayName;
@Column(name = "password_hash", length = 255)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "global_role", nullable = false, length = 16, updatable = false)
private GlobalRole globalRole;
@Enumerated(EnumType.STRING)
@Column(name = "account_status", nullable = false, length = 32)
private AccountStatus accountStatus;
@Column(name = "activated_at")
private Instant activatedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by_user_id")
private AppUser createdBy;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected AppUser() {
}
private AppUser(String email, String displayName, String passwordHash, GlobalRole globalRole,
AccountStatus accountStatus, Instant activatedAt, AppUser createdBy, Instant now) {
this.email = email;
this.displayName = displayName;
this.passwordHash = passwordHash;
this.globalRole = globalRole;
this.accountStatus = accountStatus;
this.activatedAt = activatedAt;
this.createdBy = createdBy;
this.createdAt = now;
this.updatedAt = now;
}
public static AppUser bootstrapAdmin(String email, String displayName, String passwordHash, Instant now) {
return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now);
}
public Long getId() {
return id;
}
public String getEmail() {
return email;
}
public String getDisplayName() {
return displayName;
}
public String getPasswordHash() {
return passwordHash;
}
public GlobalRole getGlobalRole() {
return globalRole;
}
public AccountStatus getAccountStatus() {
return accountStatus;
}
}
@@ -0,0 +1,61 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import java.time.LocalDate;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "intern_profiles")
public class InternProfile {
@Id
@Column(name = "user_id")
private Long userId;
@Column(name = "student_code", nullable = false, length = 64)
private String studentCode;
@Column(length = 120)
private String department;
@Column(length = 32)
private String phone;
@Column(name = "internship_start_date", nullable = false)
private LocalDate internshipStartDate;
@Column(name = "internship_end_date", nullable = false)
private LocalDate internshipEndDate;
@Enumerated(EnumType.STRING)
@Column(name = "internship_status", nullable = false, length = 24)
private InternshipStatus internshipStatus;
@Column(name = "activated_at")
private Instant activatedAt;
@Column(name = "completed_at")
private Instant completedAt;
@Column(name = "withdrawn_at")
private Instant withdrawnAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected InternProfile() {
}
}
@@ -0,0 +1,57 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "system_state")
public class SystemState {
@Id
@Column(name = "singleton_id")
private short singletonId;
@Column(nullable = false)
private boolean initialized;
@Column(name = "initialized_at")
private Instant initializedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bootstrap_admin_id")
private AppUser bootstrapAdmin;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected SystemState() {
}
public boolean isInitialized() {
return initialized;
}
public void initialize(AppUser admin, Instant now) {
if (initialized) {
throw new IllegalStateException("Bootstrap is already complete");
}
initialized = true;
initializedAt = now;
bootstrapAdmin = admin;
updatedAt = now;
}
}
@@ -0,0 +1,17 @@
package com.lab.labtimesheet.feature.account.repository;
import java.util.Optional;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
}
@@ -0,0 +1,9 @@
package com.lab.labtimesheet.feature.account.repository;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
}
@@ -0,0 +1,15 @@
package com.lab.labtimesheet.feature.account.repository;
import java.util.Optional;
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
public interface SystemStateRepository extends JpaRepository<SystemState, Short> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from SystemState s where s.singletonId = 1")
Optional<SystemState> findSingletonForUpdate();
}
@@ -0,0 +1,78 @@
package com.lab.labtimesheet.feature.account.service;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AccountService {
private final AppUserRepository users;
private final InternProfileRepository internProfiles;
AccountService(AppUserRepository users, InternProfileRepository internProfiles) {
this.users = users;
this.internProfiles = internProfiles;
}
@Transactional(readOnly = true)
public AccountIdentity requireIdentityById(long userId) {
return users.findById(userId).map(AccountService::identity)
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
}
@Transactional(readOnly = true)
public AccountIdentity requireIdentityByEmail(String email) {
return users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)).map(AccountService::identity)
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
}
@Transactional(readOnly = true)
public boolean isEligibleIntern(long userId) {
return users.findById(userId)
.filter(user -> user.getGlobalRole() == GlobalRole.INTERN)
.filter(user -> user.getAccountStatus() == AccountStatus.ACTIVE)
.filter(user -> internProfiles.existsByUserIdAndInternshipStatus(
user.getId(), InternshipStatus.ACTIVE))
.isPresent();
}
@Transactional(readOnly = true)
public AccountIdentity requireEligibleIntern(long userId) {
if (!isEligibleIntern(userId)) {
throw new IllegalArgumentException("An active Intern account and internship are required");
}
return requireIdentityById(userId);
}
@Transactional(readOnly = true)
public long requireActiveAdminId(String email) {
AppUser user = users.findByNormalizedEmail(BootstrapService.normalizeEmail(email))
.orElseThrow(() -> new IllegalStateException("Authenticated Admin is missing"));
return requireActiveAdmin(user);
}
@Transactional(readOnly = true)
public long requireActiveAdminId(long userId) {
AppUser user = users.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
return requireActiveAdmin(user);
}
private static long requireActiveAdmin(AppUser user) {
if (user.getGlobalRole() != GlobalRole.ADMIN || user.getAccountStatus() != AccountStatus.ACTIVE) {
throw new IllegalArgumentException("An active Admin is required");
}
return user.getId();
}
private static AccountIdentity identity(AppUser user) {
return new AccountIdentity(
user.getId(), user.getEmail(), user.getDisplayName(), user.getGlobalRole(), user.getAccountStatus());
}
}
@@ -0,0 +1,73 @@
package com.lab.labtimesheet.feature.account.service;
import java.time.Clock;
import java.util.Locale;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BootstrapService {
private final SystemStateRepository systemStates;
private final AppUserRepository users;
private final PasswordEncoder passwords;
private final Clock clock;
BootstrapService(SystemStateRepository systemStates, AppUserRepository users, PasswordEncoder passwords,
Clock clock) {
this.systemStates = systemStates;
this.users = users;
this.passwords = passwords;
this.clock = clock;
}
@Transactional
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
String normalizedEmail = normalizeEmail(email);
String normalizedName = requireText(displayName, "Display name");
requirePassword(password);
SystemState state = systemStates.findSingletonForUpdate()
.orElseThrow(() -> new IllegalStateException("System state is missing"));
if (state.isInitialized()) {
return BootstrapOutcome.ALREADY_INITIALIZED;
}
var now = clock.instant();
AppUser admin = users.save(AppUser.bootstrapAdmin(
normalizedEmail, normalizedName, passwords.encode(password), now));
state.initialize(admin, now);
return BootstrapOutcome.CREATED;
}
@Transactional(readOnly = true)
public boolean isInitialized() {
return systemStates.findById((short) 1).map(SystemState::isInitialized).orElse(false);
}
public static String normalizeEmail(String email) {
return requireText(email, "Email").toLowerCase(Locale.ROOT);
}
public static void requirePassword(String password) {
if (password == null || password.length() < 12 || password.length() > 128) {
throw new IllegalArgumentException("Password must contain 12 through 128 characters");
}
}
private static String requireText(String value, String field) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(field + " is required");
}
return value.trim();
}
public enum BootstrapOutcome {
CREATED,
ALREADY_INITIALIZED
}
}
@@ -0,0 +1,32 @@
package com.lab.labtimesheet.feature.account.service;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class DatabaseUserDetailsService implements UserDetailsService {
private final AppUserRepository users;
DatabaseUserDetailsService(AppUserRepository users) {
this.users = users;
}
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var account = users.findByNormalizedEmail(BootstrapService.normalizeEmail(username))
.orElseThrow(() -> new UsernameNotFoundException("Invalid credentials"));
String hash = account.getPasswordHash();
return User.withUsername(account.getEmail())
.password(hash == null ? "{noop}unavailable" : hash)
.roles(account.getGlobalRole().name())
.disabled(account.getAccountStatus() != AccountStatus.ACTIVE)
.build();
}
}
@@ -1,10 +1,11 @@
package com.lab.labtimesheet.configuration;
package com.lab.labtimesheet.feature.integration.controller;
import java.security.Principal;
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode;
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft;
import org.springframework.jdbc.core.JdbcTemplate;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -15,11 +16,11 @@ import org.springframework.web.bind.annotation.RequestParam;
@RequestMapping("/admin/smtp")
class SmtpController {
private final SmtpConfigurationService smtp;
private final JdbcTemplate jdbc;
private final AccountService accounts;
SmtpController(SmtpConfigurationService smtp, JdbcTemplate jdbc) {
SmtpController(SmtpConfigurationService smtp, AccountService accounts) {
this.smtp = smtp;
this.jdbc = jdbc;
this.accounts = accounts;
}
@GetMapping
@@ -49,7 +50,6 @@ class SmtpController {
}
private long adminId(Principal principal) {
return jdbc.queryForObject("select id from app_users where lower(btrim(email)) = lower(btrim(?))", Long.class,
principal.getName());
return accounts.requireActiveAdminId(principal.getName());
}
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.integration.model;
public enum SecurityMode {
NONE,
STARTTLS,
TLS
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.integration.model;
public enum SmtpStatus {
DRAFT,
ACTIVE,
RETIRED
}
@@ -0,0 +1,18 @@
package com.lab.labtimesheet.feature.integration.model.dto;
public record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) {
public EncryptedSecret {
ciphertext = ciphertext.clone();
nonce = nonce.clone();
}
@Override
public byte[] ciphertext() {
return ciphertext.clone();
}
@Override
public byte[] nonce() {
return nonce.clone();
}
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.integration.model.dto;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password,
String fromAddress, String fromName) {
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.integration.model.dto;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password,
String fromAddress, String fromName) {
}
@@ -0,0 +1,198 @@
package com.lab.labtimesheet.feature.integration.model.entity;
import java.time.Instant;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "smtp_configurations")
public class SmtpConfiguration {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private SmtpStatus status;
@Column(nullable = false, length = 255)
private String host;
@Column(nullable = false)
private int port;
@Enumerated(EnumType.STRING)
@Column(name = "security_mode", nullable = false, length = 16)
private SecurityMode securityMode;
@Column(length = 320)
private String username;
@Column(name = "password_ciphertext")
private byte[] passwordCiphertext;
@Column(name = "password_nonce")
private byte[] passwordNonce;
@Column(name = "secret_key_version")
private Integer secretKeyVersion;
@Column(name = "from_address", nullable = false, length = 320)
private String fromAddress;
@Column(name = "from_name", nullable = false, length = 120)
private String fromName;
@Column(name = "tested_at")
private Instant testedAt;
@Column(name = "tested_by_user_id")
private Long testedByUserId;
@Column(name = "activated_at")
private Instant activatedAt;
@Column(name = "activated_by_user_id")
private Long activatedByUserId;
@Column(name = "retired_at")
private Instant retiredAt;
@Column(name = "retired_by_user_id")
private Long retiredByUserId;
@Column(name = "created_by_user_id", nullable = false)
private Long createdByUserId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected SmtpConfiguration() {
}
public static SmtpConfiguration draft(SmtpDraft draft, EncryptedSecret password, long adminId, Instant now) {
SmtpConfiguration configuration = new SmtpConfiguration();
configuration.status = SmtpStatus.DRAFT;
configuration.createdByUserId = adminId;
configuration.createdAt = now;
configuration.updateDraft(draft, password, now);
return configuration;
}
public void updateDraft(SmtpDraft draft, EncryptedSecret password, Instant now) {
if (status != SmtpStatus.DRAFT) {
throw new IllegalStateException("Only an SMTP draft can be edited");
}
host = draft.host().trim();
port = draft.port();
securityMode = draft.securityMode();
username = clean(draft.username());
passwordCiphertext = password == null ? null : password.ciphertext();
passwordNonce = password == null ? null : password.nonce();
secretKeyVersion = password == null ? null : password.keyVersion();
fromAddress = draft.fromAddress().trim();
fromName = draft.fromName().trim();
testedAt = null;
testedByUserId = null;
updatedAt = now;
}
public void markTested(long adminId, Instant now) {
if (status != SmtpStatus.DRAFT) {
throw new IllegalStateException("SMTP draft is no longer available");
}
testedAt = now;
testedByUserId = adminId;
updatedAt = now;
}
public void activate(long adminId, Instant now) {
if (status != SmtpStatus.DRAFT || testedAt == null) {
throw new IllegalStateException("SMTP draft must pass a test before activation");
}
status = SmtpStatus.ACTIVE;
activatedAt = now;
activatedByUserId = adminId;
updatedAt = now;
}
public void retire(long adminId, Instant now) {
if (status != SmtpStatus.ACTIVE) {
throw new IllegalStateException("Only active SMTP can be retired");
}
status = SmtpStatus.RETIRED;
retiredAt = now;
retiredByUserId = adminId;
updatedAt = now;
}
private static String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public Long getId() {
return id;
}
public SmtpStatus getStatus() {
return status;
}
public String getHost() {
return host;
}
public int getPort() {
return port;
}
public SecurityMode getSecurityMode() {
return securityMode;
}
public String getUsername() {
return username;
}
public byte[] getPasswordCiphertext() {
return passwordCiphertext == null ? null : passwordCiphertext.clone();
}
public byte[] getPasswordNonce() {
return passwordNonce == null ? null : passwordNonce.clone();
}
public Integer getSecretKeyVersion() {
return secretKeyVersion;
}
public String getFromAddress() {
return fromAddress;
}
public String getFromName() {
return fromName;
}
public Instant getTestedAt() {
return testedAt;
}
}
@@ -0,0 +1,18 @@
package com.lab.labtimesheet.feature.integration.repository;
import java.util.Optional;
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
public interface SmtpConfigurationRepository extends JpaRepository<SmtpConfiguration, Long> {
Optional<SmtpConfiguration> findByStatus(SmtpStatus status);
boolean existsByStatus(SmtpStatus status);
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<SmtpConfiguration> findWithLockByIdAndStatus(Long id, SmtpStatus status);
}
@@ -1,29 +1,29 @@
package com.lab.labtimesheet.configuration;
package com.lab.labtimesheet.feature.integration.service;
import java.util.Properties;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.stereotype.Component;
@Component
class JavaMailSmtpProbe implements SmtpProbe {
@Override
public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body) {
public void send(SmtpConnection connection, String recipient, String subject, String body) {
JavaMailSenderImpl sender = new JavaMailSenderImpl();
sender.setHost(connection.host());
sender.setPort(connection.port());
sender.setUsername(connection.username());
sender.setPassword(connection.password());
Properties properties = sender.getJavaMailProperties();
if (connection.securityMode() == SmtpConfigurationService.SecurityMode.STARTTLS) {
if (connection.securityMode() == SecurityMode.STARTTLS) {
properties.setProperty("mail.smtp.starttls.enable", "true");
properties.setProperty("mail.smtp.starttls.required", "true");
} else if (connection.securityMode() == SmtpConfigurationService.SecurityMode.TLS) {
} else if (connection.securityMode() == SecurityMode.TLS) {
sender.setProtocol("smtps");
}
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(connection.fromAddress());
message.setTo(recipient);
@@ -1,13 +1,14 @@
package com.lab.labtimesheet.configuration;
package com.lab.labtimesheet.feature.integration.service;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import com.lab.labtimesheet.config.SecurityProperties;
import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Component;
@Component
@@ -19,7 +20,7 @@ public class SecretCipher {
private final SecureRandom random = new SecureRandom();
SecretCipher(SecurityProperties properties) {
this.key = new SecretKeySpec(properties.decodedMasterKey(), "AES");
key = new SecretKeySpec(properties.decodedMasterKey(), "AES");
}
EncryptedSecret encrypt(String plaintext) {
@@ -43,7 +44,4 @@ public class SecretCipher {
throw new IllegalStateException("Unable to decrypt integration secret", exception);
}
}
record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) {
}
}
@@ -0,0 +1,116 @@
package com.lab.labtimesheet.feature.integration.service;
import java.time.Clock;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SmtpConfigurationService {
private final SmtpConfigurationRepository configurations;
private final AccountService accounts;
private final SecretCipher secrets;
private final SmtpProbe probe;
private final Environment environment;
private final Clock clock;
SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts,
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock) {
this.configurations = configurations;
this.accounts = accounts;
this.secrets = secrets;
this.probe = probe;
this.environment = environment;
this.clock = clock;
}
@Transactional
public long saveDraft(long adminId, SmtpDraft draft) {
validate(draft);
EncryptedSecret password = draft.password() == null ? null : secrets.encrypt(draft.password());
var now = clock.instant();
long verifiedAdminId = accounts.requireActiveAdminId(adminId);
SmtpConfiguration configuration = configurations.findByStatus(SmtpStatus.DRAFT)
.map(existing -> {
existing.updateDraft(draft, password, now);
return existing;
})
.orElseGet(() -> SmtpConfiguration.draft(draft, password, verifiedAdminId, now));
return configurations.save(configuration).getId();
}
public void testDraft(long draftId, long adminId, String recipient) {
SmtpConfiguration draft = configurations.findById(draftId)
.filter(configuration -> configuration.getStatus() == SmtpStatus.DRAFT)
.orElseThrow(() -> new IllegalStateException("SMTP configuration is not available"));
probe.send(connection(draft), recipient, "Lab Timesheet SMTP test", "SMTP configuration test succeeded.");
long verifiedAdminId = accounts.requireActiveAdminId(adminId);
draft.markTested(verifiedAdminId, clock.instant());
configurations.save(draft);
}
@Transactional
public void activate(long draftId, long adminId) {
SmtpConfiguration draft = configurations.findWithLockByIdAndStatus(draftId, SmtpStatus.DRAFT)
.orElseThrow(() -> new IllegalStateException("SMTP draft must pass a test before activation"));
long verifiedAdminId = accounts.requireActiveAdminId(adminId);
var now = clock.instant();
configurations.findByStatus(SmtpStatus.ACTIVE)
.ifPresent(active -> active.retire(verifiedAdminId, now));
draft.activate(verifiedAdminId, now);
}
@Transactional(readOnly = true)
public boolean hasActiveConfiguration() {
return configurations.existsByStatus(SmtpStatus.ACTIVE);
}
@Transactional(readOnly = true)
public SmtpConnection activeConnection() {
return configurations.findByStatus(SmtpStatus.ACTIVE)
.map(this::connection)
.orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required"));
}
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
probe.send(activeConnection(), recipient, subject, body);
}
private SmtpConnection connection(SmtpConfiguration configuration) {
byte[] ciphertext = configuration.getPasswordCiphertext();
return new SmtpConnection(
configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(),
configuration.getUsername(),
ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()),
configuration.getFromAddress(), configuration.getFromName());
}
private void validate(SmtpDraft draft) {
if (draft.host() == null || draft.host().isBlank() || draft.port() < 1 || draft.port() > 65535
|| draft.securityMode() == null || draft.fromAddress() == null || draft.fromAddress().isBlank()
|| draft.fromName() == null || draft.fromName().isBlank()) {
throw new IllegalArgumentException("Valid SMTP host, port, security mode, From address and name are required");
}
if ((clean(draft.username()) == null) != (draft.password() == null || draft.password().isEmpty())) {
throw new IllegalArgumentException("SMTP username and password must be supplied together");
}
if (draft.securityMode() == SecurityMode.NONE
&& !environment.acceptsProfiles(Profiles.of("dev", "test"))) {
throw new IllegalArgumentException("Plaintext SMTP is allowed only in dev and test");
}
}
private static String clean(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.integration.service;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
@FunctionalInterface
public interface SmtpProbe {
void send(SmtpConnection connection, String recipient, String subject, String body);
}
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.notifications;
/** Notifications module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.projects;
/** Projects and tasks module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}
@@ -1,7 +0,0 @@
package com.lab.labtimesheet.reporting;
/** Reporting module boundary. */
public final class ModuleBoundary {
private ModuleBoundary() {
}
}