feat(registration): add email confirmation flow
Build, Test & Push Auth Service / quality-check (push) Successful in 58s
Build, Test & Push Auth Service / docker (push) Successful in 1m21s

Registrations become two-phase: first UNCONFIRMED with a 24-hour
single-use email link, then PENDING after confirmation and password
entry. Prevents registering with someone else's address. Admins see
unconfirmed requests and can delete them; they cannot accept them.
This commit is contained in:
lobo committed 2026-10-01 16:20:51 +02:00
1 parent 878ede914b
commit 0af76da9c7
43 files changed
+959 -145

No files matched your search

@@ -10,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@@ -88,4 +89,14 @@ public class AccountRepository {
}
return namedJdbc.update("DELETE FROM users WHERE id IN (:ids)", new MapSqlParameterSource("ids", userIds));
}
/**
* Settings go by their ON DELETE CASCADE. registration_date is a naive UTC timestamp. A row whose resent link
* is still valid stays.
*/
public int deleteUnconfirmedBefore(LocalDateTime cutoff) {
return jdbc.update("""
DELETE FROM users WHERE status = 'UNCONFIRMED' AND registration_date < ?
AND (email_confirmation_expires_at IS NULL OR email_confirmation_expires_at < now())""", cutoff);
}
}
@@ -7,8 +7,10 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -20,6 +22,7 @@ import java.util.List;
public class AccountCleanupService {
static final String ZONE = "Europe/Berlin";
static final Duration UNCONFIRMED_RETENTION = Duration.ofDays(7);
private final AccountRepository accountRepository;
private final CoreAccountService coreAccountService;
@@ -37,6 +40,7 @@ public class AccountCleanupService {
@Scheduled(cron = "${app.account-cleanup.cron}", zone = ZONE)
public void runNightly() {
purgeUnconfirmed();
try {
coreAccountService.replaceDeleted(accountRepository.findFlaggedIds());
} catch (RuntimeException e) {
@@ -48,6 +52,19 @@ public class AccountCleanupService {
pruneOrphans();
}
// Not gated by dryRun: an unconfirmed row never was an account and has nothing in Core.
private void purgeUnconfirmed() {
try {
int deleted = accountRepository.deleteUnconfirmedBefore(
LocalDateTime.now(Clock.systemUTC()).minus(UNCONFIRMED_RETENTION));
if (deleted > 0) {
log.info("Account cleanup: deleted {} unconfirmed registrations", deleted);
}
} catch (RuntimeException e) {
log.warn("Account cleanup: purge of unconfirmed registrations failed: {}", e.getMessage());
}
}
private void sendStates() {
try {
coreAccountService.replaceStates(accountRepository.findAllStates());
@@ -64,6 +64,13 @@ public class AdminUserController {
return ResponseEntity.noContent().build();
}
@DeleteMapping(value = "/{userId}/unconfirmed")
public ResponseEntity<Void> deleteUnconfirmed(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable long userId) {
adminUserService.deleteUnconfirmed(principal.getIdAsLong(), userId);
return ResponseEntity.noContent().build();
}
private static AdminUserResponse toApi(AdminUser user) {
return new AdminUserResponse(user.id(), user.email(), user.displayName(), user.role().name(),
user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt(),
@@ -5,7 +5,7 @@ import lombok.Getter;
@Getter
public class AdminActionRefusedException extends RuntimeException {
/**
* SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED, NOT_PENDING or TARGET_IS_PENDING
* SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED, NOT_PENDING, TARGET_IS_PENDING, TARGET_IS_UNCONFIRMED or NOT_UNCONFIRMED
*/
private final String code;
@@ -6,7 +6,7 @@ import java.time.Instant;
import java.time.LocalDateTime;
/**
* @param status PENDING (registration waits for an admin), ACTIVE or DENIED
* @param status UNCONFIRMED (address not confirmed yet), PENDING (registration waits for an admin), ACTIVE or DENIED
* @param deletedAt set while the account waits for the nightly cleanup
*/
public record AdminUser(long id, String email, String displayName, Role role, LocalDateTime registrationDate,
@@ -2,5 +2,5 @@ package auth_service.modules.admin.model;
import auth_service.shared.security.Role;
public record TargetState(Role role, boolean deleted, boolean pending) {
public record TargetState(Role role, boolean deleted, boolean pending, boolean unconfirmed) {
}
@@ -53,7 +53,7 @@ public class AdminUserRepository {
/**
* @param query part of the email or display name, any case; empty for all users
* @param status PENDING, ACTIVE or DENIED; empty for all
* @param status UNCONFIRMED, PENDING, ACTIVE or DENIED; empty for all
*/
public List<AdminUser> findPage(String query, String status, int page, int size) {
Object[] match = matchArgs(query);
@@ -74,7 +74,7 @@ public class AdminUserRepository {
public Optional<TargetState> findState(long userId) {
return jdbc.query("SELECT role, deleted_at, status FROM users WHERE id = ?",
(rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null,
"PENDING".equals(rs.getString("status"))),
"PENDING".equals(rs.getString("status")), "UNCONFIRMED".equals(rs.getString("status"))),
userId).stream().findFirst();
}
@@ -95,6 +95,15 @@ public class AdminUserRepository {
Timestamp.from(now), userId) > 0;
}
/**
* Hard delete, like the nightly purge: the address is free at once. Settings go by their ON DELETE CASCADE.
*
* @return false when the user is not unconfirmed (any more)
*/
public boolean deleteUnconfirmed(long userId) {
return jdbc.update("DELETE FROM users WHERE id = ? AND status = 'UNCONFIRMED'", userId) > 0;
}
/**
* Blocking also ends every login of the user.
*
@@ -71,7 +71,7 @@ public class AdminUserService {
@Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"),
@CacheEvict(value = "publicStats", allEntries = true)})
public void setBlocked(long actorUserId, long targetUserId, boolean blocked) {
TargetState target = notPending(checkedTarget(actorUserId, targetUserId));
TargetState target = notARequest(checkedTarget(actorUserId, targetUserId));
if (target.deleted()) {
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted");
}
@@ -90,13 +90,27 @@ public class AdminUserService {
* transaction: the flag commits before the port calls Core, so Core never hides a user Auth did not flag.
*/
public void delete(long actorUserId, long targetUserId) {
notPending(checkedTarget(actorUserId, targetUserId));
notARequest(checkedTarget(actorUserId, targetUserId));
if (accountDeleter.deleteByAdmin(targetUserId)) {
adminAuditLog.record(actorUserId, "DELETE", targetUserId, null);
log.info("Admin {} deleted user {}", actorUserId, targetUserId);
}
}
/**
* Only for a request whose address was never confirmed: a hard delete, no mail. The nightly purge does the same
* after 7 days.
*/
@Transactional
public void deleteUnconfirmed(long actorUserId, long targetUserId) {
if (!checkedTarget(actorUserId, targetUserId).unconfirmed()
|| !adminUserRepository.deleteUnconfirmed(targetUserId)) {
throw new AdminActionRefusedException("NOT_UNCONFIRMED", "The registration is confirmed already");
}
adminAuditLog.record(actorUserId, "DELETE_UNCONFIRMED", targetUserId, null);
log.info("Admin {} deleted the unconfirmed registration of user {}", actorUserId, targetUserId);
}
private TargetState checkedTarget(long actorUserId, long targetUserId) {
if (actorUserId == targetUserId) {
throw new AdminActionRefusedException("SELF_ACTION", "Admins cannot block or delete their own account");
@@ -109,10 +123,13 @@ public class AdminUserService {
return target;
}
private static TargetState notPending(TargetState target) {
private static TargetState notARequest(TargetState target) {
if (target.pending()) {
throw new AdminActionRefusedException("TARGET_IS_PENDING", "Accept or deny the registration first");
}
if (target.unconfirmed()) {
throw new AdminActionRefusedException("TARGET_IS_UNCONFIRMED", "Delete the unconfirmed registration instead");
}
return target;
}
}
@@ -45,7 +45,8 @@ public class LoginService {
/**
* Authenticates a user and starts a session. Unknown email, wrong password and deleted account all fail with the
* same InvalidCredentialsException; a pending or blocked account says so, but only after the right password.
* same InvalidCredentialsException; a pending or blocked account says so, but only after the right
* password. An unconfirmed user has no password and fails like a wrong one.
*
* @param previousRefreshToken the refresh cookie this browser still sends, or null. The new cookie replaces it,
* so its login is revoked; else it would stay valid unseen for 30 days.
@@ -29,20 +29,27 @@ public enum MailTemplate {
%s
"""),
REGISTRATION_RECEIVED(
"Your ToBePlayed registration has arrived", """
// %1$s greeting, %2$s link.
CONFIRM_EMAIL(
"Confirm your email address for ToBePlayed", """
Hello %s,
thank you for your registration. An administrator checks each new account.
You get a mail as soon as it is decided.
thank you for your registration. Please confirm your email address with this link (valid for 24 hours):
%s
After that an administrator checks your request, and you get a mail as soon as it is decided.
If you did not register, ignore this mail.
ToBePlayed
""",
"Deine ToBePlayed-Registrierung ist angekommen", """
"Bestätige deine E-Mail-Adresse für ToBePlayed", """
Hallo %s,
danke für deine Registrierung. Ein Administrator prüft jedes neue Konto.
Du bekommst eine Mail, sobald entschieden ist.
danke für deine Registrierung. Bitte bestätige deine E-Mail-Adresse über diesen Link (24 Stunden gültig):
%s
Danach prüft ein Administrator deine Anfrage, und du bekommst eine Mail, sobald entschieden ist.
Wenn du dich nicht registriert hast, ignoriere diese Mail.
ToBePlayed
"""),
@@ -30,6 +30,12 @@ public class MailService implements AccountMailer {
this.frontendUrl = frontendUrl;
}
@Override
public void confirmEmail(long userId, String rawToken) {
repository.findRecipient(userId).ifPresent(user -> insertForUser(MailTemplate.CONFIRM_EMAIL, userId, user,
user.greeting(), frontendUrl + "/confirm-email#" + rawToken));
}
@Override
public void registrationRequested(long userId) {
repository.findRecipient(userId).ifPresent(user -> {
@@ -39,7 +45,6 @@ public class MailService implements AccountMailer {
.render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email(),
frontendUrl + "/admin/users"));
}
insertForUser(MailTemplate.REGISTRATION_RECEIVED, userId, user, user.greeting());
});
}
@@ -1,7 +1,9 @@
package auth_service.modules.registration.api;
import auth_service.modules.registration.api.dto.mapper.ApiMapper;
import auth_service.modules.registration.api.dto.request.ConfirmationRequest;
import auth_service.modules.registration.api.dto.request.RegistrationRequest;
import auth_service.modules.registration.api.dto.request.ResendConfirmationRequest;
import auth_service.modules.registration.api.dto.response.RegistrationResponse;
import auth_service.modules.registration.service.RegistrationService;
import auth_service.shared.api.ClientIp;
@@ -28,4 +30,16 @@ public class RegistrationController {
var userId = registrationService.register(ApiMapper.mapToModel(request), ClientIp.of(httpRequest));
return ResponseEntity.ok(RegistrationResponse.builder().userId(userId).build());
}
@PostMapping(value = "/confirm", consumes = "application/json")
public ResponseEntity<Void> confirm(@Valid @RequestBody ConfirmationRequest request, HttpServletRequest httpRequest) {
registrationService.confirm(request.token(), request.password(), ClientIp.of(httpRequest));
return ResponseEntity.noContent().build();
}
@PostMapping(value = "/resend", consumes = "application/json")
public ResponseEntity<Void> resend(@Valid @RequestBody ResendConfirmationRequest request, HttpServletRequest httpRequest) {
registrationService.resend(request.email(), ClientIp.of(httpRequest));
return ResponseEntity.noContent().build();
}
}
@@ -1,6 +1,7 @@
package auth_service.modules.registration.api;
import auth_service.shared.api.dto.response.ErrorResponse;
import auth_service.modules.registration.exception.ConfirmationInvalidException;
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.PendingLimitReachedException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
@@ -51,6 +52,8 @@ public class RegistrationExceptionController {
return response(body, HttpStatus.CONFLICT);
}
@ExceptionHandler(ConfirmationInvalidException.class)
public ResponseEntity<ErrorResponse> clientError(ConfirmationInvalidException ex) {
return response(new ErrorResponse("CONFIRMATION_INVALID", ex.getMessage()), HttpStatus.GONE);
}
}
@@ -10,7 +10,6 @@ public abstract class ApiMapper {
public static Registration mapToModel(RegistrationRequest registrationRequest) {
return Registration.builder().
email(registrationRequest.email()).
rawPassword(registrationRequest.password()).
displayName(registrationRequest.displayName()).
language(registrationRequest.language()).
build();
@@ -0,0 +1,28 @@
package auth_service.modules.registration.api.dto.request;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.nio.charset.StandardCharsets;
/**
* The password is set here, by the owner of the address, and never at registration.
*/
public record ConfirmationRequest(@NotBlank @Size(max = 64) String token, @NotBlank @Size(min = 8) String password) {
/**
* BCrypt uses only the first 72 bytes and refuses a longer password.
*/
@AssertTrue(message = "The password may have at most 72 bytes")
@JsonIgnore
public boolean isPasswordShortEnough() {
return password == null || password.getBytes(StandardCharsets.UTF_8).length <= 72;
}
@Override
public String toString() {
return "ConfirmationRequest[]";
}
}
@@ -2,22 +2,18 @@ package auth_service.modules.registration.api.dto.request;
import auth_service.shared.api.validation.DisplayNameFormat;
import auth_service.shared.account.EmailAddress;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Builder;
import java.nio.charset.StandardCharsets;
/**
* No Steam ID: it comes only from "Sign in through Steam", later in the settings.
* No Steam ID: it comes only from "Sign in through Steam", later in the settings. No password: the address owner
* sets it when confirming the address.
*/
@Builder
public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email,
@NotBlank @Size(min = 8) String password,
@Pattern(regexp = DisplayNameFormat.PATTERN) String displayName,
// For the mails; null = never chosen.
@Pattern(regexp = "de|en") String language) {
@@ -25,13 +21,4 @@ public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_
public RegistrationRequest {
email = EmailAddress.normalize(email);
}
/**
* BCrypt uses only the first 72 bytes and refuses a longer password.
*/
@AssertTrue(message = "The password may have at most 72 bytes")
@JsonIgnore
public boolean isPasswordShortEnough() {
return password == null || password.getBytes(StandardCharsets.UTF_8).length <= 72;
}
}
@@ -0,0 +1,13 @@
package auth_service.modules.registration.api.dto.request;
import auth_service.shared.account.EmailAddress;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ResendConfirmationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email) {
public ResendConfirmationRequest {
email = EmailAddress.normalize(email);
}
}
@@ -0,0 +1,7 @@
package auth_service.modules.registration.exception;
public class ConfirmationInvalidException extends RuntimeException {
public ConfirmationInvalidException() {
super("The confirmation link is unknown, used or expired.");
}
}
@@ -2,16 +2,11 @@ package auth_service.modules.registration.model;
import lombok.Builder;
import lombok.Data;
import lombok.ToString;
@Builder
@Data
public class Registration {
private String email;
@ToString.Exclude
private String rawPassword;
@ToString.Exclude
private String encryptedPassword;
private String displayName;
private String language;
}
@@ -2,15 +2,32 @@ package auth_service.modules.registration.persistence;
import auth_service.modules.registration.model.Registration;
import java.time.Instant;
import java.util.Optional;
public interface RegisteredUserRepository {
boolean existsByEmail(String email);
boolean existsByDisplayName(String displayName);
/**
* Stores the user as PENDING: an admin accepts or denies the registration.
* Stores the user as UNCONFIRMED: the address owner confirms by link, then an admin accepts or denies it.
*/
long save(Registration registration);
long countPending();
/**
* UNCONFIRMED and PENDING: the requests that wait for the owner or the admin.
*/
long countWaiting();
Optional<Long> findUnconfirmedIdByEmail(String email);
void storeConfirmation(long userId, String tokenHash, Instant expiresAt);
/**
* Single use: sets PENDING and the password hash and clears the token hash.
*
* @return the user id, empty when the hash is unknown, used or expired
*/
Optional<Long> confirm(String tokenHash, String passwordHash, Instant now);
}
@@ -7,8 +7,11 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Optional;
@Repository
@RequiredArgsConstructor
@@ -32,9 +35,9 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
@Transactional
public long save(Registration registration) {
Long id = jdbc.queryForObject("""
INSERT INTO users (email, password, enabled, display_name, registration_date, status)
VALUES (?, ?, TRUE, ?, ?, 'PENDING') RETURNING id""", Long.class,
registration.getEmail(), registration.getEncryptedPassword(), registration.getDisplayName(),
INSERT INTO users (email, enabled, display_name, registration_date, status)
VALUES (?, TRUE, ?, ?, 'UNCONFIRMED') RETURNING id""", Long.class,
registration.getEmail(), registration.getDisplayName(),
LocalDateTime.now(Clock.systemUTC()));
if (id == null) {
throw new IllegalStateException("No id for the new user");
@@ -46,8 +49,28 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
}
@Override
public long countPending() {
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE status = 'PENDING'", Long.class);
public long countWaiting() {
Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE status IN ('UNCONFIRMED', 'PENDING')", Long.class);
return count != null ? count : 0L;
}
@Override
public Optional<Long> findUnconfirmedIdByEmail(String email) {
return jdbc.queryForList("SELECT id FROM users WHERE email = ? AND status = 'UNCONFIRMED'", Long.class,
EmailAddress.normalize(email)).stream().findFirst();
}
@Override
public void storeConfirmation(long userId, String tokenHash, Instant expiresAt) {
jdbc.update("UPDATE users SET email_confirmation_hash = ?, email_confirmation_expires_at = ? WHERE id = ?",
tokenHash, Timestamp.from(expiresAt), userId);
}
@Override
public Optional<Long> confirm(String tokenHash, String passwordHash, Instant now) {
return jdbc.queryForList("""
UPDATE users SET status = 'PENDING', password = ?, email_confirmation_hash = NULL, email_confirmation_expires_at = NULL
WHERE email_confirmation_hash = ? AND email_confirmation_expires_at > ? AND status = 'UNCONFIRMED'
RETURNING id""", Long.class, passwordHash, tokenHash, Timestamp.from(now)).stream().findFirst();
}
}
@@ -18,6 +18,10 @@ public class RegistrationAttemptService {
static final Duration LOCK_DURATION = Duration.ofMinutes(15);
private final AttemptLimiter limiter = new AttemptLimiter(LOCK_DURATION);
static final int MAX_CONFIRMATION_MAILS_PER_ADDRESS = 3;
static final Duration CONFIRMATION_MAIL_WINDOW = Duration.ofHours(1);
private final AttemptLimiter confirmationMails = new AttemptLimiter(CONFIRMATION_MAIL_WINDOW);
private final AttemptLimiter requests = new AttemptLimiter(LOCK_DURATION);
public void acquire(String clientIp) {
@@ -38,4 +42,12 @@ public class RegistrationAttemptService {
throw new TooManyRegistrationAttemptsException(LOCK_DURATION.toSeconds());
}
}
/**
* At most 3 confirmation mails per address and hour over all IPs, so nobody can fill a stranger's inbox or the
* daily mail budget. The caller answers the same when it is false.
*/
public boolean tryConfirmationMail(String email) {
return confirmationMails.tryAcquire(email, MAX_CONFIRMATION_MAILS_PER_ADDRESS);
}
}
@@ -1,17 +1,23 @@
package auth_service.modules.registration.service;
import auth_service.modules.registration.exception.ConfirmationInvalidException;
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.exception.PendingLimitReachedException;
import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.OpaqueToken;
import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
@Service
@Public
@RequiredArgsConstructor
@@ -23,32 +29,71 @@ public class RegistrationService {
private final AccountMailer accountMailer;
static final int MAX_PENDING = 20;
static final Duration CONFIRMATION_TTL = Duration.ofHours(24);
/**
* Stores a request that waits for the admin. When MAX_PENDING wait already, it is refused and counts as a failed
* attempt of the IP.
* Stores an unconfirmed request without a password and mails the confirmation link. The address owner sets the
* password when confirming; the admin sees the request after that. When MAX_PENDING requests wait already, it is
* refused and counts as a failed attempt of the IP. An address that is already unconfirmed gets a new link and no
* second row.
*/
// The user and the mail rows commit together.
@Transactional
public long register(Registration registration, String clientIp) {
registrationAttemptService.acquire(clientIp);
Optional<Long> unconfirmed = registeredUserRepository.findUnconfirmedIdByEmail(registration.getEmail());
if (unconfirmed.isPresent()) {
registrationAttemptService.release(clientIp);
registrationAttemptService.countRequest(clientIp);
sendConfirmation(unconfirmed.get(), registration.getEmail());
return unconfirmed.get();
}
// ponytail: two requests at the same time can pass at 19 and make 21; a pg_advisory_xact_lock would make it
// exact, but the limit is a brake, not a quota.
if (registeredUserRepository.countPending() >= MAX_PENDING) {
if (registeredUserRepository.countWaiting() >= MAX_PENDING) {
throw new PendingLimitReachedException();
}
validate(registration);
registrationAttemptService.release(clientIp);
registrationAttemptService.countRequest(clientIp);
var rawPassword = registration.getRawPassword();
registration.setEncryptedPassword(passwordEncoder.encode(rawPassword));
long userId = registeredUserRepository.save(registration);
accountMailer.registrationRequested(userId);
sendConfirmation(userId, registration.getEmail());
return userId;
}
/**
* Sets the password of the account. A failed confirmation counts as a failed attempt of the IP.
*/
@Transactional
public void confirm(String rawToken, String rawPassword, String clientIp) {
registrationAttemptService.acquire(clientIp);
long userId = registeredUserRepository
.confirm(OpaqueToken.hash(rawToken), passwordEncoder.encode(rawPassword), Instant.now())
.orElseThrow(ConfirmationInvalidException::new);
registrationAttemptService.release(clientIp);
accountMailer.registrationRequested(userId);
}
/**
* Never fails for an unknown address: the answer must not tell whether it exists.
*/
@Transactional
public void resend(String email, String clientIp) {
registrationAttemptService.countRequest(clientIp);
registeredUserRepository.findUnconfirmedIdByEmail(email).ifPresent(userId -> sendConfirmation(userId, email));
}
// A new link replaces the old one. Over the per-address limit nothing is sent and nothing tells the caller.
private void sendConfirmation(long userId, String email) {
if (!registrationAttemptService.tryConfirmationMail(email)) {
return;
}
String token = OpaqueToken.generate();
registeredUserRepository.storeConfirmation(userId, OpaqueToken.hash(token), Instant.now().plus(CONFIRMATION_TTL));
accountMailer.confirmEmail(userId, token);
}
private void validate(Registration registration) {
if (registeredUserRepository.existsByEmail(registration.getEmail())) {
throw new EmailAlreadyInUseException("The provided email address is already in use.");
@@ -9,7 +9,12 @@ import java.time.LocalDate;
public interface AccountMailer {
/**
* "A new registration waits" to the admin and "we got your registration" to the user.
* The link with the raw token; the caller stores only its hash.
*/
void confirmEmail(long userId, String rawToken);
/**
* "A new registration waits" to the admin, after the user confirmed the address.
*/
void registrationRequested(long userId);
@@ -0,0 +1,34 @@
package auth_service.shared.security;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HexFormat;
/**
* A random token for a mail link. The raw token goes to the user, only the hash is stored, never log the raw one.
*/
public final class OpaqueToken {
private static final SecureRandom RANDOM = new SecureRandom();
private OpaqueToken() {
}
public static String generate() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
public static String hash(String rawToken) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(rawToken.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is missing", e);
}
}
}
@@ -0,0 +1,12 @@
-- UNCONFIRMED until the address owner clicks the link in the mail; then PENDING for the admin.
ALTER TABLE users ALTER COLUMN status TYPE VARCHAR(11);
ALTER TABLE users DROP CONSTRAINT users_status_check;
ALTER TABLE users ADD CONSTRAINT users_status_check
CHECK (status IN ('UNCONFIRMED', 'PENDING', 'ACTIVE', 'DENIED'));
-- An unconfirmed user has no password: the owner of the address sets it on the confirm page.
ALTER TABLE users ALTER COLUMN password DROP NOT NULL;
-- SHA-256 hex of the link token, NULL once used; the expiry is 24 h from the last send.
ALTER TABLE users ADD COLUMN email_confirmation_hash VARCHAR(64),
ADD COLUMN email_confirmation_expires_at TIMESTAMPTZ;
CREATE UNIQUE INDEX uq_users_email_confirmation_hash ON users (email_confirmation_hash)
WHERE email_confirmation_hash IS NOT NULL;
@@ -192,7 +192,7 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
}
@Test
void registration_WaitsAndDoesNotCountUntilTheAccept() throws Exception {
void registration_IsUnconfirmedAndDoesNotCount() throws Exception {
String newEmail = "delete-flow-registered@example.com";
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
long before = totalUsers();
@@ -201,12 +201,11 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
mockMvc.perform(post("/api/registration/new")
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + newEmail + "\",\"password\":\"" + PASSWORD
+ "\"}"))
.content("{\"email\":\"" + newEmail + "\"}"))
.andExpect(status().is2xxSuccessful());
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE email = ?", String.class, newEmail))
.isEqualTo("PENDING");
.isEqualTo("UNCONFIRMED");
assertThat(totalUsers()).isEqualTo(before);
} finally {
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
@@ -220,8 +219,7 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
mockMvc.perform(post("/api/registration/new")
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD
+ "\"}"))
.content("{\"email\":\"" + EMAIL + "\"}"))
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
}
}
@@ -9,6 +9,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.UUID;
@@ -100,4 +101,35 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
assertThat(count("select count(*) from users where id = ?", bob)).isEqualTo(1);
assertThat(repository.deleteUsers(List.of())).isZero();
}
@Test
void deleteUnconfirmedBefore_RemovesOldUnconfirmedUsersWithTheirSettingsOnly() {
long old = insertUser("old-unconfirmed@example.com");
long recent = insertUser("recent-unconfirmed@example.com");
long oldPending = insertUser("old-pending@example.com");
LocalDateTime now = LocalDateTime.now();
jdbc.update("update users set status = 'UNCONFIRMED', registration_date = ? where id = ?", now.minusDays(8), old);
jdbc.update("update users set status = 'UNCONFIRMED', registration_date = ? where id = ?", now.minusDays(6), recent);
jdbc.update("update users set status = 'PENDING', registration_date = ? where id = ?", now.minusDays(8), oldPending);
assertThat(repository.deleteUnconfirmedBefore(now.minusDays(7))).isEqualTo(1);
assertThat(count("select count(*) from users where id = ?", old)).isZero();
assertThat(count("select count(*) from user_settings where user_id = ?", old)).isZero();
assertThat(count("select count(*) from users where id in (?, ?)", recent, oldPending)).isEqualTo(2);
}
@Test
void deleteUnconfirmedBefore_KeepsAnOldUnconfirmedUserWhoseResentLinkIsStillValid() {
long oldWithValidLink = insertUser("old-valid-link@example.com");
LocalDateTime now = LocalDateTime.now();
jdbc.update("""
update users set status = 'UNCONFIRMED', registration_date = ?, email_confirmation_hash = 'h',
email_confirmation_expires_at = now() + interval '20 hours' where id = ?""",
now.minusDays(8), oldWithValidLink);
assertThat(repository.deleteUnconfirmedBefore(now.minusDays(7))).isZero();
assertThat(count("select count(*) from users where id = ?", oldWithValidLink)).isEqualTo(1);
}
}
@@ -6,17 +6,23 @@ import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.springframework.web.client.RestClientException;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -140,4 +146,19 @@ class AccountCleanupServiceTest extends AbstractUnitTest {
verify(accountRepository).deleteUsers(List.of(7L));
}
@Test
void runNightly_PurgesUnconfirmedRegistrationsEvenInDryRunAndBeforeTheCoreCalls() {
when(coreAccountService.purge(any(), anyBoolean())).thenReturn(core(true));
when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(true));
service(true).runNightly();
InOrder order = inOrder(accountRepository, coreAccountService);
ArgumentCaptor<LocalDateTime> cutoff = ArgumentCaptor.forClass(LocalDateTime.class);
order.verify(accountRepository).deleteUnconfirmedBefore(cutoff.capture());
order.verify(coreAccountService).replaceDeleted(any());
assertThat(cutoff.getValue()).isBetween(LocalDateTime.now(Clock.systemUTC()).minusDays(7).minusMinutes(1),
LocalDateTime.now(Clock.systemUTC()).minusDays(7).plusMinutes(1));
}
}
@@ -134,6 +134,46 @@ class AdminFlowTest extends AbstractIntegrationTest {
decide(admin, playerId, true).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_PENDING"));
}
private ResultActions deleteUnconfirmed(Session session, long userId) throws Exception {
return mockMvc.perform(delete("/api/admin/users/" + userId + "/unconfirmed")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access())));
}
@Test
void unconfirmedRequest_IsListedCannotBeAcceptedAndTheAdminDeletesIt() throws Exception {
jdbc.update("UPDATE users SET status = 'UNCONFIRMED' WHERE id = ?", playerId);
Session admin = Session.of(login(ADMIN));
mockMvc.perform(get("/api/admin/users").param("status", "UNCONFIRMED")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
.andExpect(jsonPath("$.content[?(@.email == '" + PLAYER + "')].status").value("UNCONFIRMED"));
decide(admin, playerId, true).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_PENDING"));
block(admin, playerId, true).andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("TARGET_IS_UNCONFIRMED"));
deleteUnconfirmed(admin, playerId).andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Long.class, playerId)).isZero();
assertThat(audits("DELETE_UNCONFIRMED", playerId)).isEqualTo(1);
deleteUnconfirmed(admin, playerId).andExpect(status().isNotFound());
}
@Test
void deleteUnconfirmed_RefusesOtherStatusesAndAUser() throws Exception {
Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER));
deleteUnconfirmed(player, playerId).andExpect(status().isForbidden());
deleteUnconfirmed(admin, playerId).andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("NOT_UNCONFIRMED"));
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", playerId);
deleteUnconfirmed(admin, playerId).andExpect(status().isConflict());
deleteUnconfirmed(admin, otherAdminId).andExpect(status().isConflict());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Long.class, playerId)).isEqualTo(1);
}
@Test
void deniedRequest_CannotLogInAndWaitsForTheCleanup() throws Exception {
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", playerId);
@@ -119,4 +119,27 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest {
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, alice)).isEqualTo("DENIED");
assertThat(repository.findState(alice).orElseThrow().deleted()).isTrue();
}
@Test
void findPageAndCount_ListAnUnconfirmedUserAndFilterByIt() {
jdbc.update("UPDATE users SET status = 'UNCONFIRMED' WHERE id = ?", alice);
assertThat(repository.count("", "")).isEqualTo(3);
assertThat(repository.findPage("", "UNCONFIRMED", 0, 10)).extracting(AdminUser::status)
.containsExactly("UNCONFIRMED");
assertThat(repository.count("", "PENDING")).isZero();
assertThat(repository.findState(alice).orElseThrow().unconfirmed()).isTrue();
}
@Test
void deleteUnconfirmed_RemovesOnlyAnUnconfirmedUserWithItsSettings() {
assertThat(repository.deleteUnconfirmed(alice)).isFalse();
jdbc.update("UPDATE users SET status = 'UNCONFIRMED' WHERE id = ?", alice);
assertThat(repository.deleteUnconfirmed(alice)).isTrue();
assertThat(repository.deleteUnconfirmed(alice)).isFalse();
assertThat(repository.count("", "")).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM user_settings WHERE user_id = ?", Long.class, alice))
.isZero();
}
}
@@ -56,11 +56,15 @@ class AdminUserServiceTest extends AbstractUnitTest {
}
private void target(Role role, boolean deleted) {
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted, false)));
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted, false, false)));
}
private void pendingTarget() {
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(Role.USER, false, true)));
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(Role.USER, false, true, false)));
}
private void unconfirmedTarget() {
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(Role.USER, false, false, true)));
}
private String refusal(Runnable action) {
@@ -194,4 +198,38 @@ class AdminUserServiceTest extends AbstractUnitTest {
assertThat(refusal(() -> service.delete(ADMIN, TARGET))).isEqualTo("TARGET_IS_PENDING");
verifyNoInteractions(accountDeleter);
}
@Test
void deleteUnconfirmed_DeletesAndAudits() {
unconfirmedTarget();
when(adminUserRepository.deleteUnconfirmed(TARGET)).thenReturn(true);
service.deleteUnconfirmed(ADMIN, TARGET);
verify(adminAuditLog).record(ADMIN, "DELETE_UNCONFIRMED", TARGET, null);
verifyNoInteractions(accountMailer, accountDeleter);
}
@Test
void deleteUnconfirmed_RefusesAnyOtherStatusAndALostRace() {
pendingTarget();
assertThat(refusal(() -> service.deleteUnconfirmed(ADMIN, TARGET))).isEqualTo("NOT_UNCONFIRMED");
target(Role.USER, false);
assertThat(refusal(() -> service.deleteUnconfirmed(ADMIN, TARGET))).isEqualTo("NOT_UNCONFIRMED");
unconfirmedTarget();
when(adminUserRepository.deleteUnconfirmed(TARGET)).thenReturn(false);
assertThat(refusal(() -> service.deleteUnconfirmed(ADMIN, TARGET))).isEqualTo("NOT_UNCONFIRMED");
verifyNoInteractions(adminAuditLog);
}
@Test
void decideBlockAndDelete_RefuseAnUnconfirmedRequest() {
unconfirmedTarget();
assertThat(refusal(() -> service.decide(ADMIN, TARGET, true))).isEqualTo("NOT_PENDING");
assertThat(refusal(() -> service.setBlocked(ADMIN, TARGET, true))).isEqualTo("TARGET_IS_UNCONFIRMED");
assertThat(refusal(() -> service.delete(ADMIN, TARGET))).isEqualTo("TARGET_IS_UNCONFIRMED");
verifyNoInteractions(accountDeleter, accountMailer);
}
}
@@ -1,6 +1,5 @@
package auth_service.modules.login.model;
import auth_service.modules.registration.model.Registration;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
@@ -20,7 +19,5 @@ class UserTest extends AbstractUnitTest {
void toString_ShowsNoPassword() {
assertThat(User.builder().email("a@example.com").password("hash-secret").build().toString())
.contains("a@example.com").doesNotContain("hash-secret");
assertThat(Registration.builder().email("a@example.com").rawPassword("raw-secret").encryptedPassword("hash-secret")
.build().toString()).doesNotContain("raw-secret").doesNotContain("hash-secret");
}
}
@@ -192,6 +192,15 @@ class LoginServiceTest extends AbstractUnitTest {
verify(loginAttemptService).recordSuccess("test@example.com", IP);
}
@Test
void login_UnconfirmedUserWithoutAPassword_LooksLikeAnyWrongPassword() {
User dbUser = User.builder().id(815L).email("test@example.com").enabled(true).build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any());
}
@Test
void login_PendingUserWithAWrongPassword_LooksLikeAnyWrongPassword() {
User dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true)
@@ -62,4 +62,13 @@ class MailTemplateTest extends AbstractUnitTest {
assertThat(MailTemplate.date("en", date)).isEqualTo("5 November 2026");
assertThat(MailTemplate.date(null, date)).isEqualTo("5 November 2026");
}
@Test
void render_ConfirmEmail_CarriesTheLinkInBothLanguages() {
for (String language : new String[]{"de", "en"}) {
Mail mail = MailTemplate.CONFIRM_EMAIL.render(language, "Alice", "https://tbp.example/confirm-email#tok");
assertThat(mail.body()).contains("https://tbp.example/confirm-email#tok");
}
}
}
@@ -6,7 +6,6 @@ import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -14,9 +13,12 @@ import org.springframework.mail.MailSendException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -26,6 +28,7 @@ import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
@@ -36,6 +39,9 @@ class MailFlowTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
private static final String NEW_USER = "mail-flow-new@example.com";
private static final String ADMIN = "mail-flow-admin@example.com";
// The per-address mail limit lives in memory for an hour, so the limit tests use an address no other test mails.
private static final String OWNER = "mail-flow-owner@example.com";
private static final String CAPPED = "mail-flow-capped@example.com";
private static final String PLAYER = "mail-flow-player@example.com";
@Autowired
@@ -64,17 +70,19 @@ class MailFlowTest extends AbstractIntegrationTest {
}
private String accessToken(String email) throws Exception {
return login(email).andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
}
private ResultActions login(String email) throws Exception {
return mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"))
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"));
}
private void register() throws Exception {
mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD
+ "\",\"displayName\":\"MailFlow\",\"language\":\"de\"}"))
.content("{\"email\":\"" + NEW_USER + "\",\"displayName\":\"MailFlow\",\"language\":\"de\"}"))
.andExpect(status().isOk());
}
@@ -83,22 +91,48 @@ class MailFlowTest extends AbstractIntegrationTest {
}
@Test
void registration_WritesBothMailsAndTheJobSendsThem() throws Exception {
void registration_MailsTheLinkFirstAndTheAdminOnlyAfterTheClick() throws Exception {
register();
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address") + " " + row.get("status"))
.containsExactly("REGISTRATION_REQUESTED admin@test.local PENDING",
"REGISTRATION_RECEIVED " + NEW_USER + " PENDING");
assertThat(rows().get(1).get("subject")).isEqualTo("Deine ToBePlayed-Registrierung ist angekommen");
.containsExactly("CONFIRM_EMAIL " + NEW_USER + " PENDING");
assertThat(rows().get(0).get("subject")).isEqualTo("Bestätige deine E-Mail-Adresse für ToBePlayed");
String body = jdbc.queryForObject("SELECT body FROM mail_outbox", String.class);
Matcher link = Pattern.compile("/confirm-email#(\\S+)").matcher(body);
assertThat(link.find()).isTrue();
String token = link.group(1);
assertThat(body).doesNotContain(jdbc.queryForObject("SELECT email_confirmation_hash FROM users WHERE email = ?",
String.class, NEW_USER));
confirm(token, PASSWORD).andExpect(status().isNoContent());
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address"))
.containsExactly("CONFIRM_EMAIL " + NEW_USER, "REGISTRATION_REQUESTED admin@test.local");
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE email = ?", String.class, NEW_USER)).isEqualTo("PENDING");
login(NEW_USER).andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("ACCOUNT_PENDING"));
confirm(token, PASSWORD).andExpect(status().isGone());
mailOutboxService.sendDue();
ArgumentCaptor<SimpleMailMessage> sent = ArgumentCaptor.forClass(SimpleMailMessage.class);
verify(mailSender, times(2)).send(sent.capture());
assertThat(sent.getAllValues()).extracting(SimpleMailMessage::getFrom).containsOnly("noreply@test.local");
verify(mailSender, times(2)).send(any(SimpleMailMessage.class));
assertThat(rows()).extracting(row -> row.get("status")).containsOnly("SENT");
}
@Test
void login_BeforeTheClick_IsRefusedAndTheUserHasNoPassword() throws Exception {
register();
login(NEW_USER).andExpect(status().isUnauthorized());
assertThat(jdbc.queryForObject("SELECT password FROM users WHERE email = ?", String.class, NEW_USER)).isNull();
}
private ResultActions confirm(String token, String password) throws Exception {
return mockMvc.perform(post("/api/registration/confirm").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"" + token + "\",\"password\":\"" + password + "\"}"));
}
@Test
void acceptAndDeny_MailTheUser() throws Exception {
insertUser(ADMIN, "PLATFORM_ADMIN");
@@ -128,7 +162,7 @@ class MailFlowTest extends AbstractIntegrationTest {
assertThat(rows()).extracting(row -> row.get("status") + " " + row.get("attempts") + " " + row.get("last_error"))
.containsOnly("PENDING 1 connection refused");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE next_attempt_at > NOW()", Integer.class))
.isEqualTo(2);
.isEqualTo(1);
jdbc.update("UPDATE mail_outbox SET attempts = ?, next_attempt_at = NOW()", MailOutboxService.MAX_ATTEMPTS - 1);
mailOutboxService.sendDue();
@@ -142,8 +176,7 @@ class MailFlowTest extends AbstractIntegrationTest {
mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD
+ "\"}"))
.content("{\"email\":\"" + NEW_USER + "\"}"))
.andExpect(status().isConflict());
assertThat(rows()).isEmpty();
@@ -192,4 +225,83 @@ class MailFlowTest extends AbstractIntegrationTest {
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address"))
.containsExactly("ACCOUNT_DELETED " + PLAYER);
}
private ResultActions registerAs(String email) throws Exception {
return mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + email + "\",\"language\":\"de\"}"));
}
private int confirmMailsTo(String email) {
return jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE kind = 'CONFIRM_EMAIL' AND to_address = ?",
Integer.class, email);
}
private String newestToken() {
String body = jdbc.queryForObject("SELECT body FROM mail_outbox WHERE kind = 'CONFIRM_EMAIL' ORDER BY id DESC LIMIT 1",
String.class);
Matcher link = Pattern.compile("/confirm-email#(\\S+)").matcher(body);
assertThat(link.find()).isTrue();
return link.group(1);
}
// A stranger who registers an address first sets no password: the owner's click sets it, and only a link from
// the owner's mail can do that. Both mails carry a token of their own; the newest one wins.
@Test
void strangerRegisteringFirst_cannotLogInToTheOwnersAccount() throws Exception {
registerAs(OWNER).andExpect(status().isOk());
String strangerToken = newestToken();
registerAs(OWNER).andExpect(status().isOk()); // the address owner, later: same row, new link
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE email = ?", Integer.class, OWNER)).isEqualTo(1);
confirm(strangerToken, "stranger password 1").andExpect(status().isGone());
confirm(newestToken(), PASSWORD).andExpect(status().isNoContent());
insertAdminAndAccept();
login(OWNER).andExpect(status().isOk());
int strangerLogin = mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + OWNER + "\",\"password\":\"stranger password 1\"}"))
.andReturn().getResponse().getStatus();
assertThat(strangerLogin).isEqualTo(401);
}
private void insertAdminAndAccept() throws Exception {
insertUser(ADMIN, "PLATFORM_ADMIN");
long userId = jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, OWNER);
mockMvc.perform(put("/api/admin/users/" + userId + "/decision").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, accessToken(ADMIN)))
.contentType(MediaType.APPLICATION_JSON).content("{\"accepted\":true}"))
.andExpect(status().isNoContent());
}
// An address has at most 3 mails per hour over all IPs, and every answer stays the same.
@Test
void resendFromManyIps_isCappedPerAddress() throws Exception {
registerAs(CAPPED).andExpect(status().isOk());
for (int i = 0; i < 10; i++) {
mockMvc.perform(post("/api/registration/resend").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + CAPPED + "\"}"))
.andExpect(status().isNoContent());
}
for (int i = 0; i < 5; i++) {
registerAs(CAPPED).andExpect(status().isOk());
}
assertThat(confirmMailsTo(CAPPED)).isEqualTo(3);
}
@Test
void unconfirmedRegistrations_countTowardsThePendingLimit() throws Exception {
int maxPending = 20; // RegistrationService.MAX_PENDING
for (int i = 0; i < maxPending + 1; i++) {
registerAs("mail-flow-flood-" + i + "@example.com");
}
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE kind = 'CONFIRM_EMAIL'", Integer.class))
.isEqualTo(maxPending);
}
}
@@ -51,17 +51,26 @@ class MailServiceTest extends AbstractUnitTest {
assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body())
.startsWith("Eine neue Registrierung").contains("Alice", "alice@example.com", "https://tbp.example/admin/users");
assertThat(inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com").body()).startsWith("Hello Alice,");
verify(repository, never()).insert(any(), anyLong(), eq("alice@example.com"), any());
}
@Test
void registrationRequested_GreetsByEmailWithoutADisplayName() {
void confirmEmail_MailsTheLinkWithTheTokenInTheFragmentInTheUserLanguage() {
user("Alice", "de");
service(ADMIN).confirmEmail(USER, "raw-token");
Mail mail = inserted(MailTemplate.CONFIRM_EMAIL, "alice@example.com");
assertThat(mail.subject()).contains("Bestätige");
assertThat(mail.body()).startsWith("Hallo Alice,").contains("https://tbp.example/confirm-email#raw-token");
}
@Test
void registrationRequested_NamesTheUserWithoutADisplayNameAsDash() {
user(null, "de");
service(ADMIN).registrationRequested(USER);
assertThat(inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com").body())
.startsWith("Hallo alice@example.com,");
assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body()).contains("Name: -");
}
@@ -71,8 +80,7 @@ class MailServiceTest extends AbstractUnitTest {
service("").registrationRequested(USER);
verify(repository, never()).insert(eq(MailTemplate.REGISTRATION_REQUESTED), anyLong(), anyString(), any());
inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com");
verify(repository, never()).insert(any(), anyLong(), anyString(), any());
}
@Test
@@ -9,6 +9,7 @@ import org.springframework.http.converter.HttpMessageNotWritableException;
import org.hibernate.exception.ConstraintViolationException;
import auth_service.configuration.security.JwtService;
import auth_service.modules.registration.api.dto.request.RegistrationRequest;
import auth_service.modules.registration.exception.ConfirmationInvalidException;
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.PendingLimitReachedException;
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
@@ -18,6 +19,7 @@ import auth_service.shared.test.AbstractWebMvcTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
@@ -37,6 +39,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -83,7 +86,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
// Arrange
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.build();
long expectedUserId = 42L;
@@ -104,7 +106,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
// Arrange
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.build();
when(registrationService.register(any(Registration.class), any()))
@@ -124,7 +125,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","steamId":"76561198000000001"}"""))
{"email":"test@example.com","inviteCode":"code","steamId":"76561198000000001"}"""))
.andExpect(status().isOk());
}
@@ -132,7 +133,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
void register_DuplicateEmailRace_Is409EmailAlreadyInUse() throws Exception {
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.build();
when(registrationService.register(any(Registration.class), any()))
.thenThrow(duplicate("users_email_lower"));
@@ -152,7 +152,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}"""))
{"email":"test@example.com","displayName":"Lobo"}"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE"));
}
@@ -164,7 +164,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","language":"de"}"""))
{"email":"test@example.com","language":"de"}"""))
.andExpect(status().isOk());
ArgumentCaptor<Registration> registration = ArgumentCaptor.forClass(Registration.class);
@@ -177,7 +177,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","language":"fr"}"""))
{"email":"test@example.com","language":"fr"}"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
}
@@ -196,7 +196,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!"}"""))
{"email":"test@example.com"}"""))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
.andExpect(content().string(not(containsString("secret detail"))));
@@ -207,7 +207,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","displayName":"x"}"""))
{"email":"test@example.com","displayName":"x"}"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.fields[0].field").value("displayName"));
@@ -224,7 +224,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}"""))
{"email":"test@example.com","displayName":"Lobo"}"""))
.andExpect(status().isOk());
// Assert
@@ -243,7 +243,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.header("X-Forwarded-For", "10.0.0.1, 203.0.113.7")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!"}"""))
{"email":"test@example.com"}"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(42L));
}
@@ -258,7 +258,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!"}"""))
{"email":"test@example.com"}"""))
.andExpect(status().isTooManyRequests())
.andExpect(header().string("Retry-After", "900"))
.andExpect(jsonPath("$.code").value("TOO_MANY_ATTEMPTS"));
@@ -271,33 +271,15 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!"}"""))
{"email":"test@example.com"}"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("PENDING_LIMIT_REACHED"));
}
@ParameterizedTest
@CsvSource(delimiter = '|', value = {
" |SuperSecret123!",
"no-at-sign|SuperSecret123!",
"test@example.com| ",
"test@example.com|short",
})
void register_InvalidInput_Is400WithoutRegistering(String email, String password) throws Exception {
var request = RegistrationRequest.builder().email(email).password(password).build();
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content(registrationRequestJson.write(request).getJson()))
.andExpect(status().isBadRequest());
verifyNoInteractions(registrationService);
}
// BCrypt uses only 72 bytes: a longer password would mostly be ignored, or refused with a 500.
@Test
void register_PasswordOver72Bytes_Is400() throws Exception {
var request = RegistrationRequest.builder().email("test@example.com").password("é".repeat(37)).build();
@ValueSource(strings = {" ", "no-at-sign"})
void register_InvalidEmail_Is400WithoutRegistering(String email) throws Exception {
var request = RegistrationRequest.builder().email(email).build();
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
@@ -309,7 +291,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
@Test
void register_Email_IsTrimmedAndLowerCase() throws Exception {
var request = RegistrationRequest.builder().email(" Bob@Example.COM ").password("SuperSecret123!").build();
var request = RegistrationRequest.builder().email(" Bob@Example.COM ").build();
when(registrationService.register(any(Registration.class), any())).thenReturn(1L);
mockMvc.perform(post("/api/registration/new")
@@ -331,10 +313,89 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!"}"""))
{"email":"test@example.com"}"""))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
assertTrue(output.getAll().contains("body not writable"), "the 500 left no trace in the log");
}
@Test
void confirm_Is204AndPassesTokenPasswordAndIp() throws Exception {
mockMvc.perform(post("/api/registration/confirm").header("X-Forwarded-For", "203.0.113.7")
.contentType(MediaType.APPLICATION_JSON).content("{\"token\":\"abc\",\"password\":\"SuperSecret123!\"}"))
.andExpect(status().isNoContent());
verify(registrationService).confirm("abc", "SuperSecret123!", "203.0.113.7");
}
@Test
void confirm_AnInvalidLink_Is410ConfirmationInvalid() throws Exception {
doThrow(new ConfirmationInvalidException()).when(registrationService).confirm(any(), any(), any());
mockMvc.perform(post("/api/registration/confirm").contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"abc\",\"password\":\"SuperSecret123!\"}"))
.andExpect(status().isGone())
.andExpect(jsonPath("$.code").value("CONFIRMATION_INVALID"));
}
@ParameterizedTest
@CsvSource(delimiter = '|', value = {
"abc| ",
"abc|short",
"abc|ééééééééééééééééééééééééééééééééééééé",
})
void confirm_InvalidInput_Is400WithoutConfirming(String token, String password) throws Exception {
mockMvc.perform(post("/api/registration/confirm").contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"" + token + "\",\"password\":\"" + password + "\"}"))
.andExpect(status().isBadRequest());
verifyNoInteractions(registrationService);
}
@Test
void confirm_BlankToken_Is400() throws Exception {
mockMvc.perform(post("/api/registration/confirm").contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\" \",\"password\":\"SuperSecret123!\"}"))
.andExpect(status().isBadRequest());
verifyNoInteractions(registrationService);
}
@Test
void confirm_NoPassword_Is400() throws Exception {
mockMvc.perform(post("/api/registration/confirm").contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"abc\"}"))
.andExpect(status().isBadRequest());
verifyNoInteractions(registrationService);
}
@Test
void resend_Is204AndNormalizesTheEmail() throws Exception {
mockMvc.perform(post("/api/registration/resend").contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\" Bob@Example.COM \"}"))
.andExpect(status().isNoContent());
verify(registrationService).resend(eq("bob@example.com"), any());
}
@Test
void resend_BadEmail_Is400() throws Exception {
mockMvc.perform(post("/api/registration/resend").contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"no-at-sign\"}"))
.andExpect(status().isBadRequest());
verifyNoInteractions(registrationService);
}
@Test
void resend_TooManyRequests_Is429WithRetryAfter() throws Exception {
doThrow(new TooManyRegistrationAttemptsException(900)).when(registrationService).resend(any(), any());
mockMvc.perform(post("/api/registration/resend").contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"bob@example.com\"}"))
.andExpect(status().isTooManyRequests())
.andExpect(header().string("Retry-After", "900"));
}
}
@@ -7,10 +7,13 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -23,7 +26,7 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
private JdbcTemplate jdbc;
private static Registration registration(String email, String displayName) {
return Registration.builder().email(email).encryptedPassword("hashed").displayName(displayName).build();
return Registration.builder().email(email).displayName(displayName).build();
}
@Test
@@ -48,7 +51,7 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
Map<String, Object> saved = jdbc.queryForMap("SELECT * FROM users WHERE id = ?", id);
assertEquals("save@example.com", saved.get("email"));
assertEquals("hashed", saved.get("password"));
assertNull(saved.get("password"));
assertEquals(true, saved.get("enabled"));
assertEquals("Saver", saved.get("display_name"));
assertEquals("USER", saved.get("role"));
@@ -65,13 +68,13 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
}
@Test
void save_StoresAPendingRequestAndCountsIt() {
long before = registeredUserRepository.countPending();
void save_StoresAnUnconfirmedRequestThatCountsAsWaiting() {
long before = registeredUserRepository.countWaiting();
long id = registeredUserRepository.save(registration("pending@example.com", null));
assertEquals("PENDING", jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, id));
assertEquals(before + 1, registeredUserRepository.countPending());
assertEquals("UNCONFIRMED", jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, id));
assertEquals(before + 1, registeredUserRepository.countWaiting());
}
@Test
@@ -95,4 +98,43 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
// GlobalExceptionController finds the name in the message, in every server language.
assertTrue(refused.getMostSpecificCause().getMessage().contains("users_email_lower"));
}
@Test
void confirm_ReturnsTheIdOnceAndMakesTheUserPending() {
long id = registeredUserRepository.save(registration("confirm-once@example.com", null));
registeredUserRepository.storeConfirmation(id, "hash-once", Instant.now().plusSeconds(60));
assertEquals(Optional.of(id), registeredUserRepository.confirm("hash-once", "pw-hash", Instant.now()));
assertEquals(Optional.empty(), registeredUserRepository.confirm("hash-once", "pw-hash", Instant.now()));
assertEquals("PENDING", jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, id));
assertEquals("pw-hash", jdbc.queryForObject("SELECT password FROM users WHERE id = ?", String.class, id));
assertEquals(Optional.empty(), registeredUserRepository.findUnconfirmedIdByEmail("confirm-once@example.com"));
}
@Test
void confirm_AnExpiredHashIsEmptyAndKeepsTheUserUnconfirmed() {
long id = registeredUserRepository.save(registration("confirm-late@example.com", null));
registeredUserRepository.storeConfirmation(id, "hash-late", Instant.now().minusSeconds(1));
assertEquals(Optional.empty(), registeredUserRepository.confirm("hash-late", "pw-hash", Instant.now()));
assertEquals(Optional.of(id), registeredUserRepository.findUnconfirmedIdByEmail("Confirm-Late@example.com"));
assertNull(jdbc.queryForObject("SELECT password FROM users WHERE id = ?", String.class, id));
}
@Test
void storeConfirmation_ANewHashInvalidatesTheOldOne() {
long id = registeredUserRepository.save(registration("confirm-resend@example.com", null));
registeredUserRepository.storeConfirmation(id, "hash-old", Instant.now().plusSeconds(60));
registeredUserRepository.storeConfirmation(id, "hash-new", Instant.now().plusSeconds(60));
assertEquals(Optional.empty(), registeredUserRepository.confirm("hash-old", "pw-hash", Instant.now()));
assertEquals(Optional.of(id), registeredUserRepository.confirm("hash-new", "pw-hash", Instant.now()));
}
@Test
void findUnconfirmedIdByEmail_IgnoresAPendingUser() {
jdbc.update("INSERT INTO users (email, password, enabled, status) VALUES ('pend@example.com', 'x', true, 'PENDING')");
assertEquals(Optional.empty(), registeredUserRepository.findUnconfirmedIdByEmail("pend@example.com"));
}
}
@@ -16,6 +16,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -106,4 +107,14 @@ class RegistrationAttemptServiceTest extends AbstractUnitTest {
assertTrue(passed.get() <= RegistrationAttemptService.MAX_FAILURES_PER_IP,
"attempts that passed the limit: " + passed.get());
}
@Test
void allowsThreeConfirmationMailsPerAddressWhateverTheIp() {
for (int i = 0; i < RegistrationAttemptService.MAX_CONFIRMATION_MAILS_PER_ADDRESS; i++) {
assertTrue(attempts.tryConfirmationMail("a@example.com"));
}
assertFalse(attempts.tryConfirmationMail("a@example.com"));
assertTrue(attempts.tryConfirmationMail("b@example.com"));
}
}
@@ -1,5 +1,6 @@
package auth_service.modules.registration.service;
import auth_service.modules.registration.exception.ConfirmationInvalidException;
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.exception.PendingLimitReachedException;
@@ -7,16 +8,24 @@ import auth_service.modules.registration.exception.TooManyRegistrationAttemptsEx
import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.OpaqueToken;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
@@ -47,7 +56,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
void setUp() {
validRegistration = Registration.builder()
.email("test@example.com")
.rawPassword("SuperSecret123!")
.build();
}
@@ -55,8 +63,8 @@ class RegistrationServiceTest extends AbstractUnitTest {
void register_Success() {
// Arrange
long expectedUserId = 42L;
when(registrationAttemptService.tryConfirmationMail(any())).thenReturn(true);
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId);
// Act
@@ -64,18 +72,85 @@ class RegistrationServiceTest extends AbstractUnitTest {
// Assert
assertEquals(expectedUserId, result);
verify(passwordEncoder).encode("SuperSecret123!");
verifyNoInteractions(passwordEncoder);
verify(registeredUserRepository).save(validRegistration);
assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword());
verify(registrationAttemptService).acquire(IP);
verify(registrationAttemptService).release(IP);
verify(registrationAttemptService).countRequest(IP);
verify(accountMailer).registrationRequested(expectedUserId);
verify(accountMailer, never()).registrationRequested(anyLong());
ArgumentCaptor<String> token = ArgumentCaptor.forClass(String.class);
verify(accountMailer).confirmEmail(eq(expectedUserId), token.capture());
ArgumentCaptor<Instant> expires = ArgumentCaptor.forClass(Instant.class);
verify(registeredUserRepository).storeConfirmation(eq(expectedUserId), eq(OpaqueToken.hash(token.getValue())),
expires.capture());
assertThat(expires.getValue()).isBetween(Instant.now().plus(Duration.ofHours(23)),
Instant.now().plus(Duration.ofHours(25)));
}
@Test
void register_AnUnconfirmedEmail_IsNoConflictNoSecondRowButANewLink() {
when(registrationAttemptService.tryConfirmationMail(any())).thenReturn(true);
when(registeredUserRepository.findUnconfirmedIdByEmail(validRegistration.getEmail())).thenReturn(Optional.of(5L));
assertEquals(5L, registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).save(any());
verify(registeredUserRepository, never()).existsByEmail(anyString());
verify(passwordEncoder, never()).encode(any());
verify(registrationAttemptService).release(IP);
verify(registrationAttemptService).countRequest(IP);
verify(accountMailer).confirmEmail(eq(5L), anyString());
}
@Test
void confirm_AValidToken_ReleasesTheAttemptAndTellsTheAdmin() {
when(passwordEncoder.encode("SuperSecret123!")).thenReturn("hashedPasswordXYZ");
when(registeredUserRepository.confirm(eq(OpaqueToken.hash("raw")), eq("hashedPasswordXYZ"), any()))
.thenReturn(Optional.of(9L));
registrationService.confirm("raw", "SuperSecret123!", IP);
verify(registrationAttemptService).acquire(IP);
verify(registrationAttemptService).release(IP);
verify(accountMailer).registrationRequested(9L);
}
@Test
void confirm_AnInvalidToken_CountsAsAFailedAttemptAndMailsNothing() {
when(registeredUserRepository.confirm(anyString(), any(), any())).thenReturn(Optional.empty());
assertThrows(ConfirmationInvalidException.class, () -> registrationService.confirm("raw", "SuperSecret123!", IP));
verify(registrationAttemptService, never()).release(anyString());
verifyNoInteractions(accountMailer);
}
@Test
void resend_AnUnknownAddress_MailsNothingAndDoesNotThrow() {
when(registeredUserRepository.findUnconfirmedIdByEmail("nobody@example.com")).thenReturn(Optional.empty());
registrationService.resend("nobody@example.com", IP);
verify(registrationAttemptService).countRequest(IP);
verifyNoInteractions(accountMailer);
verify(registeredUserRepository, never()).storeConfirmation(anyLong(), anyString(), any());
}
@Test
void resend_AnUnconfirmedAddress_StoresANewHashAndMailsTheLink() {
when(registeredUserRepository.findUnconfirmedIdByEmail("a@example.com")).thenReturn(Optional.of(5L));
when(registrationAttemptService.tryConfirmationMail("a@example.com")).thenReturn(true);
registrationService.resend("a@example.com", IP);
verify(registrationAttemptService).countRequest(IP);
verify(registeredUserRepository).storeConfirmation(eq(5L), anyString(), any());
verify(accountMailer).confirmEmail(eq(5L), anyString());
}
@Test
void register_AFullQueue_RefusesAndCountsAsAFailedAttempt() {
when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING);
when(registeredUserRepository.countWaiting()).thenReturn((long) RegistrationService.MAX_PENDING);
assertThrows(PendingLimitReachedException.class, () -> registrationService.register(validRegistration, IP));
@@ -85,10 +160,43 @@ class RegistrationServiceTest extends AbstractUnitTest {
verifyNoInteractions(accountMailer);
}
@Test
void register_AFullQueue_StillAnswersAnUnconfirmedAddressWithANewLink() {
when(registeredUserRepository.findUnconfirmedIdByEmail(validRegistration.getEmail())).thenReturn(Optional.of(5L));
when(registrationAttemptService.tryConfirmationMail(any())).thenReturn(true);
assertEquals(5L, registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).countWaiting();
}
@Test
void register_AnUnconfirmedAddressOverTheMailLimit_AnswersTheSameAndMailsNothing() {
when(registeredUserRepository.findUnconfirmedIdByEmail(validRegistration.getEmail())).thenReturn(Optional.of(5L));
when(registrationAttemptService.tryConfirmationMail(validRegistration.getEmail())).thenReturn(false);
assertEquals(5L, registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).storeConfirmation(anyLong(), anyString(), any());
verifyNoInteractions(accountMailer);
}
@Test
void resend_OverTheMailLimit_MailsNothingAndKeepsTheOldLink() {
when(registeredUserRepository.findUnconfirmedIdByEmail("a@example.com")).thenReturn(Optional.of(5L));
when(registrationAttemptService.tryConfirmationMail("a@example.com")).thenReturn(false);
registrationService.resend("a@example.com", IP);
verify(registeredUserRepository, never()).storeConfirmation(anyLong(), anyString(), any());
verifyNoInteractions(accountMailer);
}
@Test
void register_OneBelowTheLimit_IsTaken() {
when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING - 1);
when(registeredUserRepository.countWaiting()).thenReturn((long) RegistrationService.MAX_PENDING - 1);
when(registeredUserRepository.save(any(Registration.class))).thenReturn(43L);
when(registrationAttemptService.tryConfirmationMail(any())).thenReturn(true);
assertEquals(43L, registrationService.register(validRegistration, IP));
}
@@ -0,0 +1,23 @@
package auth_service.shared.security;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class OpaqueTokenTest extends AbstractUnitTest {
@Test
void generate_Is43UrlSafeCharsAndDiffersPerCall() {
String first = OpaqueToken.generate();
assertThat(first).matches("[A-Za-z0-9_-]{43}");
assertThat(OpaqueToken.generate()).isNotEqualTo(first);
}
@Test
void hash_Is64HexCharsAndDeterministic() {
assertThat(OpaqueToken.hash("abc")).matches("[0-9a-f]{64}").isEqualTo(OpaqueToken.hash("abc"))
.isNotEqualTo(OpaqueToken.hash("abd"));
}
}