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:
2026-04-04 18:22:47 +02:00
parent 2f690eb3cb
commit 3be9f502c6
5 changed files with 163 additions and 0 deletions

View File

@@ -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;
}
}

View File

@@ -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();
}

View 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);
}
}

View File

@@ -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"));