security hardening #12

Merged
Cedric merged 15 commits from feature/security-hardening into main 2026-05-05 17:13:54 +02:00
5 changed files with 216 additions and 117 deletions
Showing only changes of commit a948bca2fc - Show all commits
@@ -1,5 +1,6 @@
package de.zendric.app.xpensely_server.controller; package de.zendric.app.xpensely_server.controller;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -51,7 +52,7 @@ public class AppUserController {
} }
@PostMapping("/createUser") @PostMapping("/createUser")
public ResponseEntity<AppUser> createUser(@RequestBody AppUserCreateRequest userRequest) { public ResponseEntity<AppUser> createUser(@RequestBody @Valid AppUserCreateRequest userRequest) {
try { try {
AppUser convertedUser = userRequest.convertToAppUser(); AppUser convertedUser = userRequest.convertToAppUser();
@@ -1,30 +1,18 @@
package de.zendric.app.xpensely_server.controller; package de.zendric.app.xpensely_server.controller;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.*;
import de.zendric.app.xpensely_server.model.Expense; import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
import de.zendric.app.xpensely_server.model.ExpenseChangeRequest;
import de.zendric.app.xpensely_server.model.ExpenseInput;
import de.zendric.app.xpensely_server.model.ExpenseList;
import de.zendric.app.xpensely_server.model.InviteRequest;
import de.zendric.app.xpensely_server.model.XpenselyStandardCategories;
import de.zendric.app.xpensely_server.services.CategoryService; import de.zendric.app.xpensely_server.services.CategoryService;
import de.zendric.app.xpensely_server.services.ExpenseListService; import de.zendric.app.xpensely_server.services.ExpenseListService;
import de.zendric.app.xpensely_server.services.UserService; import de.zendric.app.xpensely_server.services.UserService;
@@ -33,93 +21,51 @@ import de.zendric.app.xpensely_server.services.UserService;
@RequestMapping("/api/expenselist") @RequestMapping("/api/expenselist")
class ExpenseListController { class ExpenseListController {
private ExpenseListService expenseListService; private final ExpenseListService expenseListService;
private UserService userService; private final UserService userService;
private CategoryService categoryService; private final CategoryService categoryService;
private final AuthenticatedUserResolver authenticatedUserResolver;
@Autowired
public ExpenseListController(ExpenseListService expenseListService, UserService userService, public ExpenseListController(ExpenseListService expenseListService, UserService userService,
CategoryService categoryService) { CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) {
this.expenseListService = expenseListService; this.expenseListService = expenseListService;
this.userService = userService; this.userService = userService;
this.categoryService = categoryService; this.categoryService = categoryService;
this.authenticatedUserResolver = authenticatedUserResolver;
} }
@GetMapping("/all") @GetMapping("/mine")
public ResponseEntity<List<ExpenseList>> getAll() { public ResponseEntity<List<ExpenseList>> getMine(Authentication authentication) {
try { AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
List<ExpenseList> items = new ArrayList<>(); List<ExpenseList> items = expenseListService.findByUserId(user.getId());
if (items.isEmpty())
expenseListService.findAll().forEach(items::add); return new ResponseEntity<>(HttpStatus.NO_CONTENT);
return new ResponseEntity<>(items, HttpStatus.OK);
if (items.isEmpty())
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
return new ResponseEntity<>(items, HttpStatus.OK);
} catch (Exception e) {
return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@GetMapping("/byUser")
public ResponseEntity<List<ExpenseList>> getByUser(@RequestParam Long userId) {
try {
List<ExpenseList> items = expenseListService.findByUserId(userId);
if (items.isEmpty())
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
return new ResponseEntity<>(items, HttpStatus.OK);
} catch (Exception e) {
return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@GetMapping("/byUsername")
public ResponseEntity<List<ExpenseList>> getByUser(@RequestParam String username) {
try {
List<ExpenseList> items = expenseListService.findByUsername(username);
if (items.isEmpty())
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
return new ResponseEntity<>(items, HttpStatus.OK);
} catch (Exception e) {
return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
}
} }
@GetMapping("/byId") @GetMapping("/byId")
public ResponseEntity<ExpenseList> getById(@RequestParam Long id) { public ResponseEntity<ExpenseList> getById(@RequestParam Long id, Authentication authentication) {
AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
Optional<ExpenseList> existingItemOptional = expenseListService.findById(id); Optional<ExpenseList> existingItemOptional = expenseListService.findById(id);
if (existingItemOptional.isEmpty())
if (existingItemOptional.isPresent()) {
return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK);
} else {
return new ResponseEntity<>(HttpStatus.NOT_FOUND); return new ResponseEntity<>(HttpStatus.NOT_FOUND);
} assertMember(user, existingItemOptional.get());
return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK);
} }
@PostMapping("/create") @PostMapping("/create")
// TODO add handling of categories by using DTO public ResponseEntity<ExpenseList> create(@RequestBody ExpenseList expenseList,
public ResponseEntity<ExpenseList> create(@RequestBody ExpenseList expenseList) { Authentication authentication) {
try { try {
if (expenseList.getOwner() != null) { AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication);
AppUser existingOwner = userService.getUser(expenseList.getOwner().getId()); expenseList.setOwner(authenticatedUser);
if (existingOwner == null) { XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories();
throw new IllegalArgumentException("Owner does not exist."); expenseList.setXpenselyStandardCategories(standardCategories);
}
expenseList.setOwner(existingOwner);
XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories();
expenseList.setXpenselyStandardCategories(standardCategories);
} else {
throw new IllegalArgumentException("Owner is required.");
}
expenseList.setSharedWith(null); expenseList.setSharedWith(null);
ExpenseList savedItem = expenseListService.createList(expenseList); ExpenseList savedItem = expenseListService.createList(expenseList);
return new ResponseEntity<>(savedItem, HttpStatus.CREATED); return new ResponseEntity<>(savedItem, HttpStatus.CREATED);
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED); return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED);
@@ -127,7 +73,12 @@ class ExpenseListController {
} }
@DeleteMapping("{id}") @DeleteMapping("{id}")
public ResponseEntity<HttpStatus> delete(@PathVariable("id") Long 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 { try {
expenseListService.deleteById(id); expenseListService.deleteById(id);
return new ResponseEntity<>(HttpStatus.NO_CONTENT); return new ResponseEntity<>(HttpStatus.NO_CONTENT);
@@ -139,11 +90,16 @@ class ExpenseListController {
@PostMapping("/{id}/add") @PostMapping("/{id}/add")
public ResponseEntity<Expense> addExpenseToList( public ResponseEntity<Expense> addExpenseToList(
@PathVariable("id") Long expenseListId, @PathVariable("id") Long expenseListId,
@RequestBody ExpenseInput expenseInput) { @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 { try {
AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner()); AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner());
Expense expense = expenseInput.convertToExpense(expenseOwner.getId()); Expense expense = expenseInput.convertToExpense(expenseOwner.getId());
Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense); Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense);
return new ResponseEntity<>(addedExpense, HttpStatus.CREATED); return new ResponseEntity<>(addedExpense, HttpStatus.CREATED);
} catch (Exception e) { } catch (Exception e) {
@@ -154,18 +110,18 @@ class ExpenseListController {
@PutMapping("/{id}/update") @PutMapping("/{id}/update")
public ResponseEntity<Expense> updateExpenseInList( public ResponseEntity<Expense> updateExpenseInList(
@PathVariable("id") Long expenseListId, @PathVariable("id") Long expenseListId,
@RequestBody ExpenseChangeRequest expenseChangeRequest) { @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 { try {
AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName()); AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName());
Optional<ExpenseList> expenseList = expenseListService.findById(expenseListId); Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get());
if (expenseList.isPresent()) { Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense);
Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseList.get()); return new ResponseEntity<>(updatedExpense, HttpStatus.OK);
Expense addedExpense = expenseListService.updateExpense(expenseListId, expense);
return new ResponseEntity<>(addedExpense, HttpStatus.CREATED);
}
return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST);
} catch (Exception e) { } catch (Exception e) {
return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST); return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST);
} }
@@ -174,7 +130,13 @@ class ExpenseListController {
@DeleteMapping("/{id}/delete") @DeleteMapping("/{id}/delete")
public ResponseEntity<Expense> deleteExpenseFromList( public ResponseEntity<Expense> deleteExpenseFromList(
@PathVariable("id") Long expenseListId, @PathVariable("id") Long expenseListId,
@RequestParam("expenseId") Long expenseId) { @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 { try {
expenseListService.deleteExpenseFromList(expenseListId, expenseId); expenseListService.deleteExpenseFromList(expenseListId, expenseId);
return new ResponseEntity<>(HttpStatus.NO_CONTENT); return new ResponseEntity<>(HttpStatus.NO_CONTENT);
@@ -184,13 +146,20 @@ class ExpenseListController {
} }
@PostMapping("/{listId}/invite") @PostMapping("/{listId}/invite")
public ResponseEntity<String> generateInvite(@PathVariable Long listId) { 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); String inviteCode = expenseListService.generateInviteCode(listId);
return ResponseEntity.ok(inviteCode); return ResponseEntity.ok(inviteCode);
} }
@PostMapping("/accept-invite") @PostMapping("/accept-invite")
public ResponseEntity<?> acceptInvite(@RequestBody InviteRequest inviteRequest) { public ResponseEntity<?> acceptInvite(@RequestBody @Valid InviteRequest inviteRequest,
Authentication authentication) {
AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication);
ExpenseList list = expenseListService.findByInviteCode(inviteRequest.getInviteCode()); ExpenseList list = expenseListService.findByInviteCode(inviteRequest.getInviteCode());
if (list == null || list.getInviteCodeExpiration() == null || if (list == null || list.getInviteCodeExpiration() == null ||
@@ -200,21 +169,24 @@ class ExpenseListController {
if (list.getSharedWith() != null) { if (list.getSharedWith() != null) {
return ResponseEntity.status(HttpStatus.IM_USED).body("List has already been shared"); return ResponseEntity.status(HttpStatus.IM_USED).body("List has already been shared");
} }
if (list.getOwner().getId() == inviteRequest.getUserId()) { if (list.getOwner().getId().equals(authenticatedUser.getId())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cant join your own List"); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list");
}
AppUser user = null;
try {
user = userService.getUser(inviteRequest.getUserId());
} catch (Exception e) {
throw new RuntimeException("User not found");
}
if (user != null) {
list.setSharedWith(user);
expenseListService.save(list);
} else {
throw new RuntimeException("User not found");
} }
list.setSharedWith(authenticatedUser);
expenseListService.save(list);
return ResponseEntity.ok("User added to the list"); return ResponseEntity.ok("User added to the list");
} }
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);
}
} }
@@ -0,0 +1,30 @@
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()));
}
}
@@ -0,0 +1,36 @@
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");
}
}
}
@@ -0,0 +1,60 @@
package de.zendric.app.xpensely_Server.controller;
import de.zendric.app.xpensely_server.controller.AppUserController;
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.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
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)
@AutoConfigureMockMvc(addFilters = false)
@ActiveProfiles("test")
class AppUserControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean 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());
}
}