[Sparta] teamProject 3일차 TIL
오늘 할일
- 인가 필터링중 오류 발생시 예외처리(상태코드,상태메시지) 동작 구현, 로그인파트 병합-
예외처리를 하기위한 ExceptionHandler 사용, 팀원이 적용시켜둔 부분을 이해하고 작업해야할 필요성 느낌
@RestControllerAdvice
public class GlobalExceptionHandler {
// @ExceptionHandler({CustomException.class})
...
}
들어가기전 가볍게 개념 익히기
Spring AOP(Aspect-Oriented Programming, AOP)
Spring AOP란?
- Spring AOP는 스프링 프레임워크에서 제공하는 기능 중 하나로 관점 지향 프로그래밍을 지원하는 기술입니다. Spring AOP는 로깅, 보안, 트랜잭션 관리 등과 같은 공통적인 관심사를 모듈화 하여 코드 중복을 줄이고 유지 보수성을 향상하는데 도움을 줍니다.
💡 관점 지향 프로그래밍(Aspect-Oriented Programming, AOP) 이란?
- 객체 지향 프로그래밍 패러다임을 보완하는 기술로 메소드나 객체의 기능을 핵심 관심사(Core Concern)와 공통 관심사(Cross-cutting Concern)로 나누어 프로그래밍하는 것을 말합니다. “핵심 관심사”는 각 객체가 가져야 할 본래의 기능이며, “공통 관심사”는 여러 객체에서 공통적으로 사용되는 코드를 말합니다.
- 여러 개의 클래스에서 반복해서 사용하는 코드가 있다면 해당 코드를 모듈화 하여 공통 관심사로 분리합니다. 이렇게 분리한 공통 관심사를 Aspect로 정의하고 Aspect를 적용할 메소드나 클래스에 Advice를 적용하여 공통 관심사와 핵심 관심사를 분리할 수 있습니다. 이렇게 AOP에서는 공통 관심사를 별도의 모듈로 분리하여 관리하며, 이를 통해 코드의 재사용성과 유지 보수성을 높일 수 있습니다.
<참고>
[Java] Spring Boot AOP(Aspect-Oriented Programming) 이해하고 설정하기
해당 글에서는 Spring AOP에 대해 이해하고 환경설정을 해보는 방법에 대해서 공유를 목적으로 작성한 글입니다. 1) Spring AOP(Aspect-Oriented Programming, AOP) 1. AOP 용어 이해하기 💡 Spring AOP란? - Spring AOP
adjh54.tistory.com
여기서 에러처리에 사용할 글로벌 핸들러는 'Aspect 정의' 에 해당, 그리고 Advice 애너테이션을 선언함으로써
우리가 실제로 동작할 메서드(API) 등에 <여기서는 RestController단의 메서드들> 적절한 시기때 정의해둔 Aspect가 동작하는 방식이 아닐까 싶다
하지만 스프링 시큐리티는 필터단에서 모든 검증이 동작하는 구조다 보니 왠지 예외처리 핸들러도 적당히 가져다 쓰다가는 안될것 같은 느낌이 들어 알아보니.. 예상대로였다
[Spring Security] Filter 예외처리는 어떻게 할까?
🔐 Spring Security 를 이용해서 토큰을 검증하고 인증된 사용자 정보를 담은 객체를 생성했다. 이 과정에서 토큰이 유효하지 않은 경우, 예외처리를 하려고 한다. 예외처리를 어떻게 할 수 있는지
velog.io
대략 정리해보자면 @ControllerAdvice 나 @ExceptionHandler는 dispatcherServlet이 동작하는 이후 단 (컨트롤러 단 등) 에서만 재대로 적용이 되고 그 이전인 필터단에서는 동작하지 않는다는 내용
심지어 ResponseEntity에 오류발생에 대한 상태코드와 메시지를 담아도 필터단이다 보니 재대로된 반환이 되지않아
브라우저쪽에 재대로 전달이 되지 않았다고 한다
결국 필터단에서도 존재하는 HTTP request/response Servlet 객체에 오류처리에 대한 내용을 담아서 동작시켜야 한다
- 오류 발생 ( response.getWrite().write()에 대한 오류발생! )
- The response object has been recycled and is no longer associated with this facade
- getWriter() has already been called for this response
http서블렛response 객체에서 스트림을 다루는 getWrite()는 하나밖에 존재할 수 없기때문에 로그인 시도, 다른 api 시도때마다 검증하는데 그때마다 getWriter를 사용하면 오류가 나서 적용할수가 없었다
원인 1. 아래와 같이 successLogin() 메서드를 doFilter 들어오기만 하면 무조건 실행되는 위치에 두다보니..
검증시 1번, success시 1번, 이렇게 의도와 다르게 getWriter가 여러번 실행되서 오류가 난것 같다
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
...
// 중간에 valid로 검증코드, 에러시 마찬가지로 getWriter() 사용
successLogin(res); // 로그인 성공, getWriter() 사용
}
+ 기존방식때 오류가 났던 이유로 @RestControllerAdvice 로 글로벌 예외 핸들링중이였는데
재발급or테스트 api 시 필터단에서 getWriter를 썼는데, 어떻게 컨트롤러 단까지 요청이 닿고 그로인해 발생한 exception에 대해 위 핸들링에서 ResponseEntity가 동작하다보니 response 객체를 한번의 api에 대해 여러번 호출? 하게되는 상황이 발생한 것 같다.
해결: 구조적으로 변경에 대한 시도
그전에 exceptionHandler에 대한 재대로된 이해
기존에 했던 시도
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
...
public jwtExceptionHandler () {
...
}
}
Advice에 대해 재대로 모르다보니 Global핸들러를 SecurityConfig에서 주입받아 Authori 필터에 생성자로 초기화후
거기서 jwtExceptionHandler를 사용하려고 했다.. 결과적으로 뻘짓이였고
그냥 jwtExceptionHandler 메서드를 따로 필터에 만들어 주고 동작시키는 방식으로 바꿈
하지만 이때 getWriter에 대해 재대로된 위치에 선언되지 않다보니 2번이상 호출됬다는 오류가 발생했고,
뭔가 코드자체도 하드코딩같은 느낌에 다른방식을 찾아봤더니
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
response.setCharacterEncoding("utf-8");
try{
filterChain.doFilter(request, response);
} catch (ExpiredJwtException e){
//만료 에러
request.setAttribute("exception", Code.EXPIRED_TOKEN.getCode());
...
}
필터를 하나더 만든후, 체인의 doFilter 메서드 자체를 try catch문으로 감싸 발생한 예외를 이 필터에서 핸들링 하는 방식이 있어 매우 좋아보였음
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (JwtCustomException e) {
response.getWriter().write(objectMapper.writeValueAsString(CommonResponse
.builder()
.msg(e.getErrorEnum().getMsg())
.statusCode(e.getErrorEnum().getStatusCode())));
}
}
public void validToken(String token, TokenType tokenType, HttpServletRequest request) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
} catch (SecurityException | MalformedJwtException | SignatureException e) {
request.setAttribute("NOT_VALID_TOKEN", ErrorEnum.NOT_VALID_TOKEN);
throw new jwtCustomException(ErrorEnum.NOT_VALID);
} catch (ExpiredJwtException e) {
if(tokenType.equals(TokenType.ACCESS_TOKEN)) {
request.setAttribute("EXPIRED_TOKEN", ErrorEnum.EXPIRED_TOKEN_VALUE);
throw new jwtCustomException(ErrorEnum.EXPIRED);
}
else {
request.setAttribute("EXPIRED_TOKEN", ErrorEnum.EXPIRED_REFRESH_TOKEN_VALUE);
throw new jwtCustomException(ErrorEnum.NO_USER);
}
// 위의 코드중 request.setAttribute에 대한부분은 최종해결법에 사용되는 코드
필터에서 validToken을 통해 내가 커스텀한 에러가 발생하면 위에 정의한 exceptionFilter가 doFilter에서 발생한 에러를 잡아와 핸들링하고자 한 설계...
하지만 결과적으로 실패했는데.. 자세히 에러검증을 안해서 정확한 원인인지는 모르겠지만 OncePerRequestFilter를 상속받은 위 필터를 securityConfig 에서 필터 순서를 정해주는데 에러를 핸들링 하는 위치다보니 내가 선언한 Authori필터보다
앞단에 위치하게 해야했다
http.addFilterBefore(jwtExceptionFilter(), jwtAuthorizationFilter.class);
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
근데 생각해보니 orderby기준 스프링시큐리티는 정해진 필터는 고유 order존재 (위의 UsernamePassword..는 1800이엿나) 그리고 사용자 정의필터는 저런 고유필터에 before, after 을 사용해 + - 1씩 세팅이 된다고 한다
하지만 위같은 경우 AuthorizationFilter 역시 상대필터다보니 상대필터에 대한 before 오류가 아닐까? 싶다
최종 시도 : AuthenticationEntryPoint 적용해보기
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
Enumeration<String> exceptionName = request.getAttributeNames();
while(exceptionName.hasMoreElements())
{
String exception = exceptionName.nextElement();
if(exception.equals("NOT_VALID_TOKEN")){
ErrorEnum e = (ErrorEnum)request.getAttribute(exception);
TestResponse testResponse = new TestResponse(e.getStatusCode(), e.getMsg());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(testResponse));
}
else if(exception.equals("EXPIRED_TOKEN")){
ErrorEnum e = (ErrorEnum)request.getAttribute(exception);
TestResponse testResponse = new TestResponse(e.getStatusCode(), e.getMsg());
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(testResponse));
}
}
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf((csrf) -> csrf.disable());
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers("/user/init").permitAll()
.anyRequest().authenticated()
);
// Config에 적용하기 위해 추가된 부분
http.exceptionHandling((exception)
->exception.authenticationEntryPoint(jwtAuthenticationEntryPoint()).accessDeniedPage("/user"));
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
적용중 이전까진 Handling().authenticationEntry...(내가만든엔트리포인트()); 하면 끝이였다고 하는데 Spring 6.2부터 deprecate되어 위와같은 형식으로 해줘야 한다고함.. 저 마지막 accessDenied~~ 부분은 정확히 어떻게 동작하는지 추후 다시 공부하기로 했다
일단은 위 EntryPoint는 스프링 시큐리티에서 AuthenticationException 에러가 감지되면 자동으로 오류를 핸들링해 저 commence 부분이 실행된다는 느낌으로 이해했다. 어쨋든 성공적으로 오류에 대한 예외처리를 했다
+ 추가 오류 검증 ( 재발급 과정 오류 )
토큰 재발급을 해도 계속 만료됐다는 오류가 발생한부분이 있었는데.. 엄청 단순한 실수였었다
else {
if (req.getRequestURI().equals("/user/reissue")) {
jwtUtil.validToken(refreshTokenValue, TokenType.REFRESH_TOKEN, req);
tokenValue = authService.tokenReissuance(refreshTokenValue, res);
tokenValue = jwtUtil.substringToken(tokenValue);
} else {
jwtUtil.validToken(refreshTokenValue, TokenType.REFRESH_TOKEN, req);
jwtUtil.validToken(tokenValue, TokenType.ACCESS_TOKEN, req);
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
setAuthentication(info.getSubject());
}
원인 : tokenValue값(만료됨)에 대해 쿠키에만 저장하고 필터단에서 다시 받아오지 않음
해결 : tokenValue에 다시 새롭게 받은 토큰값을 대입함으로써 해결
저 info에 담기위한 getUser메서드 역시 jwtParser에 의한 검증이 있는데 기존까진 재발급한 토큰 value를 쿠키에만 담고 위의 Auth필터에 반환을 안한채로 했던거였다... 코딩할때 오타실수나 이런 단순한 실수를 더더욱 조심해야겠다
+ 스프링 시큐리티 AuthenticationFilter에서 추가로 배운것
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
String password = ((UserDetailsImpl) authResult.getPrincipal()).getPassword();
LoginRequestDto loginRequestDto = new LoginRequestDto();
loginRequestDto.setUsername(username);
loginRequestDto.setPassword(password);
authService.login(loginRequestDto, response);
successLogin(response);
}
로그인 시도중 위에서 받은 password와 db에 들어가있는 암호화된 password를 비교하는데 passwordEncoder.matchs()에서 자꾸 틀리다는 오류가 발생
원인 : 받아온 password가 이미 암호화된 상태여서 matchs()가 재대로 판단하지 못한것
> 알고보니 authResult.getPrincipal()로 반환된 UserDetailsImpl 안에있던 password는 알아서 passwordEncoder로 encode된 값이였다. 아마 인증토큰을 만드는 과정에서 사용자 입력받은 password를 자동으로 암호화한듯 하다
해결 : 암호화 되기 이전, 사용자가 받은 password를 넣어줌으로써 해결