본문 바로가기
프로젝트/아리(Ari)

[Spring Boot, Spring Security, Data JPA] 로그인/로그아웃 2

by sepang 2022. 9. 18.

  이번에는 reissue와 로그아웃을 기능을 구현해볼 차례이다. 저번 글에서 확인해서 알겠지만 refresh token을 관계형 DB에 저장하지 않고 redis라는 인메모리 비관계형 DB에 저장했다. 아무래도 reissue 요청이나 로그아웃 요청은 빈번히 발생하기 때문에 성능적으로도 유리한 것도 있지만, 사실 프로젝트 수준에서 이는 크게 체감되지 않았고 오히려 데이터 저장 시간을 간단히 설정할 수 있다는 점이 유용하다고 생각했다.

  어쨌든 이 글에서는 reissue, logout 기능을 어떤 식으로 구현했는지 살펴보자.

Reissue

  access token의 유효기간은 5분정도로 설정해놓았다. 그리고 클라이언트에서 유효기간 만료 몇 초전에 access token과 refresh token을 body에 담아 reissue 요청을 보내게 되고 서버가 새로운 access token과 refresh token(유효기간 5일)을 제공해준다. 그렇다는건 사용자가 따로 로그아웃 요청을 보내지 않는 이상, 사이트를 나간 뒤 5일동안 돌아오지 않을 때만 로그인 상태가 풀린다는 것이다. 이 배경을 가지고 코드를 살펴보자.

public ResponseEntity<?> reissue(TokenRequestDto reissue) {

    /*1. refresh token 검증*/
    if (!tokenProvider.validateToken(reissue.getRefreshToken())) {
        return response.fail("Refresh Token 정보가 유효하지 않습니다.", HttpStatus.BAD_REQUEST);
    }

    /*2. Access Token에서 User id을 가져옴*/
    Authentication authentication = tokenProvider.getAuthentication(reissue.getAccessToken());

    /*3. redis에서 user id를 기반으로 저장된 refresh token 값을 가져옴*/
    String refreshToken = (String) redisTemplate.opsForValue().get("RT:" + authentication.getName());

    log.info("refresh Token: {}", refreshToken);

    //(추가) 로그아웃되어 redis에 refresh Token이 존재하지 않는 경우 처리
    if (ObjectUtils.isEmpty(refreshToken)) {
        return response.fail("잘못된 요청입니다", HttpStatus.BAD_REQUEST);
    }
    
    if (!refreshToken.equals(reissue.getRefreshToken())) {
        return response.fail("Refresh Token 정보가 일치하지 않습니다.", HttpStatus.BAD_REQUEST);
    }

    /*4. 새로운 토큰 생성*/
    TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

    /*5. refresh token Redis 업데이트*/
    redisTemplate.opsForValue()
            .set("RT:" + authentication.getName(), tokenDto.getRefreshToken(),
                    tokenDto.getRefreshTokenExpiresIn(), TimeUnit.MILLISECONDS);

    return response.success(tokenDto, "토큰 정보가 갱신되었습니다.", HttpStatus.OK);
}
  1. 우선 refresh token을 검증한다. tokenProvider의 validateToekn()을 사용한다.
  2. 그리고 access token을 뜯어서 user의 id를 가져온다.
  3. 로그인을 하게 되면 refresh token을 redis에 저장한다고 했다. (추가)부분은 조금 있다 살펴보자. redis에 저장된 refresh token과 클라이언트가 보낸 refresh token이 같은지 확인한다. 만약 같지 않다면 BAD_REQUEST를 반환한다.
  4. 이후 tokenProvider의 generateTokenDto()을 이용해 새로운 access/refresh token을 생성해준다.
  5. 새로 만들어진 토큰정보를 redis에 갱신해준다. 이렇게 되면 이전 refresh token 대신 새롭게 만들어진 refresh token으로 갱신된다.

fig 1. reissue

 

Logout

  로그아웃에도 redis가 사용된다. 원래는 access token을 발급받으면 유효기간 동안 인증을 받을 수 있다. 하지만 로그아웃 요청을 보내게 되면 클라이언트는 여전히 유효한 access token을 헤더에 담아 보내겠지만 해당 토큰으로는 인증이 통과되지 않아야한다. 그렇기 때문에 유효기간 동안 로그아웃 요청 시 보낸 access token을 남은 유효기간 동안 redis에 담고 이후 로그아웃된 access token으로 접근하게 되면 redis에 해당 토큰이 있음을 확인하고 인증을 막아버려야 한다. 그리고 refresh token도 저장할 필요가 없게 되므로 이것도 지워줘야 한다.

  이를 바탕으로 코드를 살펴보자.

