diff --git a/readme.md b/readme.md index 2daee34..18e1d28 100644 --- a/readme.md +++ b/readme.md @@ -38,6 +38,12 @@ To Be Played is split into three repositories: 10 minutes, `typ=public-profile`) that Core accepts for the public library data only. Its `profileStats` claim names the stat groups the user chose to show there (none by default). The ticket is not a login: both services reject every token with a `typ` claim on normal endpoints. +- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets + `users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no public profile, no Steam + link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an undo needs a + new Steam link. A nightly job (04:15, Europe/Berlin) removes + users flagged 30 days ago, in Core first and then in Auth. It calls Core's `/internal/accounts/*` with a + **service token** (EdDSA JWT, 60 s, `typ=service`). `app.account-cleanup.dry-run` (default `true`) only logs. ## API @@ -51,6 +57,7 @@ To Be Played is split into three repositories: | `PUT` | `/api/users/settings` | access token | | `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie | | `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie | +| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token | | `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `PUT` | `/api/users/settings/appearance` | access token | | `PUT` | `/api/users/settings/profile-stats` (`{groups}`: `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; `204`) | access token | @@ -82,6 +89,8 @@ layers: `profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package. `version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image. +`account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core +through `CoreAccountService`. ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base @@ -135,6 +144,27 @@ sequenceDiagram once. `SteamOpenIdService` checks mode, endpoint, `return_to` and the signed fields, then asks Steam. `UserService.linkVerifiedSteamId` stores the ID and takes it from an account that only typed it in. +### Delete account flow + +```mermaid +sequenceDiagram + participant B as Browser (Frontend) + participant A as Auth + participant C as Core + B->>A: DELETE /api/users/me {password} + A->>A: users.deleted_at = now, revoke refresh tokens + A->>C: POST /internal/accounts/deleted (service token) + A-->>B: 204, both cookies cleared + Note over A,C: nightly 04:15 + A->>C: PUT /internal/accounts/deleted (all flagged users) + A->>C: POST /internal/accounts/purge (flagged 30+ days) + A->>A: DELETE users (only when Core answered and no dry run) + A->>C: POST /internal/accounts/prune (all user ids) +``` + +A failed call to Core does not fail the delete: the next night sends the whole flagged set again. Undo within 30 +days is `UPDATE users SET deleted_at = NULL` (see the Deployment README). + ## Run locally Needs Java 25 and Docker. diff --git a/src/main/java/auth_service/configuration/security/JwtService.java b/src/main/java/auth_service/configuration/security/JwtService.java index 5ba27c5..dc37999 100644 --- a/src/main/java/auth_service/configuration/security/JwtService.java +++ b/src/main/java/auth_service/configuration/security/JwtService.java @@ -2,6 +2,7 @@ package auth_service.configuration.security; import auth_service.shared.security.ProfileTicketIssuer; import auth_service.shared.security.PublicKeyPublisher; +import auth_service.shared.security.ServiceTokenIssuer; import auth_service.shared.security.SessionProperties; import auth_service.shared.security.TokenGenerator; import auth_service.shared.security.UserPrincipal; @@ -28,12 +29,14 @@ import java.util.Optional; * Signing: EdDSA (Ed25519). Auth holds the private key and signs; Core holds only the public key and verifies. */ @Service -public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKeyPublisher { +public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceTokenIssuer, PublicKeyPublisher { private static final long PROFILE_TICKET_EXPIRATION_MS = 10 * 60 * 1000; // Only non-login tokens carry this claim; isTokenValid rejects them. private static final String TYPE_CLAIM = "typ"; private static final String PROFILE_TICKET_TYPE = "public-profile"; + private static final long SERVICE_TOKEN_EXPIRATION_MS = 60 * 1000; + private static final String SERVICE_TOKEN_TYPE = "service"; private static final String ED25519 = "Ed25519"; private final JwtConfig jwtConfig; @@ -90,6 +93,18 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe .compact(); } + @Override + public String generateServiceToken() { + return Jwts.builder() + .header().keyId(jwtConfig.getKid()).and() + .subject("tbp-auth") + .claim(TYPE_CLAIM, SERVICE_TOKEN_TYPE) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + SERVICE_TOKEN_EXPIRATION_MS)) + .signWith(privateKey, Jwts.SIG.EdDSA) + .compact(); + } + @Override public List> publicJwks() { return List.of(Jwks.builder() diff --git a/src/main/java/auth_service/modules/account/api/AccountController.java b/src/main/java/auth_service/modules/account/api/AccountController.java new file mode 100644 index 0000000..7e32334 --- /dev/null +++ b/src/main/java/auth_service/modules/account/api/AccountController.java @@ -0,0 +1,31 @@ +package auth_service.modules.account.api; + +import auth_service.modules.account.api.dto.request.DeleteAccountRequest; +import auth_service.modules.account.service.AccountService; +import auth_service.shared.api.AuthCookies; +import auth_service.shared.security.UserPrincipal; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class AccountController { + + private final AccountService accountService; + private final AuthCookies authCookies; + + @DeleteMapping(value = "/api/users/me", version = "1.0") + public ResponseEntity deleteAccount(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody DeleteAccountRequest request) { + accountService.deleteAccount(principal.getIdAsLong(), request.password()); + HttpHeaders headers = new HttpHeaders(); + authCookies.cleared().forEach(cookie -> headers.add(HttpHeaders.SET_COOKIE, cookie.toString())); + return ResponseEntity.noContent().headers(headers).build(); + } +} diff --git a/src/main/java/auth_service/modules/account/api/AccountExceptionController.java b/src/main/java/auth_service/modules/account/api/AccountExceptionController.java new file mode 100644 index 0000000..d2cf347 --- /dev/null +++ b/src/main/java/auth_service/modules/account/api/AccountExceptionController.java @@ -0,0 +1,32 @@ +package auth_service.modules.account.api; + +import auth_service.modules.account.exception.InvalidPasswordException; +import auth_service.modules.account.exception.TooManyDeleteAttemptsException; +import auth_service.shared.api.dto.response.ErrorResponse; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = AccountController.class) +@Order(value = 1) +public class AccountExceptionController { + + // 403, not 401: the frontend answers a 401 with a refresh and sends the request again. + @ExceptionHandler(InvalidPasswordException.class) + public ResponseEntity clientError(InvalidPasswordException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).contentType(MediaType.APPLICATION_JSON) + .body(new ErrorResponse("INVALID_PASSWORD", ex.getMessage())); + } + + @ExceptionHandler(TooManyDeleteAttemptsException.class) + public ResponseEntity clientError(TooManyDeleteAttemptsException ex) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfterSeconds())) + .contentType(MediaType.APPLICATION_JSON) + .body(new ErrorResponse("TOO_MANY_ATTEMPTS", ex.getMessage())); + } +} diff --git a/src/main/java/auth_service/modules/account/api/dto/request/DeleteAccountRequest.java b/src/main/java/auth_service/modules/account/api/dto/request/DeleteAccountRequest.java new file mode 100644 index 0000000..829a480 --- /dev/null +++ b/src/main/java/auth_service/modules/account/api/dto/request/DeleteAccountRequest.java @@ -0,0 +1,6 @@ +package auth_service.modules.account.api.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record DeleteAccountRequest(@NotBlank String password) { +} diff --git a/src/main/java/auth_service/modules/account/exception/InvalidPasswordException.java b/src/main/java/auth_service/modules/account/exception/InvalidPasswordException.java new file mode 100644 index 0000000..0a42a94 --- /dev/null +++ b/src/main/java/auth_service/modules/account/exception/InvalidPasswordException.java @@ -0,0 +1,7 @@ +package auth_service.modules.account.exception; + +public class InvalidPasswordException extends RuntimeException { + public InvalidPasswordException() { + super("The password is wrong"); + } +} diff --git a/src/main/java/auth_service/modules/account/exception/TooManyDeleteAttemptsException.java b/src/main/java/auth_service/modules/account/exception/TooManyDeleteAttemptsException.java new file mode 100644 index 0000000..0f3a300 --- /dev/null +++ b/src/main/java/auth_service/modules/account/exception/TooManyDeleteAttemptsException.java @@ -0,0 +1,13 @@ +package auth_service.modules.account.exception; + +import lombok.Getter; + +@Getter +public class TooManyDeleteAttemptsException extends RuntimeException { + private final long retryAfterSeconds; + + public TooManyDeleteAttemptsException(long retryAfterSeconds) { + super("Too many wrong passwords. Try again later."); + this.retryAfterSeconds = retryAfterSeconds; + } +} diff --git a/src/main/java/auth_service/modules/account/model/CoreCleanupResult.java b/src/main/java/auth_service/modules/account/model/CoreCleanupResult.java new file mode 100644 index 0000000..7e72646 --- /dev/null +++ b/src/main/java/auth_service/modules/account/model/CoreCleanupResult.java @@ -0,0 +1,14 @@ +package auth_service.modules.account.model; + +import java.util.List; +import java.util.Map; + +/** + * Core's answer to a purge or prune. + * + * @param users the users Core touched, or would touch in a dry run + * @param rows rows per table Core deleted, or would delete + * @param refused true when Core found more orphans than it allows and did nothing + */ +public record CoreCleanupResult(boolean dryRun, List users, Map rows, boolean refused) { +} diff --git a/src/main/java/auth_service/modules/account/repo/AccountRepository.java b/src/main/java/auth_service/modules/account/repo/AccountRepository.java new file mode 100644 index 0000000..1ea82ec --- /dev/null +++ b/src/main/java/auth_service/modules/account/repo/AccountRepository.java @@ -0,0 +1,75 @@ +package auth_service.modules.account.repo; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * The deleted flag on users. Plain SQL, because the user and login modules own the entities. + */ +@Repository +public class AccountRepository { + + private final JdbcTemplate jdbc; + private final NamedParameterJdbcTemplate namedJdbc; + + public AccountRepository(JdbcTemplate jdbc, NamedParameterJdbcTemplate namedJdbc) { + this.jdbc = jdbc; + this.namedJdbc = namedJdbc; + } + + public Optional findPasswordHashOfActive(long userId) { + return jdbc.queryForList("SELECT password FROM users WHERE id = ? AND deleted_at IS NULL", String.class, userId) + .stream().findFirst(); + } + + /** + * Sets the flag, frees the Steam ID for a new account and ends every login of the user. A user flagged before + * keeps the first time. + * + * @return 1 when the user was flagged now + */ + @Transactional + public int flagDeleted(long userId, Instant now) { + Timestamp at = Timestamp.from(now); + int flagged = jdbc.update(""" + UPDATE users SET deleted_at = ?, steam_id = NULL, steam_id_verified = FALSE + WHERE id = ? AND deleted_at IS NULL""", at, userId); + jdbc.update("UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", at, userId); + return flagged; + } + + public List findFlaggedIds() { + return jdbc.queryForList("SELECT id FROM users WHERE deleted_at IS NOT NULL ORDER BY id", Long.class); + } + + public List findFlaggedBefore(Instant cutoff) { + return jdbc.queryForList("SELECT id FROM users WHERE deleted_at < ? ORDER BY id", Long.class, + Timestamp.from(cutoff)); + } + + /** + * Also the flagged users: their rows in Core are no orphans yet. + */ + public List findAllIds() { + return jdbc.queryForList("SELECT id FROM users ORDER BY id", Long.class); + } + + /** + * Settings and refresh tokens go by their ON DELETE CASCADE. + */ + public int deleteUsers(Collection userIds) { + if (userIds.isEmpty()) { + return 0; + } + return namedJdbc.update("DELETE FROM users WHERE id IN (:ids)", new MapSqlParameterSource("ids", userIds)); + } +} diff --git a/src/main/java/auth_service/modules/account/service/AccountCleanupService.java b/src/main/java/auth_service/modules/account/service/AccountCleanupService.java new file mode 100644 index 0000000..7025dc7 --- /dev/null +++ b/src/main/java/auth_service/modules/account/service/AccountCleanupService.java @@ -0,0 +1,83 @@ +package auth_service.modules.account.service; + +import auth_service.modules.account.model.CoreCleanupResult; +import auth_service.modules.account.repo.AccountRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +/** + * Nightly, after Core's sync: tells Core who is flagged, removes users flagged longer than the retention (Core + * first, then Auth), and lets Core remove rows of users Auth does not know. + */ +@Service +@Slf4j +public class AccountCleanupService { + + private final AccountRepository accountRepository; + private final CoreAccountService coreAccountService; + private final boolean dryRun; + private final Duration retention; + + public AccountCleanupService(AccountRepository accountRepository, CoreAccountService coreAccountService, + @Value("${app.account-cleanup.dry-run}") boolean dryRun, + @Value("${app.account-cleanup.retention}") Duration retention) { + this.accountRepository = accountRepository; + this.coreAccountService = coreAccountService; + this.dryRun = dryRun; + this.retention = retention; + } + + @Scheduled(cron = "${app.account-cleanup.cron}", zone = "Europe/Berlin") + public void runNightly() { + try { + coreAccountService.replaceDeleted(accountRepository.findFlaggedIds()); + } catch (RuntimeException e) { + log.warn("Account cleanup stopped: Core did not take the flagged users: {}", e.getMessage()); + return; + } + purgeDue(); + pruneOrphans(); + } + + private void purgeDue() { + List due = accountRepository.findFlaggedBefore(Instant.now().minus(retention)); + if (due.isEmpty()) { + return; + } + try { + CoreCleanupResult result = coreAccountService.purge(due, dryRun); + log.info("Account cleanup: Core {} users {}, rows {}", result.dryRun() ? "would delete" : "deleted", + result.users(), result.rows()); + if (dryRun || result.dryRun()) { + log.info("Account cleanup (dry run): would delete users {} in Auth", due); + return; + } + // Core skips a user without a tombstone; that user stays flagged and goes next night. + List purged = due.stream().filter(result.users()::contains).toList(); + accountRepository.deleteUsers(purged); + log.info("Account cleanup: deleted users {} in Auth", purged); + } catch (RuntimeException e) { + log.warn("Account cleanup: purge of users {} failed, tried again next night: {}", due, e.getMessage()); + } + } + + private void pruneOrphans() { + try { + CoreCleanupResult result = coreAccountService.prune(accountRepository.findAllIds(), dryRun); + if (result.refused()) { + log.warn("Account cleanup: Core refused the prune, too many orphans: {}", result.users()); + } else if (!result.users().isEmpty()) { + log.info("Account cleanup: Core {} orphans {}, rows {}", result.dryRun() ? "would delete" : "deleted", + result.users(), result.rows()); + } + } catch (RuntimeException e) { + log.warn("Account cleanup: prune failed: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/auth_service/modules/account/service/AccountService.java b/src/main/java/auth_service/modules/account/service/AccountService.java new file mode 100644 index 0000000..e30e9ec --- /dev/null +++ b/src/main/java/auth_service/modules/account/service/AccountService.java @@ -0,0 +1,65 @@ +package auth_service.modules.account.service; + +import auth_service.modules.account.exception.InvalidPasswordException; +import auth_service.modules.account.exception.TooManyDeleteAttemptsException; +import auth_service.modules.account.repo.AccountRepository; +import auth_service.shared.security.AttemptLimiter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Service +@Slf4j +public class AccountService { + + private static final Duration LOCK = Duration.ofMinutes(15); + private static final int MAX_WRONG_PASSWORDS = 5; + + private final AccountRepository accountRepository; + private final PasswordEncoder passwordEncoder; + private final CoreAccountService coreAccountService; + private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK); + + public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder, + CoreAccountService coreAccountService) { + this.accountRepository = accountRepository; + this.passwordEncoder = passwordEncoder; + this.coreAccountService = coreAccountService; + } + + /** + * Flags the user as deleted and ends all logins; the nightly cleanup removes the user 30 days later. Core + * hides the user at once; when Core is down, the nightly cleanup tells it. + */ + @CacheEvict(value = {"usersByEmail", "publicStats"}, allEntries = true) + public void deleteAccount(long userId, String password) { + String key = String.valueOf(userId); + if (!attemptLimiter.tryAcquire(key, MAX_WRONG_PASSWORDS)) { + throw new TooManyDeleteAttemptsException(LOCK.toSeconds()); + } + Optional hash = accountRepository.findPasswordHashOfActive(userId); + if (hash.isEmpty()) { + // Already flagged, e.g. a second click: the answer is the same. + attemptLimiter.release(key); + return; + } + if (!passwordEncoder.matches(password, hash.get())) { + throw new InvalidPasswordException(); + } + attemptLimiter.reset(key); + accountRepository.flagDeleted(userId, Instant.now()); + log.info("Account of user {} flagged as deleted", userId); + try { + coreAccountService.markDeleted(List.of(userId)); + } catch (RuntimeException e) { + log.warn("Core did not take the delete of user {}, the nightly cleanup sends it again: {}", userId, + e.getMessage()); + } + } +} diff --git a/src/main/java/auth_service/modules/account/service/CoreAccountService.java b/src/main/java/auth_service/modules/account/service/CoreAccountService.java new file mode 100644 index 0000000..7ff1d03 --- /dev/null +++ b/src/main/java/auth_service/modules/account/service/CoreAccountService.java @@ -0,0 +1,75 @@ +package auth_service.modules.account.service; + +import auth_service.modules.account.model.CoreCleanupResult; +import auth_service.shared.security.ServiceTokenIssuer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * Core's /internal/accounts endpoints, called with a service token. Every error throws. + */ +@Service +public class CoreAccountService { + + private final RestClient restClient; + private final ServiceTokenIssuer serviceTokenIssuer; + + @Autowired + public CoreAccountService(@Value("${app.core.internal-url}") String coreUrl, ServiceTokenIssuer serviceTokenIssuer) { + this(RestClient.builder().baseUrl(coreUrl).requestFactory(requestFactory()).build(), serviceTokenIssuer); + } + + CoreAccountService(RestClient restClient, ServiceTokenIssuer serviceTokenIssuer) { + this.restClient = restClient; + this.serviceTokenIssuer = serviceTokenIssuer; + } + + private static SimpleClientHttpRequestFactory requestFactory() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(3)); + factory.setReadTimeout(Duration.ofSeconds(10)); + return factory; + } + + public void markDeleted(List userIds) { + restClient.post().uri("/internal/accounts/deleted").headers(this::authorize) + .body(Map.of("userIds", userIds)).retrieve().toBodilessEntity(); + } + + /** + * After this, Core hides exactly these users. + */ + public void replaceDeleted(List userIds) { + restClient.put().uri("/internal/accounts/deleted").headers(this::authorize) + .body(Map.of("userIds", userIds)).retrieve().toBodilessEntity(); + } + + /** + * @param dryRun Core only counts; Core also counts when its own dry run is on + */ + public CoreCleanupResult purge(List userIds, boolean dryRun) { + return restClient.post().uri("/internal/accounts/purge").headers(this::authorize) + .body(Map.of("userIds", userIds, "dryRun", dryRun)).retrieve().body(CoreCleanupResult.class); + } + + /** + * @param knownUserIds every user of Auth, also the flagged ones + * @param dryRun see purge + */ + public CoreCleanupResult prune(List knownUserIds, boolean dryRun) { + return restClient.post().uri("/internal/accounts/prune").headers(this::authorize) + .body(Map.of("knownUserIds", knownUserIds, "dryRun", dryRun)).retrieve().body(CoreCleanupResult.class); + } + + private void authorize(HttpHeaders headers) { + headers.setBearerAuth(serviceTokenIssuer.generateServiceToken()); + } +} diff --git a/src/main/java/auth_service/modules/login/model/User.java b/src/main/java/auth_service/modules/login/model/User.java index dc4e245..699e2fc 100644 --- a/src/main/java/auth_service/modules/login/model/User.java +++ b/src/main/java/auth_service/modules/login/model/User.java @@ -11,4 +11,9 @@ public class User { private String password; private Long steamId; private boolean enabled; + private boolean deleted; + + public boolean canLogin() { + return enabled && !deleted; + } } diff --git a/src/main/java/auth_service/modules/login/persistence/entities/MinimalUserEntity.java b/src/main/java/auth_service/modules/login/persistence/entities/MinimalUserEntity.java index 75dd2e4..42c57b9 100644 --- a/src/main/java/auth_service/modules/login/persistence/entities/MinimalUserEntity.java +++ b/src/main/java/auth_service/modules/login/persistence/entities/MinimalUserEntity.java @@ -3,6 +3,7 @@ package auth_service.modules.login.persistence.entities; import jakarta.persistence.*; import lombok.*; +import java.time.Instant; import java.time.LocalDateTime; /** @@ -35,4 +36,7 @@ public class MinimalUserEntity { @Column(name = "steam_id") private Long steamId; + + @Column(name = "deleted_at", insertable = false, updatable = false) + private Instant deletedAt; } diff --git a/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java index 64a72f3..89eed38 100644 --- a/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/login/persistence/mapper/EntityMapper.java @@ -19,6 +19,7 @@ public abstract class EntityMapper { model.setPassword(entity.getPassword()); model.setSteamId(entity.getSteamId()); model.setEnabled(entity.isEnabled()); + model.setDeleted(entity.getDeletedAt() != null); } return model; diff --git a/src/main/java/auth_service/modules/login/service/LoginService.java b/src/main/java/auth_service/modules/login/service/LoginService.java index 4586eb7..d4cafc8 100644 --- a/src/main/java/auth_service/modules/login/service/LoginService.java +++ b/src/main/java/auth_service/modules/login/service/LoginService.java @@ -50,7 +50,7 @@ public class LoginService { String hash = foundUser.map(User::getPassword).orElse(dummyHash); boolean passwordMatches = passwordEncoder.matches(userToLogin.getPassword(), hash); - if (foundUser.isEmpty() || !passwordMatches || !foundUser.get().isEnabled()) { + if (foundUser.isEmpty() || !passwordMatches || !foundUser.get().canLogin()) { throw new InvalidCredentialsException("Invalid email or password"); } diff --git a/src/main/java/auth_service/modules/login/service/RefreshTokenService.java b/src/main/java/auth_service/modules/login/service/RefreshTokenService.java index 643893e..7b462df 100644 --- a/src/main/java/auth_service/modules/login/service/RefreshTokenService.java +++ b/src/main/java/auth_service/modules/login/service/RefreshTokenService.java @@ -46,8 +46,8 @@ public class RefreshTokenService { } /** - * Empty when the token cannot renew the session: missing, unknown, expired, revoked, or its user is gone or - * disabled. + * Empty when the token cannot renew the session: missing, unknown, expired, revoked, or its user is gone, + * disabled or deleted. * A token that was rotated before means a second copy exists, so the whole family is revoked. This method * returns instead of throwing, so that revoke is committed. */ @@ -70,7 +70,7 @@ public class RefreshTokenService { refreshTokenRepository.revokeFamily(token.familyId(), now); return Optional.empty(); } - Optional user = userRepository.findById(token.userId()).filter(User::isEnabled); + Optional user = userRepository.findById(token.userId()).filter(User::canLogin); if (user.isEmpty()) { refreshTokenRepository.revokeFamily(token.familyId(), now); return Optional.empty(); diff --git a/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java b/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java index cd7a958..7fa2f35 100644 --- a/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java +++ b/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java @@ -17,13 +17,13 @@ public class PublicProfileRepository { } /** - * Only enabled users who switched the public profile on. The name ignores case, like its unique index. + * Only enabled, not deleted users who switched the public profile on. The name ignores case, like its unique index. */ public Optional findPublicByDisplayName(String displayName) { return jdbc.query(""" SELECT u.id, u.display_name, u.registration_date, s.profile_stats FROM users u JOIN user_settings s ON s.user_id = u.id - WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND s.public_profile + WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND u.deleted_at IS NULL AND s.public_profile """, (rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"), rs.getTimestamp("registration_date") != null diff --git a/src/main/java/auth_service/modules/stats/repo/StatsRepository.java b/src/main/java/auth_service/modules/stats/repo/StatsRepository.java index 3565e23..cd452b4 100644 --- a/src/main/java/auth_service/modules/stats/repo/StatsRepository.java +++ b/src/main/java/auth_service/modules/stats/repo/StatsRepository.java @@ -15,13 +15,13 @@ public class StatsRepository { } public long countTotalUsers() { - Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE enabled = TRUE", Long.class); + Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE enabled = TRUE AND deleted_at IS NULL", Long.class); return v != null ? v : 0L; } public long countUsersWithSteamLinked() { Long v = jdbc.queryForObject( - "SELECT COUNT(*) FROM users WHERE enabled = TRUE AND steam_id IS NOT NULL", Long.class); + "SELECT COUNT(*) FROM users WHERE enabled = TRUE AND deleted_at IS NULL AND steam_id IS NOT NULL", Long.class); return v != null ? v : 0L; } } diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java index bedaf15..cc42270 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java @@ -148,7 +148,8 @@ public class UserRepositoryImpl implements UserRepository { @Override public void setVerifiedSteamId(long userId, long steamId) { int updated = entityManager.createQuery( - "UPDATE UserEntity u SET u.steamId = :steamId, u.steamIdVerified = TRUE WHERE u.id = :userId") + "UPDATE UserEntity u SET u.steamId = :steamId, u.steamIdVerified = TRUE " + + "WHERE u.id = :userId AND u.deletedAt IS NULL") .setParameter("steamId", steamId) .setParameter("userId", userId) .executeUpdate(); diff --git a/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java b/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java index f692ea7..151e869 100644 --- a/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java +++ b/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java @@ -3,6 +3,7 @@ package auth_service.modules.user.persistence.entities; import jakarta.persistence.*; import lombok.*; +import java.time.Instant; import java.time.LocalDateTime; /** @@ -36,6 +37,10 @@ public class UserEntity { @Column(name = "steam_id_verified", nullable = false) private boolean steamIdVerified; + // Read only: a settings save that loaded the user before a delete must not write the old NULL back. + @Column(name = "deleted_at", insertable = false, updatable = false) + private Instant deletedAt; + @Column(name = "display_name", length = 24) private String displayName; diff --git a/src/main/java/auth_service/shared/security/ServiceTokenIssuer.java b/src/main/java/auth_service/shared/security/ServiceTokenIssuer.java new file mode 100644 index 0000000..3565665 --- /dev/null +++ b/src/main/java/auth_service/shared/security/ServiceTokenIssuer.java @@ -0,0 +1,9 @@ +package auth_service.shared.security; + +/** + * Port for the short-lived token Auth sends with its calls to Core's /internal endpoints. Signed like a login token + * but no login: the login filters of Auth and Core reject it. + */ +public interface ServiceTokenIssuer { + String generateServiceToken(); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e2412bd..dcf0d6e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -59,6 +59,15 @@ app: public-url: ${APP_PUBLIC_URL:http://localhost:8080} # Where the user lands after the Steam link. frontend-url: ${APP_FRONTEND_URL:http://localhost:5173} + core: + # Core inside the Docker network, for the account cleanup calls. + internal-url: ${APP_CORE_INTERNAL_URL:http://localhost:8090} + account-cleanup: + cron: "0 15 4 * * *" + # How long a deleted account waits before the cleanup removes it for good. + retention: 30d + # Log only, delete nothing. See the Deployment README before flipping it. + dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true} # Registration settings auth: diff --git a/src/main/resources/db/migration/V13__Deleted_At.sql b/src/main/resources/db/migration/V13__Deleted_At.sql new file mode 100644 index 0000000..9c5df68 --- /dev/null +++ b/src/main/resources/db/migration/V13__Deleted_At.sql @@ -0,0 +1,5 @@ +-- "Delete my account" only sets this; the nightly cleanup removes the user 30 days later. Undo: set it back to NULL. +ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ; + +-- The delete revokes all refresh tokens of the user. +CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id); diff --git a/src/test/java/auth_service/configuration/security/JwtServiceTest.java b/src/test/java/auth_service/configuration/security/JwtServiceTest.java index 29228ee..46083a9 100644 --- a/src/test/java/auth_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/auth_service/configuration/security/JwtServiceTest.java @@ -159,4 +159,17 @@ class JwtServiceTest extends AbstractUnitTest { String token = jwtService.generateToken("7", "a@b.de", null); assertEquals("7", Jwts.parser().verifyWith(fromJwk).build().parseSignedClaims(token).getPayload().get("userId")); } + + @Test + void generateServiceToken_IsShortLivedAndNoLogin() { + String token = jwtService.generateServiceToken(); + + var parsed = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token); + assertEquals(KID, parsed.getHeader().getKeyId()); + assertEquals("service", parsed.getPayload().get("typ", String.class)); + assertNull(parsed.getPayload().get("userId")); + long secondsLeft = (parsed.getPayload().getExpiration().getTime() - System.currentTimeMillis()) / 1000; + assertTrue(secondsLeft > 0 && secondsLeft <= 60, "expires in " + secondsLeft + "s"); + assertEquals(Optional.empty(), jwtService.parseLoginToken(token)); + } } diff --git a/src/test/java/auth_service/modules/account/api/DeletedAccountFlowTest.java b/src/test/java/auth_service/modules/account/api/DeletedAccountFlowTest.java new file mode 100644 index 0000000..3cf0509 --- /dev/null +++ b/src/test/java/auth_service/modules/account/api/DeletedAccountFlowTest.java @@ -0,0 +1,167 @@ +package auth_service.modules.account.api; + +import auth_service.configuration.security.ClientHeaderFilter; +import auth_service.modules.user.model.SteamLinkOutcome; +import auth_service.modules.user.service.UserService; +import auth_service.shared.api.AuthCookies; +import auth_service.shared.test.AbstractIntegrationTest; +import com.jayway.jsonpath.JsonPath; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Delete my account against a real database. Core is not reachable in tests, so this also proves that a failed + * mark in Core does not fail the delete. + */ +@AutoConfigureMockMvc +class DeletedAccountFlowTest extends AbstractIntegrationTest { + + private static final String EMAIL = "delete-flow@example.com"; + private static final String PASSWORD = "correct horse battery"; + private static final String NAME = "DeleteFlow"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private UserService userService; + + @Test + void steamIdOfAFlaggedUser_CanBeLinkedByANewAccount() throws Exception { + long steamId = 76561198099990001L; + String newEmail = "delete-flow-new@example.com"; + jdbc.update("DELETE FROM users WHERE email = ?", newEmail); + jdbc.update("UPDATE users SET steam_id = ?, steam_id_verified = true WHERE email = ?", steamId, EMAIL); + String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue(); + deleteAccount(access, PASSWORD).andExpect(status().isNoContent()); + Long newUser = jdbc.queryForObject("INSERT INTO users (email, password, enabled) VALUES (?, 'x', true) RETURNING id", + Long.class, newEmail); + + try { + assertThat(userService.linkVerifiedSteamId(newUser, steamId)).isEqualTo(SteamLinkOutcome.LINKED); + } finally { + jdbc.update("DELETE FROM users WHERE email IN (?, ?)", newEmail, EMAIL); + } + } + + @BeforeEach + void seedUser() { + jdbc.update("DELETE FROM users WHERE email = ?", EMAIL); + Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) VALUES (?, ?, true, ?) " + + "RETURNING id", Long.class, EMAIL, passwordEncoder.encode(PASSWORD), NAME); + jdbc.update("INSERT INTO user_settings (user_id, language, theme, public_profile) VALUES (?, 'de', 'dark', true)", id); + } + + private MockHttpServletResponse login() 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(); + } + + private ResultActions deleteAccount(String accessToken, String password) throws Exception { + return mockMvc.perform(delete("/api/users/me") + .header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, accessToken)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"" + password + "\"}")); + } + + private long totalUsers() throws Exception { + String body = mockMvc.perform(get("/api/stats/public")).andReturn().getResponse().getContentAsString(); + return JsonPath.read(body, "$.totalUsers").longValue(); + } + + @Test + void wrongPassword_Is403AndKeepsTheAccount() throws Exception { + String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue(); + + deleteAccount(access, "wrong").andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("INVALID_PASSWORD")); + + assertThat(jdbc.queryForObject("SELECT deleted_at FROM users WHERE email = ?", Object.class, EMAIL)).isNull(); + } + + @Test + void sixthWrongPassword_Is429WithRetryAfter() throws Exception { + String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue(); + for (int i = 0; i < 5; i++) { + deleteAccount(access, "wrong").andExpect(status().isForbidden()); + } + + deleteAccount(access, PASSWORD).andExpect(status().isTooManyRequests()) + .andExpect(header().string("Retry-After", "900")) + .andExpect(jsonPath("$.code").value("TOO_MANY_ATTEMPTS")); + } + + @Test + void withoutLogin_Is401() throws Exception { + mockMvc.perform(delete("/api/users/me").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .contentType(MediaType.APPLICATION_JSON).content("{\"password\":\"" + PASSWORD + "\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void blankPassword_Is400() throws Exception { + String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue(); + + deleteAccount(access, " ").andExpect(status().isBadRequest()); + } + + @Test + void delete_FlagsTheUserAndHidesItEverywhere() throws Exception { + MockHttpServletResponse session = login(); + String access = session.getCookie(AuthCookies.ACCESS_COOKIE).getValue(); + String refresh = session.getCookie(AuthCookies.REFRESH_COOKIE).getValue(); + long usersBefore = totalUsers(); + + MockHttpServletResponse deleted = deleteAccount(access, PASSWORD).andExpect(status().isNoContent()) + .andReturn().getResponse(); + + assertThat(deleted.getCookie(AuthCookies.ACCESS_COOKIE).getMaxAge()).isZero(); + assertThat(deleted.getCookie(AuthCookies.REFRESH_COOKIE).getMaxAge()).isZero(); + assertThat(jdbc.queryForObject("SELECT deleted_at FROM users WHERE email = ?", Object.class, EMAIL)).isNotNull(); + mockMvc.perform(post("/api/auth/refresh").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(new Cookie(AuthCookies.REFRESH_COOKIE, refresh))) + .andExpect(status().isUnauthorized()); + assertThat(login().getStatus()).isEqualTo(401); + mockMvc.perform(get("/api/public/profiles/" + NAME)).andExpect(status().isNotFound()); + assertThat(totalUsers()).isEqualTo(usersBefore - 1); + } + + @Test + void registration_WithTheEmailOfAFlaggedUser_IsStillRefused() throws Exception { + jdbc.update("UPDATE users SET deleted_at = NOW() WHERE email = ?", EMAIL); + + mockMvc.perform(post("/api/registration/new") + .header(ClientHeaderFilter.CLIENT_HEADER, "web") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + + "\",\"inviteCode\":\"test-invite-code\"}")) + .andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE")); + } +} diff --git a/src/test/java/auth_service/modules/account/repo/AccountRepositoryTest.java b/src/test/java/auth_service/modules/account/repo/AccountRepositoryTest.java new file mode 100644 index 0000000..4c7716b --- /dev/null +++ b/src/test/java/auth_service/modules/account/repo/AccountRepositoryTest.java @@ -0,0 +1,92 @@ +package auth_service.modules.account.repo; + +import auth_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(AccountRepository.class) +class AccountRepositoryTest extends AbstractPersistenceTest { + + private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS); + + @Autowired + private AccountRepository repository; + + @Autowired + private JdbcTemplate jdbc; + + private long alice; + private long bob; + + private long insertUser(String email) { + Long id = jdbc.queryForObject("insert into users (email, password, enabled) values (?, 'hash-' || ?, true) returning id", + Long.class, email, email); + jdbc.update("insert into user_settings (user_id, language, theme) values (?, 'de', 'dark')", id); + jdbc.update(""" + insert into refresh_tokens (user_id, token_hash, family_id, expires_at, created_at) + values (?, ?, ?, now() + interval '30 days', now())""", id, UUID.randomUUID().toString(), UUID.randomUUID()); + return id; + } + + private Long count(String sql, Object... args) { + return jdbc.queryForObject(sql, Long.class, args); + } + + @BeforeEach + void seed() { + alice = insertUser("alice@example.com"); + bob = insertUser("bob@example.com"); + } + + @Test + void flagDeleted_SetsTheFlagAndRevokesOnlyThatUsersTokens() { + assertThat(repository.flagDeleted(alice, NOW)).isEqualTo(1); + + assertThat(jdbc.queryForObject("select deleted_at from users where id = ?", Timestamp.class, alice).toInstant()) + .isEqualTo(NOW); + assertThat(count("select count(*) from refresh_tokens where user_id = ? and revoked_at is null", alice)).isZero(); + assertThat(count("select count(*) from refresh_tokens where user_id = ? and revoked_at is null", bob)).isEqualTo(1); + assertThat(repository.flagDeleted(alice, NOW.plusSeconds(60))).as("second flag keeps the first time").isZero(); + } + + @Test + void findPasswordHashOfActive_IsEmptyForAFlaggedUser() { + assertThat(repository.findPasswordHashOfActive(alice)).contains("hash-alice@example.com"); + + repository.flagDeleted(alice, NOW); + + assertThat(repository.findPasswordHashOfActive(alice)).isEmpty(); + } + + @Test + void findFlagged_ListsFlaggedUsersAndThoseDue() { + repository.flagDeleted(alice, NOW.minus(31, ChronoUnit.DAYS)); + repository.flagDeleted(bob, NOW.minus(29, ChronoUnit.DAYS)); + + assertThat(repository.findFlaggedIds()).containsExactlyInAnyOrder(alice, bob); + assertThat(repository.findFlaggedBefore(NOW.minus(30, ChronoUnit.DAYS))).containsExactly(alice); + assertThat(repository.findAllIds()).contains(alice, bob); + } + + @Test + void deleteUsers_RemovesTheUserWithSettingsAndTokens() { + assertThat(repository.deleteUsers(List.of(alice))).isEqualTo(1); + + assertThat(count("select count(*) from users where id = ?", alice)).isZero(); + assertThat(count("select count(*) from user_settings where user_id = ?", alice)).isZero(); + assertThat(count("select count(*) from refresh_tokens where user_id = ?", alice)).isZero(); + assertThat(count("select count(*) from users where id = ?", bob)).isEqualTo(1); + assertThat(repository.deleteUsers(List.of())).isZero(); + } +} diff --git a/src/test/java/auth_service/modules/account/service/AccountCleanupServiceTest.java b/src/test/java/auth_service/modules/account/service/AccountCleanupServiceTest.java new file mode 100644 index 0000000..a1e6731 --- /dev/null +++ b/src/test/java/auth_service/modules/account/service/AccountCleanupServiceTest.java @@ -0,0 +1,126 @@ +package auth_service.modules.account.service; + +import auth_service.modules.account.model.CoreCleanupResult; +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.Mock; +import org.springframework.web.client.RestClientException; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +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.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class AccountCleanupServiceTest extends AbstractUnitTest { + + private static final Duration RETENTION = Duration.ofDays(30); + + @Mock + private AccountRepository accountRepository; + + @Mock + private CoreAccountService coreAccountService; + + private AccountCleanupService service(boolean dryRun) { + return new AccountCleanupService(accountRepository, coreAccountService, dryRun, RETENTION); + } + + private static CoreCleanupResult core(boolean dryRun) { + return new CoreCleanupResult(dryRun, List.of(7L), Map.of("user_game", 3), false); + } + + @BeforeEach + void setUp() { + lenient().when(accountRepository.findFlaggedIds()).thenReturn(List.of(7L, 8L)); + lenient().when(accountRepository.findFlaggedBefore(any())).thenReturn(List.of(7L)); + lenient().when(accountRepository.findAllIds()).thenReturn(List.of(1L, 7L, 8L)); + } + + @Test + void runNightly_SendsTheFlaggedSetThenPurgesThenPrunes() { + when(coreAccountService.purge(eq(List.of(7L)), anyBoolean())).thenReturn(core(false)); + when(coreAccountService.prune(eq(List.of(1L, 7L, 8L)), anyBoolean())).thenReturn(core(false)); + + service(false).runNightly(); + + verify(coreAccountService).replaceDeleted(List.of(7L, 8L)); + verify(accountRepository).deleteUsers(List.of(7L)); + } + + @Test + void runNightly_InDryRunAsksCoreForADryRunAndDeletesNothingInAuth() { + when(coreAccountService.purge(eq(List.of(7L)), anyBoolean())).thenReturn(core(true)); + when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(true)); + + service(true).runNightly(); + + verify(coreAccountService).purge(List.of(7L), true); + verify(coreAccountService).prune(List.of(1L, 7L, 8L), true); + verify(accountRepository, never()).deleteUsers(any()); + } + + @Test + void runNightly_ADryRunInCoreKeepsTheUserInAuth() { + when(coreAccountService.purge(eq(List.of(7L)), anyBoolean())).thenReturn(core(true)); + when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(true)); + + service(false).runNightly(); + + verify(accountRepository, never()).deleteUsers(any()); + } + + @Test + void runNightly_AFailedPurgeKeepsTheUserInAuthButStillPrunes() { + when(coreAccountService.purge(eq(List.of(7L)), anyBoolean())).thenThrow(new RestClientException("Core down")); + when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(false)); + + service(false).runNightly(); + + verify(accountRepository, never()).deleteUsers(any()); + verify(coreAccountService).prune(List.of(1L, 7L, 8L), false); + } + + @Test + void runNightly_StopsWhenCoreCannotTakeTheFlaggedSet() { + doThrow(new RestClientException("Core down")).when(coreAccountService).replaceDeleted(any()); + + service(false).runNightly(); + + verify(coreAccountService).replaceDeleted(List.of(7L, 8L)); + verifyNoMoreInteractions(coreAccountService); + verify(accountRepository, never()).deleteUsers(any()); + } + + @Test + void runNightly_NothingDueSkipsThePurge() { + when(accountRepository.findFlaggedBefore(any())).thenReturn(List.of()); + when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(false)); + + service(false).runNightly(); + + verify(coreAccountService, never()).purge(any(), anyBoolean()); + verify(accountRepository, never()).deleteUsers(any()); + } + + @Test + void runNightly_KeepsAUserCoreSkipped() { + when(accountRepository.findFlaggedBefore(any())).thenReturn(List.of(7L, 8L)); + when(coreAccountService.purge(eq(List.of(7L, 8L)), anyBoolean())).thenReturn(core(false)); + when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(false)); + + service(false).runNightly(); + + verify(accountRepository).deleteUsers(List.of(7L)); + } +} diff --git a/src/test/java/auth_service/modules/account/service/AccountServiceTest.java b/src/test/java/auth_service/modules/account/service/AccountServiceTest.java new file mode 100644 index 0000000..025f443 --- /dev/null +++ b/src/test/java/auth_service/modules/account/service/AccountServiceTest.java @@ -0,0 +1,103 @@ +package auth_service.modules.account.service; + +import auth_service.modules.account.exception.InvalidPasswordException; +import auth_service.modules.account.exception.TooManyDeleteAttemptsException; +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.Mock; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class AccountServiceTest extends AbstractUnitTest { + + private static final long USER_ID = 42L; + + @Mock + private AccountRepository accountRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private CoreAccountService coreAccountService; + + private AccountService service; + + @BeforeEach + void setUp() { + service = new AccountService(accountRepository, passwordEncoder, coreAccountService); + lenient().when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash")); + lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true); + } + + @Test + void deleteAccount_FlagsTheUserAndTellsCore() { + service.deleteAccount(USER_ID, "right"); + + verify(accountRepository).flagDeleted(eq(USER_ID), any()); + verify(coreAccountService).markDeleted(List.of(USER_ID)); + } + + @Test + void deleteAccount_WrongPasswordFlagsNothing() { + assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class); + + verify(accountRepository, never()).flagDeleted(anyLong(), any()); + verifyNoInteractions(coreAccountService); + } + + @Test + void deleteAccount_SixthWrongPasswordIsRateLimited() { + for (int i = 0; i < 5; i++) { + assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class); + } + + assertThatThrownBy(() -> service.deleteAccount(USER_ID, "right")).isInstanceOf(TooManyDeleteAttemptsException.class); + verify(accountRepository, never()).flagDeleted(anyLong(), any()); + } + + @Test + void deleteAccount_SuccessResetsTheCounter() { + for (int i = 0; i < 4; i++) { + assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class); + } + service.deleteAccount(USER_ID, "right"); + + for (int i = 0; i < 5; i++) { + assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class); + } + } + + @Test + void deleteAccount_ACoreErrorDoesNotFailTheDelete() { + doThrow(new IllegalStateException("Core down")).when(coreAccountService).markDeleted(any()); + + assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "right")); + verify(accountRepository).flagDeleted(eq(USER_ID), any()); + } + + @Test + void deleteAccount_AlreadyFlaggedUserIsANoOp() { + when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.empty()); + + assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "anything")); + verify(accountRepository, never()).flagDeleted(anyLong(), any()); + verifyNoInteractions(coreAccountService); + } +} diff --git a/src/test/java/auth_service/modules/account/service/CoreAccountServiceTest.java b/src/test/java/auth_service/modules/account/service/CoreAccountServiceTest.java new file mode 100644 index 0000000..5ccab37 --- /dev/null +++ b/src/test/java/auth_service/modules/account/service/CoreAccountServiceTest.java @@ -0,0 +1,77 @@ +package auth_service.modules.account.service; + +import auth_service.modules.account.model.CoreCleanupResult; +import auth_service.shared.security.ServiceTokenIssuer; +import auth_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withNoContent; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class CoreAccountServiceTest extends AbstractUnitTest { + + private static final String CORE = "http://core:8090"; + private static final ServiceTokenIssuer TOKENS = () -> "service-token"; + + private MockRestServiceServer core; + private CoreAccountService service; + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder().baseUrl(CORE); + core = MockRestServiceServer.bindTo(builder).build(); + service = new CoreAccountService(builder.build(), TOKENS); + } + + @Test + void markAndReplace_SendTheUserIdsWithTheServiceToken() { + core.expect(requestTo(CORE + "/internal/accounts/deleted")).andExpect(method(HttpMethod.POST)) + .andExpect(header("Authorization", "Bearer service-token")) + .andExpect(content().json("{\"userIds\":[42]}")) + .andRespond(withNoContent()); + core.expect(requestTo(CORE + "/internal/accounts/deleted")).andExpect(method(HttpMethod.PUT)) + .andExpect(content().json("{\"userIds\":[]}")) + .andRespond(withNoContent()); + + service.markDeleted(List.of(42L)); + service.replaceDeleted(List.of()); + + core.verify(); + } + + @Test + void purgeAndPrune_ReadTheResult() { + String result = "{\"dryRun\":true,\"users\":[42],\"rows\":{\"user_game\":3},\"refused\":false}"; + core.expect(requestTo(CORE + "/internal/accounts/purge")).andExpect(content().json("{\"userIds\":[42],\"dryRun\":false}")) + .andRespond(withSuccess(result, MediaType.APPLICATION_JSON)); + core.expect(requestTo(CORE + "/internal/accounts/prune")).andExpect(content().json("{\"knownUserIds\":[1,42],\"dryRun\":true}")) + .andRespond(withSuccess(result, MediaType.APPLICATION_JSON)); + + CoreCleanupResult expected = new CoreCleanupResult(true, List.of(42L), Map.of("user_game", 3), false); + assertThat(service.purge(List.of(42L), false)).isEqualTo(expected); + assertThat(service.prune(List.of(1L, 42L), true)).isEqualTo(expected); + } + + @Test + void anErrorOfCoreThrows() { + core.expect(requestTo(CORE + "/internal/accounts/deleted")).andRespond(withServerError()); + + assertThatThrownBy(() -> service.markDeleted(List.of(42L))).isInstanceOf(RestClientException.class); + } +} diff --git a/src/test/java/auth_service/modules/login/service/LoginServiceTest.java b/src/test/java/auth_service/modules/login/service/LoginServiceTest.java index d2fec44..43ea3f0 100644 --- a/src/test/java/auth_service/modules/login/service/LoginServiceTest.java +++ b/src/test/java/auth_service/modules/login/service/LoginServiceTest.java @@ -17,6 +17,7 @@ import java.util.Optional; 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.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; @@ -165,4 +166,16 @@ class LoginServiceTest extends AbstractUnitTest { verifyNoInteractions(minimalUserRepository); verify(passwordEncoder, never()).matches(any(), eq("encodedPassword")); } + + @Test + void login_DeletedUser_IsRefusedLikeAWrongPassword() { + var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true) + .deleted(true).build(); + when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser)); + when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true); + + assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null)); + + verify(refreshTokenService, never()).startFamily(anyLong()); + } } diff --git a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java index 1d5f7be..c853281 100644 --- a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java +++ b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; import java.time.LocalDateTime; import java.util.List; @@ -26,6 +27,22 @@ class UserRepositoryTest extends AbstractPersistenceTest { @Autowired private TestEntityManager entityManager; + @Autowired + private JdbcTemplate jdbc; + + @Test + void updateSettings_KeepsADeleteThatCommittedAfterTheLoad() { + var user = entityManager.persistFlushFind(UserEntity.builder().email("race@example.com").password("password") + .enabled(true).registrationDate(LocalDateTime.now()).build()); + // The delete request commits after the settings request loaded the user. + jdbc.update("UPDATE users SET deleted_at = NOW() WHERE id = ?", user.getId()); + + userRepository.updateSettings(user.getId(), "RaceName", UserSettings.builder().language("de").theme("dark").build()); + entityManager.flush(); + + assertNotNull(jdbc.queryForObject("SELECT deleted_at FROM users WHERE id = ?", Object.class, user.getId())); + } + @Test void findByEmail_Success() { // Arrange @@ -141,6 +158,15 @@ class UserRepositoryTest extends AbstractPersistenceTest { assertThrows(UserNotFoundException.class, () -> userRepository.setVerifiedSteamId(999_999L, 76561198000000001L)); } + @Test + void setVerifiedSteamId_DeletedUser_IsRefused() { + var deleted = entityManager.persistFlushFind(UserEntity.builder().email("deleted@example.com").password("password") + .enabled(true).registrationDate(LocalDateTime.now()).build()); + jdbc.update("UPDATE users SET deleted_at = NOW() WHERE id = ?", deleted.getId()); + + assertThrows(UserNotFoundException.class, () -> userRepository.setVerifiedSteamId(deleted.getId(), 76561198000000001L)); + } + @Test void updateSettings_UnknownUser() { var settings = UserSettings.builder().language("en").theme("light").build(); diff --git a/src/test/java/auth_service/shared/test/AbstractIntegrationTest.java b/src/test/java/auth_service/shared/test/AbstractIntegrationTest.java index dcf78cb..bd7194d 100644 --- a/src/test/java/auth_service/shared/test/AbstractIntegrationTest.java +++ b/src/test/java/auth_service/shared/test/AbstractIntegrationTest.java @@ -18,7 +18,9 @@ import java.util.Base64; * Full Spring context with Testcontainers. */ @Tag("IntegrationTest") -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +// The account cleanup must not run by itself, and Core is never reachable in tests. +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = {"app.account-cleanup.cron=-", "app.core.internal-url=http://127.0.0.1:1"}) @ActiveProfiles("dev") public abstract class AbstractIntegrationTest {