diff --git a/backend/src/asciidoc/index.adoc b/backend/src/asciidoc/index.adoc index f67f3df7..6dd18430 100644 --- a/backend/src/asciidoc/index.adoc +++ b/backend/src/asciidoc/index.adoc @@ -483,6 +483,28 @@ include::users/users-all-unauthorized/http-response.adoc[] It returns a message indicating authentication is required. +===== 3.1.2.1 Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/all-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/all-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. + +===== 3.1.2.2 Invalid Token +This is an example output when a request with an invalid token is sent. + +.request +include::users/all-unauthorized-invalid-token/http-request.adoc[] + +.response +include::users/all-unauthorized-invalid-token/http-response.adoc[] + +It returns a message indicating the authentication token is invalid or missing. + === 3.2 Delete User (Global) This is an example output for the `DELETE /users/{userId}/true` endpoint when globally deleting a user. @@ -505,6 +527,28 @@ include::users/delete-global-error/http-response.adoc[] It shows the response returned when the deletion cannot be processed. +==== 3.2.2 Error 401 - Invalid Token +This is an example output when a request with an invalid token is sent. + +.request +include::users/delete-global-unauthorized-invalid-token/http-request.adoc[] + +.response +include::users/delete-global-unauthorized-invalid-token/http-response.adoc[] + +It returns a message indicating the authentication token is invalid or missing. + +==== 3.2.3 Error 401 - Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/delete-global-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/delete-global-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. + === 3.3 Delete User (Local) This is an example output for the `DELETE /users/{userId}/false` endpoint when performing a local (soft) delete. @@ -549,6 +593,28 @@ include::users/delete-user-deletion-failed/http-response.adoc[] It returns a message indicating the deletion could not be completed. +==== 3.3.4 Error 401 - Invalid Token +This is an example output when a request with an invalid token is sent. + +.request +include::users/delete-local-unauthorized-invalid-token/http-request.adoc[] + +.response +include::users/delete-local-unauthorized-invalid-token/http-response.adoc[] + +It returns a message indicating the authentication token is invalid or missing. + +==== 3.3.5 Error 401 - Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/delete-local-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/delete-local-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. + === 3.4 Promote User Role This is an example output for the `PUT /users/{userId}/promote-role` endpoint. @@ -581,3 +647,344 @@ include::users/promote-role-already-exists/http-request.adoc[] include::users/promote-role-already-exists/http-response.adoc[] It returns a message indicating the user already has the requested role. + +=== 3.5 Me +This is an example output for the `GET /users/me` endpoint. + +.request +include::users/me/http-request.adoc[] + +.response +include::users/me/http-response.adoc[] + +It returns the currently authenticated user. + +==== 3.5.1 Error 401 - Invalid Token +This is an example output when a request with an invalid token is sent. + +.request +include::users/me-unauthorized-invalid-token/http-request.adoc[] + +.response +include::users/me-unauthorized-invalid-token/http-response.adoc[] + +It returns a message indicating the authentication token is invalid or missing. + +==== 3.5.2 Error 401 - Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/me-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/me-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. + +=== 3.6 All Users With Deleted +This is an example output for the `GET /users/all-with-deleted` endpoint. + +.request +include::users/all-with-deleted/http-request.adoc[] + +.response +include::users/all-with-deleted/http-response.adoc[] + +It returns the list of users, including soft-deleted users. + +==== 3.6.1 Error 401 - Invalid Token +This is an example output when a request with an invalid token is sent. + +.request +include::users/all-with-deleted-unauthorized-invalid-token/http-request.adoc[] + +.response +include::users/all-with-deleted-unauthorized-invalid-token/http-response.adoc[] + +It returns a message indicating the authentication token is invalid or missing. + +==== 3.6.2 Error 401 - Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/all-with-deleted-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/all-with-deleted-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. + +=== 3.7 Deleted Users +This is an example output for the `GET /users/deleted` endpoint. + +.request +include::users/deleted/http-request.adoc[] + +.response +include::users/deleted/http-response.adoc[] + +It returns the list of soft-deleted users. + +==== 3.7.1 Error 401 - Invalid Token +This is an example output when a request with an invalid token is sent. + +.request +include::users/deleted-unauthorized-invalid-token/http-request.adoc[] + +.response +include::users/deleted-unauthorized-invalid-token/http-response.adoc[] + +It returns a message indicating the authentication token is invalid or missing. + +==== 3.7.2 Error 401 - Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/deleted-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/deleted-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. + +=== 3.8 Promote Local App Role +This is an example output for the `PUT /users/{userId}/promote-local-app-role` endpoint. + +.request +include::users/promote-local-app-role/http-request.adoc[] + +.response +include::users/promote-local-app-role/http-response.adoc[] + +It confirms the user was promoted to the local app role. + +==== 3.8.1 Error 401 - Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/promote-local-app-role-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/promote-local-app-role-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. + +=== 3.9 Promote Manager +This is an example output for the `PUT /users/{userId}/promote-manager` endpoint. + +.request +include::users/promote-manager/http-request.adoc[] + +.response +include::users/promote-manager/http-response.adoc[] + +It confirms the user was promoted to manager. + +==== 3.9.1 Error 401 - Invalid Token +This is an example output when a request with an invalid token is sent. + +.request +include::users/promote-manager-unauthorized-invalid-token/http-request.adoc[] + +.response +include::users/promote-manager-unauthorized-invalid-token/http-response.adoc[] + +It returns a message indicating the authentication token is invalid or missing. + +==== 3.9.2 Error 401 - Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/promote-manager-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/promote-manager-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. + +=== 3.10 Revoke Manager +This is an example output for the `PUT /users/{userId}/revoke-manager` endpoint. + +.request +include::users/revoke-manager/http-request.adoc[] + +.response +include::users/revoke-manager/http-response.adoc[] + +It confirms the manager role was revoked. + +==== 3.10.1 Error 401 - Invalid Token +This is an example output when a request with an invalid token is sent. + +.request +include::users/revoke-manager-unauthorized-invalid-token/http-request.adoc[] + +.response +include::users/revoke-manager-unauthorized-invalid-token/http-response.adoc[] + +It returns a message indicating the authentication token is invalid or missing. + +==== 3.10.2 Error 401 - Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/revoke-manager-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/revoke-manager-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. + +=== 3.11 Promote Admin +This is an example output for the `PUT /users/{userId}/promote-admin` endpoint. + +.request +include::users/promote-admin/http-request.adoc[] + +.response +include::users/promote-admin/http-response.adoc[] + +It confirms the user was promoted to admin. + +==== 3.11.1 Error 401 - Invalid Token +This is an example output when a request with an invalid token is sent. + +.request +include::users/promote-admin-unauthorized-invalid-token/http-request.adoc[] + +.response +include::users/promote-admin-unauthorized-invalid-token/http-response.adoc[] + +It returns a message indicating the authentication token is invalid or missing. + +==== 3.11.2 Error 401 - Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/promote-admin-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/promote-admin-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. + +=== 3.12 Revoke Admin +This is an example output for the `PUT /users/{userId}/revoke-admin` endpoint. + +.request +include::users/revoke-admin/http-request.adoc[] + +.response +include::users/revoke-admin/http-response.adoc[] + +It confirms the admin role was revoked. + +==== 3.12.1 Error 401 - Invalid Token +This is an example output when a request with an invalid token is sent. + +.request +include::users/revoke-admin-unauthorized-invalid-token/http-request.adoc[] + +.response +include::users/revoke-admin-unauthorized-invalid-token/http-response.adoc[] + +It returns a message indicating the authentication token is invalid or missing. + +==== 3.12.2 Error 401 - Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/revoke-admin-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/revoke-admin-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. + +=== 3.13 Downgrade Admin +This is an example output for the `PUT /users/{userId}/downgrade-admin` endpoint. + +.request +include::users/downgrade-admin-success/http-request.adoc[] + +.response +include::users/downgrade-admin-success/http-response.adoc[] + +It confirms the admin role was downgraded. + +==== 3.13.1 Error 401 - Unauthorized +This is an example output when the endpoint is called without valid authentication. + +.request +include::users/downgrade-admin-unauthorized/http-request.adoc[] + +.response +include::users/downgrade-admin-unauthorized/http-response.adoc[] + +It returns a message indicating authentication is required. + +=== 3.14 Delete User (Permanent Local) +This is an example output for the `DELETE /users/{userId}/false/permanent` endpoint. + +.request +include::users/delete-permanent-local/http-request.adoc[] + +.response +include::users/delete-permanent-local/http-response.adoc[] + +It confirms the local permanent deletion request. + +==== 3.14.1 Error 401 - Invalid Token +This is an example output when a request with an invalid token is sent. + +.request +include::users/delete-permanent-local-unauthorized-invalid-token/http-request.adoc[] + +.response +include::users/delete-permanent-local-unauthorized-invalid-token/http-response.adoc[] + +It returns a message indicating the authentication token is invalid or missing. + +==== 3.14.2 Error 401 - Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/delete-permanent-local-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/delete-permanent-local-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. + +=== 3.15 Delete User (Permanent Global) +This is an example output for the `DELETE /users/{userId}/true/permanent` endpoint. + +.request +include::users/delete-permanent-global/http-request.adoc[] + +.response +include::users/delete-permanent-global/http-response.adoc[] + +It confirms the global permanent deletion request. + +==== 3.15.1 Error 401 - Invalid Token +This is an example output when a request with an invalid token is sent. + +.request +include::users/delete-permanent-global-unauthorized-invalid-token/http-request.adoc[] + +.response +include::users/delete-permanent-global-unauthorized-invalid-token/http-response.adoc[] + +It returns a message indicating the authentication token is invalid or missing. + +==== 3.15.2 Error 401 - Missing Token +This is an example output when a request with a missing token is sent. + +.request +include::users/delete-permanent-global-unauthorized-missing-token/http-request.adoc[] + +.response +include::users/delete-permanent-global-unauthorized-missing-token/http-response.adoc[] + +It returns a message indicating authentication is required. diff --git a/backend/src/main/java/ch/sectioninformatique/template/app/exceptions/AppException.java b/backend/src/main/java/ch/sectioninformatique/template/app/exceptions/AppException.java index a304f6ac..e121d17a 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/app/exceptions/AppException.java +++ b/backend/src/main/java/ch/sectioninformatique/template/app/exceptions/AppException.java @@ -21,6 +21,11 @@ public class AppException extends RuntimeException { */ private final HttpStatus status; + /** + * Optional arguments for resolving i18n messages. + */ + private final Object[] messageArgs; + /** * Constructs a new AppException with the specified message and status. * This constructor: @@ -32,8 +37,20 @@ public class AppException extends RuntimeException { * @param status The HTTP status code to be returned in the response */ public AppException(String message, HttpStatus status) { + this(message, status, null); + } + + /** + * Constructs a new AppException with the specified message, status, and args. + * + * @param message The message key or literal message + * @param status The HTTP status code to be returned in the response + * @param messageArgs Optional arguments for i18n message formatting + */ + public AppException(String message, HttpStatus status, Object[] messageArgs) { super(message); this.status = status; + this.messageArgs = messageArgs; } /** @@ -47,5 +64,14 @@ public AppException(String message, HttpStatus status) { public HttpStatus getStatus() { return status; } + + /** + * Returns optional arguments for i18n message formatting. + * + * @return Arguments for the message, or null if none + */ + public Object[] getMessageArgs() { + return messageArgs; + } } diff --git a/backend/src/main/java/ch/sectioninformatique/template/app/exceptions/GlobalExceptionHandler.java b/backend/src/main/java/ch/sectioninformatique/template/app/exceptions/GlobalExceptionHandler.java index 29089fd4..248a410f 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/app/exceptions/GlobalExceptionHandler.java +++ b/backend/src/main/java/ch/sectioninformatique/template/app/exceptions/GlobalExceptionHandler.java @@ -1,5 +1,7 @@ package ch.sectioninformatique.template.app.exceptions; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -32,6 +34,16 @@ @ControllerAdvice public class GlobalExceptionHandler { + private final MessageSource messageSource; + + public GlobalExceptionHandler(MessageSource messageSource) { + this.messageSource = messageSource; + } + + private String getMessage(String key, Object[] args, String defaultMessage) { + return messageSource.getMessage(key, args, defaultMessage, LocaleContextHolder.getLocale()); + } + /** * Builds a standardized error response entity. * @@ -58,15 +70,16 @@ private ResponseEntity buildResponse(HttpStatus status, String message) /** * Handles AppException - the custom application exception. * - * Returns the exception's status and message as-is, allowing developers - * to customize responses directly when throwing exceptions. + * Resolves the exception's message as an i18n key (with optional args) and + * falls back to the raw message when no bundle entry exists. * - * @param ex The AppException instance containing status and message - * @return ResponseEntity with the exception's status and message + * @param ex The AppException instance containing status, key, and args + * @return ResponseEntity with the exception's status and resolved message */ @ExceptionHandler(AppException.class) public ResponseEntity handleAppException(AppException ex) { - return buildResponse(ex.getStatus(), ex.getMessage()); + String message = getMessage(ex.getMessage(), ex.getMessageArgs(), ex.getMessage()); + return buildResponse(ex.getStatus(), message); } // ======================================================================== @@ -85,7 +98,11 @@ public ResponseEntity handleAppException(AppException ex) { */ @ExceptionHandler(AccessDeniedException.class) public ResponseEntity handleAccessDenied(AccessDeniedException ex) { - return buildResponse(HttpStatus.FORBIDDEN, "Access denied: You do not have permission to access this resource"); + String message = getMessage( + "error.accessDenied", + null, + "Access denied: You do not have permission to access this resource"); + return buildResponse(HttpStatus.FORBIDDEN, message); } /** @@ -120,7 +137,7 @@ public ResponseEntity handleValidationErrors(MethodArgumentNotValidExcep String combinedMessage = fieldErrors.entrySet().stream() .map(entry -> entry.getKey() + ": " + entry.getValue()) .reduce((e1, e2) -> e1 + "; " + e2) - .orElse("Validation failed"); + .orElse(getMessage("error.validation.failed", null, "Validation failed")); // Build response with both message and detailed fieldErrors Map response = new HashMap<>(); @@ -145,7 +162,8 @@ public ResponseEntity handleValidationErrors(MethodArgumentNotValidExcep */ @ExceptionHandler(HttpMediaTypeNotSupportedException.class) public ResponseEntity handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) { - return buildResponse(HttpStatus.UNSUPPORTED_MEDIA_TYPE, ex.getMessage()); + String message = getMessage("error.mediaTypeNotSupported", null, "Unsupported media type"); + return buildResponse(HttpStatus.UNSUPPORTED_MEDIA_TYPE, message); } /** @@ -162,7 +180,11 @@ public ResponseEntity handleUnsupportedMediaType(HttpMediaTypeNotSupport */ @ExceptionHandler(MissingServletRequestParameterException.class) public ResponseEntity handleMissingParams(MissingServletRequestParameterException ex) { - return buildResponse(HttpStatus.BAD_REQUEST, ex.getParameterName() + " parameter is missing"); + String message = getMessage( + "error.missingParameter", + new Object[] { ex.getParameterName() }, + ex.getParameterName() + " parameter is missing"); + return buildResponse(HttpStatus.BAD_REQUEST, message); } /** @@ -187,7 +209,10 @@ public ResponseEntity handleMissingParams(MissingServletRequestParameter */ @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleMalformedJson(HttpMessageNotReadableException ex) { - String message = "Malformed or missing JSON request body"; + String message = getMessage( + "error.json.malformed", + null, + "Malformed or missing JSON request body"); // Extract more specific error information if available Throwable cause = ex.getCause(); @@ -196,13 +221,25 @@ public ResponseEntity handleMalformedJson(HttpMessageNotReadableExceptio // Provide more specific guidance based on the parsing error if (causeMessage != null) { if (causeMessage.contains("Unexpected end-of-input")) { - message = "JSON is incomplete - missing closing bracket or quote"; + message = getMessage( + "error.json.incomplete", + null, + "JSON is incomplete - missing closing bracket or quote"); } else if (causeMessage.contains("Unexpected character")) { - message = "JSON contains invalid character - check for unescaped quotes or missing commas"; + message = getMessage( + "error.json.invalidCharacter", + null, + "JSON contains invalid character - check for unescaped quotes or missing commas"); } else if (causeMessage.contains("cannot deserialize")) { - message = "Invalid value type for a field - check your data types match the schema"; + message = getMessage( + "error.json.invalidType", + null, + "Invalid value type for a field - check your data types match the schema"); } else if (causeMessage.contains("No content to map")) { - message = "Empty or missing request body"; + message = getMessage( + "error.json.empty", + null, + "Empty or missing request body"); } } } diff --git a/backend/src/main/java/ch/sectioninformatique/template/auth/AuthExceptions.java b/backend/src/main/java/ch/sectioninformatique/template/auth/AuthExceptions.java index 29de0973..a909fbf8 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/auth/AuthExceptions.java +++ b/backend/src/main/java/ch/sectioninformatique/template/auth/AuthExceptions.java @@ -14,7 +14,7 @@ public class AuthExceptions { */ public static class InvalidCredentialsException extends AppException { public InvalidCredentialsException() { - super("Invalid credentials", HttpStatus.UNAUTHORIZED); + super("auth.invalidCredentials", HttpStatus.UNAUTHORIZED); } } @@ -32,7 +32,7 @@ public RegistrationFailedException(String message) { */ public static class UserNotFoundException extends AppException { public UserNotFoundException() { - super("User not found", HttpStatus.NOT_FOUND); + super("user.notFound", HttpStatus.NOT_FOUND); } public UserNotFoundException(String message) { @@ -45,7 +45,7 @@ public UserNotFoundException(String message) { */ public static class UserAlreadyExistsException extends AppException { public UserAlreadyExistsException() { - super("User already exists", HttpStatus.CONFLICT); + super("auth.userAlreadyExists", HttpStatus.CONFLICT); } public UserAlreadyExistsException(String message) { @@ -76,7 +76,7 @@ public OAuth2AuthenticationException(String message) { */ public static class LoginAlreadyExistsException extends AppException { public LoginAlreadyExistsException() { - super("Login already exists", HttpStatus.BAD_REQUEST); + super("auth.loginAlreadyExists", HttpStatus.BAD_REQUEST); } public LoginAlreadyExistsException(String message) { diff --git a/backend/src/main/java/ch/sectioninformatique/template/item/ItemExceptions.java b/backend/src/main/java/ch/sectioninformatique/template/item/ItemExceptions.java index e446f4d9..4d934d02 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/item/ItemExceptions.java +++ b/backend/src/main/java/ch/sectioninformatique/template/item/ItemExceptions.java @@ -14,7 +14,7 @@ public static class ItemNotFoundException extends AppException { * @param id The ID of the item that was not found */ public ItemNotFoundException(Long id) { - super("Could not find item " + id,HttpStatus.NOT_FOUND); + super("item.notFound",HttpStatus.NOT_FOUND); } } @@ -28,7 +28,7 @@ public static class UnauthorizedItemException extends AppException { * @param message The operation that was attempted (e.g., "update", "delete") */ public UnauthorizedItemException(String message) { - super("You can only " + message + " your own items",HttpStatus.UNAUTHORIZED); + super("item.unauthorized",HttpStatus.UNAUTHORIZED); } } diff --git a/backend/src/main/java/ch/sectioninformatique/template/item/ItemService.java b/backend/src/main/java/ch/sectioninformatique/template/item/ItemService.java index d0e1e084..cc4d13f5 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/item/ItemService.java +++ b/backend/src/main/java/ch/sectioninformatique/template/item/ItemService.java @@ -5,11 +5,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; +import ch.sectioninformatique.template.app.exceptions.AppException; import ch.sectioninformatique.template.item.ItemExceptions.ItemNotFoundException; import ch.sectioninformatique.template.item.ItemExceptions.UnauthorizedItemException; import ch.sectioninformatique.template.user.User; @@ -80,7 +82,7 @@ public Item createItem(Item newItem) { } User author = userRepository.findByLogin(currentUserEmail) - .orElseThrow(() -> new RuntimeException("User not found")); + .orElseThrow(() -> new AppException("user.notFound", HttpStatus.NOT_FOUND)); newItem.setAuthor(author); @@ -118,7 +120,7 @@ public void deleteItem(final Long id) { String currentUserEmail = getCurrentUserEmail(); User currentUser = userRepository.findByLogin(currentUserEmail) - .orElseThrow(() -> new RuntimeException("User not found")); + .orElseThrow(() -> new AppException("user.notFound", HttpStatus.NOT_FOUND)); Item item = itemRepository.findById(id) .orElseThrow(() -> new ItemNotFoundException(id)); @@ -150,7 +152,7 @@ public Item updateItem(Long id, Item newItem) { String currentUserEmail = getCurrentUserEmail(); User currentUser = userRepository.findByLogin(currentUserEmail) - .orElseThrow(() -> new RuntimeException("User not found")); + .orElseThrow(() -> new AppException("user.notFound", HttpStatus.NOT_FOUND)); logger.debug("Found user with ID: {}", currentUser.getId()); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); diff --git a/backend/src/main/java/ch/sectioninformatique/template/security/JwtAuthFilter.java b/backend/src/main/java/ch/sectioninformatique/template/security/JwtAuthFilter.java index 99bde1f6..623b1c4b 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/security/JwtAuthFilter.java +++ b/backend/src/main/java/ch/sectioninformatique/template/security/JwtAuthFilter.java @@ -7,6 +7,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.HttpHeaders; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; @@ -47,6 +49,7 @@ public class JwtAuthFilter extends OncePerRequestFilter { * - Manages user authentication state */ private final UserAuthenticationProvider userAuthenticationProvider; + private final MessageSource messageSource; private static final ObjectMapper mapper = new ObjectMapper(); @@ -87,24 +90,40 @@ protected void doFilterInternal( SecurityContextHolder.getContext().setAuthentication( userAuthenticationProvider.validateToken(authElements[1])); - } catch (SecurityExceptions.JwtTokenExpiredException | - SecurityExceptions.InvalidJwtSignatureException | - SecurityExceptions.MalformedJwtException | - SecurityExceptions.InvalidTokenTypeException | - SecurityExceptions.JwtVerificationException e) { + } catch (SecurityExceptions.JwtTokenExpiredException e) { SecurityContextHolder.clearContext(); log.debug("JWT validation failed: {}", e.getMessage()); - sendErrorResponse(response, e.getMessage()); + sendErrorResponse(response, getMessage("security.jwt.expired")); + return; + } catch (SecurityExceptions.InvalidJwtSignatureException e) { + SecurityContextHolder.clearContext(); + log.debug("JWT validation failed: {}", e.getMessage()); + sendErrorResponse(response, getMessage("security.jwt.invalidSignature")); + return; + } catch (SecurityExceptions.MalformedJwtException e) { + SecurityContextHolder.clearContext(); + log.debug("JWT validation failed: {}", e.getMessage()); + sendErrorResponse(response, getMessage("security.jwt.malformed")); + return; + } catch (SecurityExceptions.InvalidTokenTypeException e) { + SecurityContextHolder.clearContext(); + log.debug("JWT validation failed: {}", e.getMessage()); + sendErrorResponse(response, getMessage("security.jwt.invalidType")); + return; + } catch (SecurityExceptions.JwtVerificationException e) { + SecurityContextHolder.clearContext(); + log.debug("JWT validation failed: {}", e.getMessage()); + sendErrorResponse(response, getMessage("security.jwt.verificationFailed")); return; } catch (JWTVerificationException e) { SecurityContextHolder.clearContext(); log.debug("Invalid JWT token: {}", e.getMessage()); - sendErrorResponse(response, e.getMessage()); + sendErrorResponse(response, getMessage("security.jwt.invalid")); return; } catch (InvalidTokenException e) { SecurityContextHolder.clearContext(); log.debug("Invalid token: {}", e.getMessage()); - sendErrorResponse(response, e.getMessage()); + sendErrorResponse(response, getMessage("security.token.invalid")); return; } catch (RuntimeException e) { // Preserve behavior for other runtime exceptions @@ -114,7 +133,7 @@ protected void doFilterInternal( } else if (header != null && !header.isEmpty()) { // Header is present but malformed log.debug("Malformed Authorization header"); - sendErrorResponse(response, "Malformed Authorization header"); + sendErrorResponse(response, getMessage("security.authHeader.malformed")); return; } } @@ -136,4 +155,8 @@ private void sendErrorResponse(HttpServletResponse response, String message) thr mapper.writeValue(response.getWriter(), errorBody); response.getWriter().flush(); } + + private String getMessage(String key) { + return messageSource.getMessage(key, null, LocaleContextHolder.getLocale()); + } } \ No newline at end of file diff --git a/backend/src/main/java/ch/sectioninformatique/template/security/SecurityExceptions.java b/backend/src/main/java/ch/sectioninformatique/template/security/SecurityExceptions.java index 2932b775..b0d078b9 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/security/SecurityExceptions.java +++ b/backend/src/main/java/ch/sectioninformatique/template/security/SecurityExceptions.java @@ -14,7 +14,7 @@ public class SecurityExceptions { */ public static class InvalidTokenException extends AppException { public InvalidTokenException() { - super("Invalid or expired token", HttpStatus.UNAUTHORIZED); + super("security.token.invalid", HttpStatus.UNAUTHORIZED); } public InvalidTokenException(String message) { @@ -27,7 +27,7 @@ public InvalidTokenException(String message) { */ public static class InvalidRefreshTokenException extends AppException { public InvalidRefreshTokenException() { - super("Invalid or expired refresh token", HttpStatus.UNAUTHORIZED); + super("security.refresh.invalid", HttpStatus.UNAUTHORIZED); } public InvalidRefreshTokenException(String message) { @@ -44,7 +44,7 @@ public JwtVerificationException(String message) { } public JwtVerificationException() { - super("JWT token verification failed", HttpStatus.UNAUTHORIZED); + super("security.jwt.verificationFailed", HttpStatus.UNAUTHORIZED); } } @@ -53,7 +53,7 @@ public JwtVerificationException() { */ public static class JwtTokenExpiredException extends AppException { public JwtTokenExpiredException() { - super("JWT token has expired", HttpStatus.UNAUTHORIZED); + super("security.jwt.expired", HttpStatus.UNAUTHORIZED); } } @@ -62,7 +62,7 @@ public JwtTokenExpiredException() { */ public static class InvalidJwtSignatureException extends AppException { public InvalidJwtSignatureException() { - super("Invalid JWT signature", HttpStatus.UNAUTHORIZED); + super("security.jwt.invalidSignature", HttpStatus.UNAUTHORIZED); } } @@ -71,7 +71,7 @@ public InvalidJwtSignatureException() { */ public static class MalformedJwtException extends AppException { public MalformedJwtException() { - super("Malformed JWT token", HttpStatus.BAD_REQUEST); + super("security.jwt.malformed", HttpStatus.BAD_REQUEST); } public MalformedJwtException(String message) { @@ -84,7 +84,7 @@ public MalformedJwtException(String message) { */ public static class InvalidTokenTypeException extends AppException { public InvalidTokenTypeException() { - super("Invalid token type for this endpoint", HttpStatus.UNAUTHORIZED); + super("security.jwt.invalidType", HttpStatus.UNAUTHORIZED); } public InvalidTokenTypeException(String message) { @@ -97,7 +97,7 @@ public InvalidTokenTypeException(String message) { */ public static class AuthenticationRequiredException extends AppException { public AuthenticationRequiredException() { - super("Authentication required", HttpStatus.UNAUTHORIZED); + super("security.auth.required", HttpStatus.UNAUTHORIZED); } } @@ -106,7 +106,7 @@ public AuthenticationRequiredException() { */ public static class MissingAuthorizationHeaderException extends AppException { public MissingAuthorizationHeaderException() { - super("Missing authorization header", HttpStatus.UNAUTHORIZED); + super("security.authHeader.missing", HttpStatus.UNAUTHORIZED); } } @@ -115,7 +115,7 @@ public MissingAuthorizationHeaderException() { */ public static class InvalidAuthorizationHeaderException extends AppException { public InvalidAuthorizationHeaderException() { - super("Invalid authorization header format", HttpStatus.BAD_REQUEST); + super("security.authHeader.invalidFormat", HttpStatus.BAD_REQUEST); } } @@ -124,7 +124,7 @@ public InvalidAuthorizationHeaderException() { */ public static class AccessDeniedException extends AppException { public AccessDeniedException() { - super("Access denied", HttpStatus.FORBIDDEN); + super("error.accessDenied", HttpStatus.FORBIDDEN); } public AccessDeniedException(String message) { @@ -137,11 +137,11 @@ public AccessDeniedException(String message) { */ public static class InsufficientRoleException extends AppException { public InsufficientRoleException(String requiredRole) { - super("Insufficient role. Required: " + requiredRole, HttpStatus.FORBIDDEN); + super("security.role.insufficient", HttpStatus.FORBIDDEN, new Object[] { requiredRole }); } public InsufficientRoleException() { - super("Insufficient role", HttpStatus.FORBIDDEN); + super("security.role.insufficient", HttpStatus.FORBIDDEN); } } @@ -150,11 +150,11 @@ public InsufficientRoleException() { */ public static class InsufficientPermissionException extends AppException { public InsufficientPermissionException(String requiredPermission) { - super("Insufficient permission. Required: " + requiredPermission, HttpStatus.FORBIDDEN); + super("security.permission.insufficient", HttpStatus.FORBIDDEN, new Object[] { requiredPermission }); } public InsufficientPermissionException() { - super("Insufficient permission", HttpStatus.FORBIDDEN); + super("security.permission.insufficient", HttpStatus.FORBIDDEN); } } @@ -163,7 +163,7 @@ public InsufficientPermissionException() { */ public static class InvalidSecurityContextException extends AppException { public InvalidSecurityContextException() { - super("Invalid security context", HttpStatus.UNAUTHORIZED); + super("security.context.invalid", HttpStatus.UNAUTHORIZED); } public InvalidSecurityContextException(String message) { @@ -176,11 +176,11 @@ public InvalidSecurityContextException(String message) { */ public static class TokenCreationException extends AppException { public TokenCreationException(String message) { - super("Failed to create token: " + message, HttpStatus.INTERNAL_SERVER_ERROR); + super("security.token.creationFailed", HttpStatus.INTERNAL_SERVER_ERROR, new Object[] { message }); } public TokenCreationException() { - super("Failed to create token", HttpStatus.INTERNAL_SERVER_ERROR); + super("security.token.creationFailed", HttpStatus.INTERNAL_SERVER_ERROR); } } @@ -189,7 +189,7 @@ public TokenCreationException() { */ public static class SecurityConfigurationException extends AppException { public SecurityConfigurationException(String message) { - super("Security configuration error: " + message, HttpStatus.INTERNAL_SERVER_ERROR); + super("security.config.error", HttpStatus.INTERNAL_SERVER_ERROR, new Object[] { message }); } } @@ -198,7 +198,7 @@ public SecurityConfigurationException(String message) { */ public static class CorsViolationException extends AppException { public CorsViolationException() { - super("CORS policy violation", HttpStatus.FORBIDDEN); + super("security.cors.violation", HttpStatus.FORBIDDEN); } public CorsViolationException(String message) { @@ -211,7 +211,7 @@ public CorsViolationException(String message) { */ public static class InvalidSessionException extends AppException { public InvalidSessionException() { - super("Invalid or expired session", HttpStatus.UNAUTHORIZED); + super("security.session.invalid", HttpStatus.UNAUTHORIZED); } public InvalidSessionException(String message) { @@ -224,7 +224,7 @@ public InvalidSessionException(String message) { */ public static class AuthenticationProviderException extends AppException { public AuthenticationProviderException(String message) { - super("Authentication provider error: " + message, HttpStatus.INTERNAL_SERVER_ERROR); + super("security.authProvider.error", HttpStatus.INTERNAL_SERVER_ERROR, new Object[] { message }); } } } diff --git a/backend/src/main/java/ch/sectioninformatique/template/security/UserAuthenticationEntryPoint.java b/backend/src/main/java/ch/sectioninformatique/template/security/UserAuthenticationEntryPoint.java index 984ea58a..61098f48 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/security/UserAuthenticationEntryPoint.java +++ b/backend/src/main/java/ch/sectioninformatique/template/security/UserAuthenticationEntryPoint.java @@ -4,6 +4,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; @@ -30,6 +32,11 @@ public class UserAuthenticationEntryPoint implements AuthenticationEntryPoint { /** Object mapper for JSON serialization of error responses */ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final MessageSource messageSource; + + public UserAuthenticationEntryPoint(MessageSource messageSource) { + this.messageSource = messageSource; + } /** * Handles unauthenticated requests by sending a JSON response with an error message. @@ -37,9 +44,10 @@ public class UserAuthenticationEntryPoint implements AuthenticationEntryPoint { * The response includes: * - HTTP 401 Unauthorized status code * - Content-Type: application/json header - * - JSON body containing either: - * - The specific authentication exception message if available - * - A default "Invalid or missing authentication token" message if no specific message is available + * - JSON body containing an error message resolved from i18n keys: + * - Uses the authentication exception message if present + * - Falls back to "Invalid or missing authentication token" when the exception has no message + * - Uses a generic authentication failure message if no exception is provided * * @param request The HTTP request that triggered the authentication failure * @param response The HTTP response to be sent back to the client @@ -55,14 +63,20 @@ public void commence( response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); - String errorMessage = "Authentication failed"; + String errorMessage = getMessage("security.auth.failed"); if (authException != null) { - errorMessage = authException.getMessage(); - if (errorMessage == null || errorMessage.isEmpty()) { - errorMessage = "Invalid or missing authentication token"; + String authMessage = authException.getMessage(); + if (authMessage != null && !authMessage.isEmpty()) { + errorMessage = authMessage; + } else { + errorMessage = getMessage("security.auth.missingOrInvalidToken"); } } OBJECT_MAPPER.writeValue(response.getOutputStream(), new ErrorDto(errorMessage)); } + + private String getMessage(String key) { + return messageSource.getMessage(key, null, LocaleContextHolder.getLocale()); + } } diff --git a/backend/src/main/java/ch/sectioninformatique/template/test/TestController.java b/backend/src/main/java/ch/sectioninformatique/template/test/TestController.java index 312ce687..b85c0fa8 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/test/TestController.java +++ b/backend/src/main/java/ch/sectioninformatique/template/test/TestController.java @@ -5,6 +5,8 @@ import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.core.env.Environment; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -38,6 +40,7 @@ public class TestController { /** Service for handling user-related operations */ private final UserService userService; + private final MessageSource messageSource; @Autowired private Environment environment; @@ -95,7 +98,11 @@ public ResponseEntity authenticatedUser() { public ResponseEntity promoteToTestAdmin(@PathVariable Long userId) { userService.promoteToLocalAppRole(userId); - return ResponseEntity.ok(Map.of("message", "User promoted to local app role successfully.")); + String message = messageSource.getMessage( + "user.promoted.local", + null, + LocaleContextHolder.getLocale()); + return ResponseEntity.ok(Map.of("message", message)); } diff --git a/backend/src/main/java/ch/sectioninformatique/template/user/UserController.java b/backend/src/main/java/ch/sectioninformatique/template/user/UserController.java index 1b364f8c..9c646482 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/user/UserController.java +++ b/backend/src/main/java/ch/sectioninformatique/template/user/UserController.java @@ -4,6 +4,8 @@ import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; @@ -39,6 +41,8 @@ public class UserController { /** Service for handling user-related operations */ private final UserService userService; + private final MessageSource messageSource; + /** * Client for making user-related HTTP requests to the authentication service */ @@ -137,7 +141,11 @@ public Mono> delete(@RequestHeader("Authorization") String tok .map(message -> ResponseEntity.ok(Map.of("message", message))); } else { userService.deleteUser(userId); - return Mono.just(ResponseEntity.ok(Map.of("message", "Local User deleted successfully"))); + String message = messageSource.getMessage( + "user.deleted.local", + null, + LocaleContextHolder.getLocale()); + return Mono.just(ResponseEntity.ok(Map.of("message", message))); } } @@ -166,7 +174,11 @@ public Mono> deletePermanent(@RequestHeader("Authorization") S } else { // Permanently delete user from local database only userService.deleteUserPermanent(userId); - return Mono.just(ResponseEntity.ok(Map.of("message", "Local User deleted successfully"))); + String message = messageSource.getMessage( + "user.deleted.local", + null, + LocaleContextHolder.getLocale()); + return Mono.just(ResponseEntity.ok(Map.of("message", message))); } } @@ -300,6 +312,10 @@ public Mono> downgradeAdmin(@RequestHeader("Authorization @PreAuthorize("hasAuthority('user:update')") public ResponseEntity promoteToLocalAppRole(@PathVariable Long userId) { userService.promoteToLocalAppRole(userId); - return ResponseEntity.ok().body("User promoted to local app role successfully."); + String message = messageSource.getMessage( + "user.promoted.local", + null, + LocaleContextHolder.getLocale()); + return ResponseEntity.ok().body(message); } } diff --git a/backend/src/main/java/ch/sectioninformatique/template/user/UserExceptions.java b/backend/src/main/java/ch/sectioninformatique/template/user/UserExceptions.java index 0d666307..f4695ee1 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/user/UserExceptions.java +++ b/backend/src/main/java/ch/sectioninformatique/template/user/UserExceptions.java @@ -14,7 +14,7 @@ public class UserExceptions { */ public static class UserNotFoundException extends AppException { public UserNotFoundException() { - super("User not found", HttpStatus.NOT_FOUND); + super("user.notFound", HttpStatus.NOT_FOUND); } public UserNotFoundException(String message) { @@ -22,7 +22,7 @@ public UserNotFoundException(String message) { } public UserNotFoundException(Long userId) { - super("User not found with ID: " + userId, HttpStatus.NOT_FOUND); + super("user.notFound.id", HttpStatus.NOT_FOUND, new Object[] { userId }); } } @@ -31,7 +31,7 @@ public UserNotFoundException(Long userId) { */ public static class UserNotFoundByLoginException extends AppException { public UserNotFoundByLoginException(String login) { - super("User not found with login: " + login, HttpStatus.NOT_FOUND); + super("user.notFound.login", HttpStatus.NOT_FOUND, new Object[] { login }); } } @@ -40,11 +40,11 @@ public UserNotFoundByLoginException(String login) { */ public static class UserAlreadyHasRoleException extends AppException { public UserAlreadyHasRoleException(String roleName) { - super("The user already has the " + roleName + " role", HttpStatus.CONFLICT); + super("user.role.alreadyHas", HttpStatus.CONFLICT, new Object[] { roleName }); } public UserAlreadyHasRoleException() { - super("User already has the specified role", HttpStatus.CONFLICT); + super("user.role.alreadyHas", HttpStatus.CONFLICT); } } @@ -53,11 +53,11 @@ public UserAlreadyHasRoleException() { */ public static class UserCreationException extends AppException { public UserCreationException(String message) { - super("Failed to create user: " + message, HttpStatus.BAD_REQUEST); + super("user.create.failed", HttpStatus.BAD_REQUEST, new Object[] { message }); } public UserCreationException() { - super("Failed to create user", HttpStatus.BAD_REQUEST); + super("user.create.failed", HttpStatus.BAD_REQUEST); } } @@ -66,11 +66,11 @@ public UserCreationException() { */ public static class UserUpdateException extends AppException { public UserUpdateException(String message) { - super("Failed to update user: " + message, HttpStatus.BAD_REQUEST); + super("user.update.failed", HttpStatus.BAD_REQUEST, new Object[] { message }); } public UserUpdateException() { - super("Failed to update user", HttpStatus.BAD_REQUEST); + super("user.update.failed", HttpStatus.BAD_REQUEST); } } @@ -79,11 +79,15 @@ public UserUpdateException() { */ public static class UserDeletionException extends AppException { public UserDeletionException(String message) { - super("Failed to delete user: " + message, HttpStatus.BAD_REQUEST); + super("user.delete.failed", HttpStatus.BAD_REQUEST, new Object[] { message }); + } + + public UserDeletionException(String messageKey, boolean useKey) { + super(messageKey, HttpStatus.BAD_REQUEST); } public UserDeletionException() { - super("Failed to delete user", HttpStatus.BAD_REQUEST); + super("user.delete.failed", HttpStatus.BAD_REQUEST); } } @@ -92,11 +96,11 @@ public UserDeletionException() { */ public static class UserAlreadyDeletedException extends AppException { public UserAlreadyDeletedException() { - super("User is already deleted", HttpStatus.CONFLICT); + super("user.alreadyDeleted", HttpStatus.CONFLICT); } public UserAlreadyDeletedException(Long userId) { - super("User with ID " + userId + " is already deleted", HttpStatus.CONFLICT); + super("user.alreadyDeleted.id", HttpStatus.CONFLICT, new Object[] { userId }); } } @@ -105,7 +109,7 @@ public UserAlreadyDeletedException(Long userId) { */ public static class UserPromotionException extends AppException { public UserPromotionException(String message) { - super("Failed to promote user: " + message, HttpStatus.BAD_REQUEST); + super("user.promote.failed", HttpStatus.BAD_REQUEST, new Object[] { message }); } } @@ -114,11 +118,11 @@ public UserPromotionException(String message) { */ public static class RoleNotFoundException extends AppException { public RoleNotFoundException(String roleName) { - super(roleName + " role not found", HttpStatus.NOT_FOUND); + super("user.role.notFound", HttpStatus.NOT_FOUND, new Object[] { roleName }); } public RoleNotFoundException() { - super("Role not found", HttpStatus.NOT_FOUND); + super("user.role.notFound", HttpStatus.NOT_FOUND); } } @@ -127,7 +131,7 @@ public RoleNotFoundException() { */ public static class DefaultRoleNotFoundException extends AppException { public DefaultRoleNotFoundException() { - super("Default role not found", HttpStatus.INTERNAL_SERVER_ERROR); + super("user.role.defaultNotFound", HttpStatus.INTERNAL_SERVER_ERROR); } } @@ -136,7 +140,11 @@ public DefaultRoleNotFoundException() { */ public static class UserValidationException extends AppException { public UserValidationException(String message) { - super("User validation failed: " + message, HttpStatus.BAD_REQUEST); + super("user.validation.failed", HttpStatus.BAD_REQUEST, new Object[] { message }); + } + + public UserValidationException(String messageKey, boolean useKey) { + super(messageKey, HttpStatus.BAD_REQUEST); } } @@ -145,7 +153,7 @@ public UserValidationException(String message) { */ public static class UserMappingException extends AppException { public UserMappingException(String message) { - super("Failed to map user: " + message, HttpStatus.INTERNAL_SERVER_ERROR); + super("user.mapping.failed", HttpStatus.INTERNAL_SERVER_ERROR, new Object[] { message }); } } @@ -154,7 +162,7 @@ public UserMappingException(String message) { */ public static class UserSeedingException extends AppException { public UserSeedingException(String message) { - super("User seeding failed: " + message, HttpStatus.INTERNAL_SERVER_ERROR); + super("user.seeding.failed", HttpStatus.INTERNAL_SERVER_ERROR, new Object[] { message }); } } @@ -163,7 +171,7 @@ public UserSeedingException(String message) { */ public static class UserRetrievalException extends AppException { public UserRetrievalException(String message) { - super("Failed to retrieve user: " + message, HttpStatus.INTERNAL_SERVER_ERROR); + super("user.retrieve.failed", HttpStatus.INTERNAL_SERVER_ERROR, new Object[] { message }); } } @@ -172,7 +180,7 @@ public UserRetrievalException(String message) { */ public static class InactiveUserException extends AppException { public InactiveUserException() { - super("User account is inactive", HttpStatus.FORBIDDEN); + super("user.inactive", HttpStatus.FORBIDDEN); } public InactiveUserException(String message) { @@ -185,7 +193,7 @@ public InactiveUserException(String message) { */ public static class UserRoleUpdateException extends AppException { public UserRoleUpdateException(String message) { - super("Failed to update user role: " + message, HttpStatus.BAD_REQUEST); + super("user.role.update.failed", HttpStatus.BAD_REQUEST, new Object[] { message }); } } @@ -194,11 +202,11 @@ public UserRoleUpdateException(String message) { */ public static class DuplicateUserException extends AppException { public DuplicateUserException(String message) { - super("Duplicate user detected: " + message, HttpStatus.CONFLICT); + super("user.duplicate", HttpStatus.CONFLICT, new Object[] { message }); } public DuplicateUserException() { - super("Duplicate user detected", HttpStatus.CONFLICT); + super("user.duplicate", HttpStatus.CONFLICT); } } @@ -207,11 +215,11 @@ public DuplicateUserException() { */ public static class PermanentUserDeletionException extends AppException { public PermanentUserDeletionException(String message) { - super("Failed to permanently delete user: " + message, HttpStatus.BAD_REQUEST); + super("user.delete.permanent.failed", HttpStatus.BAD_REQUEST, new Object[] { message }); } public PermanentUserDeletionException() { - super("Failed to permanently delete user", HttpStatus.BAD_REQUEST); + super("user.delete.permanent.failed", HttpStatus.BAD_REQUEST); } } } diff --git a/backend/src/main/java/ch/sectioninformatique/template/user/UserService.java b/backend/src/main/java/ch/sectioninformatique/template/user/UserService.java index 24a595ea..ce12755b 100644 --- a/backend/src/main/java/ch/sectioninformatique/template/user/UserService.java +++ b/backend/src/main/java/ch/sectioninformatique/template/user/UserService.java @@ -182,7 +182,7 @@ public User register(RegisterDto registerDto) { if (registerDto.login() == null || registerDto.login().isBlank() || registerDto.firstName() == null || registerDto.firstName().isBlank() || registerDto.lastName() == null || registerDto.lastName().isBlank()) { - throw new UserValidationException("Missing mandatory user fields"); + throw new UserValidationException("user.validation.missingFields", true); } Optional optionalUser = userRepository.findByLogin(registerDto.login()); @@ -432,7 +432,7 @@ public UserDto findByLogin(String login) { }); if (user.isDeleted()) { - throw new InactiveUserException("User is inactive or deleted"); + throw new InactiveUserException("user.inactive.orDeleted"); } log.debug("User details - ID: {}, FirstName: {}, LastName: {}, Roles: {}", @@ -473,7 +473,7 @@ public reactor.core.publisher.Mono deleteGlobalAndLocal(String token, Lo return reactor.core.publisher.Mono.just(body.get("message")); } else { return reactor.core.publisher.Mono.error( - new UserDeletionException("Failed to delete user: missing response data")); + new UserDeletionException("user.delete.failed.missingResponse", true)); } }); } @@ -500,7 +500,7 @@ public reactor.core.publisher.Mono deleteGlobalAndLocalPermanent(String return reactor.core.publisher.Mono.just(body.get("message")); } else { return reactor.core.publisher.Mono.error( - new UserDeletionException("Failed to delete user: missing response data")); + new UserDeletionException("user.delete.failed.missingResponse", true)); } }); } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 52835aa7..f2178c93 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -50,3 +50,6 @@ spring.security.oauth2.client.registration.azure.authorization-grant-type=author spring.security.oauth2.client.registration.azure.redirect-uri={baseUrl}/login/oauth2/code/{registrationId} spring.security.oauth2.client.registration.azure.scope=openid,profile,email,User.Read spring.security.oauth2.client.registration.azure.client-name=Azure + +# Folder Redirection +spring.messages.basename=messages/messages \ No newline at end of file diff --git a/backend/src/main/resources/messages/messages.properties b/backend/src/main/resources/messages/messages.properties new file mode 100644 index 00000000..fcb68033 --- /dev/null +++ b/backend/src/main/resources/messages/messages.properties @@ -0,0 +1,74 @@ +## Errors (generic) +error.accessDenied=Access denied: You do not have permission to access this resource +error.validation.failed=Validation failed +error.missingParameter={0} parameter is missing +error.mediaTypeNotSupported=Unsupported media type +error.json.malformed=Malformed or missing JSON request body +error.json.incomplete=JSON is incomplete - missing closing bracket or quote +error.json.invalidCharacter=JSON contains invalid character - check for unescaped quotes or missing commas +error.json.invalidType=Invalid value type for a field - check your data types match the schema +error.json.empty=Empty or missing request body + +## Auth +auth.invalidCredentials=Invalid credentials +auth.userAlreadyExists=User already exists +auth.loginAlreadyExists=Login already exists +auth.password.updated=Password updated successfully +auth.password.update.failed=Password update failed: {0} +auth.register.failed=Registration failed: {0} + +## Items +item.notFound=Could not find item +item.unauthorized=You can only perform this operation on your own items + +## Users +user.notFound=User not found +user.notFound.id=User not found with ID: {0} +user.notFound.login=User not found with login: {0} +user.role.alreadyHas=The user already has the {0} role +user.create.failed=Failed to create user: {0} +user.update.failed=Failed to update user: {0} +user.validation.missingFields=Missing mandatory user fields +user.inactive.orDeleted=User is inactive or deleted +user.delete.failed=Failed to delete user: {0} +user.alreadyDeleted=User is already deleted +user.alreadyDeleted.id=User with ID {0} is already deleted +user.promote.failed=Failed to promote user: {0} +user.role.notFound={0} role not found +user.role.defaultNotFound=Default role not found +user.delete.failed.missingResponse=Failed to delete user: missing response data +user.validation.failed=User validation failed: {0} +user.mapping.failed=Failed to map user: {0} +user.seeding.failed=User seeding failed: {0} +user.retrieve.failed=Failed to retrieve user: {0} +user.inactive=User account is inactive +user.role.update.failed=Failed to update user role: {0} +user.duplicate=Duplicate user detected: {0} +user.delete.permanent.failed=Failed to permanently delete user: {0} +user.deleted.local=Local user deleted successfully +user.promoted.local=User promoted to local app role successfully + +## Security / JWT +security.auth.required=Authentication required +security.authHeader.missing=Missing authorization header +security.authHeader.invalidFormat=Invalid authorization header format +security.authHeader.malformed=Malformed Authorization header +security.jwt.expired=JWT token has expired +security.jwt.invalidSignature=Invalid JWT signature +security.jwt.malformed=Malformed JWT token +security.jwt.invalidType=Invalid token type for this endpoint +security.jwt.verificationFailed=JWT token verification failed +security.refresh.invalid=Invalid or expired refresh token +security.role.insufficient=Insufficient role. Required: {0} +security.permission.insufficient=Insufficient permission. Required: {0} +security.context.invalid=Invalid security context +security.token.creationFailed=Failed to create token: {0} +security.config.error=Security configuration error: {0} +security.cors.violation=CORS policy violation +security.session.invalid=Invalid or expired session +security.authProvider.error=Authentication provider error: {0} +security.token.invalid=Invalid or expired token +security.jwt.invalid=Invalid JWT token +security.auth.unauthorized=Unauthorized +security.auth.failed=Authentication failed +security.auth.missingOrInvalidToken=Invalid or missing authentication token diff --git a/backend/src/main/resources/messages/messages_fr.properties b/backend/src/main/resources/messages/messages_fr.properties new file mode 100644 index 00000000..f66ef6b8 --- /dev/null +++ b/backend/src/main/resources/messages/messages_fr.properties @@ -0,0 +1,74 @@ +## Erreurs (général) +error.accessDenied=Accès refusé : vous n''avez pas l''autorisation d''accéder à cette ressource +error.validation.failed=Échec de la validation +error.missingParameter=Le paramètre {0} est manquant +error.mediaTypeNotSupported=Type de média non pris en charge +error.json.malformed=Corps JSON malformé ou manquant +error.json.incomplete=JSON incomplet - crochet ou guillemet de fermeture manquant +error.json.invalidCharacter=JSON contient un caractère invalide - vérifiez les guillemets non échappés ou les virgules manquantes +error.json.invalidType=Type de valeur invalide pour un champ - vérifiez que les types correspondent au schéma +error.json.empty=Corps de requête vide ou manquant + +## Auth +auth.invalidCredentials=Identifiants invalides +auth.userAlreadyExists=Utilisateur déjà existant +auth.loginAlreadyExists=Identifiant déjà utilisé +auth.password.updated=Mot de passe mis à jour avec succès +auth.password.update.failed=Échec de la mise à jour du mot de passe : {0} +auth.register.failed=Échec de l''inscription : {0} + +## Éléments +item.notFound=Impossible de trouver l''élément +item.unauthorized=Vous ne pouvez effectuer cette opération que sur vos propres éléments + +## Utilisateurs +user.notFound=Utilisateur introuvable +user.notFound.id=Utilisateur introuvable avec l''ID : {0} +user.notFound.login=Utilisateur introuvable avec le login : {0} +user.role.alreadyHas=L''utilisateur possède déjà le rôle {0} +user.create.failed=Échec de la création de l''utilisateur : {0} +user.update.failed=Échec de la mise à jour de l''utilisateur : {0} +user.validation.missingFields=Champs obligatoires de l''utilisateur manquants +user.inactive.orDeleted=L''utilisateur est inactif ou supprimé +user.delete.failed=Échec de la suppression de l''utilisateur : {0} +user.delete.failed.missingResponse=Échec de la suppression de l''utilisateur : données de réponse manquantes +user.alreadyDeleted=L''utilisateur est déjà supprimé +user.alreadyDeleted.id=L''utilisateur avec l''ID {0} est déjà supprimé +user.promote.failed=Échec de la promotion de l''utilisateur : {0} +user.role.notFound=Rôle {0} introuvable +user.role.defaultNotFound=Rôle par défaut introuvable +user.validation.failed=Échec de la validation de l''utilisateur : {0} +user.mapping.failed=Échec du mappage de l''utilisateur : {0} +user.seeding.failed=Échec de l''initialisation des utilisateurs : {0} +user.retrieve.failed=Échec de la récupération de l''utilisateur : {0} +user.inactive=Le compte utilisateur est inactif +user.role.update.failed=Échec de la mise à jour du rôle utilisateur : {0} +user.duplicate=Utilisateur en double détecté : {0} +user.delete.permanent.failed=Échec de la suppression définitive de l''utilisateur : {0} +user.deleted.local=Utilisateur local supprimé avec succès +user.promoted.local=Utilisateur promu au rôle local de l''application avec succès + +## Sécurité / JWT +security.auth.required=Authentification requise +security.authHeader.missing=En-tête Authorization manquant +security.authHeader.invalidFormat=Format d''en-tête Authorization invalide +security.authHeader.malformed=En-tête Authorization malformé +security.jwt.expired=Le jeton JWT a expiré +security.jwt.invalidSignature=Signature JWT invalide +security.jwt.malformed=Jeton JWT malformé +security.jwt.invalidType=Type de jeton invalide pour ce point de terminaison +security.jwt.verificationFailed=Échec de la vérification du jeton JWT +security.refresh.invalid=Jeton d''actualisation invalide ou expiré +security.role.insufficient=Rôle insuffisant. Requis : {0} +security.permission.insufficient=Permission insuffisante. Requise : {0} +security.context.invalid=Contexte de sécurité invalide +security.token.creationFailed=Échec de la création du jeton : {0} +security.config.error=Erreur de configuration de sécurité : {0} +security.cors.violation=Violation de la politique CORS +security.session.invalid=Session invalide ou expirée +security.authProvider.error=Erreur du fournisseur d''authentification : {0} +security.token.invalid=Jeton invalide ou expiré +security.jwt.invalid=Jeton JWT invalide +security.auth.unauthorized=Non autorisé +security.auth.failed=Échec de l''authentification +security.auth.missingOrInvalidToken=Jeton d''authentification invalide ou manquant diff --git a/backend/src/test/java/ch/sectioninformatique/template/auth/AuthControllerTest.java b/backend/src/test/java/ch/sectioninformatique/template/auth/AuthControllerTest.java index bdc991c6..d8df02a9 100644 --- a/backend/src/test/java/ch/sectioninformatique/template/auth/AuthControllerTest.java +++ b/backend/src/test/java/ch/sectioninformatique/template/auth/AuthControllerTest.java @@ -10,6 +10,8 @@ import jakarta.servlet.http.Cookie; import ch.sectioninformatique.template.security.UserAuthenticationProvider; import reactor.core.publisher.Mono; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; @@ -73,6 +75,9 @@ public class AuthControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private MessageSource messageSource; + @MockitoBean private AuthClient authClient; // mock this instead of the controller @@ -163,6 +168,10 @@ private void performRequest( } + private String getMessage(String key, Object... args) { + return messageSource.getMessage(key, args, LocaleContextHolder.getLocale()); + } + /** * Helper method for performing and documenting HTTP requests with a cookie. * This keeps tests consistent with performRequest while allowing cookie-based @@ -292,6 +301,7 @@ public void login_withInvalidCredentials_shouldReturn401() throws Exception { response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message").value(getMessage("auth.invalidCredentials"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -368,6 +378,7 @@ public void register_withExistingUser_shouldReturn409Conflict() throws Exception response -> { try { response.andExpect(status().isConflict()); + response.andExpect(jsonPath("$.message").value(getMessage("auth.userAlreadyExists"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -451,7 +462,8 @@ public void refresh_withInvalidToken_shouldReturn401() throws Exception { @Test @Transactional public void updatePassword_withValidToken_shouldReturn200() throws Exception { - MessageResponseDto messageResponse = new MessageResponseDto("Password updated successfully"); + String successMessage = getMessage("auth.password.updated"); + MessageResponseDto messageResponse = new MessageResponseDto(successMessage); when(authClient.updatePassword(any(String.class), any(PasswordUpdateDto.class))) .thenReturn(Mono.just(ResponseEntity.ok(messageResponse))); @@ -469,7 +481,7 @@ public void updatePassword_withValidToken_shouldReturn200() throws Exception { response -> { try { response.andExpect(status().isOk()); - response.andExpect(jsonPath("$.message").value("Password updated successfully")); + response.andExpect(jsonPath("$.message").value(successMessage)); } catch (Exception e) { throw new RuntimeException(e); } @@ -545,8 +557,9 @@ public void oauth2Login_inTestEnvironment_shouldReturn401() throws Exception { @Test @Transactional public void updatePassword_withFailure_shouldReturn400PasswordUpdateFailed() throws Exception { + String errorMessage = getMessage("auth.password.update.failed", "Old password is incorrect"); when(authClient.updatePassword(any(String.class), any(PasswordUpdateDto.class))) - .thenReturn(Mono.error(new PasswordUpdateFailedException("Old password is incorrect"))); + .thenReturn(Mono.error(new PasswordUpdateFailedException(errorMessage))); String validToken = getValidTokenForTestUser(); @@ -561,8 +574,7 @@ public void updatePassword_withFailure_shouldReturn400PasswordUpdateFailed() thr response -> { try { response.andExpect(status().isBadRequest()); - // Verify error message in response - response.andExpect(jsonPath("$.message").exists()); + response.andExpect(jsonPath("$.message").value(errorMessage)); } catch (Exception e) { throw new RuntimeException(e); } @@ -585,8 +597,9 @@ public void updatePassword_withFailure_shouldReturn400PasswordUpdateFailed() thr @Test @Transactional public void register_withValidationError_shouldReturn400RegistrationFailed() throws Exception { + String errorMessage = getMessage("auth.register.failed", "Invalid email format"); when(authClient.register(any(RegisterDto.class))) - .thenReturn(Mono.error(new AuthExceptions.RegistrationFailedException("Invalid email format"))); + .thenReturn(Mono.error(new AuthExceptions.RegistrationFailedException(errorMessage))); performRequest( "POST", @@ -599,6 +612,7 @@ public void register_withValidationError_shouldReturn400RegistrationFailed() thr response -> { try { response.andExpect(status().isBadRequest()); + response.andExpect(jsonPath("$.message").value(errorMessage)); } catch (Exception e) { throw new RuntimeException(e); } @@ -623,7 +637,7 @@ public void register_withValidationError_shouldReturn400RegistrationFailed() thr @Transactional public void refresh_withExpiredToken_shouldReturn401InvalidToken() throws Exception { when(authClient.refreshLogin(anyString())) - .thenReturn(Mono.error(new InvalidTokenException("Token has expired"))); + .thenReturn(Mono.error(new InvalidTokenException())); Cookie refreshCookie = new Cookie("refresh_token", "expiredToken"); performRequest( @@ -638,6 +652,7 @@ public void refresh_withExpiredToken_shouldReturn401InvalidToken() throws Except response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message").value(getMessage("security.token.invalid"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -663,7 +678,7 @@ public void refresh_withExpiredToken_shouldReturn401InvalidToken() throws Except @Transactional public void refresh_withMalformedRefreshToken_shouldReturn401InvalidRefreshToken() throws Exception { when(authClient.refreshLogin(anyString())) - .thenReturn(Mono.error(new InvalidRefreshTokenException("Malformed refresh token"))); + .thenReturn(Mono.error(new InvalidRefreshTokenException())); Cookie refreshCookie = new Cookie("refresh_token", "malformed"); performRequest( @@ -678,6 +693,7 @@ public void refresh_withMalformedRefreshToken_shouldReturn401InvalidRefreshToken response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message").value(getMessage("security.refresh.invalid"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -716,7 +732,7 @@ public void login_withWrongPassword_shouldReturn401InvalidCredentials() throws E response -> { try { response.andExpect(status().isUnauthorized()); - response.andExpect(jsonPath("$.message").exists()); + response.andExpect(jsonPath("$.message").value(getMessage("auth.invalidCredentials"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -781,8 +797,7 @@ public void updatePassword_withMalformedToken_shouldReturn401InvalidToken() thro @Transactional public void login_withNonExistentUser_shouldReturn404UserNotFound() throws Exception { when(authClient.login(any(CredentialsDto.class))) - .thenReturn(Mono.error( - new AuthExceptions.UserNotFoundException("User with login 'nonexistent@test.com' not found"))); + .thenReturn(Mono.error(new AuthExceptions.UserNotFoundException())); performRequest( "POST", @@ -795,6 +810,7 @@ public void login_withNonExistentUser_shouldReturn404UserNotFound() throws Excep response -> { try { response.andExpect(status().isNotFound()); + response.andExpect(jsonPath("$.message").value(getMessage("user.notFound"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -816,7 +832,7 @@ public void login_withNonExistentUser_shouldReturn404UserNotFound() throws Excep @Transactional public void register_withDuplicateLogin_shouldReturn409Conflict() throws Exception { when(authClient.register(any(RegisterDto.class))) - .thenReturn(Mono.error(new UserAlreadyExistsException("User with this email already registered"))); + .thenReturn(Mono.error(new UserAlreadyExistsException())); performRequest( "POST", @@ -829,6 +845,7 @@ public void register_withDuplicateLogin_shouldReturn409Conflict() throws Excepti response -> { try { response.andExpect(status().isConflict()); + response.andExpect(jsonPath("$.message").value(getMessage("auth.userAlreadyExists"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -855,7 +872,7 @@ public void register_withDuplicateLogin_shouldReturn409Conflict() throws Excepti @Transactional public void refresh_withInvalidJwtSignature_shouldReturn401JwtVerificationFailed() throws Exception { when(authClient.refreshLogin(anyString())) - .thenReturn(Mono.error(new JwtVerificationException("JWT signature mismatch"))); + .thenReturn(Mono.error(new JwtVerificationException())); Cookie refreshCookie = new Cookie("refresh_token", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.invalidSignature"); performRequest( @@ -870,6 +887,7 @@ public void refresh_withInvalidJwtSignature_shouldReturn401JwtVerificationFailed response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message").value(getMessage("security.jwt.verificationFailed"))); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/backend/src/test/java/ch/sectioninformatique/template/test/TestControllerTest.java b/backend/src/test/java/ch/sectioninformatique/template/test/TestControllerTest.java index f655b35a..f9b4d6fc 100644 --- a/backend/src/test/java/ch/sectioninformatique/template/test/TestControllerTest.java +++ b/backend/src/test/java/ch/sectioninformatique/template/test/TestControllerTest.java @@ -12,6 +12,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.transaction.annotation.Transactional; @@ -66,6 +68,9 @@ public class TestControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private MessageSource messageSource; + @MockitoBean private AuthClient authClient; @@ -115,6 +120,10 @@ private void performRequest( } + private String getMessage(String key, Object... args) { + return messageSource.getMessage(key, args, LocaleContextHolder.getLocale()); + } + /** * Test: GET /tests/ * @@ -160,7 +169,8 @@ public void getHello_missingToken_shouldReturnUnauthorized() throws Exception { "get-hello/401/missing-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -218,7 +228,8 @@ public void getHello_withExpiredToken_shouldReturnUnauthorized() throws Exceptio "get-hello/401/expired-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(getMessage("security.jwt.expired"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -279,7 +290,8 @@ public void me_missingToken_shouldReturnUnauthorized() throws Exception { "me/401/missing-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -337,7 +349,8 @@ public void me_withExpiredToken_shouldReturnUnauthorized() throws Exception { "me/401/expired-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(getMessage("security.jwt.expired"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -371,7 +384,8 @@ public void promoteToTestAdmin_withRealData_shouldReturnSuccess() throws Excepti "promote-test", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(getMessage("user.promoted.local"))); UserDto updatedUser = userService.findByLogin("test.user@test.com"); @@ -404,7 +418,8 @@ public void promoteToTestAdmin_missingToken_shouldReturnUnauthorized() throws Ex "promote-test/401/missing-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -465,7 +480,8 @@ public void promoteToTestAdmin_withExpiredToken_shouldReturnUnauthorized() throw "promote-test/401/expired-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(getMessage("security.jwt.expired"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -495,7 +511,8 @@ public void promoteToTestAdmin_asNonAdmin_shouldReturnForbidden() throws Excepti "promote-test/403/non-admin", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(getMessage("error.accessDenied"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -528,7 +545,8 @@ public void promoteToTestAdmin_userNotFound_shouldReturnNotFound() throws Except "promote-test/404/user-not-found", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(getMessage("user.notFound"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -609,7 +627,8 @@ public void all_missingToken_shouldReturnUnauthorized() throws Exception { "all/401/missing-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -640,7 +659,8 @@ public void all_withExpiredToken_shouldReturnUnauthorized() throws Exception { "all/401/expired-token", request -> { try { - request.andExpect(jsonPath("$.message").exists()); + request.andExpect(jsonPath("$.message") + .value(getMessage("security.jwt.expired"))); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/backend/src/test/java/ch/sectioninformatique/template/user/UserControllerTest.java b/backend/src/test/java/ch/sectioninformatique/template/user/UserControllerTest.java index e73eda8d..b5f8e1ec 100644 --- a/backend/src/test/java/ch/sectioninformatique/template/user/UserControllerTest.java +++ b/backend/src/test/java/ch/sectioninformatique/template/user/UserControllerTest.java @@ -9,6 +9,8 @@ import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -75,6 +77,9 @@ public class UserControllerTest { @Autowired private UserRepository userRepository; + @Autowired + private MessageSource messageSource; + /** * Mock client for external auth service (only used for global delete * operations) @@ -103,6 +108,10 @@ private String getValidTokenForUser(String login) { return userAuthenticationProvider.createToken(userDto); } + private String getMessage(String key, Object... args) { + return messageSource.getMessage(key, args, LocaleContextHolder.getLocale()); + } + /** * Helper method for performing and documenting HTTP requests in tests. * This reduces repetition by centralizing the request execution and REST Docs @@ -198,6 +207,8 @@ public void me_withToken_shouldReturnSuccess() throws Exception { response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -221,6 +232,8 @@ public void me_withoutToken_shouldReturnUnauthorized() throws Exception { response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -246,6 +259,8 @@ public void allUsers_withToken_shouldReturnSuccess() throws Exception { response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -298,6 +313,8 @@ public void allUsers_withoutToken_shouldReturnUnauthorized() throws Exception { response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -323,6 +340,8 @@ public void allWithDeletedUsers_withToken_shouldReturnSuccess() throws Exception response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -352,7 +371,8 @@ public void deleteLocalUser_withoutGlobalFlag_shouldReturn200AndDeleteFromDataba true, response -> { try { - response.andExpect(jsonPath("$.message").value("Local User deleted successfully")); + response.andExpect(jsonPath("$.message") + .value(getMessage("user.deleted.local"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -437,7 +457,8 @@ public void deleteGlobalUser_whenAuthServiceFails_shouldPropagateError() throws true, response -> { try { - response.andExpect(jsonPath("$.message").value("Failed to delete user: Failed to delete user from auth service")); + response.andExpect(jsonPath("$.message").value( + getMessage("user.delete.failed", "Failed to delete user from auth service"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -557,7 +578,8 @@ public void deleteUser_withDeletionFailure_shouldReturn400UserDeletionFailed() t true, response -> { try { - response.andExpect(jsonPath("$.message").value("Failed to delete user: Database constraint violation")); + response.andExpect(jsonPath("$.message").value( + getMessage("user.delete.failed", "Database constraint violation"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -590,7 +612,13 @@ public void deleteUser_withNonExistentId_shouldReturn404UserNotFound() throws Ex MediaType.APPLICATION_JSON, 404, "delete-user-not-found", - null); + response -> { + try { + response.andExpect(jsonPath("$.message").value(getMessage("user.notFound"))); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); } /** @@ -623,6 +651,8 @@ public void allUsers_withoutAuthentication_shouldReturn401Unauthorized() throws response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -646,6 +676,8 @@ public void allWithDeletedUsers_withoutToken_shouldReturnUnauthorized() throws E response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -671,6 +703,8 @@ public void deletedUsers_withToken_shouldReturnSuccess() throws Exception { response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -694,6 +728,8 @@ public void deletedUsers_withoutToken_shouldReturnUnauthorized() throws Exceptio response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -719,6 +755,8 @@ public void promoteToLocalAppRole_withoutToken_shouldReturnUnauthorized() throws response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -745,6 +783,8 @@ public void deleteUser_locally_shouldReturnSuccess() throws Exception { response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -769,6 +809,8 @@ public void deleteUser_locallyWithoutToken_shouldReturnUnauthorized() throws Exc response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -802,6 +844,8 @@ public void deleteUser_globally_withMockedWebClient_shouldReturnSuccess() throws response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -826,6 +870,8 @@ public void deleteUser_globallyWithoutToken_shouldReturnUnauthorized() throws Ex response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -852,6 +898,8 @@ public void deleteUserPermanent_locally_shouldReturnSuccess() throws Exception { response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -875,6 +923,8 @@ public void deleteUserPermanent_locallyWithoutToken_shouldReturnUnauthorized() t response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -908,6 +958,8 @@ public void deleteUserPermanent_globally_withMockedWebClient_shouldReturnSuccess response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -932,6 +984,8 @@ public void deleteUserPermanent_globallyWithoutToken_shouldReturnUnauthorized() response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -961,6 +1015,8 @@ public void promoteToManager_withMockedWebClient_shouldReturnSuccess() throws Ex response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -985,6 +1041,8 @@ public void promoteToManager_withoutToken_shouldReturnUnauthorized() throws Exce response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1014,6 +1072,8 @@ public void revokeManager_withMockedWebClient_shouldReturnSuccess() throws Excep response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1038,6 +1098,8 @@ public void revokeManager_withoutToken_shouldReturnUnauthorized() throws Excepti response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1067,6 +1129,8 @@ public void promoteToAdmin_withMockedWebClient_shouldReturnSuccess() throws Exce response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1091,6 +1155,8 @@ public void promoteToAdmin_withoutToken_shouldReturnUnauthorized() throws Except response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1120,6 +1186,8 @@ public void revokeAdmin_withMockedWebClient_shouldReturnSuccess() throws Excepti response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1144,6 +1212,8 @@ public void revokeAdmin_withoutToken_shouldReturnUnauthorized() throws Exception response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1173,6 +1243,8 @@ public void downgradeAdmin_withMockedWebClient_shouldReturnSuccess() throws Exce response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } @@ -1197,6 +1269,8 @@ public void downgradeAdmin_withoutToken_shouldReturnUnauthorized() throws Except response -> { try { response.andExpect(status().isUnauthorized()); + response.andExpect(jsonPath("$.message") + .value(getMessage("security.auth.missingOrInvalidToken"))); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/doc/process-documentation.md b/doc/process-documentation.md index 115d79ce..cc9738d2 100644 --- a/doc/process-documentation.md +++ b/doc/process-documentation.md @@ -55,7 +55,6 @@ _Illustrates interactions between the frontend and backend modules, as well as t --- ### 1.2 Root Files - | File | Description | | ------------------------ | ---------------------------------------------------------------- | | `pom.xml` | Defines project dependencies, plugins, and build configurations. | @@ -125,7 +124,6 @@ sequenceDiagram Client->>AuthController: /auth/login with credentials AuthController->>AuthClient: authClient.login(credentialsDto) AuthClient->>spring-auth: POST /auth/login with credentials - spring-auth->>spring-auth: Validate credentials & create token spring-auth-->>AuthClient: Response with UserDto and token AuthClient-->>AuthController: UserDto wrapped in ResponseEntity AuthController-->>Client: Response with UserDto and access token @@ -139,8 +137,6 @@ sequenceDiagram participant AuthClient participant spring-auth participant UserService - - Note over AuthController,spring-auth: Login Flow Client->>AuthController: POST /auth/login with credentials AuthController->>AuthClient: login(credentialsDto) AuthClient->>spring-auth: POST /auth/login @@ -248,7 +244,6 @@ classDiagram <> +String firstName +String lastName - +String login +char[] password } @@ -260,13 +255,6 @@ classDiagram +List~String~ authoritiesToPermissions(Collection~GrantedAuthority~ authorities) } - %% Relationships - User --> "1" Role : mainRole - User --> "0..*" Role : appSpecificRoles - Role --> "0..*" User : users - UserMapper ..> User : uses - UserMapper ..> UserDto : creates - UserMapper ..> RegisterDto : uses %% Relationships User --> "1" Role : mainRole User --> "0..*" Role : appSpecificRoles @@ -283,6 +271,7 @@ _Class Diagram showing the `User`, `Role`, `UserDto`, and `RegisterDto` structur sequenceDiagram participant Client participant JwtAuthFilter + participant SecurityLayer participant UserController participant UserService participant UserRepository @@ -393,7 +382,7 @@ _Sequence Diagram showing JWT authentication and request handling flow._ | `SecurityConfig.java` | Security configuration defining the filter chain and access rules. | | `SecurityExceptions.java` | Container class for security-specific custom exceptions. | | `UserAuthenticationEntryPoint.java` | Handles unauthenticated access by returning a 401 response. | -| `UserAuthenticationProvider.java` | Authentication provider for validating user credentials. | +| `UserAuthenticationProvider.java` | Authentication provider for validating JWT access tokens. | | `WebClientConfig.java` | Configuration for WebClient used in inter-service communication. | | `WebConfig.java` | Web configuration for general web-related settings. | @@ -419,9 +408,7 @@ _Sequence Diagram showing JWT authentication and request handling flow._ | `ItemRepository.java` | Interface for database operations related to items. | | `ItemSeeder.java` | Seeds the database with test items for development. | | `ItemService.java` | Business logic for item functionalities. | -| `ItemExceptionHandler.java` | Exception handler specific to item-related operations. | -| `ItemNotFoundException.java` | Custom exception thrown when an item is not found. | -| `UnauthorizedItemException.java` | Custom exception for unauthorized item access attempts. | +| `ItemExceptions.java` | Container class for item-specific custom exceptions. | ---