[하녜담] Chapter 8. Spring Security - Security 구조, 폼 로그인#105
Conversation
|
Security 설정이 깔끔하게 잘 구성된 것 같아요 ! |
| // query: id(ID순) 또는 rating(별점순) | ||
| @GetMapping("/reviews/my") | ||
| public ApiResponse<MissionResDTO.Pagination<ReviewResDTO.GetReview>> getMyReviews( | ||
| @RequestParam Long userId, |
There was a problem hiding this comment.
아마 다음 주차에 나올 것 같긴한데, JWT를 도입하게 되면 @AuthenticationPrincipal를 사용해서 시큐리티컨텍스트에 저장된 객체를 빼서 주입해줄 수 있습니다. 이렇게 사용하면 userId 파라미터에 의존하고 있는 현재 구조를 더 안정적으로 바꿀 수 있다는 장점이 있는데, 다음주에 코드 수정하실때 한번 확인하고 해주시면 좋을 것 같아서 리뷰 남겨봅니당
| package com.example.Spring_Boot.global.resolver; | ||
|
|
||
| import com.example.Spring_Boot.domain.review.dto.ReviewCursor; | ||
| import com.example.Spring_Boot.global.apiPayload.code.GeneralErrorCode; | ||
| import com.example.Spring_Boot.global.exception.ProjectException; | ||
| import org.springframework.core.MethodParameter; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.bind.support.WebDataBinderFactory; | ||
| import org.springframework.web.context.request.NativeWebRequest; | ||
| import org.springframework.web.method.support.HandlerMethodArgumentResolver; | ||
| import org.springframework.web.method.support.ModelAndViewContainer; | ||
|
|
||
| @Component | ||
| public class ReviewCursorArgumentResolver implements HandlerMethodArgumentResolver { | ||
|
|
||
| @Override | ||
| public boolean supportsParameter(MethodParameter parameter) { | ||
| return parameter.getParameterType().equals(ReviewCursor.class); | ||
| } | ||
|
|
||
| @Override | ||
| public Object resolveArgument(MethodParameter parameter, | ||
| ModelAndViewContainer mavContainer, | ||
| NativeWebRequest webRequest, | ||
| WebDataBinderFactory binderFactory) { | ||
|
|
||
| String cursor = webRequest.getParameter("cursor"); | ||
| String query = webRequest.getParameter("query"); | ||
|
|
||
| if (cursor == null) cursor = "-1"; | ||
| if (query == null) query = "id"; | ||
|
|
||
| if (cursor.equals("-1")) { | ||
| return ReviewCursor.builder() | ||
| .query(query) | ||
| .cursor(cursor) | ||
| .hasCursor(false) | ||
| .build(); | ||
| } | ||
|
|
||
| String[] cursorSplit = cursor.split(":"); | ||
|
|
||
| switch (query.toLowerCase()) { | ||
| case "id" -> { | ||
| Long idCursor = Long.parseLong(cursorSplit[1]); | ||
| return ReviewCursor.builder() | ||
| .query(query) | ||
| .cursor(cursor) | ||
| .idCursor(idCursor) | ||
| .hasCursor(true) | ||
| .build(); | ||
| } | ||
| case "rating" -> { | ||
| Integer ratingCursor = Integer.parseInt(cursorSplit[1]); | ||
| Long idCursor = Long.parseLong(cursorSplit[2]); | ||
| return ReviewCursor.builder() | ||
| .query(query) | ||
| .cursor(cursor) | ||
| .ratingCursor(ratingCursor) | ||
| .idCursor(idCursor) | ||
| .hasCursor(true) | ||
| .build(); | ||
| } | ||
| default -> throw new ProjectException(GeneralErrorCode.BAD_REQUEST); | ||
| } | ||
| } | ||
| } No newline at end of file |
| package com.example.Spring_Boot.global.security; | ||
|
|
||
| import com.example.Spring_Boot.global.apiPayload.ApiResponse; | ||
| import com.example.Spring_Boot.global.apiPayload.code.BaseErrorCode; | ||
| import com.example.Spring_Boot.global.apiPayload.code.GeneralErrorCode; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
| import org.springframework.security.core.AuthenticationException; | ||
| import org.springframework.security.web.AuthenticationEntryPoint; | ||
| import java.io.IOException; | ||
|
|
||
| public class CustomEntryPoint implements AuthenticationEntryPoint { | ||
|
|
||
| @Override | ||
| public void commence( | ||
| HttpServletRequest request, | ||
| HttpServletResponse response, | ||
| AuthenticationException authException | ||
| ) throws IOException { | ||
| ObjectMapper objectMapper = new ObjectMapper(); | ||
| BaseErrorCode code = GeneralErrorCode.UNAUTHORIZED; | ||
|
|
||
| response.setContentType("application/json;charset=UTF-8"); | ||
| response.setStatus(code.getStatus().value()); | ||
|
|
||
| ApiResponse<Void> errorResponse = ApiResponse.onFailure(code, null); | ||
| objectMapper.writeValue(response.getOutputStream(), errorResponse); | ||
| } | ||
| } No newline at end of file |
There was a problem hiding this comment.
이번에 인증 실패 시 응답통일을 위해 CustomEntryPoint를 구현했는데, ExceptionTranslationFilter 말고 다른 방식으로도 처리할 수 있는지 궁금합니다.
-> 아주 좋은 질문인 것 같습니다!
지금 저희가 CustomEntryPoint를 만든 이유부터 생각해 보면 좋을 것 같습니다. 만약 컨트롤러나 서비스에서 예외가 발생했다면 @RestControllerAdvice를 활용해서 만들어두신 GeneralExceptionAdvice가 바로 잡아 냈을 것입니다. 그러나 시큐리티 필터에서 에러가 났다면 RestControllerAdvice가 잡아낼 수 없기 때문에 CustomEntryPoint를 만들고 objectMapper로 JSON을 만들어 클라이언트에게 반환하게 해두신거죠
그런데 여기서 한 가지 아쉬운 점이 있습니다. CustomEntryPoint에서 ObjectMapper로 직접 JSON 응답을 만들고 있잖아요? 이러면 에러 응답 포맷을 바꿀 때 GeneralExceptionAdvice랑 CustomEntryPoint 두 군데를 다 수정해야 합니다.
이걸 해결하는 방법? 좀 더 깔끔한 구조를 만드는 방법?으로 HandlerExceptionResolver를 CustomEntryPoint에 주입하는 방식이 있습니다.
위에서 말씀드렸듯이 시큐리티 필터에서 에러가 났다면 RestControllerAdvice가 잡아낼 수 없기 때문에 이 방법을 사용해서 필터에서 터진 예외를 resolver.resolveException()으로 MVC 쪽에 토스해버리는 겁니다. 그러면 우리가 이미 만들어둔 @RestControllerAdvice가 그 예외를 잡아서 처리해주니까, 에러 응답 로직이 한 곳으로 모이게 됩니다.
구현은 GeneralExceptionAdvice에 AuthenticationException과 AccessDeniedException 전용 핸들러를 추가하는 방식으로 하면 될 것 같습니다!
근데 HandlerExceptionResolver 방식도 결국 ExceptionTranslationFilter → EntryPoint 호출 흐름 안에서 동작하는 거라, 아예 다른 방식이 있는지 궁금하신 거였다면,
다음 주차에 JWT 필터를 다룰 때 쓰게 될 방법이 하나 더 있습니다. 아예 필터 체인 앞에 예외 처리 전용 필터(ExceptionHandlerFilter)를 하나 세워두는 방식인데요, 이 필터가 try-catch로 뒤에 있는 필터들(JwtFilter 등)을 감싸서, 어디서 예외가 터지든 잡아내는 구조입니다.
HTTP 요청
↓
[SecurityContextPersistenceFilter] ← 기본
↓
[ExceptionHandlerFilter] ← 커스텀 (끼워넣음)
↓
[JwtFilter] ← 커스텀 (끼워넣음)
↓
[UsernamePasswordAuthenticationFilter] ← 기본
↓
[AnonymousAuthenticationFilter] ← 기본
↓
[ExceptionTranslationFilter] ← 기본
↓
[FilterSecurityInterceptor] ← 기본
↓
애플리케이션 로직
사실 ExceptionTranslationFilter는 자기보다 뒤에 있는 필터에서 터진 예외만 잡을 수 있기 때문에, JwtFilter처럼 앞에 위치하는 필터의 예외는 커버가 안 됩니다. 그래서 JWT 환경에서는 이런 ExceptionHandlerFilter를 앞에 세워두는 방식이 자주 쓰인다는 점도 참고하시면 좋을 것 같습니다!
✏️ 작업 내용
#️⃣ 연관된 이슈
closes #104
💡 함께 공유하고 싶은 부분
🤔 질문
✅ 워크북 체크리스트
✅ 컨벤션 체크리스트
📌 주안점