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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
20
backend/src/main/java/com/recipeapp/common/WebMvcConfig.java
Normal file
20
backend/src/main/java/com/recipeapp/common/WebMvcConfig.java
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ public class HouseholdResolver {
|
|||||||
return findMembership(userEmail).getUser().getId();
|
return findMembership(userEmail).getUser().getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String resolveRole(String userEmail) {
|
||||||
|
return findMembership(userEmail).getRole();
|
||||||
|
}
|
||||||
|
|
||||||
private HouseholdMember findMembership(String userEmail) {
|
private HouseholdMember findMembership(String userEmail) {
|
||||||
return householdMemberRepository.findByUserEmailIgnoreCase(userEmail)
|
return householdMemberRepository.findByUserEmailIgnoreCase(userEmail)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
|
.orElseThrow(() -> new ResourceNotFoundException("User is not in a household"));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user