티스토리 뷰

현재 Flow 정리

JwtAuthenticationFilter 클래스

  1. Login 요청이 오면 JwtAuthenticationFilter에서 attemptAuthentication()를 호출하여 인증을 처리한다.
  2. Username&Password가 정상이면 -> AuthenticationToken을 발급한다.
    1. 인증토큰 객체를 이용해서 UserDetailsService의 loadUserByUsername()을 호출하여 회원 존재 여부를 검증 한다.
    2. UserDetails 객체로 반환된 객체에 대해서 PasswordEncoder를 통해 패스워드를 검증한다.
  3. 제대로 검증이 됐다면 인증토큰 객체를 반환하여 시큐리티_세션에 저장한다.
  4. 검증 결과에 따라 successfulAuthentication() 또는 unsuccessfulAuthentication()가 실행된다.
    • 성공했다면 successful()에서 JWT를 발급해서 response의 Header에 추가해준다.

JwtAuthorizationFilter 클래스

  1. 인증이나 권한이 필요한 요청이 오면JwtAuthorizationFilter가 호출된다.
  2. Header를 확인해서 토큰의 존재여부를 확인한다.
  3. Header가 유효하다면 토큰의 payload에서 username을 꺼내서 회원을 조회한다.
  4. (회원객체 + 회원 권한) 조합으로 authentication을 생성하고 SpringSecurityContextHolder에 접근하여
    Authentication을 SecurityContext에 넣어준다.

이러한 로직에서 Refresh Token을 추가해서 적용해보도록하자. 그럼 아래와 같은 로직으로 변경될 것이다.


+ RefreshToken Flow 정리

JwtAuthenticationFilter 클래스

  1. Login 요청이 오면 JwtAuthenticationFilter에서 attemptAuthentication()를 호출하여 인증을 처리한다.
  2. Username&Password가 정상이면 -> AuthenticationToken을 발급한다.
    1. 인증토큰 객체를 이용해서 UserDetailsService의 loadUserByUsername()을 호출하여 회원 존재 여부를 검증 한다.
    2. UserDetails 객체로 반환된 객체에 대해서 PasswordEncoder를 통해 패스워드를 검증한다.
  3. 제대로 검증이 됐다면 인증토큰 객체를 반환하여 시큐리티_세션에 저장한다.
  4. 검증 결과에 따라 successfulAuthentication() 또는 unsuccessfulAuthentication()가 실행된다.
    • 성공한 경우
      • successfulAuthentication()에서 AccessToken, RefreshToken을 발급한다.
        이때 AccessToken에는 User_PK, Username 두개를 Payload에 넣지만,
        RefreshToken에는 따로 Claim을 넣지 않는다. 용도가 다르기 때문.
      • 회원정보를 통해 해당 회원에 RefreshToken을 저장한다. (DB에 저장된다.)
      • response Header에 Access, Refresh 두개를 담고 반환한다.
      • 참고로 redirect로 다른 주소로 보내면 header가 사라진다. 주의!
    • 실패한 경우
      • unsuccessfulAuthentication()를 실행한다. (오버라이드로 추가해줬다.)
      • 실패 이유를 Response Body에 담아서 반환해준다.

JwtAuthorizationFilter 클래스

  1. 인증이나 권한이 필요한 요청이 오면JwtAuthorizationFilter가 호출된다.
  2. Header를 확인해서 토큰의 존재여부를 확인한다.
    • 이때 Refresh, Access 토큰 둘다 확인한다.
  3. Header가 유효하다면 AccessToken, RefreshToken을 꺼낸다.
  4. RefreshToken이 유효한지 검증한다.
    • 유효하면 RefreshToken으로 회원을 조회
    • 유효하지만 RefreshToken 만료날짜가 7일 이내라면 재발급
    • 유효하지 않다면 요청 종료
  5. AccessToken의 재발급 경우를 확인 후 회원의 ID, Username을 이용해서 재발급해준다.
    • AccessToken이 유효한 경우 재발급
    • AccessTokne이 만료된 경우 재발급
    • 그 외 요청 종료
  6. RefreshToken으로 조회한 회원 객체로 Authentication 객체를 생성 후  SpringSecurityContextHolder에 접근하여
    Authentication을 SecurityContext에 넣어준다.

이렇게 새로운 방식으로 구현이 될것이다. 즉 RefreshToken을 함께 사용함으로서 회원이 자주 로그인을 하지 않아도 되도록 해주는 것이다.

권한이나 인증이 필요한 요청을 보낼 때마다 Refresh 토큰을 확인해서 AccessToken을 재발급 해주고, RefreshToken도 7일 이내에 만료 예정이라면 재발급을 해주므로 사용자는 자기도 모르게 안전하게 계속 로그인이 유지되게 된다.


