# Security Hardening Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add input validation, JWT-based authorization enforcement, and per-user rate limiting to XpenselyServer. **Architecture:** Bean Validation annotations on all request models with a `@ControllerAdvice` exception handler for structured 400 responses; an `AuthenticatedUserResolver` component extracts the authenticated `AppUser` from the JWT `sub` claim and guards every endpoint via `assertOwner`/`assertMember` helpers; a `RateLimitFilter` registered inside the Spring Security chain uses Bucket4j in-memory buckets keyed by JWT subject (or IP fallback) to enforce per-endpoint limits. **Tech Stack:** Spring Boot 3.4.1, Jakarta Bean Validation (spring-boot-starter-validation), Bucket4j 8.10.1 (bucket4j-core), Spring Security Test (jwt() post processor), JUnit 5, Mockito --- > **Before starting:** Check out the feature branch. > ``` > git checkout feature/security-hardening > ``` > **Client compatibility note:** `GET /api/expenselist/byUser` and `GET /api/expenselist/byUsername` are removed; the Flutter client must be updated to call `GET /api/expenselist/mine` instead. This is out of scope for this plan — coordinate with the client update separately. --- ## File Map | File | Action | Responsibility | |---|---|---| | `pom.xml` | Modify | Add validation + Bucket4j dependencies | | `model/AppUserCreateRequest.java` | Modify | Add Bean Validation annotations | | `model/ExpenseInput.java` | Modify | Add validation; remove stray JPA annotations | | `model/ExpenseChangeRequest.java` | Modify | Add Bean Validation annotations | | `model/InviteRequest.java` | Modify | Add validation; remove `userId` field | | `controller/GlobalExceptionHandler.java` | Create | Structured 400/403 error responses | | `controller/AppUserController.java` | Modify | Add `@Valid`, ownership guards, JWT user resolution | | `controller/ExpenseListController.java` | Modify | Add `@Valid`, ownership guards, rename/remove endpoints | | `security/AuthenticatedUserResolver.java` | Create | Resolve JWT sub claim to `AppUser` entity | | `security/RateLimitFilter.java` | Create | Per-user/IP Bucket4j rate limiting | | `test/.../controller/AppUserControllerTest.java` | Create | Validation + authorization tests for user controller | | `test/.../controller/ExpenseListControllerTest.java` | Create | Validation + authorization tests for expense list controller | | `test/.../security/AuthenticatedUserResolverTest.java` | Create | Unit tests for JWT resolution | | `test/.../security/RateLimitFilterTest.java` | Create | Unit tests for rate limiting | --- ## Task 1: Add Maven Dependencies **Files:** - Modify: `pom.xml` - [ ] **Step 1: Add dependencies** In `pom.xml`, inside ``, add after the existing `spring-boot-starter-security` dependency: ```xml org.springframework.boot spring-boot-starter-validation com.bucket4j bucket4j-core 8.10.1 ``` - [ ] **Step 2: Verify build compiles** Run: `mvn compile -q` Expected: `BUILD SUCCESS` with no errors. - [ ] **Step 3: Commit** ``` git add pom.xml git commit -m "build: add spring-boot-starter-validation and bucket4j-core" ``` --- ## Task 2: Annotate Request Models **Files:** - Modify: `src/main/java/de/zendric/app/xpensely_server/model/AppUserCreateRequest.java` - Modify: `src/main/java/de/zendric/app/xpensely_server/model/ExpenseInput.java` - Modify: `src/main/java/de/zendric/app/xpensely_server/model/ExpenseChangeRequest.java` - Modify: `src/main/java/de/zendric/app/xpensely_server/model/InviteRequest.java` - [ ] **Step 1: Update AppUserCreateRequest.java** Replace the entire file content with: ```java package de.zendric.app.xpensely_server.model; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.Data; @Data public class AppUserCreateRequest { @NotBlank(message = "Username is required") @Size(min = 3, max = 30, message = "Username must be between 3 and 30 characters") @Pattern(regexp = "^[a-zA-Z0-9_.\\-]+$", message = "Username may only contain letters, digits, underscores, dots, and hyphens") private String username; @NotBlank(message = "Google ID is required") private String googleId; public AppUser convertToAppUser() { AppUser appUser = new AppUser(); appUser.setGoogleId(googleId); appUser.setUsername(username); return appUser; } } ``` - [ ] **Step 2: Update ExpenseInput.java** Replace the entire file content with (removes stray `@Id`/`@GeneratedValue`, adds validation): ```java package de.zendric.app.xpensely_server.model; import java.time.LocalDate; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @AllArgsConstructor @NoArgsConstructor public class ExpenseInput { private Long id; @NotBlank(message = "Title is required") @Size(max = 100, message = "Title must not exceed 100 characters") private String title; @NotBlank(message = "Owner is required") private String owner; @NotNull(message = "Amount is required") @DecimalMin(value = "0.01", message = "Amount must be greater than zero") private Double amount; private Double personalUseAmount; private Double otherPersonAmount; @NotNull(message = "Date is required") private LocalDate date; @NotBlank(message = "Category is required") private String category; private ExpenseList expenseList; public Expense convertToExpense(Long userId) { AppUser appUser = new AppUser(); appUser.setId(userId); appUser.setUsername(owner); Expense expense = new Expense(); expense.setAmount(amount); expense.setDate(date); expense.setPersonalUseAmount(personalUseAmount); expense.setOtherPersonAmount(otherPersonAmount); expense.setExpenseList(expenseList); expense.setId(id); expense.setOwner(appUser); expense.setTitle(title); expense.setCategory(category); return expense; } } ``` - [ ] **Step 3: Update ExpenseChangeRequest.java** Replace the entire file content with: ```java package de.zendric.app.xpensely_server.model; import java.time.LocalDate; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor public class ExpenseChangeRequest { private Long id; @NotBlank(message = "Title is required") @Size(max = 100, message = "Title must not exceed 100 characters") private String title; @NotBlank(message = "Owner name is required") private String ownerName; @NotNull(message = "Amount is required") @DecimalMin(value = "0.01", message = "Amount must be greater than zero") private Double amount; private Double personalUseAmount; private Double otherPersonAmount; @NotNull(message = "Date is required") private LocalDate date; @NotBlank(message = "Category is required") private String category; public Expense convertToExpense(Long userId, ExpenseList expenseList) { AppUser appUser = new AppUser(); appUser.setId(userId); appUser.setUsername(ownerName); Expense expense = new Expense(); expense.setAmount(amount); expense.setDate(date); expense.setPersonalUseAmount(personalUseAmount); expense.setOtherPersonAmount(otherPersonAmount); expense.setExpenseList(expenseList); expense.setId(id); expense.setOwner(appUser); expense.setTitle(title); expense.setCategory(category); return expense; } } ``` - [ ] **Step 4: Update InviteRequest.java** Replace the entire file content with (`userId` removed — will be derived from JWT): ```java package de.zendric.app.xpensely_server.model; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor public class InviteRequest { @NotBlank(message = "Invite code is required") @Size(min = 6, max = 6, message = "Invite code must be exactly 6 characters") private String inviteCode; } ``` - [ ] **Step 5: Verify build compiles** Run: `mvn compile -q` Expected: `BUILD SUCCESS` - [ ] **Step 6: Commit** ``` git add src/main/java/de/zendric/app/xpensely_server/model/ git commit -m "feat: add Bean Validation annotations to request models" ``` --- ## Task 3: Add GlobalExceptionHandler **Files:** - Create: `src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java` - Create: `src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java` - [ ] **Step 1: Write the failing test** Create `src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java`: ```java package de.zendric.app.xpensely_server.controller; import de.zendric.app.xpensely_server.services.UserService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(AppUserController.class) @ActiveProfiles("test") class AppUserControllerTest { @Autowired MockMvc mockMvc; @MockBean UserService userService; @Test void createUser_blankUsername_returns400WithFieldError() throws Exception { mockMvc.perform(post("/api/users/createUser") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"\",\"googleId\":\"gid123\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.username").exists()); } @Test void createUser_invalidUsernamePattern_returns400() throws Exception { mockMvc.perform(post("/api/users/createUser") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"hello world!\",\"googleId\":\"gid123\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.username").exists()); } @Test void createUser_usernameTooShort_returns400() throws Exception { mockMvc.perform(post("/api/users/createUser") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"ab\",\"googleId\":\"gid123\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.username").exists()); } @Test void createUser_blankGoogleId_returns400() throws Exception { mockMvc.perform(post("/api/users/createUser") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"validuser\",\"googleId\":\"\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.googleId").exists()); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `mvn test -Dtest=AppUserControllerTest -pl . -q` Expected: FAIL — validation is not wired up yet; the controller returns 417 or 500 instead of 400. - [ ] **Step 3: Create GlobalExceptionHandler** Create `src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java`: ```java package de.zendric.app.xpensely_server.controller; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import java.util.HashMap; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidationErrors(MethodArgumentNotValidException ex) { Map errors = new HashMap<>(); for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { errors.put(fieldError.getField(), fieldError.getDefaultMessage()); } return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors); } @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Map.of("error", ex.getMessage())); } } ``` - [ ] **Step 4: Add @Valid to AppUserController.createUser** In `src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java`, add `import jakarta.validation.Valid;` and update the `createUser` method signature: ```java import jakarta.validation.Valid; // Change: public ResponseEntity createUser(@RequestBody AppUserCreateRequest userRequest) { // To: public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { ``` - [ ] **Step 5: Run tests to verify they pass** Run: `mvn test -Dtest=AppUserControllerTest -pl . -q` Expected: `Tests run: 4, Failures: 0, Errors: 0` - [ ] **Step 6: Commit** ``` git add src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java git add src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java git add src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java git commit -m "feat: add GlobalExceptionHandler and @Valid to user creation endpoint" ``` --- ## Task 4: Add @Valid to ExpenseList Endpoints **Files:** - Modify: `src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java` - Create: `src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java` - [ ] **Step 1: Write the failing tests** Create `src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java`: ```java package de.zendric.app.xpensely_server.controller; import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.CategoryService; import de.zendric.app.xpensely_server.services.ExpenseListService; import de.zendric.app.xpensely_server.services.UserService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(ExpenseListController.class) @ActiveProfiles("test") class ExpenseListControllerTest { @Autowired MockMvc mockMvc; @MockBean ExpenseListService expenseListService; @MockBean UserService userService; @MockBean CategoryService categoryService; @MockBean AuthenticatedUserResolver authenticatedUserResolver; @Test void addExpense_blankTitle_returns400() throws Exception { mockMvc.perform(post("/api/expenselist/1/add") .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"\",\"owner\":\"alice\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.title").exists()); } @Test void addExpense_negativeAmount_returns400() throws Exception { mockMvc.perform(post("/api/expenselist/1/add") .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":-5.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.amount").exists()); } @Test void addExpense_nullDate_returns400() throws Exception { mockMvc.perform(post("/api/expenselist/1/add") .contentType(MediaType.APPLICATION_JSON) .content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":10.0,\"category\":\"Food\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.date").exists()); } @Test void acceptInvite_blankCode_returns400() throws Exception { mockMvc.perform(post("/api/expenselist/accept-invite") .contentType(MediaType.APPLICATION_JSON) .content("{\"inviteCode\":\"\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.inviteCode").exists()); } @Test void acceptInvite_wrongCodeLength_returns400() throws Exception { mockMvc.perform(post("/api/expenselist/accept-invite") .contentType(MediaType.APPLICATION_JSON) .content("{\"inviteCode\":\"ABC\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.inviteCode").exists()); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `mvn test -Dtest=ExpenseListControllerTest -pl . -q` Expected: FAIL — `@Valid` not yet added and `AuthenticatedUserResolver` bean does not exist yet. - [ ] **Step 3: Create stub AuthenticatedUserResolver** Create `src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java` (full implementation comes in Task 5; create the stub now so the app compiles): ```java package de.zendric.app.xpensely_server.security; import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.services.UserService; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.stereotype.Component; import org.springframework.web.server.ResponseStatusException; @Component public class AuthenticatedUserResolver { private final UserService userService; public AuthenticatedUserResolver(UserService userService) { this.userService = userService; } public AppUser resolveCurrentUser(Authentication authentication) { if (authentication == null) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not authenticated"); } Jwt jwt = (Jwt) authentication.getPrincipal(); String googleId = jwt.getSubject(); try { AppUser user = userService.getUserByGoogleId(googleId); if (user == null) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); } return user; } catch (IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User not registered"); } } } ``` - [ ] **Step 4: Add @Valid to ExpenseListController endpoints** In `src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java`, add `import jakarta.validation.Valid;` and update the three `@RequestBody` parameters: ```java import jakarta.validation.Valid; // Change addExpenseToList signature: public ResponseEntity addExpenseToList( @PathVariable("id") Long expenseListId, @RequestBody @Valid ExpenseInput expenseInput) { // Change updateExpenseInList signature: public ResponseEntity updateExpenseInList( @PathVariable("id") Long expenseListId, @RequestBody @Valid ExpenseChangeRequest expenseChangeRequest) { // Change acceptInvite signature: public ResponseEntity acceptInvite(@RequestBody @Valid InviteRequest inviteRequest) { ``` - [ ] **Step 5: Run tests to verify they pass** Run: `mvn test -Dtest=ExpenseListControllerTest -pl . -q` Expected: `Tests run: 5, Failures: 0, Errors: 0` - [ ] **Step 6: Run all tests** Run: `mvn test -q` Expected: All existing tests still pass. - [ ] **Step 7: Commit** ``` git add src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java git add src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java git add src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java git commit -m "feat: add @Valid to expense list endpoints and stub AuthenticatedUserResolver" ``` --- ## Task 5: Test and Complete AuthenticatedUserResolver **Files:** - Modify: `src/main/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolver.java` (already complete from Task 4) - Create: `src/test/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolverTest.java` - [ ] **Step 1: Write the failing tests** Create `src/test/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolverTest.java`: ```java package de.zendric.app.xpensely_server.security; import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.services.UserService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.web.server.ResponseStatusException; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class AuthenticatedUserResolverTest { UserService userService; AuthenticatedUserResolver resolver; @BeforeEach void setUp() { userService = mock(UserService.class); resolver = new AuthenticatedUserResolver(userService); } @Test void resolveCurrentUser_validJwt_returnsAppUser() { Jwt jwt = Jwt.withTokenValue("token") .header("alg", "RS256") .subject("google-id-123") .build(); JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); AppUser user = new AppUser(); user.setId(1L); user.setGoogleId("google-id-123"); when(userService.getUserByGoogleId("google-id-123")).thenReturn(user); AppUser result = resolver.resolveCurrentUser(auth); assertEquals(user, result); } @Test void resolveCurrentUser_userNotFound_throws403() { Jwt jwt = Jwt.withTokenValue("token") .header("alg", "RS256") .subject("unknown-id") .build(); JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); when(userService.getUserByGoogleId("unknown-id")).thenReturn(null); ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> resolver.resolveCurrentUser(auth)); assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); } @Test void resolveCurrentUser_userServiceThrows_throws403() { Jwt jwt = Jwt.withTokenValue("token") .header("alg", "RS256") .subject("gone-id") .build(); JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); when(userService.getUserByGoogleId("gone-id")).thenThrow(new IllegalArgumentException("not found")); ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> resolver.resolveCurrentUser(auth)); assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); } @Test void resolveCurrentUser_nullAuthentication_throws403() { ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> resolver.resolveCurrentUser(null)); assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); } } ``` - [ ] **Step 2: Run tests to verify they pass** Run: `mvn test -Dtest=AuthenticatedUserResolverTest -pl . -q` Expected: `Tests run: 4, Failures: 0, Errors: 0` (The implementation was already written in Task 4. If tests fail, revisit the resolver logic.) - [ ] **Step 3: Commit** ``` git add src/test/java/de/zendric/app/xpensely_server/security/AuthenticatedUserResolverTest.java git commit -m "test: add unit tests for AuthenticatedUserResolver" ``` --- ## Task 6: Add Authorization to ExpenseListController **Files:** - Modify: `src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java` - Modify: `src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java` - [ ] **Step 1: Write failing authorization tests** Add these tests to the existing `ExpenseListControllerTest.java` (append inside the class body, before the closing brace): ```java // --- Authorization tests --- // These run with the test profile (security disabled) but mock the resolver // to simulate different users, testing the ownership guard logic directly. @Test void getById_authenticatedUserNotMember_returns403() throws Exception { de.zendric.app.xpensely_server.model.AppUser owner = new de.zendric.app.xpensely_server.model.AppUser(); owner.setId(1L); de.zendric.app.xpensely_server.model.AppUser requester = new de.zendric.app.xpensely_server.model.AppUser(); requester.setId(2L); de.zendric.app.xpensely_server.model.ExpenseList list = new de.zendric.app.xpensely_server.model.ExpenseList(); list.setId(1L); list.setOwner(owner); org.mockito.Mockito.when(expenseListService.findById(1L)).thenReturn(java.util.Optional.of(list)); org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) .thenReturn(requester); mockMvc.perform(get("/api/expenselist/byId").param("id", "1")) .andExpect(status().isForbidden()); } @Test void getById_authenticatedUserIsOwner_returns200() throws Exception { de.zendric.app.xpensely_server.model.AppUser owner = new de.zendric.app.xpensely_server.model.AppUser(); owner.setId(1L); de.zendric.app.xpensely_server.model.ExpenseList list = new de.zendric.app.xpensely_server.model.ExpenseList(); list.setId(1L); list.setOwner(owner); org.mockito.Mockito.when(expenseListService.findById(1L)).thenReturn(java.util.Optional.of(list)); org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) .thenReturn(owner); mockMvc.perform(get("/api/expenselist/byId").param("id", "1")) .andExpect(status().isOk()); } @Test void deleteList_nonOwner_returns403() throws Exception { de.zendric.app.xpensely_server.model.AppUser owner = new de.zendric.app.xpensely_server.model.AppUser(); owner.setId(1L); de.zendric.app.xpensely_server.model.AppUser nonOwner = new de.zendric.app.xpensely_server.model.AppUser(); nonOwner.setId(2L); de.zendric.app.xpensely_server.model.ExpenseList list = new de.zendric.app.xpensely_server.model.ExpenseList(); list.setId(5L); list.setOwner(owner); org.mockito.Mockito.when(expenseListService.findById(5L)).thenReturn(java.util.Optional.of(list)); org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) .thenReturn(nonOwner); mockMvc.perform(delete("/api/expenselist/5")) .andExpect(status().isForbidden()); } @Test void getMine_returnsCurrentUserLists() throws Exception { de.zendric.app.xpensely_server.model.AppUser user = new de.zendric.app.xpensely_server.model.AppUser(); user.setId(3L); org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) .thenReturn(user); org.mockito.Mockito.when(expenseListService.findByUserId(3L)) .thenReturn(java.util.List.of(new de.zendric.app.xpensely_server.model.ExpenseList())); mockMvc.perform(get("/api/expenselist/mine")) .andExpect(status().isOk()); } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `mvn test -Dtest=ExpenseListControllerTest -pl . -q` Expected: FAIL — `getMine` endpoint doesn't exist, authorization guards not yet implemented. - [ ] **Step 3: Rewrite ExpenseListController with authorization** Replace the entire content of `src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java`: ```java package de.zendric.app.xpensely_server.controller; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import de.zendric.app.xpensely_server.model.*; import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.CategoryService; import de.zendric.app.xpensely_server.services.ExpenseListService; import de.zendric.app.xpensely_server.services.UserService; @RestController @RequestMapping("/api/expenselist") class ExpenseListController { private final ExpenseListService expenseListService; private final UserService userService; private final CategoryService categoryService; private final AuthenticatedUserResolver authenticatedUserResolver; public ExpenseListController(ExpenseListService expenseListService, UserService userService, CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) { this.expenseListService = expenseListService; this.userService = userService; this.categoryService = categoryService; this.authenticatedUserResolver = authenticatedUserResolver; } @GetMapping("/mine") public ResponseEntity> getMine(Authentication authentication) { AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); List items = expenseListService.findByUserId(user.getId()); if (items.isEmpty()) return new ResponseEntity<>(HttpStatus.NO_CONTENT); return new ResponseEntity<>(items, HttpStatus.OK); } @GetMapping("/byId") public ResponseEntity getById(@RequestParam Long id, Authentication authentication) { AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); Optional existingItemOptional = expenseListService.findById(id); if (existingItemOptional.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertMember(user, existingItemOptional.get()); return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK); } @PostMapping("/create") public ResponseEntity create(@RequestBody ExpenseList expenseList, Authentication authentication) { try { AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); expenseList.setOwner(authenticatedUser); XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories(); expenseList.setXpenselyStandardCategories(standardCategories); expenseList.setSharedWith(null); ExpenseList savedItem = expenseListService.createList(expenseList); return new ResponseEntity<>(savedItem, HttpStatus.CREATED); } catch (ResponseStatusException e) { throw e; } catch (Exception e) { e.printStackTrace(); return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); } } @DeleteMapping("{id}") public ResponseEntity delete(@PathVariable("id") Long id, Authentication authentication) { AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); Optional listOpt = expenseListService.findById(id); if (listOpt.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertOwner(user, listOpt.get()); try { expenseListService.deleteById(id); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } catch (Exception e) { return new ResponseEntity<>(HttpStatus.EXPECTATION_FAILED); } } @PostMapping("/{id}/add") public ResponseEntity addExpenseToList( @PathVariable("id") Long expenseListId, @RequestBody @Valid ExpenseInput expenseInput, Authentication authentication) { AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); Optional listOpt = expenseListService.findById(expenseListId); if (listOpt.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertMember(user, listOpt.get()); try { AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner()); Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); } catch (Exception e) { return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR); } } @PutMapping("/{id}/update") public ResponseEntity updateExpenseInList( @PathVariable("id") Long expenseListId, @RequestBody @Valid ExpenseChangeRequest expenseChangeRequest, Authentication authentication) { AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); Optional expenseListOpt = expenseListService.findById(expenseListId); if (expenseListOpt.isEmpty()) return new ResponseEntity<>(null, HttpStatus.NOT_FOUND); assertMember(user, expenseListOpt.get()); try { AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get()); Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense); return new ResponseEntity<>(updatedExpense, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); } } @DeleteMapping("/{id}/delete") public ResponseEntity deleteExpenseFromList( @PathVariable("id") Long expenseListId, @RequestParam("expenseId") Long expenseId, Authentication authentication) { AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); Optional listOpt = expenseListService.findById(expenseListId); if (listOpt.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertMember(user, listOpt.get()); try { expenseListService.deleteExpenseFromList(expenseListId, expenseId); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } catch (Exception e) { return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); } } @PostMapping("/{listId}/invite") public ResponseEntity generateInvite(@PathVariable Long listId, Authentication authentication) { AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication); Optional listOpt = expenseListService.findById(listId); if (listOpt.isEmpty()) return new ResponseEntity<>(HttpStatus.NOT_FOUND); assertOwner(user, listOpt.get()); String inviteCode = expenseListService.generateInviteCode(listId); return ResponseEntity.ok(inviteCode); } @PostMapping("/accept-invite") public ResponseEntity acceptInvite(@RequestBody @Valid InviteRequest inviteRequest, Authentication authentication) { AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication); ExpenseList list = expenseListService.findByInviteCode(inviteRequest.getInviteCode()); if (list == null || list.getInviteCodeExpiration() == null || list.getInviteCodeExpiration().isBefore(LocalDateTime.now())) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Invalid or expired invite code"); } if (list.getSharedWith() != null) { return ResponseEntity.status(HttpStatus.IM_USED).body("List has already been shared"); } if (list.getOwner().getId().equals(authenticatedUser.getId())) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list"); } list.setSharedWith(authenticatedUser); expenseListService.save(list); return ResponseEntity.ok("User added to the list"); } // --- Authorization helpers --- private void assertOwner(AppUser authenticated, ExpenseList list) { if (!list.getOwner().getId().equals(authenticated.getId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN); } private void assertMember(AppUser authenticated, ExpenseList list) { boolean isOwner = list.getOwner().getId().equals(authenticated.getId()); boolean isShared = list.getSharedWith() != null && list.getSharedWith().getId().equals(authenticated.getId()); if (!isOwner && !isShared) throw new ResponseStatusException(HttpStatus.FORBIDDEN); } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `mvn test -Dtest=ExpenseListControllerTest -pl . -q` Expected: `Tests run: 9, Failures: 0, Errors: 0` - [ ] **Step 5: Run all tests** Run: `mvn test -q` Expected: All tests pass. - [ ] **Step 6: Commit** ``` git add src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java git add src/test/java/de/zendric/app/xpensely_server/controller/ExpenseListControllerTest.java git commit -m "feat: add ownership guards and JWT-based authorization to expense list endpoints" ``` --- ## Task 7: Add Authorization to AppUserController **Files:** - Modify: `src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java` - Modify: `src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java` - [ ] **Step 1: Write failing authorization tests** Add these tests inside `AppUserControllerTest.java` (before the closing brace). Add `@MockBean AuthenticatedUserResolver authenticatedUserResolver;` to the class fields first: ```java @MockBean de.zendric.app.xpensely_server.security.AuthenticatedUserResolver authenticatedUserResolver; @Test void getUser_differentUser_returns403() throws Exception { de.zendric.app.xpensely_server.model.AppUser self = new de.zendric.app.xpensely_server.model.AppUser(); self.setId(1L); org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) .thenReturn(self); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders .get("/api/users").param("id", "99")) .andExpect(status().isForbidden()); } @Test void getUser_sameUser_returns200() throws Exception { de.zendric.app.xpensely_server.model.AppUser self = new de.zendric.app.xpensely_server.model.AppUser(); self.setId(1L); org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) .thenReturn(self); org.mockito.Mockito.when(userService.getUser(1L)).thenReturn(self); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders .get("/api/users").param("id", "1")) .andExpect(status().isOk()); } @Test void deleteUser_differentUser_returns403() throws Exception { de.zendric.app.xpensely_server.model.AppUser self = new de.zendric.app.xpensely_server.model.AppUser(); self.setId(1L); org.mockito.Mockito.when(authenticatedUserResolver.resolveCurrentUser(org.mockito.ArgumentMatchers.any())) .thenReturn(self); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders .delete("/api/users").param("id", "99")) .andExpect(status().isForbidden()); } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `mvn test -Dtest=AppUserControllerTest -pl . -q` Expected: FAIL — authorization guards not yet added. - [ ] **Step 3: Rewrite AppUserController with authorization** Replace the entire content of `src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java`: ```java package de.zendric.app.xpensely_server.controller; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.AppUserCreateRequest; import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException; import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver; import de.zendric.app.xpensely_server.services.UserService; @RestController @RequestMapping("/api/users") public class AppUserController { private final UserService userService; private final AuthenticatedUserResolver authenticatedUserResolver; public AppUserController(UserService userService, AuthenticatedUserResolver authenticatedUserResolver) { this.userService = userService; this.authenticatedUserResolver = authenticatedUserResolver; } @GetMapping public ResponseEntity getUser(@RequestParam Long id, Authentication authentication) { AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); assertSelf(self, id); return ResponseEntity.ok(userService.getUser(id)); } @GetMapping("/byName") public AppUser getUserByName(@RequestParam String username) { return userService.getUserByName(username); } @GetMapping("/byGoogleId") public ResponseEntity getUserByGoogleId(@RequestParam String id, Authentication authentication) { AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); if (!self.getGoogleId().equals(id)) throw new ResponseStatusException(HttpStatus.FORBIDDEN); return ResponseEntity.ok(self); } @PostMapping("/createUser") public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) { try { AppUser convertedUser = userRequest.convertToAppUser(); AppUser nUser = userService.createUser(convertedUser); return new ResponseEntity<>(nUser, HttpStatus.CREATED); } catch (UsernameAlreadyExistsException e) { return new ResponseEntity<>(null, HttpStatus.CONFLICT); } catch (Exception e) { return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); } } @DeleteMapping public ResponseEntity deleteUser(@RequestParam Long id, Authentication authentication) { AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication); assertSelf(self, id); AppUser user = userService.deleteUserById(id); return ResponseEntity.ok("User deleted: " + user.getUsername()); } private void assertSelf(AppUser authenticated, Long requestedId) { if (!authenticated.getId().equals(requestedId)) throw new ResponseStatusException(HttpStatus.FORBIDDEN); } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `mvn test -Dtest=AppUserControllerTest -pl . -q` Expected: `Tests run: 7, Failures: 0, Errors: 0` - [ ] **Step 5: Run all tests** Run: `mvn test -q` Expected: All tests pass. - [ ] **Step 6: Commit** ``` git add src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java git add src/test/java/de/zendric/app/xpensely_server/controller/AppUserControllerTest.java git commit -m "feat: add ownership guards to user endpoints" ``` --- ## Task 8: Add RateLimitFilter **Files:** - Create: `src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java` - Modify: `src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java` - Create: `src/test/java/de/zendric/app/xpensely_server/security/RateLimitFilterTest.java` - [ ] **Step 1: Write the failing tests** Create `src/test/java/de/zendric/app/xpensely_server/security/RateLimitFilterTest.java`: ```java package de.zendric.app.xpensely_server.security; import jakarta.servlet.FilterChain; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class RateLimitFilterTest { RateLimitFilter filter; @BeforeEach void setUp() { filter = new RateLimitFilter(); } @Test void generalEndpoint_underLimit_allowsRequest() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/expenselist/mine"); request.setRemoteAddr("10.0.0.1"); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain chain = mock(FilterChain.class); filter.doFilter(request, response, chain); verify(chain).doFilter(request, response); assertNotEquals(429, response.getStatus()); } @Test void createUser_exceedsLimit_returns429() throws Exception { FilterChain chain = mock(FilterChain.class); // Exhaust the 3-request limit for (int i = 0; i < 3; i++) { MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); req.setRemoteAddr("10.0.0.2"); MockHttpServletResponse res = new MockHttpServletResponse(); filter.doFilter(req, res, chain); assertNotEquals(429, res.getStatus(), "Request " + (i + 1) + " should be allowed"); } MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); req.setRemoteAddr("10.0.0.2"); MockHttpServletResponse res = new MockHttpServletResponse(); filter.doFilter(req, res, chain); assertEquals(429, res.getStatus()); assertNotNull(res.getHeader("Retry-After")); } @Test void createUser_differentIps_separateBuckets() throws Exception { FilterChain chain = mock(FilterChain.class); // Exhaust limit for IP A for (int i = 0; i < 3; i++) { MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); req.setRemoteAddr("10.0.0.3"); filter.doFilter(req, new MockHttpServletResponse(), chain); } // IP B should still be allowed MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); req.setRemoteAddr("10.0.0.4"); MockHttpServletResponse res = new MockHttpServletResponse(); filter.doFilter(req, res, chain); assertNotEquals(429, res.getStatus()); } @Test void exceedLimit_responseHasRetryAfterHeader() throws Exception { FilterChain chain = mock(FilterChain.class); for (int i = 0; i < 3; i++) { MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); req.setRemoteAddr("10.0.0.5"); filter.doFilter(req, new MockHttpServletResponse(), chain); } MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/users/createUser"); req.setRemoteAddr("10.0.0.5"); MockHttpServletResponse res = new MockHttpServletResponse(); filter.doFilter(req, res, chain); assertNotNull(res.getHeader("Retry-After")); assertTrue(Integer.parseInt(res.getHeader("Retry-After")) > 0); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `mvn test -Dtest=RateLimitFilterTest -pl . -q` Expected: FAIL — `RateLimitFilter` does not exist yet. - [ ] **Step 3: Create RateLimitFilter** Create `src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java`: ```java package de.zendric.app.xpensely_server.security; import io.github.bucket4j.Bandwidth; import io.github.bucket4j.Bucket; import io.github.bucket4j.Refill; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.time.Duration; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; public class RateLimitFilter extends OncePerRequestFilter { private final Map generalBuckets = new ConcurrentHashMap<>(); private final Map inviteBuckets = new ConcurrentHashMap<>(); private final Map acceptInviteBuckets = new ConcurrentHashMap<>(); private final Map createUserBuckets = new ConcurrentHashMap<>(); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String key = resolveKey(request); String uri = request.getRequestURI(); Bucket bucket = selectBucket(key, uri); var probe = bucket.tryConsumeAndReturnRemaining(1); if (!probe.isConsumed()) { long retryAfterSeconds = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()); response.setStatus(429); response.setHeader("Retry-After", String.valueOf(Math.max(1, retryAfterSeconds))); response.setContentType("application/json"); response.getWriter().write("{\"error\":\"Too many requests\"}"); return; } chain.doFilter(request, response); } private String resolveKey(HttpServletRequest request) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof Jwt jwt) { return "user:" + jwt.getSubject(); } return "ip:" + request.getRemoteAddr(); } private Bucket selectBucket(String key, String uri) { if (uri.matches(".*/[0-9]+/invite$")) { return inviteBuckets.computeIfAbsent(key, k -> createBucket(5, Duration.ofMinutes(1))); } if (uri.endsWith("/accept-invite")) { return acceptInviteBuckets.computeIfAbsent(key, k -> createBucket(10, Duration.ofMinutes(1))); } if (uri.endsWith("/createUser")) { return createUserBuckets.computeIfAbsent(key, k -> createBucket(3, Duration.ofMinutes(1))); } return generalBuckets.computeIfAbsent(key, k -> createBucket(60, Duration.ofMinutes(1))); } private Bucket createBucket(int capacity, Duration refillPeriod) { Bandwidth limit = Bandwidth.classic(capacity, Refill.intervally(capacity, refillPeriod)); return Bucket.builder().addLimit(limit).build(); } } ``` - [ ] **Step 4: Register RateLimitFilter in SecurityConfig** Replace the entire content of `src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java`: ```java package de.zendric.app.xpensely_server.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity public class SecurityConfig { @Bean @Profile("test") public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) .csrf(csrf -> csrf.disable()); return http.build(); } @Bean @Profile("!test") public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .oauth2Login(Customizer.withDefaults()) .csrf(csrf -> csrf.disable()) .addFilterAfter(new RateLimitFilter(), BearerTokenAuthenticationFilter.class); return http.build(); } } ``` - [ ] **Step 5: Run RateLimitFilter tests** Run: `mvn test -Dtest=RateLimitFilterTest -pl . -q` Expected: `Tests run: 4, Failures: 0, Errors: 0` - [ ] **Step 6: Run all tests** Run: `mvn test -q` Expected: All tests pass, `BUILD SUCCESS`. - [ ] **Step 7: Commit** ``` git add src/main/java/de/zendric/app/xpensely_server/security/RateLimitFilter.java git add src/main/java/de/zendric/app/xpensely_server/security/SecurityConfig.java git add src/test/java/de/zendric/app/xpensely_server/security/RateLimitFilterTest.java git commit -m "feat: add RateLimitFilter with per-user Bucket4j rate limiting" ``` --- ## Done All security hardening is complete. Summary of what was implemented: | Area | What changed | |---|---| | Input validation | Bean Validation on all request models; `@Valid` on all `@RequestBody`; `GlobalExceptionHandler` returns structured 400s | | Authorization | `AuthenticatedUserResolver` resolves JWT sub to `AppUser`; all endpoints enforce ownership/membership; dangerous endpoints (`/all`, `/byUser`, `/byUsername`) removed or replaced with `/mine` | | Rate limiting | `RateLimitFilter` inside security chain; per-user buckets (JWT sub) with IP fallback; 60 req/min general, 5/min invite generation, 10/min invite acceptance, 3/min account creation | | Client compat | Flutter client must be updated: replace calls to `/byUser?userId=X` with `GET /api/expenselist/mine` (no param needed) |