Compare commits

...

2 Commits

Author SHA1 Message Date
Jesse Brault
c088cb2253 Write more recipe drafts integration tests. 2026-01-25 17:35:21 -06:00
Jesse Brault
f43751ab4f Refactor security configurations. 2026-01-24 14:39:07 -06:00
15 changed files with 300 additions and 83 deletions

View File

@ -9,6 +9,7 @@ import app.mealsmadeeasy.api.recipe.body.RecipeDraftUpdateBody;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException; import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService; import app.mealsmadeeasy.api.user.UserService;
import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -26,6 +27,7 @@ import java.util.UUID;
import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.hasSize;
import static org.junit.Assert.assertThrows;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -82,12 +84,10 @@ public class RecipeDraftsControllerIntegrationTests {
} }
@Test @Test
public void whenNoUser_getReturnsUnauthorized() throws Exception { public void whenNoDraftAndNoPrincipal_getReturnsUnauthorized() throws Exception {
this.mockMvc.perform( this.mockMvc.perform(
get("/recipe-drafts/{fakeId}", UUID.randomUUID().toString()) get("/recipe-drafts/{fakeId}", UUID.randomUUID().toString())
) ).andExpect(status().isUnauthorized());
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message", is(notNullValue())));
} }
@Test @Test
@ -112,6 +112,26 @@ public class RecipeDraftsControllerIntegrationTests {
.andExpect(jsonPath("$.mainImage", is(nullValue()))); .andExpect(jsonPath("$.mainImage", is(nullValue())));
} }
@Test
public void whenDraftExistsButNoPrincipal_returnsUnauthorized() throws Exception {
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
this.mockMvc.perform(
get("/recipe-drafts/{id}", recipeDraft.getId())
).andExpect(status().isUnauthorized());
}
@Test
public void whenDraftExistsButWrongPrincipal_returnsForbidden() throws Exception {
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
final User wrongViewer = this.seedUser();
this.mockMvc.perform(
get("/recipe-drafts/{id}", recipeDraft.getId())
.header("Authorization", "Bearer " + this.getAccessToken(wrongViewer))
).andExpect(status().isForbidden());
}
@Test @Test
public void whenDraftsExist_returnDrafts() throws Exception { public void whenDraftsExist_returnDrafts() throws Exception {
final User owner = this.seedUser(); final User owner = this.seedUser();
@ -126,6 +146,21 @@ public class RecipeDraftsControllerIntegrationTests {
.andExpect(jsonPath("$", hasSize(2))); .andExpect(jsonPath("$", hasSize(2)));
} }
@Test
public void whenDraftsExistButWrongPrincipal_returnsNoDrafts() throws Exception {
final User owner = this.seedUser();
this.recipeService.createDraft(owner); // seed 1
this.recipeService.createDraft(owner); // seed 2
final User wrongViewer = this.seedUser();
this.mockMvc.perform(
get("/recipe-drafts")
.header("Authorization", "Bearer " + this.getAccessToken(wrongViewer))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$", hasSize(0)));
}
@Test @Test
public void manualDraft_returnsDraft() throws Exception { public void manualDraft_returnsDraft() throws Exception {
final User owner = this.seedUser(); final User owner = this.seedUser();
@ -166,16 +201,14 @@ public class RecipeDraftsControllerIntegrationTests {
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andExpect(jsonPath("$.created", is(notNullValue()))) .andExpect(jsonPath("$.created", is(notNullValue())))
.andExpect(jsonPath("$.state", is(RecipeDraft.State.INFER.toString()))) .andExpect(jsonPath("$.state", is(RecipeDraft.State.INFER.toString())))
.andExpect(jsonPath("$.owner.id", is(owner.getId()))); .andExpect(jsonPath("$.owner.id", is(owner.getId())))
.andExpect(jsonPath("$.owner.username", is(owner.getUsername())));
} }
} }
@Test private static RecipeDraftUpdateBody getTestRecipeDraftUpdateBody() {
public void whenUpdate_returnsUpdated() throws Exception { return RecipeDraftUpdateBody.builder()
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
final RecipeDraftUpdateBody updateBody = RecipeDraftUpdateBody.builder()
.title("Test Title") .title("Test Title")
.slug("test-slug") .slug("test-slug")
.preparationTime(15) .preparationTime(15)
@ -190,6 +223,13 @@ public class RecipeDraftsControllerIntegrationTests {
.build() .build()
)) ))
.build(); .build();
}
@Test
public void whenUpdate_returnsUpdated() throws Exception {
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
final RecipeDraftUpdateBody updateBody = getTestRecipeDraftUpdateBody();
this.mockMvc.perform( this.mockMvc.perform(
put("/recipe-drafts/{id}", recipeDraft.getId()) put("/recipe-drafts/{id}", recipeDraft.getId())
.header("Authorization", "Bearer " + this.getAccessToken(owner)) .header("Authorization", "Bearer " + this.getAccessToken(owner))
@ -209,4 +249,102 @@ public class RecipeDraftsControllerIntegrationTests {
.andExpect(jsonPath("$.ingredients[0].notes", is("Separated"))); .andExpect(jsonPath("$.ingredients[0].notes", is("Separated")));
} }
@Test
public void whenUpdateNoPrincipal_returnsUnauthorized() throws Exception {
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
final RecipeDraftUpdateBody updateBody = getTestRecipeDraftUpdateBody();
this.mockMvc.perform(
put("/recipe-drafts/{id}", recipeDraft.getId())
.header("Content-Type", "application/json")
.content(this.objectMapper.writeValueAsString(updateBody))
).andExpect(status().isUnauthorized());
}
@Test
public void whenWrongPrincipalUpdate_returnsForbidden() throws Exception {
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
final RecipeDraftUpdateBody updateBody = getTestRecipeDraftUpdateBody();
final User wrongModifier = this.seedUser();
this.mockMvc.perform(
put("/recipe-drafts/{id}", recipeDraft.getId())
.header("Authorization", "Bearer " + this.getAccessToken(wrongModifier))
.header("Content-Type", "application/json")
.content(this.objectMapper.writeValueAsString(updateBody))
).andExpect(status().isForbidden());
}
@Test
public void whenDelete_draftIsDeleted() throws Exception {
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
this.mockMvc.perform(
delete("/recipe-drafts/{id}", recipeDraft.getId())
.header("Authorization", "Bearer " + this.getAccessToken(owner))
)
.andExpect(status().isNoContent());
assertThrows(NoSuchEntityWithIdException.class, () -> this.recipeService.getDraftById(recipeDraft.getId()));
}
@Test
public void whenDeleteWithNoPrincipal_returnsUnauthorized() throws Exception {
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
this.mockMvc.perform(
delete("/recipe-drafts/{id}", recipeDraft.getId())
).andExpect(status().isUnauthorized());
}
@Test
public void whenPublish_expectDraftDeletedAndRecipeCreated() throws Exception {
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
recipeDraft.setTitle("Test Title");
recipeDraft.setSlug("test-slug");
recipeDraft.setRawText("# Hello, World!");
this.recipeService.saveDraft(recipeDraft);
this.mockMvc.perform(
post("/recipe-drafts/{id}/publish", recipeDraft.getId())
.header("Authorization", "Bearer " + this.getAccessToken(owner))
)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id", is(notNullValue())))
.andExpect(jsonPath("$.created", is(notNullValue())))
.andExpect(jsonPath("$.modified", is(nullValue())))
.andExpect(jsonPath("$.title", is("Test Title")))
.andExpect(jsonPath("$.slug", is("test-slug")))
.andExpect(jsonPath("$.preparationTime", is(nullValue())))
.andExpect(jsonPath("$.cookingTime", is(nullValue())))
.andExpect(jsonPath("$.totalTime", is(nullValue())))
.andExpect(jsonPath("$.text", is("<h1>Hello, World!</h1>")))
.andExpect(jsonPath("$.owner.id", is(owner.getId())))
.andExpect(jsonPath("$.owner.username", is(owner.getUsername())))
.andExpect(jsonPath("$.starCount", is(0)))
.andExpect(jsonPath("$.viewerCount", is(0)))
.andExpect(jsonPath("$.mainImage", is(nullValue())))
.andExpect(jsonPath("$.public", is(false)));
assertThrows(NoSuchEntityWithIdException.class, () -> this.recipeService.getDraftById(recipeDraft.getId()));
}
@Test
public void whenPublishWithNoPrincipal_returnsUnauthorized() throws Exception {
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
this.mockMvc.perform(
post("/recipe-drafts/{id}/publish", recipeDraft.getId())
).andExpect(status().isUnauthorized());
}
@Test
public void whenPublishWrongPrincipal_returnsForbidden() throws Exception {
final User owner = this.seedUser();
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
final User wrongPublisher = this.seedUser();
this.mockMvc.perform(
post("/recipe-drafts/{id}/publish", recipeDraft.getId())
.header("Authorization", "Bearer " + this.getAccessToken(wrongPublisher))
).andExpect(status().isForbidden());
}
} }

