JWT 인증 적용

좐쓰 ㅣ 2024. 1. 8. 14:05

반응형

backoffice 프로젝트를 하던 도중 팀장님의 한마디...

"세션 인증 걷어 내고 JWT 적용해볼까??"

 사실 여기에 대한 내 생각은 로드밸런서도 없고 backoffice 서비스의 특성상 사용자가 많지도 않구... 도메인도 단일 도메인인데 굳이...? 싶었지만 접근권한 통제 할 때 장점도 있고 여러가지 방법의 인증 시스템을 적용하는 법을 이해하고 공부 할 수 있는 좋은 기회다 싶어서 도전 해보기로 했다. 

 첫 회사에서도 두번째 프로젝트 때 적용해보긴 했지만 그 땐 신입개발자였고 구글링해서 따라치기 급급했기 때문에 이번에 제대로 공부해보고 적용하려 한다...!!

 

 

1. JWT 란??

JWT 는 유저를 인증하고 식별하기 위한 -토큰기반- 인증이다. (이름부터 Json Web Token) 토큰은 세션과는 달리 서버가 아닌 클라이언트에 저장되기 때문에 메모리나 스토리지 등을 통해 세션을 관리했던 서버의 부담을 덜 수 있다. JWT 가 가지는 핵심적인 특징이 있다면, 토큰 자체에 사용자의 권한 정보가 포함된다는 것이다.

 

  • Access Token 을 이용해 로그인 하는 과정

Refresh Token 이 그래서 뭔데....?

Access Token 을 발급하기 위한 인증 수단
Access Token  은 탈취를 대비해 생명주기를 30분 정도로 짧게 가져간다. 만약 클라이언트에서 유효기간이 만료된 Access Token 을 보냈을 시엔 서버에서는 인증이 불허되고 클라이언트는 Refresh Token 을 이용해 Access Token 을 재발급 받는다.

Refresh Token 을 이용해 Access Token 을 재발급 받는 과정

 

 

 

2. Access Token & Refresh Token 만료 시간 및 토큰 비밀번호 설정

 

#임시 문자열 key --> JWT 토큰을 암호화 할때의 비밀번호 << 절대 노출 X
jwt.secret-key=juhanjwttranningjuhanjwttranningjuhanjwttranningjuhanjwttranning
#accesstoken 만료시간
jwt.access-token-period=1800000
#refrechtoken 만료시간
jwt.refresh-token-period=1209600000
#재발급할 때의 최소 시간. (재발급할 때 이 기간보다 적으면 refresh Token 재발급 x)
jwt.reissue-token-period=259200000

 

 

 

 

3. JwtTokenUtil

  • createToken 
    public TokenInfoResponse createToken(Admin admin) {
            // claim 생성 (사용자의 id, role 등등을 저장)
            Claims claims = getClaims(admin);
    
            Date now = new Date();
            Date accessTokenValidity = new Date(now.getTime() + this.accessTokenValidityTime);
            Date refreshTokenValidity = new Date(now.getTime() + this.refreshTokenValidityTime);
    
            String accessToken = Jwts.builder()
                    .setClaims(claims)
                    .setIssuedAt(now)
                    .setExpiration(accessTokenValidity)
                    .signWith(SignatureAlgorithm.HS256, secretKey)
                    .compact();
    
            String refreshToken = Jwts.builder()
                    .setClaims(claims)
                    .setIssuedAt(now)
                    .setExpiration(refreshTokenValidity)
                    .signWith(SignatureAlgorithm.HS256, secretKey)
                    .compact();
    
            return TokenInfoResponse.from("Bearer", accessToken, refreshToken, refreshTokenValidityTime);
        }​
  • verifyToken
/** accessToken 검증 (filter 에서 이 메서드 호출함) */
 public boolean verifyToken(String token) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return claims.getBody().getExpiration().after(new Date());
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
            throw e;
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
            throw e;
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
            throw e;
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
            throw e;
        } catch (Exception e) {
            log.info(e.getMessage());
            throw e;
        }
    }

 

  • storeRefreshToken

/** refreshToken DB 저장
	보통 Redis 를 많이 활용한다고 하나 현재 설정된 환경에서는 적용하기까지 시간이 걸려
	실무에서는 Redis로 변경에정 */
public void storeRefreshToken(String adminId, TokenInfoResponse token) {
        this.jwtRepository.save(new Jwt(adminId, token.getRefreshToken()));
    }

 

  • tokenReissue

