[네트워크/Spring] JWT (JSON Web Token) & Spring Boot의 JWT 구현 (+ Spring Security)

전윤혁·2024년 8월 3일

Networks

목록 보기
6/6

JWT (JSON Web Token)

HTTP 프로토콜은 비연결성(Connectionless)과 무상태성(Stateless)을 가지기에 클라이언트의 상태 정보를 저장하지 않는다. 이에 따라 서버와 클라이언트의 상태 관리를 위해 세션, 쿠키, 토큰이 사용되는데, JWT란 JSON 포맷을 기반으로 하는, 가장 널리 사용되는 토큰 형식이다.

토큰은 서버 스토리지에 별도의 데이터를 저장하지 않아도 클라이언트 인증을 확인할 수 있다는 점에서 세션 방식과 구별된다. 이에 따라 토큰 기반 클라이언트 인증은 서버의 부하를 줄일 수 있고, 확장성이 뛰어나다는 장점이 있다.

쿠키 (Cookie) & 세션 (Session) & 토큰 (Token, JWT) 비교 글
https://velog.io/@airoca/%EB%84%A4%ED%8A%B8%EC%9B%8C%ED%81%AC-%EC%BF%A0%ED%82%A4-Cookie-%EC%84%B8%EC%85%98-Session-%ED%86%A0%ED%81%B0-Token-JWT

JWT의 개념, 또는 구조에 대한 이해가 부족하다면 위의 글 또는 개념에 대한 글을 먼저 읽어보는 것을 추천!


1. JWT의 두 가지 종류

1) 액세스 토큰 (Access Token)

사용자가 인증된 후, 서버가 클라이언트에 전달하는 토큰이다.

이전 글에서 설명한 사용자 검증을 위해 사용되는 JWT가 바로 액세스 토큰에 해당된다. 클라이언트는 이 토큰을 사용하여 서버에 요청을 보내고, 서버는 토큰을 검증하여 요청을 처리한다.

액세스 토큰은 일반적으로 짧은 만료 시간을 가진다. 이렇게 설정하는 이유는 보안상의 이유 때문으로, 액세스 토큰이 유출됐을 경우 짧은 만료 시간으로 인해 공격자가 이 토큰을 사용할 수 있는 시간이 제한된다.

2) 리프레시 토큰 (Refresh Token)

액세스 토큰의 만료 후 새로운 액세스 토큰을 발급받기 위해 사용하는 토큰이다.

리프레시 토큰은 그 자체에 별다른 데이터가 포함되어 있지 않다. 리프레시 토큰은 말 그대로 액세스 토큰이 만료되었을 때 서버에서 새로운 액세스 토큰을 발급해주기 위해 사용된다. 리프레시 토큰은 상대적으로 긴 만료 시간을 가진다.

액세스 토큰과 리프레시 토큰을 종합하여 이해해보자. 앞서 액세스 토큰은 보안상 이유로 짧은 만료 시간을 가진다고 설명했다. 따라서 웹사이트를 이용하는 도중 액세스 토큰이 만료된다면 사용자는 자동으로 로그아웃되고, 다시 로그인을 진행해야 한다. 사이트에 머무르는 동안 지속적으로 이러한 문제가 발생한다면 매우 불편하지 않을까?

사실 대부분의 웹사이트는 이용하는 동안 로그인이 유지되는데, 이것이 바로 리프레시 토큰을 이용하기 때문이다. 클라이언트 측에서 만료된 액세스 토큰을 전송했을 경우, 서버는 사용자의 리프레시 토큰을 요구한 후 검증하고, 리프레시 토큰이 유효할 경우 새로운 액세스 토큰을 발급해준다. 만약 이 과정에서 리프레시 토큰이 유출되더라도, 리프레스 토큰 자체에는 리프레시 토큰 자체는 보호된 리소스에 직접 접근할 수 있는 권한이 없기에 비교적 안전하다고 볼 수 있다. 자세한 인증 과정은 다음 항목에서 살펴보자.