View File

@ -1,5 +1,9 @@
package app.mealsmadeeasy.api; package app.mealsmadeeasy.api;
import app.mealsmadeeasy.api.security.EndpointAuthConfigurator;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
@ -7,6 +11,16 @@ import org.springframework.web.bind.annotation.ResponseBody;
@Controller @Controller
public final class GreetingController { public final class GreetingController {
@Component
public static class GreetingEndpointAuthConfigurator implements EndpointAuthConfigurator {
@Override
public void configure(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
registry.requestMatchers("/greeting").permitAll();
}
}
@GetMapping("/greeting") @GetMapping("/greeting")
@ResponseBody @ResponseBody
public String get() { public String get() {

View File

@ -0,0 +1,16 @@
package app.mealsmadeeasy.api.auth;
import app.mealsmadeeasy.api.security.EndpointAuthConfigurator;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.stereotype.Component;
@Component
public class AuthEndpointAuthConfigurator implements EndpointAuthConfigurator {
@Override
public void configure(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
registry.requestMatchers("/auth/**").permitAll();
}
}

View File

@ -91,9 +91,6 @@ public class ImageController {
@RequestParam(required = false) Set<String> viewers, @RequestParam(required = false) Set<String> viewers,
@AuthenticationPrincipal User principal @AuthenticationPrincipal User principal
) throws IOException, ImageException { ) throws IOException, ImageException {
if (principal == null) {
throw new AccessDeniedException("Must be logged in.");
}
final var specBuilder = ImageCreateSpec.builder() final var specBuilder = ImageCreateSpec.builder()
.alt(alt) .alt(alt)
.caption(caption) .caption(caption)
@ -119,9 +116,6 @@ public class ImageController {
@PathVariable String filename, @PathVariable String filename,
@RequestBody ImageUpdateBody body @RequestBody ImageUpdateBody body
) { ) {
if (principal == null) {
throw new AccessDeniedException("Must be logged in.");
}
final User owner = this.userService.getUser(username); final User owner = this.userService.getUser(username);
final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal); final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal);
final Image updated = this.imageService.update(image, principal, this.getImageUpdateSpec(body)); final Image updated = this.imageService.update(image, principal, this.getImageUpdateSpec(body));
@ -134,9 +128,6 @@ public class ImageController {
@PathVariable String username, @PathVariable String username,
@PathVariable String filename @PathVariable String filename
) throws IOException { ) throws IOException {
if (principal == null) {
throw new AccessDeniedException("Must be logged in.");
}
final User owner = this.userService.getUser(username); final User owner = this.userService.getUser(username);
final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal); final Image image = this.imageService.getByOwnerAndFilename(owner, filename, principal);
this.imageService.deleteImage(image, principal); this.imageService.deleteImage(image, principal);

View File

@ -0,0 +1,20 @@
package app.mealsmadeeasy.api.image;
import app.mealsmadeeasy.api.security.EndpointAuthConfigurator;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.stereotype.Component;
@Component
public class ImageEndpointAuthConfigurator implements EndpointAuthConfigurator {
@Override
public void configure(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
registry.requestMatchers(HttpMethod.GET, "/images/**").permitAll();
registry.requestMatchers(HttpMethod.POST, "/images/**").authenticated();
registry.requestMatchers(HttpMethod.PUT, "/images/**").authenticated();
registry.requestMatchers(HttpMethod.DELETE, "/images/**").authenticated();
}
}

View File

@ -10,7 +10,6 @@ import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec;
import app.mealsmadeeasy.api.recipe.view.FullRecipeView; import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
import app.mealsmadeeasy.api.recipe.view.RecipeDraftView; import app.mealsmadeeasy.api.recipe.view.RecipeDraftView;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.util.MustBeLoggedInException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -35,9 +34,6 @@ public class RecipeDraftsController {
@GetMapping @GetMapping
public ResponseEntity<List<RecipeDraftView>> getAllDraftsForUser(@AuthenticationPrincipal User user) { public ResponseEntity<List<RecipeDraftView>> getAllDraftsForUser(@AuthenticationPrincipal User user) {
if (user == null) {
throw new MustBeLoggedInException();
}
final List<RecipeDraft> recipeDrafts = this.recipeService.getDrafts(user); final List<RecipeDraft> recipeDrafts = this.recipeService.getDrafts(user);
return ResponseEntity.ok(recipeDrafts.stream() return ResponseEntity.ok(recipeDrafts.stream()
.map(recipeDraft -> this.draftToViewConverter.convert(recipeDraft, user)) .map(recipeDraft -> this.draftToViewConverter.convert(recipeDraft, user))
@ -50,18 +46,12 @@ public class RecipeDraftsController {
@PathVariable UUID id, @PathVariable UUID id,
@AuthenticationPrincipal User viewer @AuthenticationPrincipal User viewer
) { ) {
if (viewer == null) {
throw new MustBeLoggedInException();
}
final RecipeDraft recipeDraft = this.recipeService.getDraftByIdWithViewer(id, viewer); final RecipeDraft recipeDraft = this.recipeService.getDraftByIdWithViewer(id, viewer);
return ResponseEntity.ok(this.draftToViewConverter.convert(recipeDraft, viewer)); return ResponseEntity.ok(this.draftToViewConverter.convert(recipeDraft, viewer));
} }
@PostMapping("/manual") @PostMapping("/manual")
public ResponseEntity<RecipeDraftView> createManualDraft(@AuthenticationPrincipal User owner) { public ResponseEntity<RecipeDraftView> createManualDraft(@AuthenticationPrincipal User owner) {
if (owner == null) {
throw new MustBeLoggedInException();
}
final RecipeDraft recipeDraft = this.recipeService.createDraft(owner); final RecipeDraft recipeDraft = this.recipeService.createDraft(owner);
return ResponseEntity.status(HttpStatus.CREATED).body(this.draftToViewConverter.convert(recipeDraft, owner)); return ResponseEntity.status(HttpStatus.CREATED).body(this.draftToViewConverter.convert(recipeDraft, owner));
} }
@ -72,9 +62,6 @@ public class RecipeDraftsController {
@RequestParam MultipartFile sourceFile, @RequestParam MultipartFile sourceFile,
@RequestParam String sourceFileName @RequestParam String sourceFileName
) throws IOException { ) throws IOException {
if (owner == null) {
throw new MustBeLoggedInException();
}
final File file = this.fileService.create( final File file = this.fileService.create(
sourceFile.getInputStream(), sourceFile.getInputStream(),
sourceFileName, sourceFileName,
@ -91,9 +78,6 @@ public class RecipeDraftsController {
@PathVariable UUID id, @PathVariable UUID id,
@RequestBody RecipeDraftUpdateBody updateBody @RequestBody RecipeDraftUpdateBody updateBody
) { ) {
if (modifier == null) {
throw new MustBeLoggedInException();
}
final RecipeDraftUpdateSpec spec = this.updateBodyToSpecConverter.convert(updateBody, modifier); final RecipeDraftUpdateSpec spec = this.updateBodyToSpecConverter.convert(updateBody, modifier);
final RecipeDraft updated = this.recipeService.updateDraft(id, spec, modifier); final RecipeDraft updated = this.recipeService.updateDraft(id, spec, modifier);
return ResponseEntity.ok(this.draftToViewConverter.convert(updated, modifier)); return ResponseEntity.ok(this.draftToViewConverter.convert(updated, modifier));
@ -104,21 +88,15 @@ public class RecipeDraftsController {
@AuthenticationPrincipal User modifier, @AuthenticationPrincipal User modifier,
@PathVariable UUID id @PathVariable UUID id
) { ) {
if (modifier == null) {
throw new MustBeLoggedInException();
}
this.recipeService.deleteDraft(id, modifier); this.recipeService.deleteDraft(id, modifier);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PostMapping("/{id}") @PostMapping("/{id}/publish")
public ResponseEntity<FullRecipeView> publishRecipeDraft( public ResponseEntity<FullRecipeView> publishRecipeDraft(
@AuthenticationPrincipal User modifier, @AuthenticationPrincipal User modifier,
@PathVariable UUID id @PathVariable UUID id
) { ) {
if (modifier == null) {
throw new MustBeLoggedInException();
}
final Recipe recipe = this.recipeService.publishDraft(id, modifier); final Recipe recipe = this.recipeService.publishDraft(id, modifier);
final FullRecipeView view = this.recipeToFullViewConverter.convert(recipe, false, modifier); final FullRecipeView view = this.recipeToFullViewConverter.convert(recipe, false, modifier);
return ResponseEntity.status(HttpStatus.CREATED).body(view); return ResponseEntity.status(HttpStatus.CREATED).body(view);

View File

@ -0,0 +1,16 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.security.EndpointAuthConfigurator;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.stereotype.Component;
@Component
public class RecipeDraftsEndpointAuthConfigurator implements EndpointAuthConfigurator {
@Override
public void configure(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
registry.requestMatchers("/recipe-drafts/**").authenticated();
}
}

View File

@ -244,9 +244,9 @@ public class RecipeService {
} }
public RecipeDraft getDraftById(UUID id) { public RecipeDraft getDraftById(UUID id) {
return this.recipeDraftRepository.findById(id).orElseThrow(() -> new RuntimeException( return this.recipeDraftRepository.findById(id).orElseThrow(
"RecipeDraft with id " + id + " not found" () -> new NoSuchEntityWithIdException(RecipeDraft.class, id)
)); );
} }
@PostAuthorize("@recipeDraftSecurity.isViewableBy(returnObject, #viewer)") @PostAuthorize("@recipeDraftSecurity.isViewableBy(returnObject, #viewer)")

View File

@ -23,7 +23,6 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice; import org.springframework.data.domain.Slice;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -124,9 +123,6 @@ public class RecipesController {
@PathVariable String slug, @PathVariable String slug,
@Nullable @AuthenticationPrincipal User principal @Nullable @AuthenticationPrincipal User principal
) { ) {
if (principal == null) {
throw new AccessDeniedException("Must be logged in to star a recipe.");
}
return ResponseEntity.status(HttpStatus.CREATED).body(this.recipeStarService.create(username, slug, principal)); return ResponseEntity.status(HttpStatus.CREATED).body(this.recipeStarService.create(username, slug, principal));
} }
@ -136,9 +132,6 @@ public class RecipesController {
@PathVariable String slug, @PathVariable String slug,
@Nullable @AuthenticationPrincipal User principal @Nullable @AuthenticationPrincipal User principal
) { ) {
if (principal == null) {
throw new AccessDeniedException("Must be logged in to get a recipe star.");
}
final @Nullable RecipeStar star = this.recipeStarService.find(username, slug, principal).orElse(null); final @Nullable RecipeStar star = this.recipeStarService.find(username, slug, principal).orElse(null);
if (star != null) { if (star != null) {
return ResponseEntity.ok(Map.of("isStarred", true, "star", star)); return ResponseEntity.ok(Map.of("isStarred", true, "star", star));
@ -153,9 +146,6 @@ public class RecipesController {
@PathVariable String slug, @PathVariable String slug,
@Nullable @AuthenticationPrincipal User principal @Nullable @AuthenticationPrincipal User principal
) { ) {
if (principal == null) {
throw new AccessDeniedException("Must be logged in to delete a recipe star.");
}
this.recipeStarService.delete(username, slug, principal); this.recipeStarService.delete(username, slug, principal);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@ -183,9 +173,6 @@ public class RecipesController {
@RequestBody RecipeCommentCreateBody body, @RequestBody RecipeCommentCreateBody body,
@Nullable @AuthenticationPrincipal User principal @Nullable @AuthenticationPrincipal User principal
) { ) {
if (principal == null) {
throw new AccessDeniedException("Must be logged in to comment on a recipe.");
}
final RecipeComment comment = this.recipeCommentService.create(username, slug, principal, body); final RecipeComment comment = this.recipeCommentService.create(username, slug, principal, body);
return ResponseEntity.ok(RecipeCommentView.from(comment, false)); return ResponseEntity.ok(RecipeCommentView.from(comment, false));
} }

View File

@ -0,0 +1,20 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.security.EndpointAuthConfigurator;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.stereotype.Component;
@Component
public class RecipesEndpointAuthConfigurator implements EndpointAuthConfigurator {
@Override
public void configure(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
registry.requestMatchers(HttpMethod.GET, "/recipes/**").permitAll();
registry.requestMatchers(HttpMethod.POST, "/recipes/**").authenticated();
registry.requestMatchers(HttpMethod.PUT, "/recipes/**").authenticated();
registry.requestMatchers(HttpMethod.DELETE, "/recipes/**").authenticated();
}
}

View File

@ -0,0 +1,8 @@
package app.mealsmadeeasy.api.security;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
public interface EndpointAuthConfigurator {
void configure(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry);
}

View File

@ -19,6 +19,8 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.util.List;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity @EnableMethodSecurity
@ -26,15 +28,25 @@ public class SecurityConfiguration {
private final JpaUserDetailsService jpaUserDetailsService; private final JpaUserDetailsService jpaUserDetailsService;
private final BeanFactory beanFactory; private final BeanFactory beanFactory;
private final List<EndpointAuthConfigurator> endpointAuthConfigurators;
public SecurityConfiguration(JpaUserDetailsService jpaUserDetailsService, BeanFactory beanFactory) { public SecurityConfiguration(
JpaUserDetailsService jpaUserDetailsService,
BeanFactory beanFactory,
List<EndpointAuthConfigurator> endpointAuthConfigurators
) {
this.jpaUserDetailsService = jpaUserDetailsService; this.jpaUserDetailsService = jpaUserDetailsService;
this.beanFactory = beanFactory; this.beanFactory = beanFactory;
this.endpointAuthConfigurators = endpointAuthConfigurators;
} }
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.authorizeHttpRequests(requests -> requests.anyRequest().permitAll()); httpSecurity.authorizeHttpRequests(requests -> {
this.endpointAuthConfigurators.forEach(endpointAuthConfigurator -> {
endpointAuthConfigurator.configure(requests);
});
});
httpSecurity.csrf(AbstractHttpConfigurer::disable); httpSecurity.csrf(AbstractHttpConfigurer::disable);
httpSecurity.cors(Customizer.withDefaults()); httpSecurity.cors(Customizer.withDefaults());
httpSecurity.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy( httpSecurity.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(

View File

@ -0,0 +1,16 @@
package app.mealsmadeeasy.api.signup;
import app.mealsmadeeasy.api.security.EndpointAuthConfigurator;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.stereotype.Component;
@Component
public class SignUpEndpointAuthConfigurator implements EndpointAuthConfigurator {
@Override
public void configure(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
registry.requestMatchers("/sign-up/**").permitAll();
}
}

View File

@ -22,16 +22,6 @@ public class ExceptionHandlers {
)); ));
} }
public record MustBeLoggedInExceptionView(String message) {}
@ExceptionHandler(MustBeLoggedInException.class)
public ResponseEntity<MustBeLoggedInExceptionView> handleMustBeLoggedInException(
MustBeLoggedInException e
) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new MustBeLoggedInExceptionView(e.getMessage()));
}
public record NoSuchEntityWithUsernameAndSlugExceptionView( public record NoSuchEntityWithUsernameAndSlugExceptionView(
String entityName, String entityName,
String username, String username,
@ -56,4 +46,28 @@ public class ExceptionHandlers {
)); ));
} }
public record NoSuchEntityWithUsernameAndFilenameExceptionView(
String entityName,
String username,
String filename,
String message
) {}
@ExceptionHandler(NoSuchEntityWithUsernameAndFilenameException.class)
public ResponseEntity<NoSuchEntityWithUsernameAndFilenameExceptionView> handleNoSuchEntityWithUsernameAndFilename(
NoSuchEntityWithUsernameAndFilenameException e
) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new NoSuchEntityWithUsernameAndFilenameExceptionView(
e.getEntityType().getSimpleName(),
e.getUsername(),
e.getFilename(),
String.format(
"No such entity %s for username %s and filename %s",
e.getEntityType().getSimpleName(),
e.getUsername(),
e.getFilename()
)
));
}
} }

View File

@ -1,13 +0,0 @@
package app.mealsmadeeasy.api.util;
public class MustBeLoggedInException extends RuntimeException {
public MustBeLoggedInException() {
super("Must be logged in to perform the requested operation.");
}
public MustBeLoggedInException(String message) {
super(message);
}
}