본문 바로가기
부트캠프 개발일기/Main-Project

100일차: Main-Project Day 7 (Redis, RefreshToken으로 AccessToken 재발급, 로그아웃)

by shyun00 2023. 7. 6.

벌써 100일째라니!! 시간 참 빠르다.

오늘은 코드에 많은 변경이 있었던 날이다.

오전에는 Request에서 User 식별자를 어떻게 가져올지에 대해 고민하느라 코드를 이리저리 뜯어보는데 시간을 보냈다.

예전에 나는 HandlerInterceptor를 사용해서 Request에서 memberId를 가져왔었는데, 팀원중에 한분은 ContextHolder에서 memberId값을 가져왔다.

그 방법이 더 간편한것같아서 (Interceptor는 경로를 다 지정해줘야했다.) 시도해봤는데 그랬더니 에러 처리에서 문제가 생겨서 일단 나중에 다시 시도해봐야할것같다.

 

그 외에 오늘은 로그인/로그아웃 관련 전체적인 작업을 진행했다.

로그인하고 발급한 RefreshToken을 Redis에 저장하고, RefreshToken을 사용해 AccessToken 재발급을 요청하면 저장소에서 확인 후 재발급해주고, 로그아웃을 요청하면 RefreshToken과 AccessToken을 비활성화 시키는 작업을 진행했다.

 

아무래도 작업한 내용이 여러가지라서 글이 길어질듯하나 작업 내용을 남기기 위해 다 적어보고자 한다.

(확실히 글로 남겨놓으니 작업하면서 참고하기 좋은것같다. 예전 작업내용을 보면 틀린 부분도 많이 있어서 같이 수정해가고 있다.)


1. 로그인시 Refresh 토큰 Redis 저장소에 저장

로그인 인증을 정상적으로 성공하면 AccessToken은 Response 헤더에 넣어서, Refresh Token은 쿠키에 넣어서 클라이언트측에 전달했다.

Refresh Token은 AccessToken이 만료되면 AccessToken을 재발급하는데 사용된다.

따라서 정상적인 RefreshToken인지 확인하기 위해 서버측 저장소에 저장해야한다.

RefreshToken은 유효기간이 있으므로 해당 시간이 지나면 자동으로 삭제될 수 있도록 Redis를 사용했다.

Redis 적용방법은  이전글 참고

2023.06.20 - [부트캠프 개발일기/Pre-Project] - 88일차 - Pre-Project Day 8-2 (JWT 로그아웃, Redis)

 

RedisRepositoryConfig는 이전글과 동일하게 작성했고, JwtTokenizer 에서 토큰을 생성하는 과정에서 바로 리프레시토큰을 저장소에 저장하도록 했다. memberId를 키값으로, 리프레시토큰을 value로 저장하고 리프레시토큰 유효기간을 설정해주었다.

//JwtTokenizer

public String delegateRefreshToken(Member member) {
    String subject = member.getMemberId().toString; // 나중에 Redis에서 memberId를 기준으로 데이터 찾기 위해 Subject 설정
    Date expiration = getTokenExpiration(getRefreshTokenExpirationMinutes());

    String base64EncodedSecretKey = encodeBase64SecretKey(getSecretKey());

    String refreshToken = generateRefreshToken(subject, expiration, base64EncodedSecretKey);
    redisTemplate.opsForValue().set(member.getMemberId().toString(), refreshToken, getRefreshTokenExpirationMinutes(), TimeUnit.MINUTES);
    
    return refreshToken;
}

[결과확인]

포스트맨으로 로그인 요청을 했을때 AccessToken은 헤더에, RefreshToken은 쿠키에 저장되어 전달되고있다.

또한 Redis 저장소에 해당 memberId(식별자)를 키값으로 하는 데이터가 저장된 것을 확인할 수 있다.

만약 로그인을 다시 수행하면 리프레시토큰값이 변경되어 덮어씌워지며 이전 토큰은 사용할 수 없게된다.

로그인 결과(왼쪽), Redis에 저장된 멤버아이디+리프레시토큰(재로그인시 변경됨)


2. RefreshToken을 사용한 AccessToken 재발급 (with Redis)

가장 많이 고민했던 부분이다.

RefreshToken을 어디에서 검증해서 AccessToken을 재발급 하는것이 가장 효율적일까.

시큐리티 필터일까 아니면 별도의 HTTP요청으로 진행하는것이 나을까.

우선은 프론트엔드 담당 팀원과 얘기해서 아래 흐름으로 진행하기로 했다.

(해당 부분은 다음 멘토링 시간에 한번 여쭤볼 예정이다.)

 

1) MemberController

프론트엔드측에서 액세스토큰을 보내고 만료되었다는 결과를 받으면 쿠키에 담겨있는 Refresh토큰을 사용해 다시 액세스토큰을 요청한다.

해당 부분의 엔드포인트(/token/refresh)를 적용해 Post요청을 받는다.

public class MemberController {
  ...
    @PostMapping("/token/refresh")
    public ResponseEntity reIssueAccessToken(HttpServletRequest request, HttpServletResponse response) throws Exception {
        response = memberService.checkRefreshAndReIssueAccess(request, response);
        return new ResponseEntity(HttpStatus.OK);
}

2) MemberService