📌 아니, 그런데 리프레시 토큰이 안전한게 맞아?

위의 설명을 읽고 아래와 같이 생각할 수 있다.

"액세스 토큰이 탈취당할 수 있다는 것은, 리프레시 토큰 또한 탈취당할 수 있다는 뜻인데, 리프레시 토큰이 유출된 경우 공격자가 얼마든지 액세스 토큰을 발급받을 수 있잖아?"

"만약 공격자가 리프레시 토큰을 사용해서 기존 사용자보다 더 빨리 액세스 토큰을 받기라도 하는 날에는 최악인데?"

위와 같은 상황을 대비하기 위해, Refresh Token Rotation을 도입할 수 있다.

  • Refresh Token Rotation (RTR)
    Refresh Token Rotation은 각 리프레시 토큰이 사용될 때마다 새로운 리프레시 토큰을 발급하고, 이전 토큰을 무효화하는 방식이다. 클라이언트가 서버에 새로운 액세스 토큰을 요청할 때마다, 서버는 현재 리프레시 토큰을 검증한 후 새로 갱신된 리프레시 토큰을 발급한다. 이전 리프레시 토큰은 즉시 무효화되며, 새로운 리프레시 토큰이 클라이언트에게 전달된다.

추가적으로, 서버에서 쿠키를 설정할 때 HttpOnly로 선언하면 브라우저에서 해당 쿠키에 접근할 수 없기에, XSS(Cross Site Scripting) 공격 등을 예방할 수 있다.


2. JWT를 사용한 인증 과정

이전 글에서는 Access Token만을 다룬 예시로 JWT의 인증 과정을 살펴보았다면, 이번에는 Refresh Token까지 포함된 구체적인 인증 과정을 살펴보자.

  1. 사용자가 로그인을 시도하면, 서버 측에서는 회원 DB를 통해 유효한 사용자가 맞는지 확인한다. (그림의 1~2)

  2. 유효한 사용자인 경우, 서버는 Access Token(JWT)에 더불어 Refresh Token을 발급한다. 이 때 Refresh Token은 DB에 저장된다. (그림의 3)

  3. 서버는 Access Token과 Refresh Token 모두 Response 헤더에 포함시켜 응답한다. (그림의 4)

  4. 사용자는 이후 데이터 요청을 진행할 때 자동으로 Request 헤더에 Access Token을 포함시켜 함께 전송하고, 서버는 Access Token을 검증한 후, 유효할 경우 요청된 데이터를 반환한다. (그림의 5~7)

    Access Token 만료

  5. 사용자가 만료된 Access Token으로 요청을 전송할 경우, 서버는 Access Token의 만료를 확인한 후, 토큰이 만료되었다고 응답한다. (그림의 8~11)

  6. 토큰 만료 응답을 받은 사용자는 Access Token 재발급을 위해, 서버에 Access 토큰과 Refresh Token을 전송한다. (그림의 12)

  7. 서버 측에서 Refresh Token의 유효성을 검사하고, DB에 저장된 Refresh Token의 값과 비교하여 일치하는지 확인한다. (그림의 13)

  8. Refresh Token이 유효하고 원본 토큰과 일치할 경우, 서버는 사용자에게 새로운 Access Token을 재발급한다. 만약 Refresh Token도 만료되었을 경우에는 로그인을 다시 진행한 후, Access Token과 Refresh Token 모두 재발급한다. (그림의 13)


3. Spring Boot의 JWT 구현 (+ Spring Security)

🔗 JwtToken

package com.example.test.jwt;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;

@Builder
@Data
@AllArgsConstructor
public class JwtToken {
    private String grantType;

    private String accessToken;

    private String refreshToken;
}
  • JWT Token 클래스는 JWT 토큰을 담기 위한 데이터 구조이다.
  • grantType은 토큰의 유형(보통 "Bearer"로 설정됨)을 나타낸다.
  • accessTokenrefreshToken은 각각 액세스 토큰과 리프레시 토큰을 저장한다.

