diff --git a/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java index 7724dd90e..926e34b3a 100644 --- a/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java +++ b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java @@ -23,6 +23,7 @@ import reviewme.global.exception.BadRequestException; import reviewme.global.exception.DataInconsistencyException; import reviewme.global.exception.FieldErrorResponse; +import reviewme.global.exception.ForbiddenException; import reviewme.global.exception.NotFoundException; import reviewme.global.exception.UnauthorizedException; @@ -45,6 +46,11 @@ public ProblemDetail handleUnauthorizedException(UnauthorizedException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getErrorMessage()); } + @ExceptionHandler(ForbiddenException.class) + public ProblemDetail handleForbiddenException(UnauthorizedException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getErrorMessage()); + } + @ExceptionHandler(DataInconsistencyException.class) public ProblemDetail handleDataConsistencyException(DataInconsistencyException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getErrorMessage()); diff --git a/backend/src/main/java/reviewme/global/exception/ForbiddenException.java b/backend/src/main/java/reviewme/global/exception/ForbiddenException.java new file mode 100644 index 000000000..a4169a9e7 --- /dev/null +++ b/backend/src/main/java/reviewme/global/exception/ForbiddenException.java @@ -0,0 +1,8 @@ +package reviewme.global.exception; + +public abstract class ForbiddenException extends ReviewMeException { + + public ForbiddenException(String errorMessage) { + super(errorMessage); + } +} diff --git a/backend/src/main/java/reviewme/highlight/controller/HighlightController.java b/backend/src/main/java/reviewme/highlight/controller/HighlightController.java index 6570d6195..1f06f1956 100644 --- a/backend/src/main/java/reviewme/highlight/controller/HighlightController.java +++ b/backend/src/main/java/reviewme/highlight/controller/HighlightController.java @@ -6,10 +6,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -import reviewme.security.resolver.GuestReviewGroupSession; -import reviewme.security.resolver.LoginMemberSession; -import reviewme.security.resolver.dto.GuestReviewGroup; -import reviewme.security.resolver.dto.LoginMember; +import reviewme.security.aspect.RequireReviewGroupAccess; import reviewme.highlight.service.HighlightService; import reviewme.highlight.service.dto.HighlightsRequest; @@ -20,14 +17,10 @@ public class HighlightController { private final HighlightService highlightService; @PostMapping("/v2/highlight") + @RequireReviewGroupAccess(target = "#request.reviewGroupId()") public ResponseEntity highlight( - @Valid @RequestBody HighlightsRequest request, - @LoginMemberSession(required = false) LoginMember loginMember, - @GuestReviewGroupSession(required = false) GuestReviewGroup guestReviewGroup + @Valid @RequestBody HighlightsRequest request ) { - /* - TODO : aop 인증 로직 필요 (존재하는 세션에 대해 reviewGroupId와 일치 여부 확인) - */ highlightService.editHighlight(request); return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/reviewme/review/controller/ReviewController.java b/backend/src/main/java/reviewme/review/controller/ReviewController.java index b4465893d..2c45fa2e1 100644 --- a/backend/src/main/java/reviewme/review/controller/ReviewController.java +++ b/backend/src/main/java/reviewme/review/controller/ReviewController.java @@ -11,10 +11,10 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import reviewme.security.resolver.GuestReviewGroupSession; import reviewme.security.resolver.LoginMemberSession; -import reviewme.security.resolver.dto.GuestReviewGroup; import reviewme.security.resolver.dto.LoginMember; +import reviewme.security.aspect.RequireReviewAccess; +import reviewme.security.aspect.RequireReviewGroupAccess; import reviewme.review.service.ReviewDetailLookupService; import reviewme.review.service.ReviewGatheredLookupService; import reviewme.review.service.ReviewListLookupService; @@ -48,57 +48,40 @@ public ResponseEntity createReview( } @GetMapping("/v2/groups/{reviewGroupId}/reviews/received") + @RequireReviewGroupAccess(target = "#reviewGroupId") public ResponseEntity findReceivedReviews( @PathVariable long reviewGroupId, @RequestParam(required = false) Long lastReviewId, - @RequestParam(required = false) Integer size, - @LoginMemberSession(required = false) LoginMember loginMember, - @GuestReviewGroupSession(required = false) GuestReviewGroup guestReviewGroup + @RequestParam(required = false) Integer size ) { - /* - TODO : aop 인증 로직 필요 (존재하는 세션에 대해 reviewGroupId와 일치 여부 확인) - */ - ReceivedReviewPageResponse response = reviewListLookupService.getReceivedReviews(reviewGroupId, lastReviewId, - size); + ReceivedReviewPageResponse response = reviewListLookupService.getReceivedReviews(reviewGroupId, lastReviewId, size); return ResponseEntity.ok(response); } @GetMapping("/v2/reviews/{id}") + @RequireReviewAccess(target = "#id") public ResponseEntity findReceivedReviewDetail( - @PathVariable long id, - @LoginMemberSession(required = false) LoginMember loginMember, - @GuestReviewGroupSession(required = false) GuestReviewGroup guestReviewGroup + @PathVariable long id ) { - /* - TODO : aop 인증 로직 필요 (존재하는 세션에 대해 reviewId와 일치 여부 확인) - */ ReviewDetailResponse response = reviewDetailLookupService.getReviewDetail(id); return ResponseEntity.ok(response); } @GetMapping("/v2/groups/{reviewGroupId}/reviews/summary") + @RequireReviewGroupAccess(target = "#reviewGroupId") public ResponseEntity findReceivedReviewOverview( - @PathVariable long reviewGroupId, - @LoginMemberSession(required = false) LoginMember loginMember, - @GuestReviewGroupSession(required = false) GuestReviewGroup guestReviewGroup + @PathVariable long reviewGroupId ) { - /* - TODO : aop 인증 로직 필요 (존재하는 세션에 대해 reviewGroupId와 일치 여부 확인) - */ ReceivedReviewsSummaryResponse response = reviewSummaryService.getReviewSummary(reviewGroupId); return ResponseEntity.ok(response); } @GetMapping("/v2/groups/{reviewGroupId}/reviews/gather") + @RequireReviewGroupAccess(target = "#reviewGroupId") public ResponseEntity getReceivedReviewsBySectionId( @PathVariable long reviewGroupId, - @RequestParam("sectionId") long sectionId, - @LoginMemberSession(required = false) LoginMember loginMember, - @GuestReviewGroupSession(required = false) GuestReviewGroup guestReviewGroup + @RequestParam("sectionId") long sectionId ) { - /* - TODO : aop 인증 로직 필요 (존재하는 세션에 대해 reviewGroupId와 일치 여부 확인) - */ ReviewsGatheredBySectionResponse response = reviewGatheredLookupService.getReceivedReviewsBySectionId(reviewGroupId, sectionId); return ResponseEntity.ok(response); diff --git a/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java index cfa3083be..1ffe11cc0 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewDetailLookupService.java @@ -25,7 +25,6 @@ public class ReviewDetailLookupService { public ReviewDetailResponse getReviewDetail(long reviewId) { Review review = reviewRepository.findById(reviewId) .orElseThrow(() -> new ReviewNotFoundException(reviewId)); - ReviewGroup reviewGroup = reviewGroupRepository.findById(review.getReviewGroupId()) .orElseThrow(() -> new ReviewGroupNotFoundException(review.getReviewGroupId())); diff --git a/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java b/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java index 8208cac49..335a43652 100644 --- a/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java +++ b/backend/src/main/java/reviewme/review/service/ReviewListLookupService.java @@ -7,8 +7,8 @@ import org.springframework.transaction.annotation.Transactional; import reviewme.review.repository.ReviewRepository; import reviewme.review.service.dto.response.list.AuthoredReviewsResponse; -import reviewme.review.service.dto.response.list.ReceivedReviewPageResponse; import reviewme.review.service.dto.response.list.ReceivedReviewPageElementResponse; +import reviewme.review.service.dto.response.list.ReceivedReviewPageResponse; import reviewme.review.service.mapper.ReviewListMapper; import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.domain.exception.ReviewGroupNotFoundException; diff --git a/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundException.java b/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundException.java index 7837502d4..9a33d9e98 100644 --- a/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundException.java +++ b/backend/src/main/java/reviewme/review/service/exception/ReviewNotFoundException.java @@ -7,7 +7,7 @@ public class ReviewNotFoundException extends NotFoundException { public ReviewNotFoundException(long reviewId) { - super("리뷰를 찾을 수 없어요"); - log.info("Review not found - reviewId: {}", reviewId); + super("리뷰를 찾을 수 없어요."); + log.info("Review not found. - reviewId: {}", reviewId); } } diff --git a/backend/src/main/java/reviewme/reviewgroup/repository/ReviewGroupRepository.java b/backend/src/main/java/reviewme/reviewgroup/repository/ReviewGroupRepository.java index 0563f6277..34502b2bc 100644 --- a/backend/src/main/java/reviewme/reviewgroup/repository/ReviewGroupRepository.java +++ b/backend/src/main/java/reviewme/reviewgroup/repository/ReviewGroupRepository.java @@ -28,4 +28,8 @@ rg.id, rg.reviewee, rg.projectName, rg.reviewRequestCode, rg.createdAt, COUNT(r. List findByMemberIdWithLimit(long memberId, Long lastReviewGroupId, int limit); boolean existsByReviewRequestCode(String reviewRequestCode); + + boolean existsByIdAndReviewRequestCode(long id, String reviewRequestCode); + + boolean existsByIdAndMemberId(long id, long memberId); } diff --git a/backend/src/main/java/reviewme/security/aspect/RequireReviewAccess.java b/backend/src/main/java/reviewme/security/aspect/RequireReviewAccess.java new file mode 100644 index 000000000..88cad05b4 --- /dev/null +++ b/backend/src/main/java/reviewme/security/aspect/RequireReviewAccess.java @@ -0,0 +1,13 @@ +package reviewme.security.aspect; + +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 RequireReviewAccess { + + String target() default "#reviewId"; +} diff --git a/backend/src/main/java/reviewme/security/aspect/RequireReviewGroupAccess.java b/backend/src/main/java/reviewme/security/aspect/RequireReviewGroupAccess.java new file mode 100644 index 000000000..2e1f3e3f9 --- /dev/null +++ b/backend/src/main/java/reviewme/security/aspect/RequireReviewGroupAccess.java @@ -0,0 +1,13 @@ +package reviewme.security.aspect; + +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 RequireReviewGroupAccess { + + String target() default "#reviewGroupId"; +} diff --git a/backend/src/main/java/reviewme/security/aspect/ResourceAuthorizationUtils.java b/backend/src/main/java/reviewme/security/aspect/ResourceAuthorizationUtils.java new file mode 100644 index 000000000..9a35d8cb9 --- /dev/null +++ b/backend/src/main/java/reviewme/security/aspect/ResourceAuthorizationUtils.java @@ -0,0 +1,48 @@ +package reviewme.security.aspect; + +import jakarta.servlet.http.HttpSession; +import java.util.Objects; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import reviewme.security.aspect.exception.SpELEvaluationFailedException; + +public class ResourceAuthorizationUtils { + + private static final ExpressionParser EXPRESSION_PARSER = new SpelExpressionParser(); + + public static T getTarget(ProceedingJoinPoint joinPoint, String targetExpression, Class targetType) { + Object[] args = joinPoint.getArgs(); + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + StandardEvaluationContext context = new StandardEvaluationContext(); + String[] paramNames = signature.getParameterNames(); + for (int i = 0; i < paramNames.length; i++) { + context.setVariable(paramNames[i], args[i]); + } + + try { + Expression expression = EXPRESSION_PARSER.parseExpression(targetExpression); + TypeDescriptor actualTypeDescriptor = expression.getValueTypeDescriptor(context); + TypeDescriptor expectedTypeDescriptor = TypeDescriptor.valueOf(targetType); + if (actualTypeDescriptor == null || !expectedTypeDescriptor.isAssignableTo(actualTypeDescriptor)) { + throw new SpELEvaluationFailedException(signature.getMethod().getName(), targetExpression); + } + T targetValue = expression.getValue(context, targetType); + return Objects.requireNonNull(targetValue); + } catch (Exception e) { + throw new SpELEvaluationFailedException(signature.getMethod().getName(), targetExpression); + } + } + + public static HttpSession getCurrentSession() { + return ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()) + .getRequest() + .getSession(false); + } +} diff --git a/backend/src/main/java/reviewme/security/aspect/ReviewAuthorizationAspect.java b/backend/src/main/java/reviewme/security/aspect/ReviewAuthorizationAspect.java new file mode 100644 index 000000000..13a530148 --- /dev/null +++ b/backend/src/main/java/reviewme/security/aspect/ReviewAuthorizationAspect.java @@ -0,0 +1,69 @@ +package reviewme.security.aspect; + +import static reviewme.security.aspect.ResourceAuthorizationUtils.getCurrentSession; +import static reviewme.security.aspect.ResourceAuthorizationUtils.getTarget; + +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import reviewme.auth.domain.GitHubMember; +import reviewme.review.domain.Review; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.exception.ReviewNotFoundException; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.security.aspect.exception.ForbiddenReviewAccessException; +import reviewme.security.session.SessionManager; + +@Aspect +@Component +@RequiredArgsConstructor +public class ReviewAuthorizationAspect { + + private final SessionManager sessionManager; + private final ReviewRepository reviewRepository; + private final ReviewGroupRepository reviewGroupRepository; + + @Around("@annotation(requireReviewAccess)") + public Object checkReviewAccess(ProceedingJoinPoint joinPoint, + RequireReviewAccess requireReviewAccess) throws Throwable { + HttpSession session = getCurrentSession(); + if (session == null) { + throw new ForbiddenReviewAccessException(); + } + + long reviewId = getTarget(joinPoint, requireReviewAccess.target(), Long.class); + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new ReviewNotFoundException(reviewId)); + if (!(canMemberAccess(review, session) || canGuestAccess(review, session))) { + throw new ForbiddenReviewAccessException(); + } + + return joinPoint.proceed(); + } + + private boolean canMemberAccess(Review review, HttpSession session) { + GitHubMember gitHubMember = sessionManager.getGitHubMember(session); + if (gitHubMember == null) { + return false; + } + + boolean isReviewGroupCreator = reviewGroupRepository.existsByIdAndMemberId( + review.getReviewGroupId(), gitHubMember.getMemberId() + ); + boolean isReviewAuthor = review.getMemberId() != null && review.getMemberId() == gitHubMember.getMemberId(); + + return isReviewGroupCreator || isReviewAuthor; + } + + private boolean canGuestAccess(Review review, HttpSession session) { + String reviewRequestCode = sessionManager.getReviewRequestCode(session); + if (reviewRequestCode == null) { + return false; + } + + return reviewGroupRepository.existsByIdAndReviewRequestCode(review.getReviewGroupId(), reviewRequestCode); + } +} diff --git a/backend/src/main/java/reviewme/security/aspect/ReviewGroupAuthorizationAspect.java b/backend/src/main/java/reviewme/security/aspect/ReviewGroupAuthorizationAspect.java new file mode 100644 index 000000000..37bf80339 --- /dev/null +++ b/backend/src/main/java/reviewme/security/aspect/ReviewGroupAuthorizationAspect.java @@ -0,0 +1,54 @@ +package reviewme.security.aspect; + +import static reviewme.security.aspect.ResourceAuthorizationUtils.getCurrentSession; +import static reviewme.security.aspect.ResourceAuthorizationUtils.getTarget; + +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import reviewme.auth.domain.GitHubMember; +import reviewme.security.aspect.exception.ForbiddenReviewGroupAccessException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.domain.exception.ReviewGroupNotFoundException; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.security.session.SessionManager; + +@Aspect +@Component +@RequiredArgsConstructor +public class ReviewGroupAuthorizationAspect { + + private final SessionManager sessionManager; + private final ReviewGroupRepository reviewGroupRepository; + + @Around("@annotation(requireReviewGroupAccess)") + public Object checkReviewGroupAccess(ProceedingJoinPoint joinPoint, + RequireReviewGroupAccess requireReviewGroupAccess) throws Throwable { + HttpSession session = getCurrentSession(); + if (session == null) { + throw new ForbiddenReviewGroupAccessException(); + } + + long reviewGroupId = getTarget(joinPoint, requireReviewGroupAccess.target(), Long.class); + ReviewGroup reviewGroup = reviewGroupRepository.findById(reviewGroupId) + .orElseThrow(() -> new ReviewGroupNotFoundException(reviewGroupId)); + if (!(canMemberAccess(reviewGroup, session) || canGuestAccess(reviewGroup, session))) { + throw new ForbiddenReviewGroupAccessException(); + } + + return joinPoint.proceed(); + } + + private boolean canMemberAccess(ReviewGroup reviewGroup, HttpSession session) { + GitHubMember gitHubMember = sessionManager.getGitHubMember(session); + return gitHubMember != null && reviewGroup.getMemberId() == gitHubMember.getMemberId(); + } + + private boolean canGuestAccess(ReviewGroup reviewGroup, HttpSession session) { + String reviewRequestCode = sessionManager.getReviewRequestCode(session); + return reviewGroup.getReviewRequestCode().equals(reviewRequestCode); + } +} diff --git a/backend/src/main/java/reviewme/security/aspect/exception/ForbiddenReviewAccessException.java b/backend/src/main/java/reviewme/security/aspect/exception/ForbiddenReviewAccessException.java new file mode 100644 index 000000000..7542026db --- /dev/null +++ b/backend/src/main/java/reviewme/security/aspect/exception/ForbiddenReviewAccessException.java @@ -0,0 +1,13 @@ +package reviewme.security.aspect.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.ForbiddenException; + +@Slf4j +public class ForbiddenReviewAccessException extends ForbiddenException { + + public ForbiddenReviewAccessException() { + super("리뷰에 접근할 권한이 없어요."); + log.info("Forbidden review access occurred."); + } +} diff --git a/backend/src/main/java/reviewme/security/aspect/exception/ForbiddenReviewGroupAccessException.java b/backend/src/main/java/reviewme/security/aspect/exception/ForbiddenReviewGroupAccessException.java new file mode 100644 index 000000000..1c4f4f0b9 --- /dev/null +++ b/backend/src/main/java/reviewme/security/aspect/exception/ForbiddenReviewGroupAccessException.java @@ -0,0 +1,13 @@ +package reviewme.security.aspect.exception; + +import lombok.extern.slf4j.Slf4j; +import reviewme.global.exception.ForbiddenException; + +@Slf4j +public class ForbiddenReviewGroupAccessException extends ForbiddenException { + + public ForbiddenReviewGroupAccessException() { + super("리뷰 그룹에 접근할 권한이 없어요."); + log.info("Forbidden review group access occurred."); + } +} diff --git a/backend/src/main/java/reviewme/security/aspect/exception/SpELEvaluationFailedException.java b/backend/src/main/java/reviewme/security/aspect/exception/SpELEvaluationFailedException.java new file mode 100644 index 000000000..11f895045 --- /dev/null +++ b/backend/src/main/java/reviewme/security/aspect/exception/SpELEvaluationFailedException.java @@ -0,0 +1,12 @@ +package reviewme.security.aspect.exception; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SpELEvaluationFailedException extends IllegalStateException { + + public SpELEvaluationFailedException(String methodName, String expression) { + super("서버 내부 에러가 발생했어요."); + log.error("SpEL evaluation failed - method: {}, expression: {}", methodName, expression); + } +} diff --git a/backend/src/test/java/reviewme/fixture/MemberFixture.java b/backend/src/test/java/reviewme/fixture/MemberFixture.java new file mode 100644 index 000000000..2fbdd6fa7 --- /dev/null +++ b/backend/src/test/java/reviewme/fixture/MemberFixture.java @@ -0,0 +1,14 @@ +package reviewme.fixture; + +import reviewme.member.domain.Member; + +public class MemberFixture { + + public static Member 회원() { + return new Member("email@email.com"); + } + + public static Member 회원(String email) { + return new Member(email); + } +} diff --git a/backend/src/test/java/reviewme/security/aspect/AopTestClass.java b/backend/src/test/java/reviewme/security/aspect/AopTestClass.java new file mode 100644 index 000000000..96226649c --- /dev/null +++ b/backend/src/test/java/reviewme/security/aspect/AopTestClass.java @@ -0,0 +1,15 @@ +package reviewme.security.aspect; + +import org.springframework.stereotype.Component; + +@Component +class AopTestClass { + + @RequireReviewGroupAccess + public void testReviewGroupMethod(long reviewGroupId) { + } + + @RequireReviewAccess + public void testReviewMethod(long reviewId) { + } +} diff --git a/backend/src/test/java/reviewme/security/aspect/ResourceAuthorizationUtilsTest.java b/backend/src/test/java/reviewme/security/aspect/ResourceAuthorizationUtilsTest.java new file mode 100644 index 000000000..67d7be9f8 --- /dev/null +++ b/backend/src/test/java/reviewme/security/aspect/ResourceAuthorizationUtilsTest.java @@ -0,0 +1,145 @@ +package reviewme.security.aspect; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import jakarta.servlet.http.HttpSession; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import reviewme.security.aspect.exception.SpELEvaluationFailedException; +import reviewme.support.ServiceTest; + +@ServiceTest +class ResourceAuthorizationUtilsTest { + + private ProceedingJoinPoint joinPoint; + private MethodSignature signature; + + @BeforeEach + void setUp() { + joinPoint = mock(ProceedingJoinPoint.class); + signature = mock(MethodSignature.class); + given(joinPoint.getSignature()).willReturn(signature); + given(signature.getMethod()).willReturn(TestRequest.class.getMethods()[0]); + } + + @Nested + class SpEL_표현식으로_값을_추출한다 { + + @Test + void SpEL_과_일치하는_인자가_없으면_예외가_발생한다() { + // given + String[] parameterNames = {"reviewId"}; + Object[] args = {1L}; + given(signature.getParameterNames()).willReturn(parameterNames); + given(joinPoint.getArgs()).willReturn(args); + + // when & then + assertThatCode(() -> ResourceAuthorizationUtils.getTarget(joinPoint, "#wrong", Long.class)) + .isInstanceOf(SpELEvaluationFailedException.class); + } + + @Test + void 인자_타입이_일치하지_않으면_예외가_발생한다() { + // given + String[] parameterNames = {"reviewId"}; + Object[] args = {1L}; + + given(signature.getParameterNames()).willReturn(parameterNames); + given(joinPoint.getArgs()).willReturn(args); + + // when & then + assertThatCode(() -> ResourceAuthorizationUtils.getTarget( + joinPoint, "#reviewId", String.class + )).isInstanceOf(SpELEvaluationFailedException.class); + } + + @Test + void 인자가_아예_없을_때_예외가_발생한다() { + // given + String[] parameterNames = {}; + Object[] args = {}; + + given(signature.getParameterNames()).willReturn(parameterNames); + given(joinPoint.getArgs()).willReturn(args); + + // when & then + assertThatCode(() -> ResourceAuthorizationUtils.getTarget( + joinPoint, "#reviewId", Long.class + )).isInstanceOf(SpELEvaluationFailedException.class); + } + + @Test + void 단일_표현식에_해당하는_값을_반환한다() { + // given + String[] parameterNames = {"reviewId"}; + Object[] args = {1L}; + given(signature.getParameterNames()).willReturn(parameterNames); + given(joinPoint.getArgs()).willReturn(args); + + // when + Long result = ResourceAuthorizationUtils.getTarget( + joinPoint, "#reviewId", Long.class + ); + + // then + assertThat(result).isEqualTo(1L); + } + + @Test + void 메서드_호출_표현식에_해당하는_값을_반환한다() { + // given + String[] parameterNames = {"request"}; + TestRequest request = new TestRequest(1L); + Object[] args = {request}; + + given(signature.getParameterNames()).willReturn(parameterNames); + given(joinPoint.getArgs()).willReturn(args); + + // when + Long result = ResourceAuthorizationUtils.getTarget( + joinPoint, "#request.getId()", Long.class + ); + + // then + assertThat(result).isEqualTo(1L); + } + } + + @Test + void 현재_세션을_반환한다() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpSession session = new MockHttpSession(); + session.setAttribute("리뷰미", "파이팅"); + request.setSession(session); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + + // when + HttpSession actualSession = ResourceAuthorizationUtils.getCurrentSession(); + + // then + assertThat(actualSession.getAttribute("리뷰미")).isEqualTo("파이팅"); + } + + private static class TestRequest { + Long id; + + public TestRequest(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } + } +} diff --git a/backend/src/test/java/reviewme/security/aspect/ReviewAuthorizationAspectTest.java b/backend/src/test/java/reviewme/security/aspect/ReviewAuthorizationAspectTest.java new file mode 100644 index 000000000..9e30aee84 --- /dev/null +++ b/backend/src/test/java/reviewme/security/aspect/ReviewAuthorizationAspectTest.java @@ -0,0 +1,182 @@ +package reviewme.security.aspect; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static reviewme.fixture.MemberFixture.회원; +import static reviewme.fixture.ReviewFixture.비회원_작성_리뷰; +import static reviewme.fixture.ReviewFixture.회원_작성_리뷰; +import static reviewme.fixture.ReviewGroupFixture.비회원_리뷰_그룹; +import static reviewme.fixture.ReviewGroupFixture.회원_지정_리뷰_그룹; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import reviewme.auth.domain.GitHubMember; +import reviewme.member.domain.Member; +import reviewme.member.repository.MemberRepository; +import reviewme.review.domain.Review; +import reviewme.review.repository.ReviewRepository; +import reviewme.review.service.exception.ReviewNotFoundException; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.security.aspect.exception.ForbiddenReviewAccessException; +import reviewme.security.session.SessionManager; +import reviewme.support.ServiceTest; + +@ServiceTest +class ReviewAuthorizationAspectTest { + + @Autowired + private AopTestClass aopTestClass; + + @Autowired + private SessionManager sessionManager; + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private MemberRepository memberRepository; + + MockHttpServletRequest request; + MockHttpSession session; + + @BeforeEach + void setUp() { + request = new MockHttpServletRequest(); + session = new MockHttpSession(); + request.setSession(session); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @Nested + class 성공적으로_리뷰에_접근할_수_있다 { + + @Test + void 회원은_자신이_작성한_리뷰에_접근할_수_있다() { + // given + Member member = memberRepository.save(회원()); + Review review = reviewRepository.save(회원_작성_리뷰(member.getId(), 1L, 1L, List.of())); + GitHubMember gitHubMember = new GitHubMember(member.getId(), "name", "avatarUrl"); + sessionManager.saveGitHubMember(session, gitHubMember); + + // when & then + assertThatCode(() -> aopTestClass.testReviewMethod(review.getId())) + .doesNotThrowAnyException(); + } + + @Test + void 회원은_자신이_만든_리뷰_그룹에_작성된_리뷰에_접근할_수_있다() { + // given + Member member = memberRepository.save(회원()); + ReviewGroup reviewGroup = reviewGroupRepository.save(회원_지정_리뷰_그룹(member.getId())); + Review review = reviewRepository.save(비회원_작성_리뷰(1L, 1L, List.of())); + + GitHubMember gitHubMember = new GitHubMember(member.getId(), "name", "avatarUrl"); + sessionManager.saveGitHubMember(session, gitHubMember); + + // when & then + assertThatCode(() -> aopTestClass.testReviewMethod(review.getId())) + .doesNotThrowAnyException(); + } + + @Test + void 비회원은_자신이_만든_리뷰_그룹에_작성된_리뷰에_접근할_수_있다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(비회원_리뷰_그룹()); + Review review = reviewRepository.save(비회원_작성_리뷰(1L, 1L, List.of())); + sessionManager.saveReviewRequestCode(session, reviewGroup.getReviewRequestCode()); + + // when & then + assertThatCode(() -> aopTestClass.testReviewMethod(review.getId())) + .doesNotThrowAnyException(); + } + } + + @Test + void 존재하지_않는_리뷰에_접근하면_NotFound_예외가_발생한다() { + // when & then + assertThatCode(() -> aopTestClass.testReviewMethod(1L)) + .isInstanceOf(ReviewNotFoundException.class); + } + + @Nested + class 유효하지_않은_세션으로_접근하면_예외가_발생한다 { + + @Test + void 세션이_존재하지_않으면_Unauthorized_예외가_발생한다() { + // given + request.setSession(null); + + // when & then + assertThatCode(() -> aopTestClass.testReviewMethod(1L)) + .isInstanceOf(ForbiddenReviewAccessException.class); + } + + @Test + void 세션에_저장된_정보가_없으면_Unauthorized_예외가_발생한다() { + // given + Member member = memberRepository.save(회원()); + Review review = reviewRepository.save(회원_작성_리뷰(member.getId(), 1L, 1L, List.of())); + + // when & then + assertThatCode(() -> aopTestClass.testReviewMethod(review.getId())) + .isInstanceOf(ForbiddenReviewAccessException.class); + } + } + + @Nested + class 회원이_리뷰에_접근할_수_없으면_예외가_발생한다 { + + @Test + void 자신이_작성하지_않은_리뷰에_접근하면_Unauthorized_예외가_발생한다() { + // given + Review review = reviewRepository.save(비회원_작성_리뷰(1L, 1L, List.of())); + + GitHubMember gitHubMember = new GitHubMember(1L, "name", "avatarUrl"); + sessionManager.saveGitHubMember(session, gitHubMember); + + // when & then + assertThatCode(() -> aopTestClass.testReviewMethod(review.getId())) + .isInstanceOf(ForbiddenReviewAccessException.class); + } + + @Test + void 자신이_만들지_않은_리뷰그룹의_리뷰에_접근하면_Unauthorized_예외가_발생한다() { + // given + Member other = memberRepository.save(회원("email123@test.com")); + ReviewGroup othersReviewGroup = reviewGroupRepository.save(회원_지정_리뷰_그룹(other.getId())); + Review review = reviewRepository.save(비회원_작성_리뷰(1L, othersReviewGroup.getId(), List.of())); + + Member member = memberRepository.save(회원("email321@test.com")); + GitHubMember gitHubMember = new GitHubMember(member.getId(), "name", "avatarUrl"); + sessionManager.saveGitHubMember(session, gitHubMember); + + // when & then + assertThatCode(() -> aopTestClass.testReviewMethod(review.getId())) + .isInstanceOf(ForbiddenReviewAccessException.class); + } + } + + @Test + void 비회원이_리뷰에_접근할_수_없으면_예외가_발생한다() { + // given + ReviewGroup othersReviewGroup = reviewGroupRepository.save(비회원_리뷰_그룹("abcd", "efgh")); + Review review = reviewRepository.save(비회원_작성_리뷰(1L, othersReviewGroup.getId(), List.of())); + + ReviewGroup reviewGroup = reviewGroupRepository.save(비회원_리뷰_그룹("1234", "5678")); + sessionManager.saveReviewRequestCode(session, reviewGroup.getReviewRequestCode()); + + // when & then + assertThatCode(() -> aopTestClass.testReviewMethod(review.getId())) + .isInstanceOf(ForbiddenReviewAccessException.class); + } +} diff --git a/backend/src/test/java/reviewme/security/aspect/ReviewGroupAuthorizationAspectTest.java b/backend/src/test/java/reviewme/security/aspect/ReviewGroupAuthorizationAspectTest.java new file mode 100644 index 000000000..aaf113817 --- /dev/null +++ b/backend/src/test/java/reviewme/security/aspect/ReviewGroupAuthorizationAspectTest.java @@ -0,0 +1,138 @@ +package reviewme.security.aspect; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static reviewme.fixture.MemberFixture.회원; +import static reviewme.fixture.ReviewGroupFixture.비회원_리뷰_그룹; +import static reviewme.fixture.ReviewGroupFixture.회원_지정_리뷰_그룹; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import reviewme.auth.domain.GitHubMember; +import reviewme.security.aspect.exception.ForbiddenReviewGroupAccessException; +import reviewme.member.domain.Member; +import reviewme.member.repository.MemberRepository; +import reviewme.reviewgroup.domain.ReviewGroup; +import reviewme.reviewgroup.domain.exception.ReviewGroupNotFoundException; +import reviewme.reviewgroup.repository.ReviewGroupRepository; +import reviewme.security.session.SessionManager; +import reviewme.support.ServiceTest; + +@ServiceTest +class ReviewGroupAuthorizationAspectTest { + + @Autowired + private AopTestClass aopTestClass; + + @Autowired + private SessionManager sessionManager; + + @Autowired + private ReviewGroupRepository reviewGroupRepository; + + @Autowired + private MemberRepository memberRepository; + + MockHttpServletRequest request; + MockHttpSession session; + + @BeforeEach + void setUp() { + request = new MockHttpServletRequest(); + session = new MockHttpSession(); + request.setSession(session); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @Nested + class 성공적으로_리뷰_그룹에_접근할_수_있다 { + + @Test + void 회원은_자신이_만든_리뷰_그룹에_접근할_수_있다() { + // given + Member member = memberRepository.save(회원()); + ReviewGroup reviewGroup = reviewGroupRepository.save(회원_지정_리뷰_그룹(member.getId())); + GitHubMember gitHubMember = new GitHubMember(member.getId(), "name", "avatarUrl"); + sessionManager.saveGitHubMember(session, gitHubMember); + + // when & then + assertThatCode(() -> aopTestClass.testReviewGroupMethod(reviewGroup.getId())) + .doesNotThrowAnyException(); + } + + @Test + void 비회원은_자신이_만든_리뷰_그룹에_접근할_수_있다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(비회원_리뷰_그룹()); + sessionManager.saveReviewRequestCode(session, reviewGroup.getReviewRequestCode()); + + // when & then + assertThatCode(() -> aopTestClass.testReviewGroupMethod(reviewGroup.getId())) + .doesNotThrowAnyException(); + } + } + + @Test + void 존재하지_않는_리뷰_그룹에_접근하면_NotFound_예외가_발생한다() { + // when & then + assertThatCode(() -> aopTestClass.testReviewGroupMethod(100L)) + .isInstanceOf(ReviewGroupNotFoundException.class); + } + + @Nested + class 유효하지_않은_세션으로_접근하면_예외가_발생한다 { + + @Test + void 세션이_없으면_Unauthorized_예외가_발생한다() { + // given + request.setSession(null); + + // when & then + assertThatCode(() -> aopTestClass.testReviewGroupMethod(1L)) + .isInstanceOf(ForbiddenReviewGroupAccessException.class); + } + + @Test + void 세션에_저장된_정보가_없으면_Unauthorized_예외가_발생한다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(비회원_리뷰_그룹()); + + // when & then + assertThatCode(() -> aopTestClass.testReviewGroupMethod(reviewGroup.getId())) + .isInstanceOf(ForbiddenReviewGroupAccessException.class); + } + } + + @Test + void 다른_회원이_만든_리뷰_그룹에_접근하면_Unauthorized_예외가_발생한다() { + // given + Member other = memberRepository.save(회원("email456@test.com")); + ReviewGroup membersReviewGroup = reviewGroupRepository.save(회원_지정_리뷰_그룹(other.getId())); + + Member member = memberRepository.save(회원("email123@test.com")); + GitHubMember gitHubMember = new GitHubMember(member.getId(), "name", "avatarUrl"); + sessionManager.saveGitHubMember(session, gitHubMember); + + // when & then + assertThatCode(() -> aopTestClass.testReviewGroupMethod(membersReviewGroup.getId())) + .isInstanceOf(ForbiddenReviewGroupAccessException.class); + } + + @Test + void 리뷰_요청_코드가_다른_리뷰_그룹에_접근하면_Unauthorized_예외가_발생한다() { + // given + ReviewGroup other = reviewGroupRepository.save(비회원_리뷰_그룹("3333", "4444")); + sessionManager.saveReviewRequestCode(session, other.getReviewRequestCode()); + + ReviewGroup group = reviewGroupRepository.save(비회원_리뷰_그룹("1111", "2222")); + + // when & then + assertThatCode(() -> aopTestClass.testReviewGroupMethod(group.getId())) + .isInstanceOf(ForbiddenReviewGroupAccessException.class); + } +}