Files
XpenselyServer/docs/superpowers/plans/2026-05-04-security-hardening.md
T
Cedric efe84942ff docs: add security hardening implementation plan
8-task TDD plan covering input validation, JWT-based authorization
enforcement, and Bucket4j rate limiting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:22:54 +02:00

55 KiB

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 <dependencies>, add after the existing spring-boot-starter-security dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>8.10.1</version>
</dependency>
  • 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:

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):

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:

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):

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:

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:

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<Map<String, String>> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, String> 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<Map<String, String>> 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:

import jakarta.validation.Valid;

// Change:
public ResponseEntity<AppUser> createUser(@RequestBody AppUserCreateRequest userRequest) {
// To:
public ResponseEntity<AppUser> 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:

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):

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:

import jakarta.validation.Valid;

// Change addExpenseToList signature:
public ResponseEntity<Expense> addExpenseToList(
        @PathVariable("id") Long expenseListId,
        @RequestBody @Valid ExpenseInput expenseInput) {

// Change updateExpenseInList signature:
public ResponseEntity<Expense> 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:

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):

    // --- 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:

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<List<ExpenseList>> getMine(Authentication authentication) {
        AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
        List<ExpenseList> items = expenseListService.findByUserId(user.getId());
        if (items.isEmpty())
            return new ResponseEntity<>(HttpStatus.NO_CONTENT);
        return new ResponseEntity<>(items, HttpStatus.OK);
    }

    @GetMapping("/byId")
    public ResponseEntity<ExpenseList> getById(@RequestParam Long id, Authentication authentication) {
        AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
        Optional<ExpenseList> 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<ExpenseList> 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<HttpStatus> delete(@PathVariable("id") Long id, Authentication authentication) {
        AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
        Optional<ExpenseList> 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<Expense> addExpenseToList(
            @PathVariable("id") Long expenseListId,
            @RequestBody @Valid ExpenseInput expenseInput,
            Authentication authentication) {
        AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
        Optional<ExpenseList> 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<Expense> updateExpenseInList(
            @PathVariable("id") Long expenseListId,
            @RequestBody @Valid ExpenseChangeRequest expenseChangeRequest,
            Authentication authentication) {
        AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
        Optional<ExpenseList> 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<Expense> deleteExpenseFromList(
            @PathVariable("id") Long expenseListId,
            @RequestParam("expenseId") Long expenseId,
            Authentication authentication) {
        AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
        Optional<ExpenseList> 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<String> generateInvite(@PathVariable Long listId, Authentication authentication) {
        AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
        Optional<ExpenseList> 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:

    @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:

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<AppUser> 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<AppUser> 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<AppUser> 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<String> 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:

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:

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<String, Bucket> generalBuckets = new ConcurrentHashMap<>();
    private final Map<String, Bucket> inviteBuckets = new ConcurrentHashMap<>();
    private final Map<String, Bucket> acceptInviteBuckets = new ConcurrentHashMap<>();
    private final Map<String, Bucket> 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:

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)