🔗 JwtTokenProvider

package com.example.test.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
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 final Key key;

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

    public JwtToken generateToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        Date accessTokenExpiresIn = new Date(now + 86400000);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + 86400000))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

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

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

        if (claims.get("auth") == null) {
            throw new RuntimeException("Token without authorization information");
        }

        Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("auth").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();
        }
    }
}
  • JwtTokenProvider는 JWT 토큰을 생성하고 검증하는 기능을 수행하는 부분이다.
  • JwtTokenProvider의 생성자 부분에서는 서버의 비밀 키를 BASE64로 디코딩하여 JWT 서명에 사용할 Key 객체를 생성한다. (비밀 키는 application.yml 파일에 정의되어 있다.)
  • generateToken 메소드는 Authentication 객체를 입력으로 받아, 액세스 토큰과 리프레시 토큰을 생성한다. 액세스 토큰은 짧은 만료 시간(1일)과 권한 정보를 포함하며, 리프레시 토큰은 상대적으로 긴 만료 시간(30일)으로 설정되었다.
  • getAuthentication 메소드는 액세스 토큰을 파싱하여 인증 객체를 생성한다. 이 객체는 사용자의 권한 정보를 포함한다.
  • validateToken 메소드는 주어진 토큰의 유효성을 검사한다. 유효하지 않거나 만료된 경우 false를 반환한다.
  • parseClaims 메소드는 액세스 토큰의 클레임을 파싱한다. 만약 토큰이 만료된 경우, 만료된 클레임을 반환한다.
  • 위 과정에서 사용되는 parseClaimsJws는, 내부적으로 Signature 검증을 자동으로 처리하는 메소드이다.

JwtAuthenticationFilter

package com.example.test.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
    private final JwtTokenProvider jwtTokenProvider;


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

        String token = resolveToken((HttpServletRequest) request);

        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
  • JwtAuthenticationFilter는 HTTP 요청을 필터링하여 JWT 토큰을 검사하고, 인증 정보를 SecurityContext에 설정한다.
  • doFilter 메소드는 요청에서 JWT 토큰을 추출하고, 토큰의 유효성을 검증한 후, 인증 정보를 설정한다. 이후 필터 체인의 다음 단계로 요청을 전달한다.
  • resolveToken 메소드는 요청 헤더에서 JWT 토큰을 추출한다. "Authorization" 헤더에서 "Bearer" 접두사를 제거하여 토큰만 반환한다.

🔗 SecurityConfig

package com.example.test.config;

import com.example.test.jwt.JwtAuthenticationFilter;
import com.example.test.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .httpBasic(httpBasic -> httpBasic.disable())
                .csrf(csrf -> csrf.disable())
                .sessionManagement(sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeRequests(authorize ->
                        authorize
                                .anyRequest().permitAll()
                )
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}
  • SecurityConfig는 Spring Security의 설정을 담당한다.
  • SecurityFilterChainJwtAuthenticationFilter를 추가하여, 모든 요청에 대해 JWT 인증을 처리하도록 한다.
  • 현재는 authorizepermitAll()로 설정되어 있지만, 설정에 따라 JWT 인증을 통해 "인증되지 않은 사용자"의 경우 특정 요청에 대한 권한을 제한하거나, 401 에러 등을 발생시킬 수 있다.
  • 예를 들어, 만약 Authorization 헤더가 없는 요청이라면, "인증되지 않은 사용자" 신분으로 필터 체인의 다음 단계로 진행된다.

실제 JWT 발행 예시

위의 방식대로 JWT 토큰을 발행한 경우, 위와 같이 토큰이 전송된다.
예시는 사용자가 로그인에 성공한 경우, 서버로부터 JWT 토큰을 받은 상황이다.

profile
전공/개발 지식 정리

0개의 댓글