2022/2022-1

[SpringBoot] SpringBoot + SpringSecurity + JWT + Redis (회원가입 / 로그인 구현) (2)

JWonK 2022. 8. 18. 15:10
728x90
반응형

https://wonsjung.tistory.com/453

 

[SpringBoot] SpringBoot + SpringSecurity + JWT (회원가입 / 로그인 구현)

본 프로젝트를 진행할 때 처음에는 SpringSecurity + Oauth2를 이용한 로그인을 구현하려 했으나 방향성이 바뀌어 일반적인 사용자 정보를 입력한 회원가입 + 로그인을 구현하게 되었다. 회원가입 및

wonsjung.tistory.com

위 게시글은 지난 게시글로 Spring Security로 JWT를 발급하는 과정까지만 작성했었다.

 

오늘 작성하는 현재의 글은 Access Token의 만료 시간이 다 되었을 경우 Refresh Token이 유효한지 검사하고 유효하다면 Access Token을 재발급하는 과정이다. 또한, 전 게시글은 Access Token과 Refresh Token을 Http 헤더에 담아 전송하였는데 이번에는 쿠키에 담아서 전송한다. 

 

https://velog.io/@0307kwon/JWT%EB%8A%94-%EC%96%B4%EB%94%94%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%B4%EC%95%BC%ED%95%A0%EA%B9%8C-localStorage-vs-cookie

 

JWT는 어디에 저장해야할까? - localStorage vs cookie

이번에 지하철 미션을 만들면서 JWT를 클래스 property에 저장했었는데 리뷰어 분께 해당 부분을 피드백 받으면서 어디에 JWT를 저장하는 것이 좋을까 에 대해 고민해보게 되었다. 0. 기본 지식 JWT Js

velog.io

위 게시글에서 쿠키에 담는 이유를 알 수 있으며, 보안상의 이유 + 쿠키는 별도로 헤더에 담지 않아도 요청을 보낼 때 자동으로 담아서 보내지기 때문에 매 번 서버로의 요청에 담아야하는 토큰과 성격이 잘 맞기 때문이다.

 

마지막으로, Access Token은 유효시간이 짧아 재발급 과정이 진행하는데 Refresh Token은 유효시간을 넉넉히 두고 유효시간이 끝났을 경우 만료시켜야한다. 이를 휘발성을 가진 데이터 베이스 Redis에 저장해두고 관리한다.

 

정리하자면, 전 게시글에서 추가되는 내용은

  • Refresh 토큰을 이용한 Access Token 재발급
  • Access Token + Refresh Token 모두 쿠키에 담아 전송
  • 휘발성 데이터 베이스 Redis로 Refresh Token 관리하기

정도가 될 것이다.

 

 

 

1. 리팩토링 코드 과정


[ 0. Cookie 생성을 위한 Utils 작성 ]

@Component
public class CookieUtils {

    public Cookie createCookie(String cookieName, String value){
        Cookie cookie = new Cookie(cookieName, value);
        cookie.setHttpOnly(true);
        cookie.setMaxAge((int)TokenUtils.RefreshTokenValidTime);
        cookie.setPath("/");

        return cookie;
    }

    public Cookie getCookie(HttpServletRequest request, String cookieName){
        Cookie[] cookies = request.getCookies();
        if(cookies==null) return null;
        for (Cookie cookie : cookies) {
            if(cookie.getName().equals(cookieName)){
                return cookie;
            }
        }
        return null;
    }
}

 

 

 

[ 1. 로그인이 성공하여 JWT를 발급 한 후 이를 Cookie에 담아서 클라이언트에게 보내준다. ]

@RequiredArgsConstructor
public class CustomFormLoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    private final TokenUtils tokenUtils;
    private final RedisUtils redisUtils;
    private final CookieUtils cookieUtils;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {

        final Member member = ((MyUserDetails) authentication.getPrincipal()).getMember();
        final Token token = tokenUtils.generateToken(member.getEmail(), UserRole.USER.getKey());

        Cookie accessToken = cookieUtils.createCookie(AuthConstants.AUTH_HEADER, token.getAccessToken());
        Cookie refreshToken = cookieUtils.createCookie(AuthConstants.REFRESH_HEADER, token.getRefreshToken());

        redisUtils.setDataExpire(token.getRefreshToken(), member.getEmail(), tokenUtils.getRefreshTokenValidTime());

        response.addCookie(accessToken);
        response.addCookie(refreshToken);

        String tkn = new ObjectMapper().writeValueAsString(token);
        log.info("Token = {}", tkn);
    }
}

JWT를 생성하여 클라이언트에게 전송한다는 것은 로그인을 성공적으로 진행하였을 경우에만 진행해야한다. 따라서, 전에 작성한 CustomFormLoginSuccessHandler에서 작성해준다. 또한, redisUtils.setDataExpire()를 통해 Redis에 Refresh Token 유효시간 후에는 만료될 수 있도록 작성해준다.

 

 

 

[ 2. Redis 설정 및 Utils 작성 ]

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

build.gradle에 위 코드를 추가해준다.

 

@RequiredArgsConstructor
@Component
public class RedisUtils {

    private final StringRedisTemplate stringRedisTemplate;

