Notice
Recent Posts
Recent Comments
Link
«   2025/07   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
Tags more
Archives
Today
Total
관리 메뉴

가자미의 파닥파닥 프로그래밍

[Fitingle] 인증, 인가 처리 수정 2 본문

Spring

[Fitingle] 인증, 인가 처리 수정 2

GaJaMy 2024. 1. 24. 17:42
이전에 Fitingle 인증과 인가 처리에서 userId가 필요한 API들에 userId가 필요한 요청들에 대해서 직접 userId를 포함해서 보내던 것을 인증 객체에서 userId를 빼내어서 사용하는 것으로 해결해 보았습니다. 이번에는 인증 처리 Filter에서 Filter 처리하던 코드가 가독성이 너무 떨어져 이를 해결해 보려고 합니다.

 

이슈 내용

기존에 토큰을 검증하는 부분의 가독성이 떨어지고 있습니다. 바로 Filter에서 토큰을 해석하고, 맞지 않으면  response를 Filter 자체에서 반환하고 있기 때문입니다. 가독성이 떨어지면 문제가 발생했을때, 체크하기도 어렵고 내용을 팀원들과 공유하기도 힘들어집니다.

 

해결

근본적인 문제는 검증이 실패하면, 응답을 해당 Filter에서 직접 반환하기 때문입니다. 이를 좀더 깔끔하게 하기 위해 JWT 검증 필터에서는 검증을 하고, 실패하면 예외를 발생시킵니다. 그 다음 JWT 필터 앞에 예외를 캐치하고, 응답을 만들어 내도록 하는 필터를 하나더 추가하면 더 깔끔하고 구조적으로 좋게 만들 수 있습니다.

 

코드

기존에 JWT 토큰을 검증하는 필터입니다.

@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
	public static final String TOKEN_HEADER = "Authorization";
	public static final String TOKEN_PREFIX = "Bearer :";
	private final RedisTemplate redisTemplate;
	private final JwtTokenManager jwtTokenManager;

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		String jwt = request.getHeader(TOKEN_HEADER);

		//결과를 담아줄 겍체
		ResultDto errorResultDto = new ResultDto();

		String refreshToken = "";

		//쿠키에서 refresh 토큰을 가져옴
		Cookie[] cookies = request.getCookies();

		if (cookies != null) {
			for (Cookie cookie : cookies) {
				if (cookie.getName().equals("refreshToken")) {
					refreshToken = cookie.getValue();
				}
			}
		}

		ObjectMapper objectMapper = new ObjectMapper();
		if (!ObjectUtils.isEmpty(jwt)) {
			if(!jwt.startsWith(TOKEN_PREFIX)){ //토큰 타입 확인
				// Bearer 방식이 아니다 Bearer은 토큰 인증 방식을 말한다
				response.setStatus(HttpServletResponse.SC_OK);
				response.setContentType(MediaType.APPLICATION_JSON_VALUE);
				response.getWriter().write(objectMapper.writeValueAsString(
					ResultDto.builder()
						.message(ErrorCode.INVALID_TOKEN_TYPE.getMessage())
						.data(null)
						.errorCode(ErrorCode.INVALID_TOKEN_TYPE.getCode())
						.build()
				));
				return;
			} else {
				if (jwtTokenManager.refreshTokenValidate(refreshToken,errorResultDto)) {
					if (redisTemplate.opsForValue().get(jwtTokenManager.getSubject(refreshToken)).equals("logout")) {
						response.setStatus(HttpServletResponse.SC_OK);
						response.setContentType(MediaType.APPLICATION_JSON_VALUE);
						response.getWriter().write(objectMapper.writeValueAsString(ResultDto.builder()
							.message(ErrorCode.LOGOUT_USERS.getMessage())
							.data(null)
							.errorCode(ErrorCode.LOGOUT_USERS.getCode())
							.build()));
						return;
					} /*else if (!redisTemplate.opsForValue().get(jwtTokenManager.getSubject(refreshToken)).equals(refreshToken)) {
						response.setStatus(HttpServletResponse.SC_OK);
						response.setContentType(MediaType.APPLICATION_JSON_VALUE);
						response.getWriter().write(objectMapper.writeValueAsString(ResultDto.builder()
							.message(ErrorCode.DUPLICATE_LOGIN.getMessage())
							.data(null)
							.errorCode(ErrorCode.DUPLICATE_LOGIN.getCode())
							.build()));
						return;
					}*/

					//실제 access 토큰 인증 부분(subAccessToken 은 "Bearer :"을 떼어낸 것)
					String subAccessToken = jwt.substring(TOKEN_PREFIX.length());

					if (jwtTokenManager.accessTokenValidate(subAccessToken,errorResultDto)) {
						Authentication authentication = jwtTokenManager.getAuthentication(subAccessToken);
						SecurityContextHolder.getContext().setAuthentication(authentication);
					} else {
						response.setStatus(HttpServletResponse.SC_OK);
						response.setContentType(MediaType.APPLICATION_JSON_VALUE);
						response.getWriter().write(objectMapper.writeValueAsString(errorResultDto));
						return;
					}
				} else {
					response.setStatus(HttpServletResponse.SC_OK);
					response.setContentType(MediaType.APPLICATION_JSON_VALUE);
					response.getWriter().write(objectMapper.writeValueAsString(errorResultDto));
					return;
				}
			}
		}

		filterChain.doFilter(request,response);
	}
}​

 

