개발놀이터

스프링 시큐리티 + JWT 인증 레이어 추가하기 (2) : 주요 클래스 (JwtProvider와 JwtFilter) 본문

Spring/Spring Security

스프링 시큐리티 + JWT 인증 레이어 추가하기 (2) : 주요 클래스 (JwtProvider와 JwtFilter)

마늘냄새폴폴 2023. 5. 20. 04:56

https://coding-review.tistory.com/382

 

스프링 시큐리티 + JWT 인증 레이어 추가하기 (1) : 개요

이번 포스팅에선 스프링 시큐리티에 JWT 인증 레이어를 추가하는 방법에 대해서 소개해드릴까 합니다. 제가 이 프로젝트를 개발하면서 했던 고민들, 마냥 만사형통하지 않았던 험난한 과정들,

coding-review.tistory.com

이 포스팅은 이전 포스팅과 내용이 이어집니다. 

 

먼저 JwtTokenProvider입니다. 이 클래스가 하는 역할은 JWT 토큰을 발급하는 과정과 Authentication 객체를 이용해 Access Token을 재발급하는 역할, Access Token으로 Authentication 객체를 반환받는 과정, 토큰을 검증하는 과정을 담고 있습니다. 

 

package com.hello.capston.jwt;

import com.hello.capston.entity.Member;
import com.hello.capston.jwt.dto.UserResponseDto;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@Slf4j
@Component
public class JwtTokenProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 30 * 60 * 1000L;
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L;

    private final Key key;

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

    public UserResponseDto 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 + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return UserResponseDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .refreshTokenExpirationTime(REFRESH_TOKEN_EXPIRE_TIME)
                .build();
    }

    public String remakeAccessToken(Authentication authentication) {
        long now = (new Date()).getTime();
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);

        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    public Authentication getAuthentication(String accessToken) {
        Claims claims = parseClaims(accessToken);

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

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty", e);
        }
        return false;
    }

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

제가 이 클래스를 만들고 제 프로젝트에 맞게 커스터마이징 하는 과정에서 했던 고민이 있습니다. 

 

잘 보시면 remakeAccessToken() 메서드에 Authentication 객체를 필요로 한다는 것이었습니다. 

 

처음엔 문제가 되지 않습니다. 

 

하지만 어느때 문제가 생기냐하면 Access Token이 만료되고 Authentication 객체도 null 인 상황 즉, 웹 페이지를 껐다가 수일 뒤에 다시 들어온 사용자가 있을 때입니다. 

 

아직 Refresh Token은 만료가 안됐지만 Access Token은 없고? Authentication 객체도 없다? 그럼 뭘로 Access Token을 재발급하지? 

 

혹자는 이렇게 말할수도 있을겁니다. "아니 누가 Authentication 객체로 Access Token 만들래? 그냥 String으로 username이나 email 같은걸로 Access Token 만들면 되잖아"

 

물론 맞는 말이지만 일단 첫번째로 Authentication 객체로 Access Token을 만들지않으면 제 프로젝트 정체성이 흐려지고, 새로 들어온 사람의 username이나 email을 어떤 수로 아냐는 것이 문제였습니다.

 

쿠키도 만료되고 Authentication 객체도 없는데 세션이라고 남아있을리가 없었죠. 

 

그래서 고안한 것이 필터를 좀 더 강화하는 것이었습니다. 

 

package com.hello.capston.jwt;