    public String getData(String key){
        ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
        return valueOperations.get(key);
    }

    public void setData(String key, String value){
        ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
        valueOperations.set(key, value);
    }

    public void setDataExpire(String key, String value, long duration){
        ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
        Duration expireDuration = Duration.ofSeconds(duration);
        valueOperations.set(key, value, expireDuration);
    }

    public void deleteData(String key){
        stringRedisTemplate.delete(key);
    }

}

 

 

 

[ 3. Access Token이 만료되었는지 확인한 후, 재발급이 필요할 경우 재발급 진행 ]

@Slf4j
@RequiredArgsConstructor
public class JwtTokenInterceptor implements HandlerInterceptor {

    private final TokenUtils tokenUtils;
    private final CookieUtils cookieUtils;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        Cookie accessCookie = cookieUtils.getCookie(request, AuthConstants.AUTH_HEADER);
        String accessToken = accessCookie.getValue();

        if(accessToken != null && tokenUtils.isValidToken(accessToken)){
            return true;
        }

        Cookie refreshCookie = cookieUtils.getCookie(request, AuthConstants.REFRESH_HEADER);
        String refreshToken = refreshCookie.getValue();

        if(refreshToken != null && tokenUtils.isValidToken(refreshToken)) {
            Cookie newAccessTokenCookie = tokenUtils.reissueAccessToken(response, refreshToken);

            response.addCookie(cookie);
            response.setContentType("application/json;charset=UTF-8");

            log.info("New AccessToken : " + newToken.getAccessToken());
            log.info("New RefreshToken : " + newToken.getRefreshToken());

            return true;
        }

        response.sendRedirect("/error/unauthorized");
        return false;
    }

}

인터셉터에서 JWT 유효성 검사를 진행하는 코드는 전 게시글에서도 작성했었다. 현재는 유효성 검사를 진행할 때 accessToken은 유효하지 않으나 refreshToken이 유효할 때 accessToken을 재발급하는 과정을 진행한다.

 

위 코드의 흐름은 다음과 같다.

  1. 쿠키에서 AccessToken을 추출하여 유효성 검사를 진행한다.
  2. AccessToken이 유효할 경우 바로 true를 반환하여 다음 과정을 진행시킨다.
  3. AccessToken이 유효하지 않을 경우에는 RefreshToken을 추출하여 유효성 검사를 진행한다.
  4. RefreshToken이 유효한 경우에는 AccessToken을 재발급한 후 이를 다시 쿠키 형태로 담는다.
  5. RefreshToken도 유요하지 않을 경우에는 에러를 발생시킬 수 있도록 한다.

 

 

[ 4. TokenUtils에 AccessToken 재발급 코드 추가 ]

public Cookie reissueAccessToken(HttpServletResponse response, String refreshToken) {
        log.info("AccessToken 재발급 진행");

        String email = tokenUtils.getUid(refreshToken);
        Token newToken = tokenUtils.generateToken(email, UserRole.USER.getKey());

        Cookie cookie = cookieUtils.createCookie(AuthConstants.AUTH_HEADER, newToken.getAccessToken());

        return cookie;
    }

새로운 AccessToken을 재발급한 후 쿠키에 담아 반환하는 코드

메소드 안 첫 번째 줄 코드에 log를 적은 이유는 아래에서 확인할 때 가시적으로 판단하기 쉽게 하기 위해 작성해본 코드이다.

 

 

 

 

 

2. 위에서 작성한 코드들이 올바르게 작동하는지 간단한 API호출을 통해 확인


회원가입이 되어있는 사용자 정보로 로그인을 진행하려 한다. 위 처럼 올바르게 정보를 전송하면

 

200 OK와 함께 쿠키에 AccessToken과 RefreshToken이 담아져 있는 것을 확인할 수 있다.

 

 

 

AccessToken 유효시간이 지났을 때 재발급 과정이 잘 이루어지는 지 확인해보자. 위에서 로그를 통해 확인할 수 있도록 log.info()한 줄을 적어놓았으니 재발급이 성공적으로 진행된다면 찍혀있을 것이다.

 

로그에 성공적으로 "AccessToken 재발급 진행"이 찍혀있고,

 

인가기능도 성공적으로 진행되었다.

 

 

 

마지막으로, Refresh Token의 유효시간이 모두 끝났을 경우에는? 인터셉터에 아래 한 줄을 작성해주어 확인해보자.

log.info("Refresh Token 유효시간이 모두 경과하였으므로 인가 기능 처리 실패");

 

RefreshToken 유효시간을 3분으로 지정해두고,

처음으로 JWT를 발급받은 시간 3분 후에 결과를 확인해보자.

 

 

 

·························3분 기다리기·························

 

 

 

정상적으로 로그에 찍혀있으며 

 

401 Unauthorized와 함께 인가기능도 실패하는 것을 확인할 수 있다.

 


여기까지,

 

Spring Security를 공부한 후 Spring Boot + Spring Security + JWT로 회원가입 및 로그인 구현 + JWT 재발급 및 Redis를 이용한 Refresh Token관리까지 모두 진행해보았다.

 

추가적으로 프로젝트를 진행하며 보완이 필요한 부분은 보완한 후 블로그에 관련 게시글을 업로드 할 예정이다. 

728x90
반응형