Public Access
feat(account): let users delete their account
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:
1 parent
a1763addce
commit
7dc2fb613e
33 files changed
+1115
-11
No files matched your search
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user