import com.hello.capston.entity.Member;
import com.hello.capston.entity.User;
import com.hello.capston.jwt.dto.UserResponseDto;
import com.hello.capston.jwt.gitbefore.RedisAndSession;
import com.hello.capston.jwt.gitbefore.RedisTokenRepository;
import com.hello.capston.repository.MemberRepository;
import com.hello.capston.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseCookie;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collection;
import java.util.stream.Collectors;

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_TYPE = "Bearer";

    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate redisTemplate;
    private final RedisTokenRepository redisTokenRepository;
    private final MemberRepository memberRepository;
    private final UserRepository userRepository;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 1. Request Header 에서 JWT 토큰 추출
        String token = resolveToken((HttpServletRequest) request);
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        // 2. validateToken 으로 토큰 유효성 검사
        if (token != null  && isValidJwtFormat(token) && jwtTokenProvider.validateToken(token)) {
            // Redis 에 해당 accessToken logout 여부 확인
            String isLogout = (String) redisTemplate.opsForValue().get(token);

            if (ObjectUtils.isEmpty(isLogout)) {
                // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
                Authentication getAuthentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(getAuthentication);
            }
        }
        else if (authentication != null && authentication.isAuthenticated()) {  // 3. access token 이 만료된 상황
            // token 은 만료 되었으나 인증이 되어있는 사용자 = 아직 페이지를 안벗어났지만 token 의 유효시간은 끝난 사용자
            UserResponseDto tokenInfo = (UserResponseDto) redisTemplate.opsForValue().get("RT:" + authentication.getName());
            if (tokenInfo != null) {
                setNewAuthentication((HttpServletResponse) response, authentication);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        else { // 4. access token 만료되고 Authentication 객체도 null 인 상황
            String sessionId = getSessionId((HttpServletRequest) request);
            Authentication authenticationFromSessionId = null;
            RedisAndSession redisAndSession = null;
            if (sessionId != null) {
                redisAndSession = redisTokenRepository.findBySessionId(sessionId).orElse(null);
            }

            if (redisAndSession != null) {  // refresh token 이 존재하는 경우 존재하지 않으면 로그인이 풀린다.
                Member findMember = memberRepository.findBySessionId(sessionId).orElse(null);
                User findUser = userRepository.findBySessionId(sessionId).orElse(null);

                if (findMember == null && findUser != null) {   // User 객체만 존재할 경우
                    Collection<? extends GrantedAuthority> authorities =
                            Arrays.stream(findUser.getRole().toString().split(","))
                                    .map(SimpleGrantedAuthority::new)
                                    .collect(Collectors.toList());
                    UserDetails principal = new org.springframework.security.core.userdetails.User(findUser.getEmail(), "", authorities);
                    authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
                    setNewAuthentication((HttpServletResponse) response, authenticationFromSessionId);
                    SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
                }
                else if (findUser == null && findMember != null) {  // Member 객체만 존재할 경우
                    Collection<? extends GrantedAuthority> authorities =
                            Arrays.stream(findMember.getRole().toString().split(","))
                                    .map(SimpleGrantedAuthority::new)
                                    .collect(Collectors.toList());
                    UserDetails principal = new org.springframework.security.core.userdetails.User(findMember.getUsername(), "", authorities);
                    authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
                    setNewAuthentication((HttpServletResponse) response, authenticationFromSessionId);
                    SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
                }
                else if (findMember != null && findUser != null) {  // 둘 다 존재하는 경우 우선순위는 Member
                    Collection<? extends GrantedAuthority> authorities =
                            Arrays.stream(findMember.getRole().toString().split(","))
                                    .map(SimpleGrantedAuthority::new)
                                    .collect(Collectors.toList());
                    UserDetails principal = new org.springframework.security.core.userdetails.User(findMember.getUsername(), "", authorities);
                    authenticationFromSessionId = new UsernamePasswordAuthenticationToken(principal, "", authorities);
                    setNewAuthentication((HttpServletResponse) response, authenticationFromSessionId);
                    SecurityContextHolder.getContext().setAuthentication(authenticationFromSessionId);
                }
                else {  // 둘 다 null 인 경우 에는 로그인이 풀린다.
                    chain.doFilter(request, response);
                }
            }
        }
        chain.doFilter(request, response);
    }

    private void setNewAuthentication(HttpServletResponse response, Authentication authentication) {
        String remakeAccessToken = jwtTokenProvider.remakeAccessToken(authentication);
        Cookie cookie = new Cookie("AUTH-TOKEN", remakeAccessToken);
        ResponseCookie responseCookie =
                ResponseCookie.from("AUTH-TOKEN", remakeAccessToken)
                        .httpOnly(true)
                        .path("/")
                        .maxAge(Duration.ofMinutes(30))
                        .sameSite("Lax")
                        .build();
        response.addCookie(cookie);
        response.addHeader("Set-cookie", responseCookie.toString());
    }

    private String getSessionId(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                String cookieName = cookie.getName();
                if (cookieName.equals("SESSION-TOKEN")) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    private String resolveToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                String cookieName = cookie.getName();
                if (cookieName.equals("AUTH-TOKEN")) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    private boolean isValidJwtFormat(String token) {
        String[] split = token.split("\\.");
        return split.length == 3;
    }
}

 

코드가 너무 길고 if else if else 너무 많습니다. 

 

빠르게 정리해드리면

 

제 필터는 크게 세가지 상황을 상정합니다. 

 

  1. Access Token이 만료되지 않은 상황 => Access Token으로 Authentication 객체를 만들어서 SecurityContext에 넣습니다. 
  2. Access Token은 만료되었지만 Authentication 객체는 살아있는 상황 => 그냥 한 페이지에 오래 머물러서 Access Token은 만료되었지만 인증은 풀리지 않은 상태입니다. 이 상황에는 Authentication 객체의 이름을 키로하는 Redis 객체를 가져와서 Refresh Token을 검증하고 Authentication 객체는 냅두고 Access Token만 재발급합니다. 
  3. Access Token도 만료되었고 Authentication 객체도 null인 상황 => 이 상황은 오랜시간 접속하지않다가 접속한 아무것도 없는 사람이지만 Refresh Token은 살아있는 상황입니다. 이때의 행동강령은 다음과 같습니다. 
    1. 쿠키에 Refresh Token과 같은 생명주기를 가지는 SESSION-TOKEN이 있습니다. 이 쿠키의 값으로 Redis에 객체를 찾습니다. 
    2. Refresh Token 이 존재한다 = Refresh Token이 만료되지 않았다. Member (일반 로그인), User (소셜 로그인) 객체에 있는 sessionId 컬럼에서 우리가 가지고 있는 SESSION-TOKEN의 값으로 RDBMS 인 MySQL에 조회를 해봅니다. 이 때 네가지 상황이 존재합니다. 
      1. User만 존재할 경우 => User 객체로 Authenticaion 객체를 만들어서 Access Token을 만들고 쿠키에 다시 담은 뒤 SecurityContext에 우리가 만든 Authenticaion 객체를 집어넣습니다. 
      2. Member만 존재할 경우 => Member 객체로 Authentication 객체를 만들고 이후는 위와 같습니다. 
      3. User, Member 둘다 존재할 경우 => 이때는 Member로 우선순위를 정하고 위와 같습니다. 
      4. 둘 다 존재하지 않을 경우 => 이때는 바로 다음 필터 체인으로 넘깁니다. 
    3. Refresh Token이 존재하지 않으면 다음 필터 체인으로 넘어갑니다. 

 

정말 복잡하지만... 제가 가장 고민이 많았던 쿠키도 만료되고 Authentication 객체도 없는 상황을 커버할 수 있었습니다. 

 

너무 길어져서 다음으로 이어집니다. 

 

https://coding-review.tistory.com/384

 

스프링 시큐리티 + JWT 인증 레이어 추가하기 (3) : 주요클래스 (JwtService와 Controller)

https://coding-review.tistory.com/383 스프링 시큐리티 + JWT 인증 레이어 추가하기 (2) : 주요 클래스 https://coding-review.tistory.com/382 스프링 시큐리티 + JWT 인증 레이어 추가하기 (1) : 개요 이번 포스팅에선 스

coding-review.tistory.com