feat(account): add SMTP-gated activation lifecycle
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
|
||||
+10
@@ -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);
|
||||
}
|
||||
|
||||
+22
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -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());
|
||||
}
|
||||
}
|
||||
+8
-12
@@ -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) {
|
||||
|
||||
@@ -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=
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.lab.labtimesheet.feature.account.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
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.CreateAccountCommand;
|
||||
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.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpProbe;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@Import({TestcontainersConfiguration.class, AccountActivationIntegrationTest.MailProbeConfiguration.class})
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class AccountActivationIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private BootstrapService bootstrap;
|
||||
|
||||
@Autowired
|
||||
private AccountService accounts;
|
||||
|
||||
@Autowired
|
||||
private SmtpConfigurationService smtp;
|
||||
|
||||
@Autowired
|
||||
private RecordingSmtpProbe mail;
|
||||
|
||||
@Autowired
|
||||
private AppUserRepository users;
|
||||
|
||||
@Autowired
|
||||
private InternProfileRepository internProfiles;
|
||||
|
||||
@Autowired
|
||||
private UserActionTokenRepository tokens;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwords;
|
||||
|
||||
@Test
|
||||
void smtpGatedCreationHashesSingleUseActivationAndRetainsFailedDeliveryHistory() throws Exception {
|
||||
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||
long adminId = accounts.requireActiveAdminId("admin@example.com");
|
||||
|
||||
var mentor = new CreateAccountCommand(
|
||||
" MENTOR@EXAMPLE.COM ", " Mentor One ", GlobalRole.MENTOR, null, null, null);
|
||||
assertThatThrownBy(() -> accounts.create(mentor, adminId))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("SMTP");
|
||||
assertThat(users.count()).isEqualTo(1);
|
||||
|
||||
activateSmtp(adminId);
|
||||
mail.messages.clear();
|
||||
|
||||
var mentorCreation = accounts.create(mentor, adminId);
|
||||
assertThat(mentorCreation.deliverySucceeded()).isTrue();
|
||||
var pendingMentor = users.findById(mentorCreation.userId()).orElseThrow();
|
||||
assertThat(pendingMentor.getEmail()).isEqualTo("mentor@example.com");
|
||||
assertThat(pendingMentor.getDisplayName()).isEqualTo("Mentor One");
|
||||
assertThat(pendingMentor.getGlobalRole()).isEqualTo(GlobalRole.MENTOR);
|
||||
assertThat(pendingMentor.getAccountStatus()).isEqualTo(AccountStatus.PENDING_ACTIVATION);
|
||||
assertThat(pendingMentor.getPasswordHash()).isNull();
|
||||
|
||||
String rawMentorToken = mail.onlyActivationToken();
|
||||
var mentorToken = tokens.findAll().stream()
|
||||
.filter(token -> token.getUserId().equals(mentorCreation.userId()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertThat(mentorToken.getPurpose()).isEqualTo(TokenPurpose.ACTIVATION);
|
||||
assertThat(mentorToken.getTokenHash()).containsExactly(sha256(rawMentorToken));
|
||||
assertThat(mentorToken.getExpiresAt()).isEqualTo(Instant.parse("2026-08-15T00:00:00Z"));
|
||||
assertThat(mentorToken.isUsableAt(mentorToken.getExpiresAt())).isFalse();
|
||||
assertThat(HexFormat.of().formatHex(mentorToken.getTokenHash())).doesNotContain(rawMentorToken);
|
||||
|
||||
assertThat(accounts.activate("not-the-token", "new secure mentor password")).isFalse();
|
||||
assertThat(accounts.activate(rawMentorToken, "new secure mentor password")).isTrue();
|
||||
assertThat(accounts.activate(rawMentorToken, "another secure password")).isFalse();
|
||||
var activeMentor = users.findById(mentorCreation.userId()).orElseThrow();
|
||||
assertThat(activeMentor.getAccountStatus()).isEqualTo(AccountStatus.ACTIVE);
|
||||
assertThat(passwords.matches("new secure mentor password", activeMentor.getPasswordHash())).isTrue();
|
||||
assertThat(tokens.findById(mentorToken.getId()).orElseThrow().getUsedAt()).isNotNull();
|
||||
|
||||
mail.fail = true;
|
||||
var failedIntern = accounts.create(new CreateAccountCommand(
|
||||
"intern-failed@example.com", "Failed Intern", GlobalRole.INTERN, "STU-FAIL",
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId);
|
||||
assertThat(failedIntern.deliverySucceeded()).isFalse();
|
||||
assertThat(users.findById(failedIntern.userId()).orElseThrow().getAccountStatus())
|
||||
.isEqualTo(AccountStatus.PENDING_ACTIVATION);
|
||||
assertThat(tokens.findAll().stream()
|
||||
.filter(token -> token.getUserId().equals(failedIntern.userId()))
|
||||
.findFirst().orElseThrow().getInvalidatedAt()).isNotNull();
|
||||
|
||||
mail.fail = false;
|
||||
mail.messages.clear();
|
||||
var activeInternCreation = accounts.create(new CreateAccountCommand(
|
||||
"intern@example.com", "Active Intern", GlobalRole.INTERN, "STU-001",
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId);
|
||||
assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue();
|
||||
accounts.activateInternship(activeInternCreation.userId(), adminId);
|
||||
|
||||
var profile = internProfiles.findById(activeInternCreation.userId()).orElseThrow();
|
||||
assertThat(profile.getInternshipStatus()).isEqualTo(InternshipStatus.ACTIVE);
|
||||
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 8, 1))).isTrue();
|
||||
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 12, 31))).isTrue();
|
||||
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2027, 1, 1))).isFalse();
|
||||
|
||||
var summary = accounts.summary();
|
||||
assertThat(summary.activeAccounts()).isEqualTo(3);
|
||||
assertThat(summary.pendingActivations()).isEqualTo(1);
|
||||
assertThat(summary.activeInternships()).isEqualTo(1);
|
||||
}
|
||||
|
||||
private void activateSmtp(long adminId) {
|
||||
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
|
||||
"mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet"));
|
||||
smtp.testDraft(draftId, adminId, "admin@example.com");
|
||||
smtp.activate(draftId, adminId);
|
||||
}
|
||||
|
||||
private static byte[] sha256(String value) throws Exception {
|
||||
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
static class MailProbeConfiguration {
|
||||
@Bean
|
||||
@Primary
|
||||
RecordingSmtpProbe recordingSmtpProbe() {
|
||||
return new RecordingSmtpProbe();
|
||||
}
|
||||
}
|
||||
|
||||
static final class RecordingSmtpProbe implements SmtpProbe {
|
||||
private final List<Message> messages = new ArrayList<>();
|
||||
private boolean fail;
|
||||
|
||||
@Override
|
||||
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||
if (fail) {
|
||||
throw new IllegalStateException("simulated SMTP failure");
|
||||
}
|
||||
messages.add(new Message(recipient, subject, body));
|
||||
}
|
||||
|
||||
String onlyActivationToken() {
|
||||
assertThat(messages).hasSize(1);
|
||||
String body = messages.getFirst().body();
|
||||
int tokenStart = body.indexOf("token=");
|
||||
assertThat(tokenStart).isGreaterThanOrEqualTo(0);
|
||||
return body.substring(tokenStart + "token=".length()).trim();
|
||||
}
|
||||
}
|
||||
|
||||
record Message(String recipient, String subject, String body) {
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@ spring:
|
||||
compose:
|
||||
enabled: false
|
||||
lab:
|
||||
public-origin: http://localhost:8080
|
||||
security:
|
||||
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
||||
|
||||
Reference in New Issue
Block a user