You Have To(운동 보조 앱) [3]

10000DOO·2023년 1월 29일
1

YouHaveTo

목록 보기
4/12
post-thumbnail

이전에 로그인 성공 시 accessToken과 refreshToken을 발급해 주는 것까지 구현했다.
이번에는 accessToken이 만료가 되면 refreshToken을 통해 재발급 받는 것을 구현해 보려고 한다.

동작 원리

  1. 클라이언트가 헤더에 accessToken을 넣어서 http 요청을 서버로 보낸다.
  2. accessToken이 만료되었으면 만료되었다는 응답을 보내준다.
  3. 클라이언트가 accessToken과 refreshToken을 같이 헤더에 넣어서 다시 보낸다.
  4. db에 저장된 refreshToken과 넘어온 refreshToken이 일치한다면 accessToken과 refreshToken을 재발급 해준다.

JwtAuthenticationFilter.java

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;
    private final JwtTokenResolver jwtTokenResolver;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        // 1. Request Header 에서 JWT 토큰 추출
        String token = jwtTokenResolver.resolveToken((HttpServletRequest) request);

        if (token == null) {
            //회원가입 로그인 시
        } else {
            // 2. validateToken 으로 토큰 유효성 검사
            switch (jwtTokenProvider.validateToken(token)) {
                case VALID -> {
                    Authentication authentication = jwtTokenProvider.getAuthentication(token);
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
                case EXPIRED -> {
                    throw new ExpiredJWTTokenExcep("만료된 토큰입니다.");
                }
                case INVALID -> {
                    throw new InvalidJWTTokenExcep("유효하지 않은 토큰입니다.");
                }
                case UNSUPPORTED -> {
                    throw new UnsupportedJWTTokenExcep("지원되지 않는 토큰입니다");
                }
                case EMPTY -> {
                    throw new EmptyJWTTokenExcep("claims 내용이 빈 토큰입니다.");
                }
            }
        }
        chain.doFilter(request, response);
    }
}

Request Header에 토큰이 있다면 유효성 검사를 진행하고 실패한다면 각각의 이유에 맞는 예외를 발생시킨다.
예외는 모두 RuntimeException을 상속해서 만들어주었다.

JwtTokenResolver.java

@Component
public class JwtTokenResolver {

    // Request Header 에서 토큰 정보 추출
    public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

JwtTokenProvider.java

@Slf4j
@Component
public class JwtTokenProvider {

    @Getter
    private final Key key;

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    // 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
    public TokenInfo generateToken(Authentication authentication) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + 30 * 60 * 1000);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + 14 * 24 * 60 * 60 * 1000))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return TokenInfo.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get("auth") == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    // 토큰 정보를 검증하는 메서드
    public ValidationTokenSign validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return ValidationTokenSign.VALID;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT Token");
            return ValidationTokenSign.INVALID;
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT Token");
            return ValidationTokenSign.EXPIRED;
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT Token");
            return ValidationTokenSign.UNSUPPORTED;
        } catch (IllegalArgumentException e) {
            log.error("JWT claims string is empty.");
            return ValidationTokenSign.EMPTY;
        }
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

ValidationTokenSign.java

public enum ValidationTokenSign {
    VALID, INVALID, EXPIRED, UNSUPPORTED, EMPTY
}

getAuthentication 메서드는 토큰에서 PAYLOAD 부분을 추출하고 권한을 확인해서 Authentication 객체를 리턴한다.
validateToken 메서드는 토큰의 유효성을 검사해서 실패한다면 알맞은 예외를 발생시키기 위해 ValidationTokenSign 값을 리턴한다.(이 값에 따라 JwtAuthenticationFilter에서 해당 예외 발생시킴)

ExceptionHandlerFilter.java

@RequiredArgsConstructor
public class ExceptionHandlerFilter extends OncePerRequestFilter {

    private final ObjectMapper mapper;
    private final JWTService jwtService;

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

        try{
            filterChain.doFilter(request, response);
        }catch (InvalidJWTTokenExcep | UnsupportedJWTTokenExcep
                | EmptyJWTTokenExcep e){
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding("UTF-8");
            ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.UNAUTHORIZED.value(), e.getMessage());