리프레시토큰을 확인하고 액세스토큰을 재발급한다.

RefreshToken은 쿠키에 들어있으므로 getCookieValue() 메서드를 사용해 쿠키에서 Refresh토큰을 가져온다.

(쿠키 생성시 RefreshToken이라는 이름으로 생성했음)

public class MemberService {
    
    private final JwtTokenizer jwtTokenizer;

   ...

    public HttpServletResponse checkRefreshAndReIssueAccess(HttpServletRequest request, HttpServletResponse response) {
        String refreshToken = getCookieValue(request, "RefreshToken");
        String memberId = jwtTokenizer.getMemberIdFromRefreshToken(refreshToken);

        RedisTemplate redisTemplate = jwtTokenizer.getRedisTemplate();
        String findRefreshToken = (String) redisTemplate.opsForValue().get(memberId);

        if (findRefreshToken.equals(refreshToken)) {
            String accessToken = jwtTokenizer.delegateAccessToken(memberRepository.findById(Long.parseLong(memberId)).orElseThrow());
            response.setHeader("Authorization", "Bearer " + accessToken);
        } else {
            throw new BusinessLogicException(ExceptionCode.INVALID_REFRESH_TOKEN_STATE);
        }
        return response;
    }

    public static String getCookieValue(HttpServletRequest request, String cookieName) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(cookieName)) {
                    return cookie.getValue();
                }
            }
        } else throw new BusinessLogicException(ExceptionCode.NO_COOKIE);
        return null;
    }

3. JwtTokenizer

Redis에 리프레시토큰 키값으로 memberId가 저장되어있으므로, 검색을 위해 Request에 들어있는 리프레시토큰에서 memberId를 추출하는 로직을 추가했다.

@RequiredArgsConstructor
public class JwtTokenizer {

   ...

    public String getMemberIdFromRefreshToken(String refreshToken) {
        Key key = getKeyFromBase64EncodedKey(encodeBase64SecretKey(getSecretKey()));
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(refreshToken)
                .getBody();

        return claims.getSubject();
    }

[결과확인]

로그인 이후 액세스 토큰이 만료되었을때 /token/refresh 경로로 쿠키와 함께 Post 요청을 보내면 

아래와 같이 새로운 액세스 토큰이 발급되어 전달되는것을 확인할 수 있다.

아직 HTTPS에 대한 설정은 하지 않은 상태여서 쿠키 생성시 Secure는 설정하지 않았다.


3. 로그아웃

지난 프로젝트(이전글)와 거의 유사하나, Refresh토큰을 무효화하는 과정이 추가되었다.

MemberService에서 AccessToken과 Refresh토큰을 Redis에 업데이트해주었다.

추가로 MemberVerificationFilter에서 에러 처리 관련 내용을 추가했다.

(현재는 에러 발생시 500에러로 응답이 가고있어서 프론트측에서 토큰 만료인지, 로그아웃인지 확인할 수 없으므로, 사용자 정의 예외를 사용해 표시해주었다.)

 

1) MemberService

public class MemberService {
    ...

    public void logout(HttpServletRequest request) {
        String accessToken = request.getHeader("Authorization");
        String refreshToken = getCookieValue(request, "RefreshToken");
        String memberId = jwtTokenizer.getMemberIdFromRefreshToken(refreshToken);

        // Redis에 accessToken 사용 못하도록 블랙리스트 등록
        RedisTemplate redisTemplate = jwtTokenizer.getRedisTemplate();
        redisTemplate.opsForValue().set(accessToken, "logout", 5, TimeUnit.MINUTES);
        redisTemplate.opsForValue().set(memberId, "logout", 300, TimeUnit.MINUTES);
    }
}

2) JwtVerificationFilter

@Slf4j
public class JwtVerificationFilter extends OncePerRequestFilter {
    ....
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        try {
            checkLogout(request);
            Jws<Claims> claims = verifyJws(request);
            setAuthenticationToContext(claims);
        } catch (SignatureException se) {
            log.info("Exception, {}", se.getMessage());
            BusinessLogicException be = new BusinessLogicException(ExceptionCode.INVALID_ACCESS_TOKEN_STATE);
            request.setAttribute("exception", be);
        } catch (ExpiredJwtException ee) {
            log.info("ExpiredJwtException: {}", ee.getMessage());
            BusinessLogicException be = new BusinessLogicException(ExceptionCode.ACCESS_TOKEN_EXPIRED);
            request.setAttribute("exception", be);
        } catch (Exception e) {
            log.info("Exception, {}", e.getMessage());
            request.setAttribute("exception", e);
        }

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

[결과확인]

왼쪽에서부터 순서대로 위조된 토큰, 만료된 토큰, 로그아웃한 토큰을 첨부해서 요청을 보냈고 정상 처리되는것을 확인할 수 있다.

로그아웃한 쿠키를 가지고 액세스토큰 발급을 요청하면 적절하지 않은 상태라고 재발급이 거절된다.

 

[참고자료]

[Java] 쿠키(Cookie) 생성, 조회, 삭제

(Spring Security+JWT) Refresh Token을 통한 토큰 재발급에 대해서

시큐리티-HTTP Only와 Secure Cookie

AccessToken 재발급 프로세스

Access, Refresh Token, Redis(로그아웃)