public ResponseEntity<?> logout(TokenRequestDto logout) {
    /*1. Access Token 검증*/
    if (!tokenProvider.validateToken(logout.getAccessToken())) {
        return response.fail("잘못된 요청입니다.", HttpStatus.BAD_REQUEST);
    }

    /*2. Access Token에서 User id을 가져옴*/
    Authentication authentication = tokenProvider.getAuthentication(logout.getAccessToken());

    /*3. Redis에서 해당 User id로 저장된 refresh token이 있는지 여부를 확인 후, 있을 경우 삭제*/
    if (redisTemplate.opsForValue().get("RT:" + authentication.getName()) != null) {
        //refresh token 삭제
        redisTemplate.delete("RT:" + authentication.getName());
    }

    /*4. 해당 access token 유효시간 가지고 와서 BlackList로 저장*/
    Long expiration = tokenProvider.getExpiration(logout.getAccessToken());
    redisTemplate.opsForValue()
            .set(logout.getAccessToken(), "logout", expiration, TimeUnit.MILLISECONDS);

    return response.success("로그아웃 되었습니다");
}
/**
 * accessToken의 남은 유효시간 얻기
 */
public Long getExpiration(String accessToken) {
    Date expiration = Jwts.parserBuilder().setSigningKey(private_key)
            .build().parseClaimsJws(accessToken).getBody().getExpiration();

    //현재 시간
    long now = new Date().getTime();

    return (expiration.getTime() - now);
}
  1. 먼저 요청받은 AccessToken 유효성을 검증한다.
  2. Access Token에서 user id를 가져온다.
  3. 해당 user id를 키 값으로 하는 refresh token이 저장되어 있는지 확인하고 있다면 삭제한다.
  4. access token을 키 값으로 하고 유효기간을 설정하고 redis에 저장한다.

 

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

    String jwt = resolveToken(request);

    if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
        /*1. Redis 에 해당 accessToken logout 여부 확인 */
        String isLogout = (String)redisTemplate.opsForValue().get(jwt);
        if (ObjectUtils.isEmpty(isLogout)) {
            /*2. 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장 */
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
    }

    filterChain.doFilter(request, response);
}
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";
    private final TokenProvider tokenProvider;
    private final RedisTemplate redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        String jwt = resolveToken(request);

        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            /*1. Redis 에 해당 accessToken logout 여부 확인 */
            String isLogout = (String)redisTemplate.opsForValue().get(jwt);
            if (ObjectUtils.isEmpty(isLogout)) {
                /*2. 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장 */
                Authentication authentication = tokenProvider.getAuthentication(jwt);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }

    public String resolveToken(HttpServletRequest request) {
        /*1. 헤더에서 토큰을 꺼냄*/
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        /*2. 토큰이 담겨 있을 시, 앞의 "Bearer " 제거*/
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }

        return null;
    }
}

  이제 저번 글에 있던 JwtFilter의 doFilterInternal()을 이해할 수 있다. 여기서 요청이 날라왔을 때, redis에서 access token 존재 여부를 확인하고 있다. access 토큰이 redis에 저장되어 있지 않아야 토큰이 유효한 것이고, 그렇게 되면 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장한다.

 또한 reissue 서비스 메서드에 다음과 같은 코드가 있었다.

//(추가) 로그아웃되어 redis에 refresh Token이 존재하지 않는 경우 처리
if (ObjectUtils.isEmpty(refreshToken)) {
    return response.fail("잘못된 요청입니다", HttpStatus.BAD_REQUEST);
}

  우리는 현재 access toekn 만료 전에 요청을 보내지만 reissue 요청은 access token이 만료된 이후에 할 수 도 있으므로 인증정보 없이 접근이 허용되도록 해놨다. 그렇기 때문에 refresh token이 없는 유저, 즉 비회원이나 로그아웃된 유저의 접근을 따로 막아줄 필요가 있다. 그렇기 때문에 해당 코드를 추가함으로써 로그아웃된 유저가 reissue를 보냈을 때 접근을 막을 수 있다.


참고자료

댓글