ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [JWT] JSON WEB TOKEN 개념 / 구성 요소 / 동작 방식 / 장단점 / Session과 차이점 / 저장 위치
    DEV/ETC 2024. 5. 30. 15:04

    JWT란?

    JSON WEB TOKEN의 약자로 속성 정보 (Claim)를 JSON 데이터 구조로 표현한 토큰으로서 네트워크를 통해서 서로 다른 장치끼리 안전하게 전송하기 위해 설계됨

    구성 요소

    출처 : 생활코딩 - JWT

    JWT는 세 파트로 나누어지며, 각 파트는 점으로 구분하여 표현된다.

    헤더 (Header), 페이로드 (Payload), 서명 (Sinature)으로 구성된다.

     

    1) header : 해시 암호화 알고리즘과 토큰의 타입으로 구성

    • 첫 번째는 HMAC, SHA256 또는 RSA와 같은 서명 생성에 사용된 해시 알고리즘
    • 두 번째는 토큰의 유형 (JWT)

    2) payload : 내용, 즉 토큰에 담을 클레임(claim) 정보를 포함

    • Payload 에 담는 정보의 한 ‘조각’ 을 클레임이라고 부르며, name / value 의 한 쌍으로 이뤄짐
    • 토큰에는 여러개의 클레임 들을 넣을 수 있음
    • 클레임의 정보는 등록된 (registered) 클레임, 공개 (public) 클레임, 비공개 (private) 클레임으로 세 종류가 있음
      • 등록된 클레임 : 토큰 정보를 표현하기 위해 이미 정해진 종류의 데이터들로, 모두 선택적으로 작성이 가능하며 사용할 것을 권장
        • iss: 토큰 발급자(issuer)
        • sub: 토큰 제목(subject)
        • aud: 토큰 대상자(audience)
        • exp: 토큰 만료 시간(expiration)
        • nbf: 토큰 활성 날짜(not before)
        • iat: 토큰 발급 시간(issued at)
        • jti: JWT 토큰 식별자(JWT ID)
      • 공개 클레임 : 사용자 정의 클레임으로, 공개용 정보를 위해 사용된다. 충돌 방지를 위해 URI 포맷을 이용
      • 비공개 클레임 : 사용자 정의 클레임으로, 서버와 클라이언트 사이에 임의로 지정한 정보를 저장

    3) signature : 서명

    • Header, Payload, Secret Key를 합쳐 암호화한 결과값
      • HS256( base64UrlEncode(header) + "." + base64UrlEncode(payload), Secret key)

    동작 방식

    출처 : 생활코딩 - JWT

    1. 사용자가 로그인을 시도
    2. 서버는 요청을 확인하고 secret key와 payload를 서명 알고리즘에 입력하여 서명을 만들고 이를 조립한 JWT을 발급
    3. JWT 토큰을 클라이언트에 전달
    4. 클라이언트에서 API를 요청할때 클라이언트가 인증 헤더에 토큰을 담아서 보냄
    5. 서버는 JWT Signature를 체크하고 Payload로부터 사용자 정보를 확인해 데이터를 반환

    클라이언트의 로그인 정보를 서버 메모리에 저장하지 않기 때문에 토큰기반 인증 메커니즘을 제공한다.
    인증이 필요한 경로에 접근할 때 서버 측은 인증 헤더에 유효한 JWT인지 또는 존재하는지 확인한다.
    JWT에는 필요한 모든 정보를 토큰에 포함하기 때문에 데이터베이스과 같은 서버와의 커뮤니케이션 오버 헤드를 최소화 할 수 있다.

    Session과 차이점

    Session 단점

    • 요청마다 Session table 조회 필요
    • 사용자가 새로운 장치로 접근할때마다 Session을 새로 생성해야하기때문에 table에 데이터가 많아져 메모리를 많이 차지함
    • Session table의 문제가 발생하면 인증 체계가 무너져 이전에 다른 인증된 유저 또한 인증이 불가해짐
    • stateful하기 때문에 http의 장점을 발휘하지 못하고 scale out에 걸림돌이 생김
    • Session id가 탈취되었을 경우 대처는 가능하지만 클라이언트인척 위장하는 보안의 약점이 있을 수 있음

    JWT 장점

    • 토큰 자체가 인증된 정보이기때문에 세션 저장소 같은 별도의 인증 저장소가 필수적으로 필요하지 않음
    • 세션과 다르게 클라이언트 상태를 서버가 저장하지 않아도 됨
    • signature를 공통키 개인키 암호화를 통해 막아두었기 때문에 데이터 보안성이 늘어남
    • 필요한 내용을 payload에 담아 보내 놓고 다시 조회할 일이 없어 서버의 부담이 완화됨
    • 로그인할때 한번만 인증하면되어 DB조회를 한번만 함

    JWT 단점

    • 토큰은 클라이언트에 저장되어 데이터베이스에서 사용자 정보를 조작하더라도 토큰에 직접 적용할 수 없음
    • 더 많은 필드가 추가되면 토큰이 커질 수 있고 이는 네트워크에 부하를 줄 수 있음
    • 비상태 애플리케이션에서 토큰은 거의 모든 요청에 대해 전송되므로 데이터 트래픽 크기에 영향을 미칠 수 있음
    • JWT는 상태를 저장하지 않기 때문에 한번 만들어지면 제어가 불가능함. 즉, 토큰을 임의로 삭제하는 것이 불가능하므로 토큰 만료 시간을 꼭 넣어주어야 함
    • Payload 자체는 암호화 된 것이 아니라, BASE64Url로 인코딩 된 것. 따라서 중간에 Payload를 탈취하여 디코딩하면 데이터를 볼 수 있으므로, JWE로 암호화하거나 Payload에 중요 데이터를 넣지 않아야 하며 토큰 만료 시간을 짧게 지정해야함

    저장 위치

    토큰을 저장할 수 있는 방식 중 대표적으로 비공개 변수, 로컬 스토리지, 세션 스토리지, 쿠키로 분류

    1) 비공개 변수 : 브라우저가 새로고침될 때마다 유지가 되지 않기 때문에 사용자는 새로고침 할 때마다 재접속을 해야하는 불편함을 감수해야 한다.

    2) 로컬 스토리지: 브라우저가 새로고침 되더라도 정보들이 유지되기 때문에 사용자 편의성이 높은 편이다.(브라우저를 닫고 다시 열어도 정보들이 유지된다). 하지만, JS 코드를 통해 접근이 가능하기 때문에 XSS 공격에는 취약하고 CSRF 공격에는 안전하다.

    3) 세션 스토리지: 로컬 스토리지에 비해 제한적이다. 현재 떠 있는 탭에서만 유지되는 특징을 가진다. 새로 고침할 때는 사라지지 않지만, 탭을 닫고 다시 열 때는 데이터가 사라진다.

    4) 쿠키 : 쿠키에 저장하는 방식보단 로컬 스토리지에 저장하는 형식을 조금 더 권장한다. 모든 요청에 쿠키가 함께 전송되기 때문에 성능 저하의 원인이 될 수 있기 때문. 보안적인 측면에서 쿠키 또한 JS로 접근이 가능하기 때문에 서버측에서 HTTP Only,secure,Samesite 등 옵션을 걸어야한다.

    sliding session, refresh token

    사용자 입장에서는 짧은 만료 시간이 불편할 수 있다. 만료되기 직전 재발급을 어떤 방식으로 하느냐에 따라 짧은 만료 시간을 보완하는 두가지 방법이 있다.

     

    1) Sliding Session

    • 특정한 서비스를 계속 사용하고 있는 유저에 대해 만료 시간을 연장 시켜주는 방법
    • 글쓰기, 결제 등과 같은 특정 액션을 행동했을 때 새롭게 만료 시간을 늘린 JWT를 다시 제공함
    • 단, 단발성 접속이 일어나면 연장시켜줄 수 없는 상황이 생길 수 있음
    • 긴 시간의 토큰을 발급시켜준 상황에는 Sliding Session 때문에 무한정 사용하는 상황이 발생할 수 있음

    2) Refresh Token

    • JWT를 처음 발급할 때 Access Token과 함께 Refresh Token을 발급하여 짧은 만료 시간을 해결하는 방법
    • 비교적 긴 시간의 만료 시간을 가진 Refresh Token은 Access Token을 Refresh해주는 것을 보장해줌
    • 클라이언트가 Access Token이 만료됨을 본인이 인지하거나 서버로부터 만료됨을 확인 받았다면 Refresh Token으로 서버에게 새로운 Accsess Token 발급을 요청

    JWT + Spring Security 예제

    1) Spring Security와 JWT 라이브러리 추가

    // Spring Security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'org.springframework.security:spring-security-test'
    
    // JWT Token
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    

     

    2) JwtTokenUtil 생성

    : Jwt Token 방식을 사용할 때 필요한 기능들을 정리

    public class JwtTokenUtil {
    
        // JWT Token 발급
        public static String createToken(String loginId, String key, long expireTimeMs) {
            // Claim = Jwt Token에 들어갈 정보
            // Claim에 loginId를 넣어 줌으로써 나중에 loginId를 꺼낼 수 있음
            Claims claims = Jwts.claims();
            claims.put("loginId", loginId);
    
            return Jwts.builder()
                    .setClaims(claims)
                    .setIssuedAt(new Date(System.currentTimeMillis()))
                    .setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))
                    .signWith(SignatureAlgorithm.HS256, key)
                    .compact();
        }
    
        // Claims에서 loginId 꺼내기
        public static String getLoginId(String token, String secretKey) {
            return extractClaims(token, secretKey).get("loginId").toString();
        }
    
        // 발급된 Token이 만료 시간이 지났는지 체크
        public static boolean isExpired(String token, String secretKey) {
            Date expiredDate = extractClaims(token, secretKey).getExpiration();
            // Token의 만료 날짜가 지금보다 이전인지 check
            return expiredDate.before(new Date());
        }
    
        // SecretKey를 사용해 Token Parsing
        private static Claims extractClaims(String token, String secretKey) {
            return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
        }
    }
    

     

    3) Spring Security Config 작성

    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SecurityConfig {
    
        private final UserService userService;
        private static String secretKey = "my-secret-key-123123";
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
            return httpSecurity
                    .httpBasic().disable()
                    .csrf().disable()
                    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                    .addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
                    .authorizeRequests()
                    .antMatchers("/jwt-login/info").authenticated()
                    .antMatchers("/jwt-login/admin/**").hasAuthority(UserRole.ADMIN.name())
                    .and().build();
        }
    }
    
    • sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 을 사용
      • Token 로그인 방식에서는 session이 필요 없기 때문
    • Spring Security에서 로그인을 진행해주는 Filter(UsernamePasswordAuthenticationFilter)에 가기 전에 JwtTokenFilter을 거치게 함

     

    4) JwtTokenFilter 작성

    사용자의 요청에서 Jwt Token을 추출해 토큰이 유효한지 체크 후 통과하면 권한을 부여하고 실패하면 권한을 부여하지 않고 다음 필터로 진행시킴

    // OncePerRequestFilter : 매번 들어갈 때 마다 체크 해주는 필터
    @RequiredArgsConstructor
    public class JwtTokenFilter extends OncePerRequestFilter {
    
        private final UserService userService;
        private final String secretKey;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
    
            // Header의 Authorization 값이 비어있으면 => Jwt Token을 전송하지 않음 => 로그인 하지 않음
            if(authorizationHeader == null) {
                filterChain.doFilter(request, response);
                return;
            }
    
            // Header의 Authorization 값이 'Bearer '로 시작하지 않으면 => 잘못된 토큰
            if(!authorizationHeader.startsWith("Bearer ")) {
                filterChain.doFilter(request, response);
                return;
            }
    
            // 전송받은 값에서 'Bearer ' 뒷부분(Jwt Token) 추출
            String token = authorizationHeader.split(" ")[1];
    
            // 전송받은 Jwt Token이 만료되었으면 => 다음 필터 진행(인증 X)
            if(JwtTokenUtil.isExpired(token, secretKey)) {
                filterChain.doFilter(request, response);
                return;
            }
    
            // Jwt Token에서 loginId 추출
            String loginId = JwtTokenUtil.getLoginId(token, secretKey);
    
            // 추출한 loginId로 User 찾아오기
            User loginUser = userService.getLoginUserByLoginId(loginId);
    
            // loginUser 정보로 UsernamePasswordAuthenticationToken 발급
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                    loginUser.getLoginId(), null, List.of(new SimpleGrantedAuthority(loginUser.getRole().name())));
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
    
            // 권한 부여
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            filterChain.doFilter(request, response);
        }
    }
    

     

    5) JwtLoginApiController 작성

    @RestController
    @RequiredArgsConstructor
    @RequestMapping("/jwt-login")
    public class JwtLoginApiController {
    
        private final UserService userService;
    
        @PostMapping("/login")
        public String login(@RequestBody LoginRequest loginRequest) {
    
            User user = userService.login(loginRequest);
    
            // 로그인 아이디나 비밀번호가 틀린 경우 global error return
            if(user == null) {
                return"로그인 아이디 또는 비밀번호가 틀렸습니다.";
            }
    
            // 로그인 성공 => Jwt Token 발급
    
            String secretKey = "my-secret-key-123123";
            long expireTimeMs = 1000 * 60 * 60;     // Token 유효 시간 = 60분
    
            String jwtToken = JwtTokenUtil.createToken(user.getLoginId(), secretKey, expireTimeMs);
    
            return jwtToken;
        }
        
        @GetMapping("/info")
        public String userInfo(Authentication auth) {
            User loginUser = userService.getLoginUserByLoginId(auth.getName());
    
            return String.format("loginId : %s\\nnickname : %s\\nrole : %s",
                    loginUser.getLoginId(), loginUser.getNickname(), loginUser.getRole().name());
        }
    
        @GetMapping("/admin")
        public String adminPage() {
            return "관리자 페이지 접근 성공";
        }
    }
    

     

     

     

     

    참고 URL

    https://youtu.be/36lpDzQzVXs?si=Bbf1PT9cRk8xL0vw

    http://www.opennaru.com/opennaru-blog/jwt-json-web-token/

    https://mangkyu.tistory.com/56

    https://brunch.co.kr/@jinyoungchoi95/1

    https://dreamcode.tistory.com/444

    https://chb2005.tistory.com/178

    댓글

Designed by Tistory.