/** refreshToken 이용해 만료된 accessToken 재발급 */ 
public TokenInfoResponse tokenReissue(String token) {
        String adminId = getAdminId(token);
        Admin admin = adminRepository.getAdminById(adminId).orElseThrow(NotFoundAdminException::new);

        // adminId에 해당하는 refreshToken 가져오기
        String storedRefreshToken = jwtRepository.findById(adminId).orElseThrow(NotFoundRefreshToken::new).getJwt();

        //adminId에 해당하는 refreshToken이 없거나 일치하지 않을 때
        if (storedRefreshToken == null || !storedRefreshToken.equals(token))
            throw new NotFoundRefreshToken();

        // Token 생성
        TokenInfoResponse newToken = createToken(admin);

        // 저장
        storeRefreshToken(adminId, newToken);

        return newToken;
    }

 

 

 

3. Jwt 필터와 이에 대한 예외 처리

Spring Security 에서 인증 메커니즘을 구현하기 위해 JWT 필터를 사용

이 필터는 Exception handler 로 처리를 못하기 때문에 별도의 메커니즘이 필요

 

왜그럴까요??
JWT 필터는 OncePerRequestFilter를 상속받아 매 요청마다 한 번씩 실행된다. 이 필터는 HTTP 요청 헤더에 포함된 JWT를 해석하고, 유효한 토큰인 경우 Security Context에 인증 정보를 설정한다. 또한 이 필터는 Dispatcher Servlet 보다 앞단에 위치하여, Handler Interceptor는 뒷단에 존재하기 때문에, Filter에서 보낸 예외는 Exception Handler로 처리를 못한다. 따라서, JWT 필터에서 예외가 발생할 경우 이를 적절히 처리하기 위한 별도의 메커니즘이 필요하다.

 

이를 위해, JwtAuthenticationEntryPoint 클래스를 사용하여 인증되지 않은 사용자가 보호된 리소스에 액세스할 때 발생하는 예외를 처리한다. 요청 객체의 속성(request.getAttribute("exception"))을 이용하여 발생한 예외를 구분하고, 적절한 응답을 클라이언트에 전달한다.
예를 들어, 토큰이 만료되었거나, 잘못된 타입의 토큰인 경우, 추가 정보가 필요한 토큰인 경우 등 다양한 예외 상황을 구분하여 처리할 수 있다.

 

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        String exception = (String) request.getAttribute("exception");
        if (exception == null) setResponse(response, JwtExceptionList.UNKNOWN_ERROR);
        else if (exception.equals(JwtExceptionList.WRONG_TYPE_TOKEN.getErrorCode()))
            setResponse(response, JwtExceptionList.WRONG_TYPE_TOKEN);
        else if (exception.equals(JwtExceptionList.EXPIRED_TOKEN.getErrorCode())) {
            setResponse(response, JwtExceptionList.EXPIRED_TOKEN);
        } else if (exception.equals(JwtExceptionList.UNSUPPORTED_TOKEN.getErrorCode()))
            setResponse(response, JwtExceptionList.UNSUPPORTED_TOKEN);
        else if (exception.equals(JwtExceptionList.ILLEGAL_TOKEN.getErrorCode()))
            setResponse(response, JwtExceptionList.ILLEGAL_TOKEN);
        else setResponse(response, JwtExceptionList.ACCESS_DENIED);
    }

    private void setResponse(HttpServletResponse response, JwtExceptionList exceptionCode) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        JSONObject responseJson = new JSONObject();
        responseJson.put("message", exceptionCode.getMessage());
        responseJson.put("errorCode", exceptionCode.getErrorCode());

        //토큰 만료인 경우에는 토큰 재발급을 위해 error객체를 반환하고 그 외의 경우 403 페이지 반환
        if(JwtExceptionList.EXPIRED_TOKEN.getErrorCode().equals(exceptionCode.getErrorCode())){
            response.getWriter().print(responseJson);
        }else{
            response.sendError(403);
        }
    }
}

 

필자는 토큰이 만료된 경우에는 토큰 재발급을 위해 error 객체를 반환하고 그 외의 경우에는 권한 없음 페이지 반환하도록 설정

 

 

-> JWT 와 쿠키를 이용한 로그인 설정은 다음 포스팅에서 이어집니다....!

반응형