1463 lines
55 KiB
Markdown
1463 lines
55 KiB
Markdown
|
|
# 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:
|
||
|
|
|
||
|
|
```xml
|
||
|
|
<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:
|
||
|
|
|
||
|
|
```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<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:
|
||
|
|
|
||
|
|
```java
|
||
|
|
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`:
|
||
|
|
|
||
|
|
```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<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`:
|
||
|
|
|
||
|
|
```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<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:
|
||
|
|
|
||
|
|
```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<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`:
|
||
|
|
|
||
|
|
```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<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`:
|
||
|
|
|
||
|
|
```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) |
|