JwtService 클래스

RefreshToken이 들어오면서 복잡해진 JWT 검증, 발급을 담당할 JwtService 클래스를 추가한다.

@Slf4j
@Service
@Getter
@RequiredArgsConstructor
public class JwtService {

	@Value("${jwt.secret}")
	private String SECRET_KEY;
	private final MemberRepository memberRepository;

	@Transactional(readOnly = true)
	public Member getMemberByRefreshToken(String token) {
		return memberRepository.findByRefreshToken(token)
				.orElseThrow(() -> new CustomJwtException(JwtErrorCode.JWT_REFRESH_EXPIRED.getCode()));
	}

	@Transactional
	public void setRefreshToken(String username, String refreshJwt) {
		memberRepository.findByUsername(username)
				.ifPresent(member -> member.setRefreshToken(refreshJwt));
	}

	@Transactional
	public void removeRefreshToken(String token) {
		memberRepository.findByRefreshToken(token)
				.ifPresent(m -> m.setRefreshToken(null));
	}

	public void logout(HttpServletRequest request) {
		try {
			checkHeaderValid(request);
			String refreshJwtToken = request
					.getHeader(JwtProperties.REFRESH_HEADER_PREFIX)
					.replace(JwtProperties.TOKEN_PREFIX, "");
			removeRefreshToken(refreshJwtToken);
		} catch (Exception e) {
			throw new CustomJwtException(JwtErrorCode.JWT_REFRESH_NOT_VALID.name());
		}
	}

	public String createAccessToken(Long id, String username) {
		return JWT.create()
				.withSubject(JwtProperties.ACCESS_TOKEN)
				.withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME)) // 만료시간 10m
				.withClaim(JwtProperties.ID, id)
				.withClaim(JwtProperties.USERNAME, username)
				.sign(Algorithm.HMAC512(SECRET_KEY));
	}

	public String createRefreshToken() {
		return JWT.create()
				.withSubject(JwtProperties.REFRESH_TOKEN)
				.withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.REFRESH_EXPIRATION_TIME))
				.sign(Algorithm.HMAC512(SECRET_KEY));
	}

	public void checkHeaderValid(HttpServletRequest request) {
		String accessJwt = request.getHeader(JwtProperties.HEADER_PREFIX);
		String refreshJwt = request.getHeader(JwtProperties.REFRESH_HEADER_PREFIX);

		if (accessJwt == null) {
			throw new CustomJwtException(JwtErrorCode.JWT_ACCESS_NOT_VALID.getCode());
		} else if (refreshJwt == null) {
			throw new CustomJwtException(JwtErrorCode.JWT_REFRESH_NOT_VALID.getCode());
		}
	}

	public void checkTokenValid(String token) {
		JWT.require(Algorithm.HMAC512(SECRET_KEY))
				.build()
				.verify(token);
	}

	public boolean isExpiredToken(String token) {
		try {
			JWT.require(Algorithm.HMAC512(SECRET_KEY)).build().verify(token);
		} catch (TokenExpiredException e) {
			log.info("만료 토큰");
			return true;
		}
		return false;
	}

	public boolean isNeedToUpdateRefreshToken(String token) {
		try {
			Date expiresAt = JWT.require(Algorithm.HMAC512(SECRET_KEY))
					.build()
					.verify(token)
					.getExpiresAt();

			Date current = new Date(System.currentTimeMillis());
			Calendar calendar = Calendar.getInstance();
			calendar.setTime(current);
			calendar.add(Calendar.DATE, 7);

			Date after7dayFromToday = calendar.getTime();

			// 7일 이내에 만료
			if (expiresAt.before(after7dayFromToday)) {
				log.info("리프레쉬 토큰 7일 이내 만료");
				return true;
			}
		} catch (TokenExpiredException e) {
			return true;
		}
		return false;
	}
}

 

  • 토큰 발급, 검증, 7일이내 리프레쉬 토큰 만료 여부 확인, 로그아웃 등의 메서드가 구현되어있다.

1. Authentication 검증 후 응답 메시지 반환 추가

