Merge commit '98a52a1ac23591fa1cd30b7b175da81ec607e521' into work/reports-ui

This commit is contained in:
sechmachine
2026-08-15 00:35:34 +07:00
17 changed files with 748 additions and 13 deletions
@@ -0,0 +1,6 @@
package com.lab.labtimesheet.feature.account.model;
public enum TokenPurpose {
ACTIVATION,
PASSWORD_RESET
}
@@ -0,0 +1,4 @@
package com.lab.labtimesheet.feature.account.model.dto;
public record AccountCreation(long userId, boolean deliverySucceeded) {
}
@@ -0,0 +1,4 @@
package com.lab.labtimesheet.feature.account.model.dto;
public record AccountSummary(long activeAccounts, long pendingActivations, long activeInternships) {
}
@@ -0,0 +1,14 @@
package com.lab.labtimesheet.feature.account.model.dto;
import java.time.LocalDate;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
public record CreateAccountCommand(
String email,
String displayName,
GlobalRole role,
String studentCode,
LocalDate internshipStart,
LocalDate internshipEnd) {
}
@@ -77,6 +77,22 @@ public class AppUser {
return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now);
}
public static AppUser pending(
String email, String displayName, GlobalRole globalRole, AppUser createdBy, Instant now) {
return new AppUser(
email, displayName, null, globalRole, AccountStatus.PENDING_ACTIVATION, null, createdBy, now);
}
public void activate(String encodedPassword, Instant now) {
if (accountStatus != AccountStatus.PENDING_ACTIVATION) {
throw new IllegalStateException("Only a pending account can activate");
}
passwordHash = encodedPassword;
accountStatus = AccountStatus.ACTIVE;
activatedAt = now;
updatedAt = now;
}
public Long getId() {
return id;
}
@@ -100,4 +116,8 @@ public class AppUser {
public AccountStatus getAccountStatus() {
return accountStatus;
}
public Instant getActivatedAt() {
return activatedAt;
}
}
@@ -58,4 +58,41 @@ public class InternProfile {
protected InternProfile() {
}
private InternProfile(
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
this.userId = userId;
this.studentCode = studentCode;
this.internshipStartDate = internshipStartDate;
this.internshipEndDate = internshipEndDate;
this.internshipStatus = InternshipStatus.NOT_STARTED;
this.createdAt = now;
this.updatedAt = now;
}
public static InternProfile notStarted(
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
return new InternProfile(userId, studentCode, internshipStartDate, internshipEndDate, now);
}
public void activate(Instant now) {
if (internshipStatus != InternshipStatus.NOT_STARTED) {
throw new IllegalStateException("Only a not-started internship can activate");
}
internshipStatus = InternshipStatus.ACTIVE;
activatedAt = now;
updatedAt = now;
}
public InternshipStatus getInternshipStatus() {
return internshipStatus;
}
public LocalDate getInternshipStartDate() {
return internshipStartDate;
}
public LocalDate getInternshipEndDate() {
return internshipEndDate;
}
}
@@ -0,0 +1,112 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import java.util.Arrays;
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
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;
@Entity
@Table(name = "user_action_tokens")
public class UserActionToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 24)
private TokenPurpose purpose;
@Column(name = "token_hash", nullable = false, columnDefinition = "bytea")
private byte[] tokenHash;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "used_at")
private Instant usedAt;
@Column(name = "invalidated_at")
private Instant invalidatedAt;
@Column(name = "issued_by_user_id")
private Long issuedByUserId;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
protected UserActionToken() {
}
private UserActionToken(long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
this.userId = userId;
this.purpose = TokenPurpose.ACTIVATION;
this.tokenHash = Arrays.copyOf(tokenHash, tokenHash.length);
this.expiresAt = expiresAt;
this.issuedByUserId = issuedByUserId;
this.createdAt = now;
}
public static UserActionToken activation(
long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
return new UserActionToken(userId, tokenHash, expiresAt, issuedByUserId, now);
}
public boolean isUsableAt(Instant now) {
return usedAt == null && invalidatedAt == null && now.isBefore(expiresAt);
}
public void markUsed(Instant now) {
if (!isUsableAt(now)) {
throw new IllegalStateException("Activation token is not usable");
}
usedAt = now;
}
public void invalidate(Instant now) {
if (usedAt != null) {
throw new IllegalStateException("A used token cannot be invalidated");
}
if (invalidatedAt == null) {
invalidatedAt = now;
}
}
public Long getId() {
return id;
}
public Long getUserId() {
return userId;
}
public TokenPurpose getPurpose() {
return purpose;
}
public byte[] getTokenHash() {
return Arrays.copyOf(tokenHash, tokenHash.length);
}
public Instant getExpiresAt() {
return expiresAt;
}
public Instant getUsedAt() {
return usedAt;
}
public Instant getInvalidatedAt() {
return invalidatedAt;
}
}
@@ -5,7 +5,9 @@ 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 jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -13,5 +15,11 @@ 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);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from AppUser u where u.id = :id")
Optional<AppUser> findForUpdateById(@Param("id") Long id);
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
long countByAccountStatus(AccountStatus status);
}
@@ -4,11 +4,21 @@ import java.time.LocalDate;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
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;
import org.springframework.data.repository.query.Param;
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
long countByInternshipStatus(InternshipStatus status);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from InternProfile p where p.userId = :userId")
java.util.Optional<InternProfile> findForUpdateByUserId(@Param("userId") Long userId);
}
@@ -0,0 +1,22 @@
package com.lab.labtimesheet.feature.account.repository;
import java.util.Optional;
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
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;
import org.springframework.data.repository.query.Param;
public interface UserActionTokenRepository extends JpaRepository<UserActionToken, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from UserActionToken t where t.tokenHash = :hash and t.purpose = :purpose")
Optional<UserActionToken> findForUpdateByHashAndPurpose(
@Param("hash") byte[] hash, @Param("purpose") TokenPurpose purpose);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from UserActionToken t where t.id = :id")
Optional<UserActionToken> findForUpdateById(@Param("id") Long id);
}
@@ -1,25 +1,140 @@
package com.lab.labtimesheet.feature.account.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Base64;
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.TokenPurpose;
import com.lab.labtimesheet.feature.account.model.dto.AccountCreation;
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
import com.lab.labtimesheet.feature.account.model.dto.AccountSummary;
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
import com.lab.labtimesheet.feature.account.repository.UserActionTokenRepository;
import com.lab.labtimesheet.feature.integration.service.MailDeliveryService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
@Service
public class AccountService {
private static final Duration ACTIVATION_LIFETIME = Duration.ofHours(24);
private static final SecureRandom TOKEN_RANDOM = new SecureRandom();
private final AppUserRepository users;
private final InternProfileRepository internProfiles;
private final UserActionTokenRepository tokens;
private final MailDeliveryService mailDelivery;
private final PasswordEncoder passwords;
private final Clock clock;
private final TransactionTemplate transactions;
private final String publicOrigin;
AccountService(AppUserRepository users, InternProfileRepository internProfiles) {
AccountService(
AppUserRepository users,
InternProfileRepository internProfiles,
UserActionTokenRepository tokens,
MailDeliveryService mailDelivery,
PasswordEncoder passwords,
Clock clock,
TransactionTemplate transactions,
@Value("${lab.public-origin}") String publicOrigin) {
this.users = users;
this.internProfiles = internProfiles;
this.tokens = tokens;
this.mailDelivery = mailDelivery;
this.passwords = passwords;
this.clock = clock;
this.transactions = transactions;
this.publicOrigin = normalizeOrigin(publicOrigin);
}
public AccountCreation create(CreateAccountCommand command, long adminId) {
ValidatedAccount account = validate(command);
if (!mailDelivery.isAvailable()) {
throw new IllegalStateException("Active SMTP configuration is required for account creation");
}
String rawToken = newRawToken();
byte[] tokenHash = sha256(rawToken);
PendingActivation pending = transactions.execute(status -> createPending(account, adminId, tokenHash));
if (pending == null) {
throw new IllegalStateException("Account creation did not complete");
}
try {
mailDelivery.send(
account.email(),
"Activate your Lab Timesheet account",
"Activate your account using this single-use link:\n" + activationLink(rawToken));
return new AccountCreation(pending.userId(), true);
} catch (RuntimeException deliveryFailure) {
transactions.executeWithoutResult(status -> tokens.findForUpdateById(pending.tokenId())
.orElseThrow(() -> new IllegalStateException("Activation token is missing"))
.invalidate(clock.instant()));
return new AccountCreation(pending.userId(), false);
}
}
@Transactional
public boolean activate(String rawToken, String password) {
BootstrapService.requirePassword(password);
if (rawToken == null || rawToken.isBlank()) {
return false;
}
UserActionToken token = tokens.findForUpdateByHashAndPurpose(sha256(rawToken), TokenPurpose.ACTIVATION)
.orElse(null);
var now = clock.instant();
if (token == null || !token.isUsableAt(now)) {
return false;
}
AppUser user = users.findForUpdateById(token.getUserId()).orElse(null);
if (user == null || user.getAccountStatus() != AccountStatus.PENDING_ACTIVATION) {
return false;
}
user.activate(passwords.encode(password), now);
token.markUsed(now);
return true;
}
@Transactional
public void activateInternship(long internUserId, long adminId) {
AppUser admin = users.findById(adminId)
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
requireActiveAdmin(admin);
AppUser intern = users.findForUpdateById(internUserId)
.orElseThrow(() -> new IllegalArgumentException("Intern not found"));
if (intern.getGlobalRole() != GlobalRole.INTERN || intern.getAccountStatus() != AccountStatus.ACTIVE) {
throw new IllegalArgumentException("An active Intern account is required");
}
internProfiles.findForUpdateByUserId(internUserId)
.orElseThrow(() -> new IllegalArgumentException("Intern profile not found"))
.activate(clock.instant());
}
@Transactional(readOnly = true)
public AccountSummary summary() {
return new AccountSummary(
users.countByAccountStatus(AccountStatus.ACTIVE),
users.countByAccountStatus(AccountStatus.PENDING_ACTIVATION),
internProfiles.countByInternshipStatus(InternshipStatus.ACTIVE));
}
@Transactional(readOnly = true)
@@ -91,4 +206,91 @@ public class AccountService {
return new AccountIdentity(
user.getId(), user.getEmail(), user.getDisplayName(), user.getGlobalRole(), user.getAccountStatus());
}
private PendingActivation createPending(ValidatedAccount account, long adminId, byte[] tokenHash) {
AppUser admin = users.findForUpdateById(adminId)
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
requireActiveAdmin(admin);
var now = clock.instant();
AppUser user = users.save(AppUser.pending(
account.email(), account.displayName(), account.role(), admin, now));
if (account.role() == GlobalRole.INTERN) {
internProfiles.save(InternProfile.notStarted(
user.getId(), account.studentCode(), account.internshipStart(), account.internshipEnd(), now));
}
UserActionToken token = tokens.save(UserActionToken.activation(
user.getId(), tokenHash, now.plus(ACTIVATION_LIFETIME), admin.getId(), now));
return new PendingActivation(user.getId(), token.getId());
}
private String activationLink(String rawToken) {
return publicOrigin + "/activate?token=" + rawToken;
}
private static ValidatedAccount validate(CreateAccountCommand command) {
if (command == null || command.role() == null) {
throw new IllegalArgumentException("Account role is required");
}
String email = BootstrapService.normalizeEmail(command.email());
String displayName = requireText(command.displayName(), "Display name");
if (command.role() != GlobalRole.INTERN) {
if (command.studentCode() != null || command.internshipStart() != null || command.internshipEnd() != null) {
throw new IllegalArgumentException("Internship fields are allowed only for Intern accounts");
}
return new ValidatedAccount(email, displayName, command.role(), null, null, null);
}
String studentCode = requireText(command.studentCode(), "Student code");
if (command.internshipStart() == null || command.internshipEnd() == null
|| command.internshipEnd().isBefore(command.internshipStart())) {
throw new IllegalArgumentException("A valid internship date range is required");
}
return new ValidatedAccount(
email, displayName, command.role(), studentCode, command.internshipStart(), command.internshipEnd());
}
private static String requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required");
}
return value.trim();
}
private static String normalizeOrigin(String value) {
String origin = requireText(value, "Public origin");
while (origin.endsWith("/")) {
origin = origin.substring(0, origin.length() - 1);
}
if (!origin.startsWith("http://") && !origin.startsWith("https://")) {
throw new IllegalArgumentException("Public origin must use HTTP or HTTPS");
}
return origin;
}
private static String newRawToken() {
byte[] bytes = new byte[32];
TOKEN_RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private static byte[] sha256(String value) {
try {
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private record ValidatedAccount(
String email,
String displayName,
GlobalRole role,
String studentCode,
LocalDate internshipStart,
LocalDate internshipEnd) {
}
private record PendingActivation(long userId, long tokenId) {
}
}
@@ -0,0 +1,46 @@
package com.lab.labtimesheet.feature.integration.service;
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MailDeliveryService {
private final SmtpConfigurationRepository configurations;
private final SecretCipher secrets;
private final SmtpProbe probe;
MailDeliveryService(SmtpConfigurationRepository configurations, SecretCipher secrets, SmtpProbe probe) {
this.configurations = configurations;
this.secrets = secrets;
this.probe = probe;
}
@Transactional(readOnly = true)
public boolean isAvailable() {
return configurations.existsByStatus(SmtpStatus.ACTIVE);
}
public void send(String recipient, String subject, String body) {
probe.send(activeConnection(), recipient, subject, body);
}
@Transactional(readOnly = true)
public SmtpConnection activeConnection() {
return configurations.findByStatus(SmtpStatus.ACTIVE)
.map(this::connection)
.orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required"));
}
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());
}
}
@@ -23,15 +23,18 @@ public class SmtpConfigurationService {
private final SmtpProbe probe;
private final Environment environment;
private final Clock clock;
private final MailDeliveryService mailDelivery;
SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts,
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock) {
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock,
MailDeliveryService mailDelivery) {
this.configurations = configurations;
this.accounts = accounts;
this.secrets = secrets;
this.probe = probe;
this.environment = environment;
this.clock = clock;
this.mailDelivery = mailDelivery;
}
@Transactional
@@ -72,27 +75,20 @@ public class SmtpConfigurationService {
@Transactional(readOnly = true)
public boolean hasActiveConfiguration() {
return configurations.existsByStatus(SmtpStatus.ACTIVE);
return mailDelivery.isAvailable();
}
@Transactional(readOnly = true)
public SmtpConnection activeConnection() {
return configurations.findByStatus(SmtpStatus.ACTIVE)
.map(this::connection)
.orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required"));
return mailDelivery.activeConnection();
}
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
probe.send(activeConnection(), recipient, subject, body);
mailDelivery.send(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());
return mailDelivery.connection(configuration);
}
private void validate(SmtpDraft draft) {
+1
View File
@@ -7,6 +7,7 @@ spring:
host: ${LAB_SMTP_HOST:localhost}
port: ${LAB_SMTP_PORT:1025}
lab:
public-origin: ${LAB_PUBLIC_ORIGIN:http://localhost:8080}
security:
# Explicit non-production key; production must supply its own 256-bit key.
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=