Release 1.1.0 Final Security Hardening
Build and Deploy Spring Boot Server / build (push) Successful in 1m11s

This commit is contained in:
2026-07-04 22:14:58 +02:00
parent f958fb7853
commit d73d349a58
11 changed files with 173 additions and 31 deletions
+2 -6
View File
@@ -6,11 +6,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.6</version>
<relativePath/> <!-- lookup parent from repository -->
<relativePath/>
</parent>
<groupId>de.zendric.app</groupId>
<artifactId>XpenselyServer</artifactId>
<version>1.0.0</version>
<version>1.1.0</version>
<name>XpenselyServer</name>
<description>XpenselyServer used to handle the Xpensely App</description>
<url/>
@@ -52,10 +52,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
@@ -9,6 +9,7 @@ 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.UsernameUpdateRequest;
import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
import de.zendric.app.xpensely_server.services.UserService;
@@ -31,11 +32,6 @@ public class AppUserController {
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);
@@ -51,6 +47,14 @@ public class AppUserController {
return new ResponseEntity<>(nUser, HttpStatus.CREATED);
}
@PutMapping("/username")
public ResponseEntity<AppUser> updateUsername(@RequestBody @Valid UsernameUpdateRequest request,
Authentication authentication) {
AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication);
AppUser updated = userService.updateUsername(self.getId(), request.getUsername());
return ResponseEntity.ok(updated);
}
@DeleteMapping
public ResponseEntity<String> deleteUser(@RequestParam Long id, Authentication authentication) {
AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication);
@@ -17,7 +17,6 @@ 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")
@@ -26,14 +25,12 @@ public class ExpenseListController {
private static final Logger log = LoggerFactory.getLogger(ExpenseListController.class);
private final ExpenseListService expenseListService;
private final UserService userService;
private final CategoryService categoryService;
private final AuthenticatedUserResolver authenticatedUserResolver;
public ExpenseListController(ExpenseListService expenseListService, UserService userService,
public ExpenseListController(ExpenseListService expenseListService,
CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) {
this.expenseListService = expenseListService;
this.userService = userService;
this.categoryService = categoryService;
this.authenticatedUserResolver = authenticatedUserResolver;
}
@@ -93,7 +90,7 @@ public class ExpenseListController {
if (listOpt.isEmpty())
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
assertMember(user, listOpt.get());
AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner());
AppUser expenseOwner = resolveListMember(listOpt.get(), expenseInput.getOwner());
Expense expense = expenseInput.convertToExpense(expenseOwner.getId());
Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense);
return new ResponseEntity<>(addedExpense, HttpStatus.CREATED);
@@ -109,7 +106,7 @@ public class ExpenseListController {
if (expenseListOpt.isEmpty())
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
assertMember(user, expenseListOpt.get());
AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName());
AppUser expenseOwner = resolveListMember(expenseListOpt.get(), expenseChangeRequest.getOwnerName());
Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get());
Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense);
return new ResponseEntity<>(updatedExpense, HttpStatus.OK);
@@ -156,6 +153,11 @@ public class ExpenseListController {
if (list.getOwner().getId().equals(authenticatedUser.getId())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list");
}
if (list.getOwner().getUsername().equals(authenticatedUser.getUsername())) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(
"You and the list owner both use the username \"" + authenticatedUser.getUsername()
+ "\". Please change your username before joining this list.");
}
list.setSharedWith(authenticatedUser);
expenseListService.save(list);
return ResponseEntity.ok("User added to the list");
@@ -173,4 +175,20 @@ public class ExpenseListController {
if (!isOwner && !isShared)
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
/**
* Resolves an expense owner by username among the list's members. Usernames are
* not globally unique, so the owner must be one of this list's (at most two)
* members — never a global lookup.
*/
private AppUser resolveListMember(ExpenseList list, String username) {
if (list.getOwner() != null && list.getOwner().getUsername().equals(username)) {
return list.getOwner();
}
if (list.getSharedWith() != null && list.getSharedWith().getUsername().equals(username)) {
return list.getSharedWith();
}
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Expense owner must be a member of this list");
}
}
@@ -1,5 +1,6 @@
package de.zendric.app.xpensely_server.controller;
import de.zendric.app.xpensely_server.model.Exception.GoogleAccountAlreadyRegisteredException;
import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException;
import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException;
import org.slf4j.Logger;
@@ -47,6 +48,13 @@ public class GlobalExceptionHandler {
.body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(GoogleAccountAlreadyRegisteredException.class)
public ResponseEntity<Map<String, String>> handleGoogleAccountConflict(
GoogleAccountAlreadyRegisteredException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, String>> handleResponseStatus(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode())
@@ -26,9 +26,10 @@ public class AppUser {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username", nullable = false, unique = true)
@Column(name = "username", nullable = false)
private String username;
@Column(name = "google_id", unique = true)
@JsonIgnore
private String googleId;
@@ -0,0 +1,11 @@
package de.zendric.app.xpensely_server.model.Exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.CONFLICT)
public class GoogleAccountAlreadyRegisteredException extends RuntimeException {
public GoogleAccountAlreadyRegisteredException(String message) {
super(message);
}
}
@@ -0,0 +1,15 @@
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 UsernameUpdateRequest {
@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;
}
@@ -14,4 +14,6 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByGoogleId(String id);
Boolean existsByUsername(String username);
Boolean existsByGoogleId(String googleId);
}
@@ -5,8 +5,8 @@ import java.util.List;
import org.springframework.stereotype.Service;
import de.zendric.app.xpensely_server.model.AppUser;
import de.zendric.app.xpensely_server.model.Exception.GoogleAccountAlreadyRegisteredException;
import de.zendric.app.xpensely_server.model.Exception.ResourceNotFoundException;
import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException;
import de.zendric.app.xpensely_server.repo.UserRepository;
@Service
@@ -22,9 +22,18 @@ public class UserService {
}
public AppUser createUser(AppUser user) {
if (Boolean.TRUE.equals(userRepository.existsByUsername(user.getUsername()))) {
throw new UsernameAlreadyExistsException("Username already exists");
if (Boolean.TRUE.equals(userRepository.existsByGoogleId(user.getGoogleId()))) {
throw new GoogleAccountAlreadyRegisteredException(
"An account already exists for this Google account");
}
// Usernames are not unique — identity is the Google account (googleId).
return userRepository.save(user);
}
public AppUser updateUsername(Long userId, String newUsername) {
AppUser user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found with id: " + userId));
user.setUsername(newUsername);
return userRepository.save(user);
}
@@ -6,8 +6,6 @@ import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
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.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration;
import org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration;
import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -22,8 +20,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(value = AppUserController.class, excludeAutoConfiguration = {
OAuth2ClientAutoConfiguration.class,
OAuth2ClientWebSecurityAutoConfiguration.class,
OAuth2ResourceServerAutoConfiguration.class
})
@AutoConfigureMockMvc(addFilters = false)
@@ -99,4 +95,38 @@ class AppUserControllerTest {
mockMvc.perform(delete("/api/users").param("id", "99"))
.andExpect(status().isForbidden());
}
// --- Rename username ---
@Test
void updateUsername_valid_returns200WithUpdatedUser() throws Exception {
AppUser self = new AppUser(); self.setId(1L); self.setUsername("old");
AppUser updated = new AppUser(); updated.setId(1L); updated.setUsername("newname");
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(self);
when(userService.updateUsername(1L, "newname")).thenReturn(updated);
mockMvc.perform(put("/api/users/username")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"newname\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("newname"));
}
@Test
void updateUsername_invalidPattern_returns400() throws Exception {
mockMvc.perform(put("/api/users/username")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"bad name!\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.username").exists());
}
@Test
void updateUsername_tooShort_returns400() throws Exception {
mockMvc.perform(put("/api/users/username")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"ab\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.username").exists());
}
}
@@ -2,15 +2,13 @@ package de.zendric.app.xpensely_Server.controller;
import de.zendric.app.xpensely_server.controller.ExpenseListController;
import de.zendric.app.xpensely_server.model.AppUser;
import de.zendric.app.xpensely_server.model.Expense;
import de.zendric.app.xpensely_server.model.ExpenseList;
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.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration;
import org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration;
import org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -19,17 +17,17 @@ import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(value = ExpenseListController.class, excludeAutoConfiguration = {
OAuth2ClientAutoConfiguration.class,
OAuth2ClientWebSecurityAutoConfiguration.class,
OAuth2ResourceServerAutoConfiguration.class
})
@AutoConfigureMockMvc(addFilters = false)
@@ -38,7 +36,6 @@ class ExpenseListControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean ExpenseListService expenseListService;
@MockitoBean UserService userService;
@MockitoBean CategoryService categoryService;
@MockitoBean AuthenticatedUserResolver authenticatedUserResolver;
@@ -178,4 +175,55 @@ class ExpenseListControllerTest {
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.name").exists());
}
// --- Owner resolution within list members ---
@Test
void addExpense_ownerNotAListMember_returns400() throws Exception {
AppUser member = new AppUser(); member.setId(1L); member.setUsername("alice");
ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(member);
when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member);
mockMvc.perform(post("/api/expenselist/1/add")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Lunch\",\"owner\":\"ghost\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}"))
.andExpect(status().isBadRequest());
}
@Test
void addExpense_ownerIsAListMember_returns201() throws Exception {
AppUser member = new AppUser(); member.setId(1L); member.setUsername("alice");
ExpenseList list = new ExpenseList(); list.setId(1L); list.setOwner(member);
when(expenseListService.findById(1L)).thenReturn(Optional.of(list));
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(member);
when(expenseListService.addExpenseToList(eq(1L), any())).thenReturn(new Expense());
mockMvc.perform(post("/api/expenselist/1/add")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Lunch\",\"owner\":\"alice\",\"amount\":10.0,\"date\":\"2026-05-04\",\"category\":\"Food\"}"))
.andExpect(status().isCreated());
}
// --- Duplicate-username invite guard ---
@Test
void acceptInvite_sameUsernameAsOwner_returns409() throws Exception {
AppUser owner = new AppUser(); owner.setId(1L); owner.setUsername("cedric");
AppUser joiner = new AppUser(); joiner.setId(2L); joiner.setUsername("cedric");
ExpenseList list = new ExpenseList();
list.setId(1L);
list.setOwner(owner);
list.setInviteCodeExpiration(LocalDateTime.now().plusHours(1));
when(expenseListService.findByInviteCode("ABC123")).thenReturn(list);
when(authenticatedUserResolver.resolveCurrentUser(any())).thenReturn(joiner);
mockMvc.perform(post("/api/expenselist/accept-invite")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"inviteCode\":\"ABC123\"}"))
.andExpect(status().isConflict());
}
}