지금까지는 인증(로그인)이 이뤄지고 나서 실패한 경우가 아니라면 따로 응답 메시지를 주고 있지 않았다. 이부분을 추가해준다.

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

	private final AuthenticationManager authenticationManager;
	private final JwtService jwtService;

	// login 요청 시 로그인을 위해 실행되는 함수
	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
		log.info("TRY LOGIN USERNAME & PASSWORD : 인증 검증 _ JwtAuthenticationFilter.attemptAuthentication");
		ObjectMapper om = new ObjectMapper();
		try {
			LoginReq login = om.readValue(request.getInputStream(), LoginReq.class);
			Authentication authentication =
					new UsernamePasswordAuthenticationToken(login.getUsername(), login.getPassword());
			return authenticationManager.authenticate(authentication);
		} catch (IOException e) {
			e.printStackTrace();
		}
		return null;
	}

	// 인증이 정상적으로 완료되면 실행된다.
	// JWT 토큰을 만들어서 request 요청한 사용자에게 JWT 토큰을 response 해준다.
	@Override
	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
		log.info("인증 완료 : successfulAuthentication");

		PrincipalDetails principal = (PrincipalDetails) authResult.getPrincipal();
		Member member = principal.getMember();
		String accessJwt = jwtService.createAccessToken(member.getSeq(), member.getUsername());
		String refreshJwt = jwtService.createRefreshToken();

		// login 성공 -> Refresh 토큰 재발급
		jwtService.setRefreshToken(member.getUsername(), refreshJwt);

		response.addHeader(JwtProperties.HEADER_PREFIX, JwtProperties.TOKEN_PREFIX + accessJwt);
		response.addHeader(JwtProperties.REFRESH_HEADER_PREFIX, JwtProperties.TOKEN_PREFIX + refreshJwt);
		setResponse(response, "로그인 성공");
		log.info("===============================인증 프로세스 완료========================================");
	}

	@Override
	protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
		log.info("인증 실패 : unsuccessfulAuthentication");
		String failReason =
				failed.getMessage().equals(ExMessage.MEMBER_ERROR_NOT_FOUND_ENG.getMessage())
						? ExMessage.MEMBER_ERROR_NOT_FOUND.getMessage()
						: ExMessage.MEMBER_ERROR_PASSWORD.getMessage();

		setFailResponse(response, failReason);
		log.info("===============================인증 프로세스 완료========================================");
	}

	private void setSuccessResponse(HttpServletResponse response, String message) throws IOException {
		response.setStatus(HttpServletResponse.SC_OK);
		response.setContentType("application/json;charset=UTF-8");

		JSONObject jsonObject = new JSONObject();
		jsonObject.put("success", true);
		jsonObject.put("code", 1);
		jsonObject.put("message", message);

		response.getWriter().print(jsonObject);
	}
    
	private void setFailResponse(HttpServletResponse response, String message) throws IOException {
		response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
		response.setContentType("application/json;charset=UTF-8");

		JSONObject jsonObject = new JSONObject();
		jsonObject.put("success", false);
		jsonObject.put("code", -1);
		jsonObject.put("message", message);

		response.getWriter().print(jsonObject);
	}
}
  • 성공 시 successfulAuthentication()
    • 성공한 경우 성공 메시지 body를 만들어서 보내도록 setSuccessResponse() 메서드를 생성했다.
  • 실패 시 unsuccessfulAuthentication()
    • 실패한 경우 실패 메시지 body를 만들어서 보내도록 setFailResponse() 메서드를 생성했다.

아이디 오류 / 패스워드 오류 / 성공

2. Authorization(권한/인증) 검증 시 예외처리 추가