여러 분기문의 남발과 응답 객체의 생성으로 인해 가독성이 떨어지는 것을 볼 수 있습니다. 그리고 응답 객체를 만들어 내기위해서 ResultDto의 반환을 위해서 분기문이 더 발생하게 됩니다. 이것은 아래와 같이 ResultDto 중점적으로 생성하는 것이 아닌 성공과 실패로만 구분지으면 더 간결하게 바뀌게 됩니다. 

 

@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
	public static final String TOKEN_HEADER = "Authorization";
	public static final String TOKEN_PREFIX = "Bearer :";
	private final RedisTemplate redisTemplate;
	private final JwtTokenManager jwtTokenManager;

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		String authorizationHeaderValue = request.getHeader(TOKEN_HEADER);
		String refreshToken = "";

		//쿠키에서 refresh 토큰을 가져옴
		Cookie[] cookies = request.getCookies();

		if (cookies != null) {
			for (Cookie cookie : cookies) {
				if (cookie.getName().equals("refreshToken")) {
					refreshToken = cookie.getValue();
				}
			}
		}

		if (!ObjectUtils.isEmpty(authorizationHeaderValue)) {
			//토큰 타입 확인
			//Bearer 방식이 아닐 때
			if (!authorizationHeaderValue.startsWith(TOKEN_PREFIX)) {
				log.info("INVALID_TOKEN_TYPE : Not Bearer");
				throw new CustomException(ErrorCode.INVALID_TOKEN_TYPE);
			}

			ErrorCode errorCode = jwtTokenManager.refreshTokenValidate(refreshToken);
			
            //refresh 토큰이 검증이 실패하면
			if (errorCode != ErrorCode.SUCCESS) {
				log.info("Authorization Error : ",errorCode.getMessage(),errorCode.getCode());
				throw new CustomException(errorCode); // 예외 발생
			}

			//refrsh 토큰이 redis에 logout 처리 되어 있다면
			if (redisTemplate.opsForValue().get(jwtTokenManager.getSubject(refreshToken)).equals("logout")) {
				log.info("Logout Users : Please login");
				throw new CustomException(ErrorCode.LOGOUT_USERS); // 예외 발생
			}

			//Bearer 를 땐 실제 Access 토큰
			String subAccessToken = authorizationHeaderValue.substring(TOKEN_PREFIX.length());
			errorCode = jwtTokenManager.accessTokenValidate(subAccessToken);

			//access 토큰 검증이 실패하면
			if (errorCode != ErrorCode.SUCCESS) {
				log.info("Authorization Error : ",errorCode.getMessage(),errorCode.getCode());
				throw new CustomException(errorCode); 예외 발생
			}

			//토큰 검증이 완료 되면, SecurityContext에 인증 객체를 추가
			log.info("Success Authorization");
			Authentication authentication = jwtTokenManager.getAuthentication(subAccessToken);
			SecurityContextHolder.getContext().setAuthentication(authentication);
		}
        //다음 필터 수행
		filterChain.doFilter(request,response);
	}
}

 

