diff --git a/readme.md b/readme.md
index 997bb20..a5b57b4 100644
--- a/readme.md
+++ b/readme.md
@@ -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
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
-`tbp_access` cookie or a `Bearer` header, whichever verifies first. Core reads `userId`, `steamId` and the active
-`role` (missing or unknown = USER) from the claims and grants that role and every lower one
-(`USER < AUDITOR < PLATFORM_ADMIN`).
+`tbp_access` cookie or a `Bearer` header, whichever verifies first. Every token names its kind in `aud`
+(`tbp-login`, `tbp-public-profile`, `tbp-internal`, with `iss=tbp-auth`); a login's `sub` is the user id. Core reads
+`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
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.
-`/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
only guard.
diff --git a/src/main/java/core_service/configuration/security/DevJwtAuthenticationFilter.java b/src/main/java/core_service/configuration/security/DevJwtAuthenticationFilter.java
index 94d5a5d..1b39648 100644
--- a/src/main/java/core_service/configuration/security/DevJwtAuthenticationFilter.java
+++ b/src/main/java/core_service/configuration/security/DevJwtAuthenticationFilter.java
@@ -65,6 +65,6 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
private UserPrincipal dummyPrincipal() {
log.trace("Dev mode: no JWT present, injecting dummy principal");
- return new UserPrincipal("1", "dev@example.com", 76561198000000001L);
+ return new UserPrincipal("1", 76561198000000001L);
}
}
diff --git a/src/main/java/core_service/configuration/security/JwtService.java b/src/main/java/core_service/configuration/security/JwtService.java
index 5929dc0..414bd42 100644
--- a/src/main/java/core_service/configuration/security/JwtService.java
+++ b/src/main/java/core_service/configuration/security/JwtService.java
@@ -10,17 +10,26 @@ import io.jsonwebtoken.Jwts;
import org.springframework.stereotype.Service;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key.
+ *
+ * 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
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 PROFILE_TICKET_TYPE = "public-profile";
private static final String SERVICE_TOKEN_TYPE = "service";
@@ -36,6 +45,20 @@ public class JwtService implements ProfileTicketVerifier {
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
* signed with the same key, but is no login.
@@ -43,11 +66,15 @@ public class JwtService implements ProfileTicketVerifier {
public Optional parseLoginToken(String token) {
try {
Claims claims = claims(token);
- String userId = claims.get("userId", String.class);
- if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
+ if (!isKind(claims, LOGIN_AUDIENCE, null)) {
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)));
} catch (Exception e) {
return Optional.empty();
@@ -60,7 +87,7 @@ public class JwtService implements ProfileTicketVerifier {
public boolean isServiceToken(String token) {
try {
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) {
return false;
}
@@ -74,7 +101,7 @@ public class JwtService implements ProfileTicketVerifier {
public Optional verifyProfileTicket(String ticket) {
try {
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();
}
long userId = Long.parseLong(claims.get("profileUserId", String.class));
diff --git a/src/main/java/core_service/modules/library/api/GameImportController.java b/src/main/java/core_service/modules/library/api/GameImportController.java
index 85f0fca..f04858b 100644
--- a/src/main/java/core_service/modules/library/api/GameImportController.java
+++ b/src/main/java/core_service/modules/library/api/GameImportController.java
@@ -1,5 +1,6 @@
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.ImportRequest;
import core_service.modules.library.api.dto.ImportSaveRequest;
@@ -28,9 +29,9 @@ import java.util.Map;
@RequiredArgsConstructor
public class GameImportController {
- private static final Map 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)
- .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;
@@ -42,7 +43,7 @@ public class GameImportController {
.body(Map.of("id", created.id()));
case GameImportService.CreateResult.AlreadyMatching ignored -> IMPORT_RUNNING;
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 NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
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 NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
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;
};
}
@@ -93,7 +94,7 @@ public class GameImportController {
case DONE -> ResponseEntity.noContent().build();
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
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"));
};
}
}
diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java
index 3fb14a6..510c21f 100644
--- a/src/main/java/core_service/modules/library/api/LibraryController.java
+++ b/src/main/java/core_service/modules/library/api/LibraryController.java
@@ -1,5 +1,6 @@
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.ActivityDto;
import core_service.modules.library.api.dto.BacklogEntryDto;
@@ -62,6 +63,7 @@ import java.util.Set;
@RequiredArgsConstructor
public class LibraryController {
+ private static final ErrorResponse GAME_NOT_FOUND = new ErrorResponse("GAME_NOT_FOUND", "Game not in user library");
private static final Set ALLOWED_SIZES = Set.of(20, 40, 80);
private static final int DEFAULT_SIZE = 20;
private static final int MIN_ACTIVITY_DAYS = 7;
@@ -129,7 +131,7 @@ public class LibraryController {
@PostMapping("/sync")
public ResponseEntity> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
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 {
syncService.forceSync(principal.getIdAsLong(), String.valueOf(principal.steamId()));
@@ -152,7 +154,7 @@ public class LibraryController {
return gameDetailService.getGameDetail(userId, steamId, appid)
.>map(ResponseEntity::ok)
.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.NotFound _ -> unknownGame();
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()
- .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() {
- 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) {
- return ResponseEntity.badRequest().body(Map.of("code", "INVALID_CORRECTION", "message", message));
+ return ResponseEntity.badRequest().body(new ErrorResponse("INVALID_CORRECTION", message));
}
@PutMapping("/games/{appid}/backlog")
@@ -226,7 +228,7 @@ public class LibraryController {
return backlogService.update(principal.getIdAsLong(), appid, entry)
.>map(ResponseEntity::ok)
.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")
@@ -236,9 +238,9 @@ public class LibraryController {
return switch (backlogService.setHidden(principal.getIdAsLong(), appid, update.hidden())) {
case DONE -> ResponseEntity.ok(update);
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)
- .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) {
if (!backlogService.dismissFinishedHint(principal.getIdAsLong(), appid, dismissal.dismissed())) {
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();
}
@@ -260,7 +262,7 @@ public class LibraryController {
return backlogService.updateStatus(principal.getIdAsLong(), appid, update.status())
.>map(ResponseEntity::ok)
.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")
diff --git a/src/main/java/core_service/modules/library/api/ManualGameController.java b/src/main/java/core_service/modules/library/api/ManualGameController.java
index 25eb42c..1bf90fe 100644
--- a/src/main/java/core_service/modules/library/api/ManualGameController.java
+++ b/src/main/java/core_service/modules/library/api/ManualGameController.java
@@ -1,5 +1,6 @@
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.AddedGameDto;
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 java.net.URI;
-import java.util.Map;
/**
* Games added by hand from an IGDB search, next to the Steam library.
@@ -33,9 +33,8 @@ import java.util.Map;
@RequiredArgsConstructor
public class ManualGameController {
- private static final Map NOT_FOUND = Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library");
- private static final Map STEAM_GAME = Map.of("code", "STEAM_GAME",
- "message", "Steam games come from the sync and cannot be changed here");
+ private static final ErrorResponse NOT_FOUND = new ErrorResponse("GAME_NOT_FOUND", "Game not in user library");
+ private static final ErrorResponse STEAM_GAME = new ErrorResponse("STEAM_GAME", "Steam games come from the sync and cannot be changed here");
private final ManualGameService manualGameService;
@@ -45,7 +44,7 @@ public class ManualGameController {
return manualGameService.search(principal.getIdAsLong(), query)
.>map(ResponseEntity::ok)
.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")
@@ -58,11 +57,11 @@ public class ManualGameController {
.created(URI.create("/api/library/games/" + added.appid()))
.body(new AddedGameDto(added.appid()));
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)
- .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)
- .body(Map.of("code", "IGDB_OFF", "message", "Adding games needs the IGDB app"));
+ .body(new ErrorResponse("IGDB_OFF", "Adding games needs the IGDB app"));
};
}
diff --git a/src/main/java/core_service/modules/library/api/PublicProfileController.java b/src/main/java/core_service/modules/library/api/PublicProfileController.java
index 87ed34e..65bc496 100644
--- a/src/main/java/core_service/modules/library/api/PublicProfileController.java
+++ b/src/main/java/core_service/modules/library/api/PublicProfileController.java
@@ -1,5 +1,6 @@
package core_service.modules.library.api;
+import core_service.shared.api.ErrorResponse;
import core_service.modules.library.service.LibraryQueryService;
import core_service.shared.security.ProfileTicket;
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.RestController;
-import java.util.Map;
import java.util.Optional;
/**
@@ -31,6 +31,6 @@ public class PublicProfileController {
return (ticket == null ? Optional.empty() : profileTicketVerifier.verifyProfileTicket(ticket))
.>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats(), t.sections())))
.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")));
}
}
diff --git a/src/main/java/core_service/shared/api/ErrorResponse.java b/src/main/java/core_service/shared/api/ErrorResponse.java
new file mode 100644
index 0000000..a4f441a
--- /dev/null
+++ b/src/main/java/core_service/shared/api/ErrorResponse.java
@@ -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 fields) {
+
+ public ErrorResponse(String code, String message) {
+ this(code, message, null);
+ }
+
+ public record FieldError(String field, String message) {
+ }
+}
diff --git a/src/main/java/core_service/shared/api/GlobalExceptionController.java b/src/main/java/core_service/shared/api/GlobalExceptionController.java
new file mode 100644
index 0000000..23b3735
--- /dev/null
+++ b/src/main/java/core_service/shared/api/GlobalExceptionController.java
@@ -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