// 권한이나 인증이 필요한 특정 주소를 요청했을 때 BasicAuthenticationFilter를 타게 된다.
// 권한이나 인증이 필요하지 않다면 거치지 않는다.
@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
	private final JwtService jwtService;

	public JwtAuthorizationFilter(AuthenticationManager authenticationManager, JwtService jwtService) {
		super(authenticationManager);
		this.jwtService = jwtService;
	}

	// 인증이나 권한이 필요한 주소요청이 있을 때 해당 필터를 거친다.
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
		log.info("CHECK JWT : 인가(권한) 검증 _ JwtAuthorizationFilter.doFilterInternal");

		try {
			jwtService.checkHeaderValid(request);
			String accessJwtToken = request
					.getHeader(JwtProperties.HEADER_PREFIX)
					.replace(JwtProperties.TOKEN_PREFIX, "");
			String refreshJwtToken = request
					.getHeader(JwtProperties.REFRESH_HEADER_PREFIX)
					.replace(JwtProperties.TOKEN_PREFIX, "");
			jwtService.checkTokenValid(refreshJwtToken);

			log.info("리프레쉬 토큰 회원 조회");
			Member memberByRefreshToken = jwtService.getMemberByRefreshToken(refreshJwtToken);
			String username = memberByRefreshToken.getUsername();
			Long id = memberByRefreshToken.getSeq();

			// Refresh 토큰이 7일 이내 만료일 경우 Refresh 토큰도 재발급
			if (jwtService.isNeedToUpdateRefreshToken(refreshJwtToken)) {
				refreshJwtToken = jwtService.createRefreshToken();
				response.addHeader(JwtProperties.REFRESH_HEADER_PREFIX, JwtProperties.TOKEN_PREFIX + refreshJwtToken);
				jwtService.setRefreshToken(username, refreshJwtToken);
			}

			try {
				log.info("액세스 토큰 검증");
				jwtService.checkTokenValid(accessJwtToken);
			} catch (TokenExpiredException expired) {
				log.error("ACCESS TOKEN REISSUE : " + JwtErrorCode.JWT_ACCESS_EXPIRED);
				accessJwtToken = jwtService.createAccessToken(id, username);
				response.addHeader(JwtProperties.HEADER_PREFIX, JwtProperties.TOKEN_PREFIX + accessJwtToken);
			}

			PrincipalDetails principalDetails = new PrincipalDetails(memberByRefreshToken);
			Authentication auth = new UsernamePasswordAuthenticationToken
					(principalDetails, null, principalDetails.getAuthorities());
			SecurityContextHolder.getContext().setAuthentication(auth);
		} catch (CustomJwtException cusJwtExc) {
			request.setAttribute(JwtProperties.EXCEPTION, cusJwtExc.getMessage());
		} catch (TokenExpiredException ee) {
			request.setAttribute(JwtProperties.EXCEPTION, JwtErrorCode.JWT_REFRESH_EXPIRED);
		} catch (MalformedJwtException | UnsupportedJwtException mj) {
			request.setAttribute(JwtProperties.EXCEPTION, JwtErrorCode.JWT_NOT_VALID);
		} catch (Exception e) {
			log.error("미정의 에러 : " + e);
			log.error(e.getMessage());
			request.setAttribute(JwtProperties.EXCEPTION, JwtErrorCode.JWT_NOT_VALID);
		}

		chain.doFilter(request, response);
	}
}

필터에서 발생하는 예외는 ControllerAdvice에서 처리할 수가 없다. 따라서 HttpServletRequest에 setAttribute()를 통해 각각의 상황에서 발생하는 예외를 구분해서 적어줬다.

해당 메시지는 AuthenticationException(인증 예외)을 처리하는 AuthenticationEntryPoint 클래스에서 처리하게 된다.

권한 관련 검증은 AccessDeniedHanlder에서 처리된다.

예외 처리를 위해 스프링 시큐리티에 예외 핸들러가 추가되었다.

// 시큐리티 config 파일에 예외처리를 위해 추가되어있는 부분
.and()
.exceptionHandling()
.authenticationEntryPoint(customAuthenticationEntryPoint) 	// 인증관련 예외처리
.accessDeniedHandler(customAccessDeniedHandler);		// 인가(권한)관련 예외처리

AuthenticationEntryPoint를 구현한 CustomAuthenticationEntryPoint 클래스

@Slf4j
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

		String exceptionMessage = (String) request.getAttribute(JwtProperties.EXCEPTION);

		log.error("Exception : " + exceptionMessage);

		setResponse(response, exceptionMessage);
	}

	private void setResponse(HttpServletResponse response, String message) throws IOException {
		response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
		response.setContentType("application/json;charset=UTF-8");

		JSONObject jsonObject = new JSONObject();
		jsonObject.put("success", false);
		jsonObject.put("code", -1);
		jsonObject.put("message", message);

		response.getWriter().print(jsonObject);
	}
}

AccessDeniedHanlder를 구현한 CustomAccessDeniedHandler 클래스

@Slf4j
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
	@Override
	public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
		response.sendRedirect("/exception/accessDenied");
	}
}
----
// 인가 오류 처리 컨트롤러
@GetMapping("/accessDenied")
public void accessDeniedException(HttpServletResponse response) {
    throw new AccessDeniedException(ExMessage.JWT_ACCESS_DENIED);
}

테스트

로그인 -> 로그아웃 시 RefreshToken 제거

로그인 / 로그아웃
로그아웃 시 REFRESH_TOKEN null 처리

리프레쉬 토큰이 지워진 경우 토큰이 유효하더라도 재 로그인 요청

권한 체크 테스트

USER 권한 테스트 / ADMIN 권한 테스트
요청 처리 컨트롤러 코드

이렇게 RefreshToken까지 사용해서 인증 / 인가가 이뤄지도록 구현하였으며 토큰의 오류에 따라 적절한 오류 메시지를 반환하도록 처리하였다.

반응형
Comments
반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday