From 3be9f502c6e60b7f270ac037b8676bac5bb44f3b Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Sat, 4 Apr 2026 18:22:47 +0200 Subject: [PATCH] feat(auth): add @RequiresHouseholdRole annotation with interceptor Reusable annotation for planner-only endpoints. Uses a HandlerInterceptor that resolves the household role from the authenticated user and throws 403 if the role doesn't match. Co-Authored-By: Claude Sonnet 4.6 --- .../common/HouseholdRoleInterceptor.java | 43 ++++++++++ .../common/RequiresHouseholdRole.java | 12 +++ .../com/recipeapp/common/WebMvcConfig.java | 20 +++++ .../recipeapp/recipe/HouseholdResolver.java | 4 + .../common/HouseholdRoleInterceptorTest.java | 84 +++++++++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 backend/src/main/java/com/recipeapp/common/HouseholdRoleInterceptor.java create mode 100644 backend/src/main/java/com/recipeapp/common/RequiresHouseholdRole.java create mode 100644 backend/src/main/java/com/recipeapp/common/WebMvcConfig.java create mode 100644 backend/src/test/java/com/recipeapp/common/HouseholdRoleInterceptorTest.java diff --git a/backend/src/main/java/com/recipeapp/common/HouseholdRoleInterceptor.java b/backend/src/main/java/com/recipeapp/common/HouseholdRoleInterceptor.java new file mode 100644 index 0000000..ac3de06 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/HouseholdRoleInterceptor.java @@ -0,0 +1,43 @@ +package com.recipeapp.common; + +import com.recipeapp.recipe.HouseholdResolver; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class HouseholdRoleInterceptor implements HandlerInterceptor { + + private final HouseholdResolver householdResolver; + + public HouseholdRoleInterceptor(HouseholdResolver householdResolver) { + this.householdResolver = householdResolver; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + + RequiresHouseholdRole annotation = handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class); + if (annotation == null) { + return true; + } + + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) { + throw new ForbiddenException("Not authenticated"); + } + + String actualRole = householdResolver.resolveRole(auth.getName()); + if (!annotation.value().equals(actualRole)) { + throw new ForbiddenException("Requires household role: " + annotation.value()); + } + + return true; + } +} diff --git a/backend/src/main/java/com/recipeapp/common/RequiresHouseholdRole.java b/backend/src/main/java/com/recipeapp/common/RequiresHouseholdRole.java new file mode 100644 index 0000000..bae7b0d --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/RequiresHouseholdRole.java @@ -0,0 +1,12 @@ +package com.recipeapp.common; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequiresHouseholdRole { + String value(); +} diff --git a/backend/src/main/java/com/recipeapp/common/WebMvcConfig.java b/backend/src/main/java/com/recipeapp/common/WebMvcConfig.java new file mode 100644 index 0000000..f3da006 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/common/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.recipeapp.common; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final HouseholdRoleInterceptor householdRoleInterceptor; + + public WebMvcConfig(HouseholdRoleInterceptor householdRoleInterceptor) { + this.householdRoleInterceptor = householdRoleInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(householdRoleInterceptor); + } +} diff --git a/backend/src/main/java/com/recipeapp/recipe/HouseholdResolver.java b/backend/src/main/java/com/recipeapp/recipe/HouseholdResolver.java index 54d0249..a6164bf 100644 --- a/backend/src/main/java/com/recipeapp/recipe/HouseholdResolver.java +++ b/backend/src/main/java/com/recipeapp/recipe/HouseholdResolver.java @@ -24,6 +24,10 @@ public class HouseholdResolver { return findMembership(userEmail).getUser().getId(); } + public String resolveRole(String userEmail) { + return findMembership(userEmail).getRole(); + } + private HouseholdMember findMembership(String userEmail) { return householdMemberRepository.findByUserEmailIgnoreCase(userEmail) .orElseThrow(() -> new ResourceNotFoundException("User is not in a household")); diff --git a/backend/src/test/java/com/recipeapp/common/HouseholdRoleInterceptorTest.java b/backend/src/test/java/com/recipeapp/common/HouseholdRoleInterceptorTest.java new file mode 100644 index 0000000..aaf85ac --- /dev/null +++ b/backend/src/test/java/com/recipeapp/common/HouseholdRoleInterceptorTest.java @@ -0,0 +1,84 @@ +package com.recipeapp.common; + +import com.recipeapp.recipe.HouseholdResolver; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.method.HandlerMethod; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class HouseholdRoleInterceptorTest { + + @Mock private HouseholdResolver householdResolver; + @Mock private HttpServletRequest request; + @Mock private HttpServletResponse response; + + @InjectMocks private HouseholdRoleInterceptor interceptor; + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private void authenticateAs(String email) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(email, null)); + } + + @Test + void shouldAllowWhenUserHasRequiredRole() throws Exception { + authenticateAs("planner@example.com"); + when(householdResolver.resolveRole("planner@example.com")).thenReturn("planner"); + + var handlerMethod = mock(HandlerMethod.class); + var annotation = mock(RequiresHouseholdRole.class); + when(annotation.value()).thenReturn("planner"); + when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation); + + boolean result = interceptor.preHandle(request, response, handlerMethod); + + assertThat(result).isTrue(); + } + + @Test + void shouldThrowForbiddenWhenUserLacksRequiredRole() { + authenticateAs("member@example.com"); + when(householdResolver.resolveRole("member@example.com")).thenReturn("member"); + + var handlerMethod = mock(HandlerMethod.class); + var annotation = mock(RequiresHouseholdRole.class); + when(annotation.value()).thenReturn("planner"); + when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(annotation); + + assertThatThrownBy(() -> interceptor.preHandle(request, response, handlerMethod)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("planner"); + } + + @Test + void shouldPassThroughWhenNoAnnotation() throws Exception { + var handlerMethod = mock(HandlerMethod.class); + when(handlerMethod.getMethodAnnotation(RequiresHouseholdRole.class)).thenReturn(null); + + boolean result = interceptor.preHandle(request, response, handlerMethod); + + assertThat(result).isTrue(); + } + + @Test + void shouldPassThroughWhenNotHandlerMethod() throws Exception { + boolean result = interceptor.preHandle(request, response, new Object()); + + assertThat(result).isTrue(); + } +}