feat(account): let users delete their account
Build, Test & Push Auth Service / quality-check (push) Successful in 32s
Build, Test & Push Auth Service / docker (push) Successful in 1m23s

DELETE /api/users/me with the password sets users.deleted_at, ends all logins and frees the Steam ID. A nightly job removes users flagged 30 days ago, Core first, and prunes Core orphans. Dry run by default.
This commit is contained in:
lobo committed 2026-09-27 11:46:22 +02:00
1 parent a1763addce
commit 7dc2fb613e
33 files changed
+1115 -11

No files matched your search

+30
View File
@@ -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 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: 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. 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 ## API
@@ -51,6 +57,7 @@ To Be Played is split into three repositories:
| `PUT` | `/api/users/settings` | access token | | `PUT` | `/api/users/settings` | access token |
| `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie | | `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/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 | | `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/appearance` | access token |
| `PUT` | `/api/users/settings/profile-stats` (`{groups}`: `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; `204`) | 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. `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. `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 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 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. 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. `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 ## Run locally
Needs Java 25 and Docker. Needs Java 25 and Docker.
@@ -2,6 +2,7 @@ package auth_service.configuration.security;
import auth_service.shared.security.ProfileTicketIssuer; import auth_service.shared.security.ProfileTicketIssuer;
import auth_service.shared.security.PublicKeyPublisher; import auth_service.shared.security.PublicKeyPublisher;
import auth_service.shared.security.ServiceTokenIssuer;
import auth_service.shared.security.SessionProperties; import auth_service.shared.security.SessionProperties;
import auth_service.shared.security.TokenGenerator; import auth_service.shared.security.TokenGenerator;
import auth_service.shared.security.UserPrincipal; 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. * Signing: EdDSA (Ed25519). Auth holds the private key and signs; Core holds only the public key and verifies.
*/ */
@Service @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; private static final long PROFILE_TICKET_EXPIRATION_MS = 10 * 60 * 1000;
// Only non-login tokens carry this claim; isTokenValid rejects them. // Only non-login tokens carry this claim; isTokenValid rejects them.
private static final String TYPE_CLAIM = "typ"; private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile"; 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 static final String ED25519 = "Ed25519";
private final JwtConfig jwtConfig; private final JwtConfig jwtConfig;
@@ -90,6 +93,18 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe
.compact(); .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 @Override
public List<Map<String, ?>> publicJwks() { public List<Map<String, ?>> publicJwks() {
return List.of(Jwks.builder() return List.of(Jwks.builder()
@@ -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<Void> 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();
}
}
@@ -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<ErrorResponse> clientError(InvalidPasswordException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse("INVALID_PASSWORD", ex.getMessage()));
}
@ExceptionHandler(TooManyDeleteAttemptsException.class)
public ResponseEntity<ErrorResponse> 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()));
}
}
@@ -0,0 +1,6 @@
package auth_service.modules.account.api.dto.request;
import jakarta.validation.constraints.NotBlank;
public record DeleteAccountRequest(@NotBlank String password) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.account.exception;
public class InvalidPasswordException extends RuntimeException {
public InvalidPasswordException() {
super("The password is wrong");
}
}
@@ -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;
}
}
@@ -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<Long> users, Map<String, Integer> rows, boolean refused) {
}
@@ -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<String> 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<Long> findFlaggedIds() {
return jdbc.queryForList("SELECT id FROM users WHERE deleted_at IS NOT NULL ORDER BY id", Long.class);
}
public List<Long> 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<Long> 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<Long> userIds) {
if (userIds.isEmpty()) {
return 0;
}
return namedJdbc.update("DELETE FROM users WHERE id IN (:ids)", new MapSqlParameterSource("ids", userIds));
}
}
@@ -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<Long> 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<Long> 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());
}
}
}
@@ -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<String> 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());
}
}
}
@@ -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<Long> 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<Long> 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<Long> 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<Long> 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());
}
}
@@ -11,4 +11,9 @@ public class User {
private String password; private String password;
private Long steamId; private Long steamId;
private boolean enabled; private boolean enabled;
private boolean deleted;
public boolean canLogin() {
return enabled && !deleted;
}
} }
@@ -3,6 +3,7 @@ package auth_service.modules.login.persistence.entities;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
@@ -35,4 +36,7 @@ public class MinimalUserEntity {
@Column(name = "steam_id") @Column(name = "steam_id")
private Long steamId; private Long steamId;
@Column(name = "deleted_at", insertable = false, updatable = false)
private Instant deletedAt;
} }
@@ -19,6 +19,7 @@ public abstract class EntityMapper {
model.setPassword(entity.getPassword()); model.setPassword(entity.getPassword());
model.setSteamId(entity.getSteamId()); model.setSteamId(entity.getSteamId());
model.setEnabled(entity.isEnabled()); model.setEnabled(entity.isEnabled());
model.setDeleted(entity.getDeletedAt() != null);
} }
return model; return model;
@@ -50,7 +50,7 @@ public class LoginService {
String hash = foundUser.map(User::getPassword).orElse(dummyHash); String hash = foundUser.map(User::getPassword).orElse(dummyHash);
boolean passwordMatches = passwordEncoder.matches(userToLogin.getPassword(), hash); 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"); throw new InvalidCredentialsException("Invalid email or password");
} }
@@ -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 * Empty when the token cannot renew the session: missing, unknown, expired, revoked, or its user is gone,
* disabled. * disabled or deleted.
* A token that was rotated before means a second copy exists, so the whole family is revoked. This method * 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. * returns instead of throwing, so that revoke is committed.
*/ */
@@ -70,7 +70,7 @@ public class RefreshTokenService {
refreshTokenRepository.revokeFamily(token.familyId(), now); refreshTokenRepository.revokeFamily(token.familyId(), now);
return Optional.empty(); return Optional.empty();
} }
Optional<User> user = userRepository.findById(token.userId()).filter(User::isEnabled); Optional<User> user = userRepository.findById(token.userId()).filter(User::canLogin);
if (user.isEmpty()) { if (user.isEmpty()) {
refreshTokenRepository.revokeFamily(token.familyId(), now); refreshTokenRepository.revokeFamily(token.familyId(), now);
return Optional.empty(); return Optional.empty();
@@ -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<PublicProfile> findPublicByDisplayName(String displayName) { public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
return jdbc.query(""" return jdbc.query("""
SELECT u.id, u.display_name, u.registration_date, s.profile_stats 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 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, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"),
rs.getTimestamp("registration_date") != null rs.getTimestamp("registration_date") != null
@@ -15,13 +15,13 @@ public class StatsRepository {
} }
public long countTotalUsers() { 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; return v != null ? v : 0L;
} }
public long countUsersWithSteamLinked() { public long countUsersWithSteamLinked() {
Long v = jdbc.queryForObject( 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; return v != null ? v : 0L;
} }
} }
@@ -148,7 +148,8 @@ public class UserRepositoryImpl implements UserRepository {
@Override @Override
public void setVerifiedSteamId(long userId, long steamId) { public void setVerifiedSteamId(long userId, long steamId) {
int updated = entityManager.createQuery( 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("steamId", steamId)
.setParameter("userId", userId) .setParameter("userId", userId)
.executeUpdate(); .executeUpdate();
@@ -3,6 +3,7 @@ package auth_service.modules.user.persistence.entities;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
@@ -36,6 +37,10 @@ public class UserEntity {
@Column(name = "steam_id_verified", nullable = false) @Column(name = "steam_id_verified", nullable = false)
private boolean steamIdVerified; 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) @Column(name = "display_name", length = 24)
private String displayName; private String displayName;
@@ -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();
}
+9
View File
@@ -59,6 +59,15 @@ app:
public-url: ${APP_PUBLIC_URL:http://localhost:8080} public-url: ${APP_PUBLIC_URL:http://localhost:8080}
# Where the user lands after the Steam link. # Where the user lands after the Steam link.
frontend-url: ${APP_FRONTEND_URL:http://localhost:5173} 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 # Registration settings
auth: auth:
@@ -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);
@@ -159,4 +159,17 @@ class JwtServiceTest extends AbstractUnitTest {
String token = jwtService.generateToken("7", "a@b.de", null); String token = jwtService.generateToken("7", "a@b.de", null);
assertEquals("7", Jwts.parser().verifyWith(fromJwk).build().parseSignedClaims(token).getPayload().get("userId")); 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));
}
} }
@@ -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.<Number>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"));
}
}
@@ -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();
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -17,6 +17,7 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
@@ -165,4 +166,16 @@ class LoginServiceTest extends AbstractUnitTest {
verifyNoInteractions(minimalUserRepository); verifyNoInteractions(minimalUserRepository);
verify(passwordEncoder, never()).matches(any(), eq("encodedPassword")); 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());
}
} }
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -26,6 +27,22 @@ class UserRepositoryTest extends AbstractPersistenceTest {
@Autowired @Autowired
private TestEntityManager entityManager; 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 @Test
void findByEmail_Success() { void findByEmail_Success() {
// Arrange // Arrange
@@ -141,6 +158,15 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertThrows(UserNotFoundException.class, () -> userRepository.setVerifiedSteamId(999_999L, 76561198000000001L)); 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 @Test
void updateSettings_UnknownUser() { void updateSettings_UnknownUser() {
var settings = UserSettings.builder().language("en").theme("light").build(); var settings = UserSettings.builder().language("en").theme("light").build();
@@ -18,7 +18,9 @@ import java.util.Base64;
* Full Spring context with Testcontainers. * Full Spring context with Testcontainers.
*/ */
@Tag("IntegrationTest") @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") @ActiveProfiles("dev")
public abstract class AbstractIntegrationTest { public abstract class AbstractIntegrationTest {