이전 코드와 달리 깔끔하게 바뀌었고, 가독성이 확연히 올라 갔습니다. 그럼 이제 이 필터에서 발생한 예외들을 캐치해 줄 필터를 만들어 주고, 등록해주기만 하면 역할이 나뉘게 되어 유지 보수에서 용이성을 가져갈 수 있습니다.

 

아래는 JWT 필터에서 나타나는 예외를 캐치해주는 필터 입니다.

@Slf4j
@RequiredArgsConstructor
public class ExceptionHandleFilter extends OncePerRequestFilter {
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {
		
        //필터를 수행해 보고
        try {
			filterChain.doFilter(request,response);
		} catch (CustomException e) {
        	//CustomException이 발생하면 에러 응답을 보낸다.
			setErrorResponse(response,e.getErrorCode());
		}
	}

	private void setErrorResponse(HttpServletResponse response, ErrorCode errorCode)
		throws IOException {
		ObjectMapper objectMapper = new ObjectMapper();

		//응답 상태 코드 설정
		response.setStatus(HttpServletResponse.SC_FORBIDDEN);
		//응답 타입 설정
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
		//응답 내용 작성
        response.getWriter().write(objectMapper.writeValueAsString(
			ResultDto.builder()
				.message(errorCode.getMessage())
				.data(null)
				.errorCode(errorCode.getCode())
				.build()
		));
	}
}

 

여기는 필터를 등록하는 Config 부분입니다.

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
	private final JwtTokenManager jwtTokenManager;
	private final RedisTemplate redisTemplate;

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

		http.csrf().disable();
		http.formLogin().disable();
		http.httpBasic().disable();
		http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
		http.addFilterBefore(new JwtAuthorizationFilter(redisTemplate,jwtTokenManager), UsernamePasswordAuthenticationFilter.class)
			.addFilterBefore(new ExceptionHandleFilter(), JwtAuthorizationFilter.class) // JwtAuthorizationFilter 앞에 ExceptionHandleFilter 추가
			.authorizeRequests()
			.antMatchers("/**").permitAll()
			.anyRequest().authenticated();
		http.exceptionHandling().authenticationEntryPoint(new JwtAuthenticationEntryPoint());

		return http.build();
	}
}

 

느낀점

사실 처음 코드를 작성할 때는, 그냥 응답을 만들어내어 응답을 전달해주는 결과만 보고 작업을 했던것 같습니다. 그런데 이전 이슈인 userId를 직접 요청으로 부터 받는 이슈를 해결할때, 코드의 가독성이 너무나 떨어져서 해결하는데 애를 먹었습니다. 이번 기회를 통해서 얼마나 가독성있게 짜야하는지, 어떻게 하면 유지 보수가 쉽게 할 수 있는지, 그리고 Security에 대해서 한번더 공부할 수 있었던 것 같습니다.

'Spring' 카테고리의 다른 글

CORS에 대해서 알아보자  (0) 2024.02.16
MVC 패턴  (0) 2024.01.11
AWS EC2에 RabbitMQ 설치  (0) 2024.01.08
Fittingle 인가 처리 수정  (0) 2023.12.27
서블릿과 JSP  (0) 2023.12.22