Public Access
refactor(api): one error body, token audiences, contract fixture
This commit is contained in:
1 parent
2360beeb81
commit
73cfcb58e2
24 files changed
+633
-129
No files matched your search
@@ -77,14 +77,20 @@ To Be Played is split into three repositories:
|
|||||||
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
|
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
|
||||||
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
|
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
|
||||||
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's
|
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's
|
||||||
`tbp_access` cookie or a `Bearer` header, whichever verifies first. Core reads `userId`, `steamId` and the active
|
`tbp_access` cookie or a `Bearer` header, whichever verifies first. Every token names its kind in `aud`
|
||||||
`role` (missing or unknown = USER) from the claims and grants that role and every lower one
|
(`tbp-login`, `tbp-public-profile`, `tbp-internal`, with `iss=tbp-auth`); a login's `sub` is the user id. Core reads
|
||||||
(`USER < AUDITOR < PLATFORM_ADMIN`).
|
`steamId` and the active `role` (missing or unknown = USER) from the claims and grants that role and every lower one
|
||||||
|
(`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` comes from an Auth from before item 26 (kind in `typ`,
|
||||||
|
user id in `userId`) and is still accepted; item 29 removes that path. The claims are pinned in
|
||||||
|
`src/test/resources/contract/auth-core-contract.json`, the same file as in Auth; the tests build tokens only from it.
|
||||||
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
|
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
|
||||||
frontend origin.
|
frontend origin.
|
||||||
A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is
|
A token for another audience (for example a profile ticket) is rejected on normal endpoints; the profile ticket is
|
||||||
accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header.
|
accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header.
|
||||||
`/internal/**` has its own filter chain: it accepts only a service token of Auth (`typ=service`, with `exp`, sent
|
Every error answer of a controller has the body `{code, message}` (a 401 or 403 of the security filters has none); a refused request is `400 VALIDATION_FAILED` with
|
||||||
|
`fields: [{field, message}]`, a duplicate key `409 CONFLICT`, anything unexpected `500 INTERNAL_ERROR` without details
|
||||||
|
(`shared/api/GlobalExceptionController`).
|
||||||
|
`/internal/**` has its own filter chain: it accepts only a service token of Auth (`aud=tbp-internal`, with `exp`, sent
|
||||||
as `Bearer`), never a login, also not in the dev profile. Traefik exposes the path, so the signed token is its
|
as `Bearer`), never a login, also not in the dev profile. Traefik exposes the path, so the signed token is its
|
||||||
only guard.
|
only guard.
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,6 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
private UserPrincipal dummyPrincipal() {
|
private UserPrincipal dummyPrincipal() {
|
||||||
log.trace("Dev mode: no JWT present, injecting dummy principal");
|
log.trace("Dev mode: no JWT present, injecting dummy principal");
|
||||||
return new UserPrincipal("1", "dev@example.com", 76561198000000001L);
|
return new UserPrincipal("1", 76561198000000001L);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,17 +10,26 @@ import io.jsonwebtoken.Jwts;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key.
|
* Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key.
|
||||||
|
* <p>
|
||||||
|
* A token names its kind in {@code aud}, and {@code sub} is the user id of a login. A token without {@code aud} comes
|
||||||
|
* from an Auth from before item 26: its kind is in {@code typ} and the user id in {@code userId}. Item 29 removes
|
||||||
|
* that path.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class JwtService implements ProfileTicketVerifier {
|
public class JwtService implements ProfileTicketVerifier {
|
||||||
|
|
||||||
// Only non-login tokens carry this claim; parseLoginToken rejects them.
|
static final String ISSUER = "tbp-auth";
|
||||||
|
static final String LOGIN_AUDIENCE = "tbp-login";
|
||||||
|
static final String PROFILE_TICKET_AUDIENCE = "tbp-public-profile";
|
||||||
|
static final String SERVICE_AUDIENCE = "tbp-internal";
|
||||||
|
// The kind of a token before item 26; item 29 removes it.
|
||||||
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 String SERVICE_TOKEN_TYPE = "service";
|
private static final String SERVICE_TOKEN_TYPE = "service";
|
||||||
@@ -36,6 +45,20 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
return parser.parseSignedClaims(token).getPayload();
|
return parser.parseSignedClaims(token).getPayload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean hasAudience(Claims claims) {
|
||||||
|
return claims.getAudience() != null && !claims.getAudience().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param legacyType the {@code typ} of this kind before item 26, null for a login
|
||||||
|
*/
|
||||||
|
private static boolean isKind(Claims claims, String audience, String legacyType) {
|
||||||
|
if (hasAudience(claims)) {
|
||||||
|
return claims.getAudience().contains(audience) && ISSUER.equals(claims.getIssuer());
|
||||||
|
}
|
||||||
|
return Objects.equals(legacyType, claims.get(TYPE_CLAIM, String.class));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The user of a login token with a valid signature. Empty for anything else, also for a profile ticket: it is
|
* The user of a login token with a valid signature. Empty for anything else, also for a profile ticket: it is
|
||||||
* signed with the same key, but is no login.
|
* signed with the same key, but is no login.
|
||||||
@@ -43,11 +66,15 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
public Optional<UserPrincipal> parseLoginToken(String token) {
|
public Optional<UserPrincipal> parseLoginToken(String token) {
|
||||||
try {
|
try {
|
||||||
Claims claims = claims(token);
|
Claims claims = claims(token);
|
||||||
String userId = claims.get("userId", String.class);
|
if (!isKind(claims, LOGIN_AUDIENCE, null)) {
|
||||||
if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
|
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class),
|
String userId = hasAudience(claims) ? claims.getSubject()
|
||||||
|
: claims.getSubject() == null ? null : claims.get("userId", String.class);
|
||||||
|
if (userId == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class),
|
||||||
Role.parse(claims.get(ROLE_CLAIM) instanceof String role ? role : null)));
|
Role.parse(claims.get(ROLE_CLAIM) instanceof String role ? role : null)));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
@@ -60,7 +87,7 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
public boolean isServiceToken(String token) {
|
public boolean isServiceToken(String token) {
|
||||||
try {
|
try {
|
||||||
Claims claims = claims(token);
|
Claims claims = claims(token);
|
||||||
return SERVICE_TOKEN_TYPE.equals(claims.get(TYPE_CLAIM, String.class)) && claims.getExpiration() != null;
|
return isKind(claims, SERVICE_AUDIENCE, SERVICE_TOKEN_TYPE) && claims.getExpiration() != null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -74,7 +101,7 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
|
public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
|
||||||
try {
|
try {
|
||||||
Claims claims = claims(ticket);
|
Claims claims = claims(ticket);
|
||||||
if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) {
|
if (!isKind(claims, PROFILE_TICKET_AUDIENCE, PROFILE_TICKET_TYPE)) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
long userId = Long.parseLong(claims.get("profileUserId", String.class));
|
long userId = Long.parseLong(claims.get("profileUserId", String.class));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package core_service.modules.library.api;
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.shared.api.ErrorResponse;
|
||||||
import core_service.modules.library.api.dto.ImportHistoryDto;
|
import core_service.modules.library.api.dto.ImportHistoryDto;
|
||||||
import core_service.modules.library.api.dto.ImportRequest;
|
import core_service.modules.library.api.dto.ImportRequest;
|
||||||
import core_service.modules.library.api.dto.ImportSaveRequest;
|
import core_service.modules.library.api.dto.ImportSaveRequest;
|
||||||
@@ -28,9 +29,9 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class GameImportController {
|
public class GameImportController {
|
||||||
|
|
||||||
private static final Map<String, String> NOT_FOUND = Map.of("code", "IMPORT_NOT_FOUND", "message", "No such import");
|
private static final ErrorResponse NOT_FOUND = new ErrorResponse("IMPORT_NOT_FOUND", "No such import");
|
||||||
private static final ResponseEntity<?> IMPORT_RUNNING = ResponseEntity.status(HttpStatus.CONFLICT)
|
private static final ResponseEntity<?> IMPORT_RUNNING = ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
.body(Map.of("code", "IMPORT_RUNNING", "message", "Another import is still being matched"));
|
.body(new ErrorResponse("IMPORT_RUNNING", "Another import is still being matched"));
|
||||||
|
|
||||||
private final GameImportService importService;
|
private final GameImportService importService;
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ public class GameImportController {
|
|||||||
.body(Map.of("id", created.id()));
|
.body(Map.of("id", created.id()));
|
||||||
case GameImportService.CreateResult.AlreadyMatching ignored -> IMPORT_RUNNING;
|
case GameImportService.CreateResult.AlreadyMatching ignored -> IMPORT_RUNNING;
|
||||||
case GameImportService.CreateResult.SearchUnavailable ignored -> ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
case GameImportService.CreateResult.SearchUnavailable ignored -> ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||||
.body(Map.of("code", "IGDB_OFF", "message", "Importing games needs the IGDB app"));
|
.body(new ErrorResponse("IGDB_OFF", "Importing games needs the IGDB app"));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ public class GameImportController {
|
|||||||
case DONE -> ResponseEntity.accepted().build();
|
case DONE -> ResponseEntity.accepted().build();
|
||||||
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
|
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
|
||||||
case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT)
|
case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
.body(Map.of("code", "IMPORT_NOT_READY", "message", "The import is still matching or saved already"));
|
.body(new ErrorResponse("IMPORT_NOT_READY", "The import is still matching or saved already"));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ public class GameImportController {
|
|||||||
case DONE -> ResponseEntity.accepted().build();
|
case DONE -> ResponseEntity.accepted().build();
|
||||||
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
|
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
|
||||||
case WRONG_STATUS -> ResponseEntity.status(HttpStatus.CONFLICT)
|
case WRONG_STATUS -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
.body(Map.of("code", "IMPORT_NOT_FAILED", "message", "Only a failed import can be tried again"));
|
.body(new ErrorResponse("IMPORT_NOT_FAILED", "Only a failed import can be tried again"));
|
||||||
case ALREADY_MATCHING -> IMPORT_RUNNING;
|
case ALREADY_MATCHING -> IMPORT_RUNNING;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@ public class GameImportController {
|
|||||||
case DONE -> ResponseEntity.noContent().build();
|
case DONE -> ResponseEntity.noContent().build();
|
||||||
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
|
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
|
||||||
case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT)
|
case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
.body(Map.of("code", "IMPORT_NOT_SAVED", "message", "Only a saved import can be undone"));
|
.body(new ErrorResponse("IMPORT_NOT_SAVED", "Only a saved import can be undone"));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package core_service.modules.library.api;
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.shared.api.ErrorResponse;
|
||||||
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
||||||
import core_service.modules.library.api.dto.ActivityDto;
|
import core_service.modules.library.api.dto.ActivityDto;
|
||||||
import core_service.modules.library.api.dto.BacklogEntryDto;
|
import core_service.modules.library.api.dto.BacklogEntryDto;
|
||||||
@@ -62,6 +63,7 @@ import java.util.Set;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class LibraryController {
|
public class LibraryController {
|
||||||
|
|
||||||
|
private static final ErrorResponse GAME_NOT_FOUND = new ErrorResponse("GAME_NOT_FOUND", "Game not in user library");
|
||||||
private static final Set<Integer> ALLOWED_SIZES = Set.of(20, 40, 80);
|
private static final Set<Integer> ALLOWED_SIZES = Set.of(20, 40, 80);
|
||||||
private static final int DEFAULT_SIZE = 20;
|
private static final int DEFAULT_SIZE = 20;
|
||||||
private static final int MIN_ACTIVITY_DAYS = 7;
|
private static final int MIN_ACTIVITY_DAYS = 7;
|
||||||
@@ -129,7 +131,7 @@ public class LibraryController {
|
|||||||
@PostMapping("/sync")
|
@PostMapping("/sync")
|
||||||
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
if (principal.steamId() == null) {
|
if (principal.steamId() == null) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("code", "NO_STEAM_ID", "message", "User has no Steam ID"));
|
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID"));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
syncService.forceSync(principal.getIdAsLong(), String.valueOf(principal.steamId()));
|
syncService.forceSync(principal.getIdAsLong(), String.valueOf(principal.steamId()));
|
||||||
@@ -152,7 +154,7 @@ public class LibraryController {
|
|||||||
return gameDetailService.getGameDetail(userId, steamId, appid)
|
return gameDetailService.getGameDetail(userId, steamId, appid)
|
||||||
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
|
.body(GAME_NOT_FOUND));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,9 +203,9 @@ public class LibraryController {
|
|||||||
case GameMergeService.Result.Merged merged -> ResponseEntity.ok(new MergedGameDto(merged.appid()));
|
case GameMergeService.Result.Merged merged -> ResponseEntity.ok(new MergedGameDto(merged.appid()));
|
||||||
case GameMergeService.Result.NotFound _ -> unknownGame();
|
case GameMergeService.Result.NotFound _ -> unknownGame();
|
||||||
case GameMergeService.Result.SameGame _ -> ResponseEntity.badRequest()
|
case GameMergeService.Result.SameGame _ -> ResponseEntity.badRequest()
|
||||||
.body(Map.of("code", "SAME_GAME", "message", "A game cannot be merged with itself"));
|
.body(new ErrorResponse("SAME_GAME", "A game cannot be merged with itself"));
|
||||||
case GameMergeService.Result.SteamGames _ -> ResponseEntity.badRequest()
|
case GameMergeService.Result.SteamGames _ -> ResponseEntity.badRequest()
|
||||||
.body(Map.of("code", "STEAM_GAMES", "message", "Two Steam games cannot be merged"));
|
.body(new ErrorResponse("STEAM_GAMES", "Two Steam games cannot be merged"));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,11 +214,11 @@ public class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ResponseEntity<?> unknownGame() {
|
private static ResponseEntity<?> unknownGame() {
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("code", "GAME_NOT_FOUND", "message", "Unknown game"));
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("GAME_NOT_FOUND", "Unknown game"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ResponseEntity<?> invalidCorrection(String message) {
|
private static ResponseEntity<?> invalidCorrection(String message) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("code", "INVALID_CORRECTION", "message", message));
|
return ResponseEntity.badRequest().body(new ErrorResponse("INVALID_CORRECTION", message));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/games/{appid}/backlog")
|
@PutMapping("/games/{appid}/backlog")
|
||||||
@@ -226,7 +228,7 @@ public class LibraryController {
|
|||||||
return backlogService.update(principal.getIdAsLong(), appid, entry)
|
return backlogService.update(principal.getIdAsLong(), appid, entry)
|
||||||
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
|
.body(GAME_NOT_FOUND));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/games/{appid}/hidden")
|
@PutMapping("/games/{appid}/hidden")
|
||||||
@@ -236,9 +238,9 @@ public class LibraryController {
|
|||||||
return switch (backlogService.setHidden(principal.getIdAsLong(), appid, update.hidden())) {
|
return switch (backlogService.setHidden(principal.getIdAsLong(), appid, update.hidden())) {
|
||||||
case DONE -> ResponseEntity.ok(update);
|
case DONE -> ResponseEntity.ok(update);
|
||||||
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"));
|
.body(GAME_NOT_FOUND);
|
||||||
case MANUAL_GAME -> ResponseEntity.status(HttpStatus.CONFLICT)
|
case MANUAL_GAME -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
.body(Map.of("code", "MANUAL_GAME", "message", "A game added by hand can be deleted, not hidden"));
|
.body(new ErrorResponse("MANUAL_GAME", "A game added by hand can be deleted, not hidden"));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +250,7 @@ public class LibraryController {
|
|||||||
@Valid @RequestBody FinishedHintDismissal dismissal) {
|
@Valid @RequestBody FinishedHintDismissal dismissal) {
|
||||||
if (!backlogService.dismissFinishedHint(principal.getIdAsLong(), appid, dismissal.dismissed())) {
|
if (!backlogService.dismissFinishedHint(principal.getIdAsLong(), appid, dismissal.dismissed())) {
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"));
|
.body(GAME_NOT_FOUND);
|
||||||
}
|
}
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
@@ -260,7 +262,7 @@ public class LibraryController {
|
|||||||
return backlogService.updateStatus(principal.getIdAsLong(), appid, update.status())
|
return backlogService.updateStatus(principal.getIdAsLong(), appid, update.status())
|
||||||
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
|
.body(GAME_NOT_FOUND));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/activity")
|
@GetMapping("/activity")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package core_service.modules.library.api;
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.shared.api.ErrorResponse;
|
||||||
import core_service.modules.library.api.dto.AddGameRequest;
|
import core_service.modules.library.api.dto.AddGameRequest;
|
||||||
import core_service.modules.library.api.dto.AddedGameDto;
|
import core_service.modules.library.api.dto.AddedGameDto;
|
||||||
import core_service.modules.library.api.dto.GameSearchDto;
|
import core_service.modules.library.api.dto.GameSearchDto;
|
||||||
@@ -23,7 +24,6 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Games added by hand from an IGDB search, next to the Steam library.
|
* Games added by hand from an IGDB search, next to the Steam library.
|
||||||
@@ -33,9 +33,8 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ManualGameController {
|
public class ManualGameController {
|
||||||
|
|
||||||
private static final Map<String, String> NOT_FOUND = Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library");
|
private static final ErrorResponse NOT_FOUND = new ErrorResponse("GAME_NOT_FOUND", "Game not in user library");
|
||||||
private static final Map<String, String> STEAM_GAME = Map.of("code", "STEAM_GAME",
|
private static final ErrorResponse STEAM_GAME = new ErrorResponse("STEAM_GAME", "Steam games come from the sync and cannot be changed here");
|
||||||
"message", "Steam games come from the sync and cannot be changed here");
|
|
||||||
|
|
||||||
private final ManualGameService manualGameService;
|
private final ManualGameService manualGameService;
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ public class ManualGameController {
|
|||||||
return manualGameService.search(principal.getIdAsLong(), query)
|
return manualGameService.search(principal.getIdAsLong(), query)
|
||||||
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||||
.body(Map.of("code", "IGDB_FAILED", "message", "IGDB did not answer")));
|
.body(new ErrorResponse("IGDB_FAILED", "IGDB did not answer")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/games")
|
@PostMapping("/games")
|
||||||
@@ -58,11 +57,11 @@ public class ManualGameController {
|
|||||||
.created(URI.create("/api/library/games/" + added.appid()))
|
.created(URI.create("/api/library/games/" + added.appid()))
|
||||||
.body(new AddedGameDto(added.appid()));
|
.body(new AddedGameDto(added.appid()));
|
||||||
case ManualGameService.AddResult.AlreadyInLibrary ignored -> ResponseEntity.status(HttpStatus.CONFLICT)
|
case ManualGameService.AddResult.AlreadyInLibrary ignored -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
.body(Map.of("code", "ALREADY_IN_LIBRARY", "message", "The game is already in the library"));
|
.body(new ErrorResponse("ALREADY_IN_LIBRARY", "The game is already in the library"));
|
||||||
case ManualGameService.AddResult.UnknownGame ignored -> ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
case ManualGameService.AddResult.UnknownGame ignored -> ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||||
.body(Map.of("code", "UNKNOWN_GAME", "message", "IGDB has no such game or did not answer"));
|
.body(new ErrorResponse("UNKNOWN_GAME", "IGDB has no such game or did not answer"));
|
||||||
case ManualGameService.AddResult.SearchUnavailable ignored -> ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
case ManualGameService.AddResult.SearchUnavailable ignored -> ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||||
.body(Map.of("code", "IGDB_OFF", "message", "Adding games needs the IGDB app"));
|
.body(new ErrorResponse("IGDB_OFF", "Adding games needs the IGDB app"));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package core_service.modules.library.api;
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.shared.api.ErrorResponse;
|
||||||
import core_service.modules.library.service.LibraryQueryService;
|
import core_service.modules.library.service.LibraryQueryService;
|
||||||
import core_service.shared.security.ProfileTicket;
|
import core_service.shared.security.ProfileTicket;
|
||||||
import core_service.shared.security.ProfileTicketVerifier;
|
import core_service.shared.security.ProfileTicketVerifier;
|
||||||
@@ -11,7 +12,6 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +31,6 @@ public class PublicProfileController {
|
|||||||
return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
|
return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
|
||||||
.<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats(), t.sections())))
|
.<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats(), t.sections())))
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
.body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket")));
|
.body(new ErrorResponse("INVALID_TICKET", "Missing, invalid or expired profile ticket")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package core_service.shared.api;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The body of every error answer.
|
||||||
|
*
|
||||||
|
* @param fields the refused request fields of a {@code VALIDATION_FAILED}, else left out
|
||||||
|
*/
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record ErrorResponse(String code, String message, List<FieldError> fields) {
|
||||||
|
|
||||||
|
public ErrorResponse(String code, String message) {
|
||||||
|
this(code, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record FieldError(String field, String message) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package core_service.shared.api;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.MethodParameter;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.HttpStatusCode;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.validation.method.ParameterValidationResult;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.context.request.WebRequest;
|
||||||
|
import org.springframework.web.method.annotation.HandlerMethodValidationException;
|
||||||
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives every error the {@link ErrorResponse} body. Spring's own exceptions keep their status; a 400 is
|
||||||
|
* {@code VALIDATION_FAILED}. A module's own exceptions belong in its own handler.
|
||||||
|
*/
|
||||||
|
@RestControllerAdvice
|
||||||
|
@Order(Ordered.LOWEST_PRECEDENCE)
|
||||||
|
@Slf4j
|
||||||
|
public class GlobalExceptionController extends ResponseEntityExceptionHandler {
|
||||||
|
|
||||||
|
static final String VALIDATION_FAILED = "VALIDATION_FAILED";
|
||||||
|
private static final String INTERNAL_ERROR = "INTERNAL_ERROR";
|
||||||
|
// Postgres unique_violation. The state decides: the message text follows the server's language.
|
||||||
|
private static final String UNIQUE_VIOLATION = "23505";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
|
||||||
|
HttpHeaders headers, HttpStatusCode status,
|
||||||
|
WebRequest request) {
|
||||||
|
return validationFailed(ex.getBindingResult().getFieldErrors().stream()
|
||||||
|
.map(error -> new ErrorResponse.FieldError(error.getField(), error.getDefaultMessage()))
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ResponseEntity<Object> handleHandlerMethodValidationException(HandlerMethodValidationException ex,
|
||||||
|
HttpHeaders headers,
|
||||||
|
HttpStatusCode status,
|
||||||
|
WebRequest request) {
|
||||||
|
return validationFailed(ex.getParameterValidationResults().stream()
|
||||||
|
.flatMap(result -> result.getResolvableErrors().stream()
|
||||||
|
.map(error -> new ErrorResponse.FieldError(
|
||||||
|
error instanceof FieldError field ? field.getField() : parameterName(result),
|
||||||
|
error.getDefaultMessage())))
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base keeps its checks (a committed response, the error attribute of a 500); only the body is ours.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers,
|
||||||
|
HttpStatusCode statusCode, WebRequest request) {
|
||||||
|
ResponseEntity<Object> answer = super.handleExceptionInternal(ex, body, headers, statusCode, request);
|
||||||
|
if (answer == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ErrorResponse error;
|
||||||
|
if (statusCode.is5xxServerError()) {
|
||||||
|
log.error("Unexpected error", ex);
|
||||||
|
error = new ErrorResponse(INTERNAL_ERROR, "Unexpected error");
|
||||||
|
} else if (statusCode.value() == HttpStatus.BAD_REQUEST.value()) {
|
||||||
|
String field = ex instanceof MethodArgumentTypeMismatchException mismatch ? mismatch.getName()
|
||||||
|
: ex instanceof MissingServletRequestParameterException missing ? missing.getParameterName() : null;
|
||||||
|
error = new ErrorResponse(VALIDATION_FAILED, "The request is not valid",
|
||||||
|
field == null ? null : List.of(new ErrorResponse.FieldError(field, "Missing or not readable")));
|
||||||
|
} else {
|
||||||
|
HttpStatus status = HttpStatus.resolve(statusCode.value());
|
||||||
|
error = new ErrorResponse(status != null ? status.name() : "ERROR",
|
||||||
|
status != null ? status.getReasonPhrase() : "Error");
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(answer.getStatusCode()).headers(answer.getHeaders()).body(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A duplicate key is a parallel request that won. Any other refused write (a value too long, a missing row) is a
|
||||||
|
* bug and stays a 500.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> dataIntegrity(DataIntegrityViolationException ex) throws Exception {
|
||||||
|
if (!isUniqueViolation(ex)) {
|
||||||
|
return unexpected(ex);
|
||||||
|
}
|
||||||
|
log.warn("Database refused a write: {}", ex.getMostSpecificCause().getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.body(new ErrorResponse("CONFLICT", "The data changed at the same time; try again"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rethrows Spring Security's exceptions: its filters answer them with 401 and 403.
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ErrorResponse> unexpected(Exception ex) throws Exception {
|
||||||
|
if (ex instanceof AccessDeniedException || ex instanceof AuthenticationException) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
log.error("Unexpected error", ex);
|
||||||
|
return ResponseEntity.internalServerError().body(new ErrorResponse(INTERNAL_ERROR, "Unexpected error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isUniqueViolation(Throwable ex) {
|
||||||
|
for (Throwable cause = ex; cause != null; cause = cause.getCause()) {
|
||||||
|
if (cause instanceof SQLException sql && UNIQUE_VIOLATION.equals(sql.getSQLState())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseEntity<Object> validationFailed(List<ErrorResponse.FieldError> fields) {
|
||||||
|
return ResponseEntity.badRequest().body(new ErrorResponse(VALIDATION_FAILED, "The request is not valid", fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The name in the request, e.g. "q" of @RequestParam("q") String query.
|
||||||
|
private static String parameterName(ParameterValidationResult result) {
|
||||||
|
MethodParameter parameter = result.getMethodParameter();
|
||||||
|
RequestParam requestParam = parameter.getParameterAnnotation(RequestParam.class);
|
||||||
|
if (requestParam != null && !requestParam.name().isEmpty()) {
|
||||||
|
return requestParam.name();
|
||||||
|
}
|
||||||
|
PathVariable pathVariable = parameter.getParameterAnnotation(PathVariable.class);
|
||||||
|
if (pathVariable != null && !pathVariable.name().isEmpty()) {
|
||||||
|
return pathVariable.name();
|
||||||
|
}
|
||||||
|
return parameter.getParameterName();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,13 +7,12 @@ import java.security.Principal;
|
|||||||
*/
|
*/
|
||||||
public record UserPrincipal(
|
public record UserPrincipal(
|
||||||
String id,
|
String id,
|
||||||
String email,
|
|
||||||
Long steamId,
|
Long steamId,
|
||||||
Role role
|
Role role
|
||||||
) implements Principal {
|
) implements Principal {
|
||||||
|
|
||||||
public UserPrincipal(String id, String email, Long steamId) {
|
public UserPrincipal(String id, Long steamId) {
|
||||||
this(id, email, steamId, Role.USER);
|
this(id, steamId, Role.USER);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import static org.mockito.Mockito.lenient;
|
|||||||
*/
|
*/
|
||||||
class AccessTokenFiltersTest extends AbstractUnitTest {
|
class AccessTokenFiltersTest extends AbstractUnitTest {
|
||||||
|
|
||||||
private static final UserPrincipal USER = new UserPrincipal("42", "a@b.de", 76561198000000042L);
|
private static final UserPrincipal USER = new UserPrincipal("42", 76561198000000042L);
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private JwtService jwtService;
|
private JwtService jwtService;
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
package core_service.configuration.security;
|
package core_service.configuration.security;
|
||||||
|
|
||||||
|
import core_service.shared.security.Role;
|
||||||
import core_service.shared.test.AbstractIntegrationTest;
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import core_service.shared.test.ContractTokens;
|
||||||
import jakarta.servlet.http.Cookie;
|
import jakarta.servlet.http.Cookie;
|
||||||
import io.jsonwebtoken.Jwts;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -25,13 +24,7 @@ class ExpiredAccessTokenTest extends AbstractIntegrationTest {
|
|||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
private static String expiredAccessToken() {
|
private static String expiredAccessToken() {
|
||||||
return Jwts.builder()
|
return ContractTokens.sign(ContractTokens.login(42, Role.USER, null), TEST_KEYS.getPrivate(), -60_000);
|
||||||
.subject("a@b.de")
|
|
||||||
.claim("userId", "42")
|
|
||||||
.issuedAt(new Date(System.currentTimeMillis() - 120_000))
|
|
||||||
.expiration(new Date(System.currentTimeMillis() - 60_000))
|
|
||||||
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
|
|
||||||
.compact();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import core_service.shared.security.ProfileTicket;
|
|||||||
import core_service.shared.security.Role;
|
import core_service.shared.security.Role;
|
||||||
import core_service.shared.security.UserPrincipal;
|
import core_service.shared.security.UserPrincipal;
|
||||||
import core_service.shared.test.AbstractUnitTest;
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
|
import core_service.shared.test.ContractTokens;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -16,6 +17,7 @@ import java.security.PrivateKey;
|
|||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
void parseLoginToken_ReturnsTheUser() {
|
void parseLoginToken_ReturnsTheUser() {
|
||||||
String token = signToken("0815", "test@example.com", 123456789L);
|
String token = signToken("0815", "test@example.com", 123456789L);
|
||||||
|
|
||||||
assertEquals(Optional.of(new UserPrincipal("0815", "test@example.com", 123456789L)),
|
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)),
|
||||||
jwtService.parseLoginToken(token));
|
jwtService.parseLoginToken(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,4 +207,66 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role());
|
assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String sign(Map<String, Object> claims) {
|
||||||
|
return ContractTokens.sign(claims, signingKey, 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shapes of item 29: without the claims an Auth from before item 26 needs.
|
||||||
|
private static Map<String, Object> without(Map<String, Object> claims, String... names) {
|
||||||
|
for (String name : names) {
|
||||||
|
claims.remove(name);
|
||||||
|
}
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contractTokens_OfEveryKind_AreAccepted() {
|
||||||
|
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.claims(ContractTokens.LOGIN))).orElseThrow().id());
|
||||||
|
assertEquals(42L, jwtService.verifyProfileTicket(sign(ContractTokens.claims(ContractTokens.PUBLIC_PROFILE)))
|
||||||
|
.orElseThrow().userId());
|
||||||
|
assertTrue(jwtService.isServiceToken(sign(ContractTokens.claims(ContractTokens.SERVICE))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacyTokens_OfAnAuthFromBeforeItem26_AreAccepted() {
|
||||||
|
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.legacyClaims(ContractTokens.LOGIN))).orElseThrow().id());
|
||||||
|
assertEquals(42L, jwtService.verifyProfileTicket(sign(ContractTokens.legacyClaims(ContractTokens.PUBLIC_PROFILE)))
|
||||||
|
.orElseThrow().userId());
|
||||||
|
assertTrue(jwtService.isServiceToken(sign(ContractTokens.legacyClaims(ContractTokens.SERVICE))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokensWithoutTheOldClaims_AreAccepted() {
|
||||||
|
assertEquals("42", jwtService.parseLoginToken(sign(without(ContractTokens.claims(ContractTokens.LOGIN), "userId")))
|
||||||
|
.orElseThrow().id());
|
||||||
|
assertEquals(42L, jwtService.verifyProfileTicket(sign(without(ContractTokens.claims(ContractTokens.PUBLIC_PROFILE), "typ")))
|
||||||
|
.orElseThrow().userId());
|
||||||
|
assertTrue(jwtService.isServiceToken(sign(without(ContractTokens.claims(ContractTokens.SERVICE), "typ"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aTokenForAnotherAudience_IsRejected() {
|
||||||
|
Map<String, Object> ticketAsLogin = ContractTokens.claims(ContractTokens.LOGIN);
|
||||||
|
ticketAsLogin.put("aud", List.of(JwtService.PROFILE_TICKET_AUDIENCE));
|
||||||
|
Map<String, Object> loginAsService = ContractTokens.claims(ContractTokens.SERVICE);
|
||||||
|
loginAsService.put("aud", List.of(JwtService.LOGIN_AUDIENCE));
|
||||||
|
Map<String, Object> loginAsTicket = ContractTokens.claims(ContractTokens.PUBLIC_PROFILE);
|
||||||
|
loginAsTicket.put("aud", List.of(JwtService.LOGIN_AUDIENCE));
|
||||||
|
|
||||||
|
assertEquals(Optional.empty(), jwtService.parseLoginToken(sign(ticketAsLogin)));
|
||||||
|
assertFalse(jwtService.isServiceToken(sign(loginAsService)));
|
||||||
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(sign(loginAsTicket)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aTokenWithAnAudienceButAnotherIssuer_IsRejected() {
|
||||||
|
Map<String, Object> login = ContractTokens.claims(ContractTokens.LOGIN);
|
||||||
|
login.put("iss", "someone-else");
|
||||||
|
Map<String, Object> service = ContractTokens.claims(ContractTokens.SERVICE);
|
||||||
|
service.remove("iss");
|
||||||
|
|
||||||
|
assertEquals(Optional.empty(), jwtService.parseLoginToken(sign(login)));
|
||||||
|
assertFalse(jwtService.isServiceToken(sign(service)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package core_service.configuration.security;
|
package core_service.configuration.security;
|
||||||
|
|
||||||
import io.jsonwebtoken.Jwts;
|
import core_service.shared.test.ContractTokens;
|
||||||
import org.junit.jupiter.api.Tag;
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
@@ -19,8 +19,8 @@ import org.testcontainers.junit.jupiter.Testcontainers;
|
|||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
@@ -77,9 +77,10 @@ class PublicEndpointsTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void profileTicketIsNoLogin() throws Exception {
|
void profileTicketIsNoLogin() throws Exception {
|
||||||
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "1")
|
Map<String, Object> claims = ContractTokens.ticket(1, null, null);
|
||||||
.claim("userId", "1").expiration(new Date(System.currentTimeMillis() + 60_000))
|
// A ticket that also carries a user id is still no login.
|
||||||
.signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
claims.put("userId", "1");
|
||||||
|
String ticket = ContractTokens.sign(claims, KEYS.getPrivate(), 60_000);
|
||||||
|
|
||||||
int status = mockMvc.perform(get("/api/library/games-count").header("Authorization", "Bearer " + ticket))
|
int status = mockMvc.perform(get("/api/library/games-count").header("Authorization", "Bearer " + ticket))
|
||||||
.andReturn().getResponse().getStatus();
|
.andReturn().getResponse().getStatus();
|
||||||
@@ -95,9 +96,8 @@ class PublicEndpointsTest {
|
|||||||
// Without a login: a class role marker on the way would answer 401.
|
// Without a login: a class role marker on the way would answer 401.
|
||||||
@Test
|
@Test
|
||||||
void publicProfileWithSharedStats_WorksWithoutALogin() throws Exception {
|
void publicProfileWithSharedStats_WorksWithoutALogin() throws Exception {
|
||||||
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "1")
|
String ticket = ContractTokens.sign(ContractTokens.ticket(1, List.of("TOP_GAMES", "OVERVIEW"), null),
|
||||||
.claim("profileStats", List.of("TOP_GAMES", "OVERVIEW")).expiration(new Date(System.currentTimeMillis() + 60_000))
|
KEYS.getPrivate(), 60_000);
|
||||||
.signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket))
|
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import core_service.shared.security.Public;
|
|||||||
import core_service.shared.security.RequiresRole;
|
import core_service.shared.security.RequiresRole;
|
||||||
import core_service.shared.security.Role;
|
import core_service.shared.security.Role;
|
||||||
import core_service.shared.test.AbstractIntegrationTest;
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
import io.jsonwebtoken.Jwts;
|
import core_service.shared.test.ContractTokens;
|
||||||
import jakarta.servlet.http.Cookie;
|
import jakarta.servlet.http.Cookie;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -18,8 +18,6 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
@@ -93,13 +91,7 @@ class RoleCheckFlowTest extends AbstractIntegrationTest {
|
|||||||
private ProbeService probeService;
|
private ProbeService probeService;
|
||||||
|
|
||||||
private static String token(Role role) {
|
private static String token(Role role) {
|
||||||
return Jwts.builder()
|
return ContractTokens.loginToken(4711, role);
|
||||||
.subject("probe@example.com")
|
|
||||||
.claim("userId", "4711")
|
|
||||||
.claim("role", role.name())
|
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
|
||||||
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
|
|
||||||
.compact();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResultActions call(String path, Role role) throws Exception {
|
private ResultActions call(String path, Role role) throws Exception {
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
package core_service.modules.account.api;
|
package core_service.modules.account.api;
|
||||||
|
|
||||||
import core_service.shared.test.AbstractIntegrationTest;
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
import io.jsonwebtoken.JwtBuilder;
|
import core_service.shared.test.ContractTokens;
|
||||||
import io.jsonwebtoken.Jwts;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.ResultActions;
|
import org.springframework.test.web.servlet.ResultActions;
|
||||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
|
||||||
import java.util.Date;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
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.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -40,20 +44,15 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
|
|||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void cleanUp() {
|
void cleanUp() {
|
||||||
jdbc.update("delete from deleted_user where user_id = ?", USER_ID);
|
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
|
||||||
}
|
|
||||||
|
|
||||||
private static String token(JwtBuilder builder) {
|
|
||||||
return builder.expiration(new Date(System.currentTimeMillis() + 60_000))
|
|
||||||
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String serviceToken() {
|
private static String serviceToken() {
|
||||||
return token(Jwts.builder().subject("tbp-auth").claim("typ", "service"));
|
return ContractTokens.serviceToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String loginToken() {
|
private static String loginToken() {
|
||||||
return token(Jwts.builder().subject("a@b.de").claim("userId", String.valueOf(USER_ID)));
|
return ContractTokens.loginToken(USER_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResultActions send(MockHttpServletRequestBuilder request, String body, String token) throws Exception {
|
private ResultActions send(MockHttpServletRequestBuilder request, String body, String token) throws Exception {
|
||||||
@@ -107,6 +106,38 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void prune_WithAnEmptyListIs400() throws Exception {
|
void prune_WithAnEmptyListIs400() throws Exception {
|
||||||
send(post("/internal/accounts/prune"), "{\"knownUserIds\":[],\"dryRun\":false}", serviceToken()).andExpect(status().isBadRequest());
|
send(post("/internal/accounts/prune"), "{\"knownUserIds\":[],\"dryRun\":false}", serviceToken())
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
|
||||||
|
.andExpect(jsonPath("$.fields[0].field").value("knownUserIds"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MockHttpServletRequestBuilder contractRequest(JsonNode call) {
|
||||||
|
return request(HttpMethod.valueOf(call.path("method").asString()), call.path("path").asString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The calls as Auth's CoreAccountService sends them, with the service tokens of an old and a new Auth.
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(booleans = {false, true})
|
||||||
|
void contractCalls_AreAnswered(boolean legacyToken) throws Exception {
|
||||||
|
String token = legacyToken ? ContractTokens.sign(ContractTokens.legacyClaims(ContractTokens.SERVICE))
|
||||||
|
: ContractTokens.serviceToken();
|
||||||
|
// replaceDeleted with [] removes every tombstone, also those of other tests in this database.
|
||||||
|
List<Long> tombstones = jdbc.queryForList("select user_id from deleted_user", Long.class);
|
||||||
|
try {
|
||||||
|
for (String name : List.of("markDeleted", "replaceDeleted")) {
|
||||||
|
JsonNode call = ContractTokens.internalCall(name);
|
||||||
|
send(contractRequest(call), call.path("request").toString(), token).andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
tombstones.forEach(userId -> jdbc.update("insert into deleted_user (user_id) values (?) on conflict do nothing", userId));
|
||||||
|
}
|
||||||
|
for (String name : List.of("purge", "prune")) {
|
||||||
|
JsonNode call = ContractTokens.internalCall(name);
|
||||||
|
ResultActions result = send(contractRequest(call), call.path("request").toString(), token).andExpect(status().isOk());
|
||||||
|
for (String field : call.path("response").propertyNames()) {
|
||||||
|
result.andExpect(jsonPath("$." + field).exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ import core_service.modules.library.repo.SteamGameRepository;
|
|||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
import core_service.shared.security.Role;
|
import core_service.shared.security.Role;
|
||||||
import core_service.shared.test.AbstractIntegrationTest;
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
import io.jsonwebtoken.Jwts;
|
import core_service.shared.test.ContractTokens;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -22,7 +22,6 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
import org.springframework.test.web.servlet.ResultActions;
|
import org.springframework.test.web.servlet.ResultActions;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.hamcrest.Matchers.contains;
|
import static org.hamcrest.Matchers.contains;
|
||||||
@@ -77,13 +76,7 @@ class GameCorrectionFlowTest extends AbstractIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String token(Role role) {
|
private static String token(Role role) {
|
||||||
return Jwts.builder()
|
return ContractTokens.loginToken(AUDITOR_ID, role);
|
||||||
.subject("auditor@example.com")
|
|
||||||
.claim("userId", String.valueOf(AUDITOR_ID))
|
|
||||||
.claim("role", role.name())
|
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
|
||||||
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
|
|
||||||
.compact();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResultActions correct(Role role, long appid, String field, String valueJson) throws Exception {
|
private ResultActions correct(Role role, long appid, String field, String valueJson) throws Exception {
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ import core_service.modules.library.domain.GameStatus;
|
|||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
|
import core_service.modules.library.domain.UserStats;
|
||||||
import core_service.modules.library.repo.GameCorrectionRepository;
|
import core_service.modules.library.repo.GameCorrectionRepository;
|
||||||
import core_service.modules.library.repo.GameMoodsRepository;
|
import core_service.modules.library.repo.GameMoodsRepository;
|
||||||
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
import core_service.modules.library.repo.UserStatsRepository;
|
import core_service.modules.library.repo.UserStatsRepository;
|
||||||
import core_service.modules.library.domain.UserStats;
|
|
||||||
import core_service.shared.security.Role;
|
import core_service.shared.security.Role;
|
||||||
import core_service.shared.test.AbstractIntegrationTest;
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
import io.jsonwebtoken.Jwts;
|
import core_service.shared.test.ContractTokens;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -30,7 +30,6 @@ import org.springframework.test.web.servlet.ResultActions;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -94,13 +93,7 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String token(Role role) {
|
private static String token(Role role) {
|
||||||
return Jwts.builder()
|
return ContractTokens.loginToken(AUDITOR_ID, role);
|
||||||
.subject("auditor@example.com")
|
|
||||||
.claim("userId", String.valueOf(AUDITOR_ID))
|
|
||||||
.claim("role", role.name())
|
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
|
||||||
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
|
|
||||||
.compact();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResultActions merge(Role role, long appid, long otherAppid) throws Exception {
|
private ResultActions merge(Role role, long appid, long otherAppid) throws Exception {
|
||||||
|
|||||||
@@ -14,17 +14,28 @@ import core_service.shared.test.AbstractIntegrationTest;
|
|||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.springframework.boot.test.system.CapturedOutput;
|
||||||
|
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotWritableException;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.hamcrest.Matchers.not;
|
||||||
import static org.hamcrest.Matchers.nullValue;
|
import static org.hamcrest.Matchers.nullValue;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -102,6 +113,50 @@ class LibraryControllerStatsTest extends AbstractIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void stats_UnknownGroup_Is400() throws Exception {
|
void stats_UnknownGroup_Is400() throws Exception {
|
||||||
mockMvc.perform(get("/api/library/stats").param("groups", "MONEY"))
|
mockMvc.perform(get("/api/library/stats").param("groups", "MONEY"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
|
||||||
|
.andExpect(jsonPath("$.fields[0].field").value("groups"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anUnexpectedError_Is500WithoutDetails() throws Exception {
|
||||||
|
doThrow(new IllegalStateException("secret detail")).when(moodService).refreshListedInBackground(USER_ID);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/stats"))
|
||||||
|
.andExpect(status().isInternalServerError())
|
||||||
|
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
|
||||||
|
.andExpect(content().string(not(containsString("secret detail"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aDatabaseConflict_Is409() throws Exception {
|
||||||
|
doThrow(new DataIntegrityViolationException("doppelter Schlüsselwert", new SQLException("doppelter Schlüsselwert", "23505"))).when(moodService).refreshListedInBackground(USER_ID);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/stats"))
|
||||||
|
.andExpect(status().isConflict())
|
||||||
|
.andExpect(jsonPath("$.code").value("CONFLICT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anotherRefusedWrite_Is500() throws Exception {
|
||||||
|
doThrow(new DataIntegrityViolationException("Wert zu lang", new SQLException("Wert zu lang", "22001")))
|
||||||
|
.when(moodService).refreshListedInBackground(USER_ID);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/stats"))
|
||||||
|
.andExpect(status().isInternalServerError())
|
||||||
|
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@ExtendWith(OutputCaptureExtension.class)
|
||||||
|
void aSpringServerError_IsLoggedAsInternalError(CapturedOutput output) throws Exception {
|
||||||
|
doThrow(new HttpMessageNotWritableException("body not writable"))
|
||||||
|
.when(moodService).refreshListedInBackground(USER_ID);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/stats"))
|
||||||
|
.andExpect(status().isInternalServerError())
|
||||||
|
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
|
||||||
|
|
||||||
|
assertTrue(output.getAll().contains("body not writable"), "the 500 left no trace in the log");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-8
@@ -2,7 +2,7 @@ package core_service.modules.library.api;
|
|||||||
|
|
||||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||||
import core_service.shared.test.AbstractIntegrationTest;
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
import io.jsonwebtoken.Jwts;
|
import core_service.shared.test.ContractTokens;
|
||||||
import jakarta.servlet.http.Cookie;
|
import jakarta.servlet.http.Cookie;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -13,7 +13,6 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
@@ -42,12 +41,7 @@ class LibraryControllerSteamIdGoneTest extends AbstractIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static Cookie loginWithoutSteamId() {
|
private static Cookie loginWithoutSteamId() {
|
||||||
return new Cookie("tbp_access", Jwts.builder()
|
return new Cookie("tbp_access", ContractTokens.loginToken(USER_ID));
|
||||||
.subject("unlinked@example.com")
|
|
||||||
.claim("userId", String.valueOf(USER_ID))
|
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
|
||||||
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
|
|
||||||
.compact());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package core_service.modules.library.api;
|
|||||||
import core_service.configuration.security.ClientHeaderFilter;
|
import core_service.configuration.security.ClientHeaderFilter;
|
||||||
import core_service.modules.igdb_adapter.service.IgdbApiService;
|
import core_service.modules.igdb_adapter.service.IgdbApiService;
|
||||||
import core_service.modules.library.domain.GameSource;
|
import core_service.modules.library.domain.GameSource;
|
||||||
import core_service.modules.library.domain.GameTimeToBeat;
|
|
||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
import core_service.modules.library.domain.GameTimeToBeat;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
@@ -12,6 +12,7 @@ import core_service.modules.library.repo.GameTimeToBeatRepository;
|
|||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
import core_service.shared.test.AbstractIntegrationTest;
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import core_service.shared.test.ContractTokens;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -215,6 +216,14 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
|
|||||||
add(119388, " ").andExpect(status().isBadRequest());
|
add(119388, " ").andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_OneLetter_Is400WithTheField() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/library/game-search").param("q", "a"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
|
||||||
|
.andExpect(jsonPath("$.fields[0].field").value("q"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void search_MarksGamesAlreadyInTheLibrary() throws Exception {
|
void search_MarksGamesAlreadyInTheLibrary() throws Exception {
|
||||||
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
|
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
|
||||||
@@ -342,12 +351,7 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
|
|||||||
|
|
||||||
// Signed like an Auth login token, for a user who saved no Steam ID.
|
// Signed like an Auth login token, for a user who saved no Steam ID.
|
||||||
private static String tokenWithoutSteamId() {
|
private static String tokenWithoutSteamId() {
|
||||||
return io.jsonwebtoken.Jwts.builder()
|
return ContractTokens.loginToken(USER_ID);
|
||||||
.subject("no-steam@example.com")
|
|
||||||
.claim("userId", String.valueOf(USER_ID))
|
|
||||||
.expiration(new java.util.Date(System.currentTimeMillis() + 60_000))
|
|
||||||
.signWith(TEST_KEYS.getPrivate(), io.jsonwebtoken.Jwts.SIG.EdDSA)
|
|
||||||
.compact();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import core_service.modules.library.repo.SteamGameRepository;
|
|||||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
import core_service.shared.test.AbstractIntegrationTest;
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
import io.jsonwebtoken.Jwts;
|
import core_service.shared.test.ContractTokens;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -19,7 +19,6 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
|||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.hamcrest.Matchers.hasSize;
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
@@ -49,20 +48,15 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
|||||||
private UserGameAchievementMetaRepository userAchievementMetaRepository;
|
private UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||||
|
|
||||||
private static String ticket(long userId) {
|
private static String ticket(long userId) {
|
||||||
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
|
return ContractTokens.ticketToken(userId, null, null);
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String ticket(long userId, List<String> profileStats) {
|
private static String ticket(long userId, List<String> profileStats) {
|
||||||
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
|
return ContractTokens.ticketToken(userId, profileStats, null);
|
||||||
.claim("profileStats", profileStats)
|
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String ticket(long userId, List<String> profileStats, List<String> sections) {
|
private static String ticket(long userId, List<String> profileStats, List<String> sections) {
|
||||||
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
|
return ContractTokens.ticketToken(userId, profileStats, sections);
|
||||||
.claim("profileStats", profileStats).claim("sections", sections)
|
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserGame game(long appid, int playtime, GameStatus status, Integer rating) {
|
private UserGame game(long appid, int playtime, GameStatus status, Integer rating) {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package core_service.shared.test;
|
||||||
|
|
||||||
|
import core_service.shared.security.Role;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens only in the shapes of {@code contract/auth-core-contract.json}, the file Auth checks its tokens against. A
|
||||||
|
* test changes the values, never the claim names.
|
||||||
|
*/
|
||||||
|
public final class ContractTokens {
|
||||||
|
|
||||||
|
public static final String LOGIN = "login";
|
||||||
|
public static final String PUBLIC_PROFILE = "publicProfile";
|
||||||
|
public static final String SERVICE = "service";
|
||||||
|
|
||||||
|
private static final JsonMapper MAPPER = JsonMapper.builder().build();
|
||||||
|
private static final JsonNode CONTRACT = load();
|
||||||
|
|
||||||
|
private ContractTokens() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode load() {
|
||||||
|
try (InputStream in = ContractTokens.class.getResourceAsStream("/contract/auth-core-contract.json")) {
|
||||||
|
return MAPPER.readTree(in);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The claims Auth signs for this kind today.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public static Map<String, Object> claims(String kind) {
|
||||||
|
return new LinkedHashMap<>(MAPPER.treeToValue(CONTRACT.path("tokens").required(kind), Map.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The claims of an Auth from before item 26.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public static Map<String, Object> legacyClaims(String kind) {
|
||||||
|
return new LinkedHashMap<>(MAPPER.treeToValue(CONTRACT.path("legacyTokens").required(kind), Map.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonNode internalCall(String name) {
|
||||||
|
return CONTRACT.path("internal").required(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String sign(Map<String, Object> claims, PrivateKey key, long expiresInMs) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.claims(claims)
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + expiresInMs))
|
||||||
|
.signWith(key, Jwts.SIG.EdDSA)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String sign(Map<String, Object> claims) {
|
||||||
|
return sign(claims, AbstractIntegrationTest.TEST_KEYS.getPrivate(), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param steamId null for a user without a linked Steam account
|
||||||
|
*/
|
||||||
|
public static Map<String, Object> login(long userId, Role role, Long steamId) {
|
||||||
|
Map<String, Object> claims = claims(LOGIN);
|
||||||
|
claims.put("sub", String.valueOf(userId));
|
||||||
|
claims.put("userId", String.valueOf(userId));
|
||||||
|
claims.put("role", role.name());
|
||||||
|
putOrRemove(claims, "steamId", steamId);
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String loginToken(long userId, Role role) {
|
||||||
|
return sign(login(userId, role, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String loginToken(long userId) {
|
||||||
|
return loginToken(userId, Role.USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param profileStats null for a ticket without the claim
|
||||||
|
* @param sections null for a ticket without the claim
|
||||||
|
*/
|
||||||
|
public static Map<String, Object> ticket(long userId, List<String> profileStats, List<String> sections) {
|
||||||
|
Map<String, Object> claims = claims(PUBLIC_PROFILE);
|
||||||
|
claims.put("profileUserId", String.valueOf(userId));
|
||||||
|
putOrRemove(claims, "profileStats", profileStats);
|
||||||
|
putOrRemove(claims, "sections", sections);
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String ticketToken(long userId, List<String> profileStats, List<String> sections) {
|
||||||
|
return sign(ticket(userId, profileStats, sections));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String serviceToken() {
|
||||||
|
return sign(claims(SERVICE));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void putOrRemove(Map<String, Object> claims, String name, Object value) {
|
||||||
|
if (value == null) {
|
||||||
|
claims.remove(name);
|
||||||
|
} else {
|
||||||
|
claims.put(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"_about": "The token and internal-call contract between Auth and Core. The same file is in both repos; ToBePlayed-Deployment/scripts/check-contract.sh compares them. iat and exp are left out. legacyTokens are the shapes of an Auth from before item 26: Core and Auth accept them until item 29.",
|
||||||
|
"tokens": {
|
||||||
|
"login": {
|
||||||
|
"iss": "tbp-auth",
|
||||||
|
"aud": ["tbp-login"],
|
||||||
|
"sub": "42",
|
||||||
|
"userId": "42",
|
||||||
|
"role": "USER",
|
||||||
|
"steamId": 76561198000000001
|
||||||
|
},
|
||||||
|
"publicProfile": {
|
||||||
|
"iss": "tbp-auth",
|
||||||
|
"aud": ["tbp-public-profile"],
|
||||||
|
"sub": "public-profile",
|
||||||
|
"typ": "public-profile",
|
||||||
|
"profileUserId": "42",
|
||||||
|
"profileStats": ["OVERVIEW"],
|
||||||
|
"sections": ["SUMMARY"]
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"iss": "tbp-auth",
|
||||||
|
"aud": ["tbp-internal"],
|
||||||
|
"sub": "tbp-auth",
|
||||||
|
"typ": "service"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"legacyTokens": {
|
||||||
|
"login": {
|
||||||
|
"sub": "user@example.com",
|
||||||
|
"userId": "42",
|
||||||
|
"role": "USER",
|
||||||
|
"steamId": 76561198000000001
|
||||||
|
},
|
||||||
|
"publicProfile": {
|
||||||
|
"sub": "public-profile",
|
||||||
|
"typ": "public-profile",
|
||||||
|
"profileUserId": "42",
|
||||||
|
"profileStats": ["OVERVIEW"],
|
||||||
|
"sections": ["SUMMARY"]
|
||||||
|
},
|
||||||
|
"service": {
|
||||||
|
"sub": "tbp-auth",
|
||||||
|
"typ": "service"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"markDeleted": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/internal/accounts/deleted",
|
||||||
|
"request": {"userIds": [42]}
|
||||||
|
},
|
||||||
|
"replaceDeleted": {
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/internal/accounts/deleted",
|
||||||
|
"request": {"userIds": []}
|
||||||
|
},
|
||||||
|
"purge": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/internal/accounts/purge",
|
||||||
|
"request": {"userIds": [42], "dryRun": false},
|
||||||
|
"response": {"dryRun": true, "users": [42], "rows": {"user_game": 3}, "refused": false}
|
||||||
|
},
|
||||||
|
"prune": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/internal/accounts/prune",
|
||||||
|
"request": {"knownUserIds": [1, 42], "dryRun": true},
|
||||||
|
"response": {"dryRun": true, "users": [42], "rows": {"user_game": 3}, "refused": false}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user