            mapper.writeValue(response.getWriter(), exceptionResponse);
        }catch (ExpiredJWTTokenExcep e){
            String refreshToken = request.getHeader("RefreshToken");

            if (StringUtils.hasText(refreshToken) && refreshToken.startsWith("Bearer")) {
                String token = refreshToken.substring(7);
                ResponseResult result = jwtService.refreshToken(token.substring(0, token.length()-1));
                response.setStatus(HttpStatus.OK.value());
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                response.setCharacterEncoding("UTF-8");

                mapper.writeValue(response.getWriter(), result);
            } else{
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                response.setCharacterEncoding("UTF-8");
                ExceptionResponse exceptionResponse = new ExceptionResponse(HttpStatus.UNAUTHORIZED.value(), e.getMessage());

                mapper.writeValue(response.getWriter(), exceptionResponse);
            }
        }
    }
}

ExceptionHandlerFilter는 JwtAuthenticationFilter에서 발생시킨 예외를 처리해 준다. 유효시간 만료 예외가 발생하면 Request Header에 refreshToken이 있는지 확인하고 있다면 jwtService.refreshToken을 통해 토큰 재발급을 시도한다.
유효시간 만료 예외가 발생했는데 Request Header에 refreshToken이 존재하지 않거나 다른 예외가 발생했다면 해당 예외 메시지를 response로 전달한다.

JWTService.java

@Slf4j
@Service
@RequiredArgsConstructor
public class JWTService {

    private final MemberRepository memberRepository;
    private final JwtTokenProvider jwtTokenProvider;

    @Transactional
    public ResponseResult refreshToken(String inputToken) {
        Base64.Decoder decoder = Base64.getDecoder();
        String payload = new String(decoder.decode(inputToken.split("\\.")[1]));
        long time = new Date().getTime();
        long expTime = Long.parseLong(payload.substring(7, payload.length() - 1));

        if ((time / 1000) < expTime) {
            Optional<Member> byRefreshToken = memberRepository.findByRefreshToken(inputToken);
            if (byRefreshToken.isPresent()) {
                Member findMember = byRefreshToken.get();
                String token = findMember.getRefreshToken();
                if (token.equals(inputToken)) {
                    long now = (new Date()).getTime();
                    // Access Token 생성
                    Date accessTokenExpiresIn = new Date(now + 30 * 60 * 1000);
                    String accessToken = Jwts.builder()
                            .setSubject(findMember.getUsername())
                            .claim("auth", "ROLE_" + findMember.getRole())
                            .setExpiration(accessTokenExpiresIn)
                            .signWith(jwtTokenProvider.getKey(), SignatureAlgorithm.HS512)
                            .compact();

                    // Refresh Token 생성
                    String refreshToken = Jwts.builder()
                            .setExpiration(new Date(now + 14 * 24 * 60 * 60 * 1000))
                            .signWith(jwtTokenProvider.getKey(), SignatureAlgorithm.HS512)
                            .compact();
                    TokenInfo tokenInfo = TokenInfo.builder()
                            .grantType("Bearer")
                            .accessToken(accessToken)
                            .refreshToken(refreshToken)
                            .build();
                    findMember.setRefreshToken(tokenInfo.getRefreshToken());
                    log.info("토큰이 재발급 되었습니다.");
                    return new ResponseResult(HttpStatus.OK.value(), tokenInfo);
                } else {
                    return new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "잘못된 토큰입니다. 토큰 재발급이 불가능하니 " +
                            "다시 로그인 부탁드립니다.");
                }
            } else {
                return new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "존재하지 않는 토큰입니다. 토큰 재발급이 불가능하니 " +
                        "다시 로그인 부탁드립니다.");
            }
        } else {
            return new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "토큰이 만료 되었습니다. 다시 로그인 해주세요.");
        }
    }
}

JWTService는 넘어온 refreshToken이 만료된 토큰인지 확인하고 유효하다면 DB에 있는 refreshToken과 일치하는지 확인한다. 일치한다면 새로운 accessToken과 refreshToken을 발급해 준다.
refreshToken이 만료되었거나 db에 있는 값과 일치하지 않는다면 적절한 오류메시지를 ResponseResult에 넣어 리턴한다.

결과


📚참고자료

https://codingdog.tistory.com/entry/spring-security-filter-exception-을-custom-하게-처리해-봅시다
https://stackoverflow.com/questions/57194249/how-to-return-response-as-json-from-spring-filter

profile
iOS 개발자 지망생 https://github.com/10000DOO

0개의 댓글