Dev/Etc

JWT 란 무엇인가 ? - Session과 JWT 인증 방식의 비교 / 예제 코드 포함 ( feat. OAuth )

린네의 2024. 5. 20. 17:32

 
오늘 게시글은 인증 시 많이 사용하는 JWT의 기본 개념과 실제로 어떻게 구현하는지에 대한 내용을 정리해보고자 한다.  관련해서 OAuth 도 공유하면 좋을 것 같아서 함께 묶었다.
 
이전에 Swagger 사용 시 JWT를 통한 인증을 구현할 때 페이지에서 JWT 값이 제대로 넘어오지 않던 오류가 났던 문제를 해결한 적이 있는데, 관련 글은 아래에 첨부했다. 내 개발 블로그에서 제일 조회수가 꾸준히 증가하는 게시글인 것 같다.  누군가에게 도움이 되었을 생각을 하니 뿌듯하다 ^0^
 
 
2024.01.29 - [Dev/springboot] - Swagger 를 사용해서 Type 이 Bearer 인 JWT Token 인증하기

Swagger 를 사용해서 Type 이  Bearer 인 JWT  Token 인증하기

개발환경 IDE : intelliJ FrameWork : springboot 3.2.2 Launguage : java 17 BuildTool : Gradle Swagger : SpringDoc 문제 상황 restAPI 생성 후 Swagger 를 이용해서 문서작성 도중 JWT 인증 관련 테스트를 위해서 Bearer 타입으로

zigo-autumn.tistory.com

 

 

JWT 란 무엇일까?

 
 
우선적으로 JWT는 JSON Web Token의 준말이다. 자바 스크립트의 Key-value 형식을 가지고 있으며 이런 형식을 가진 값으로 Web Token 으로써 사용하겠다는 의미이다.
 
서버와 클라이언트간 정보를 주고받을 때 Http 리퀘스트 헤더에 JSON 토큰을 넣으면 서버는 별도의 인증 과정 없이 헤더에 포함되어 있는 JWT 정보를 통해 인증할 수 있다. 이때 사용되는 JSON 데이터는 URL-Safe(URL에 포함할 수 없는 문자를 포함하지 않는 것을 의미) 하도록 URL에 포함할 수 있는 문자만으로 생성된다.
 
JWT는 HMAC 알고리즘을 사용하여 비밀키 또는 RSA를 이용한 Public Key / Private Key 쌍으로 서명할 수 있다.
 
또한 JWT는 데이터 정보를 암호화하는 것이 아니라 인증하기 위한 것이다.  이 점을 헷갈리는 사람들이 많은데 JWT는 단지 인증을 위한 것일 뿐 데이터를 암호화하기 위한 목적으로 설계된 것이 아니다. 따라서 누구나 접근해서 읽을 수 있으므로 민감한 정보는 전달하지 말아야 한다.
 
 

Session과 JWT의 차이 ( feat. JWT 동작방식 ) 

 
 그럼 세션이랑 JWT 차이는 무엇일까? 
 

  • session

세션은 기본적으로 서버 인증 기반이다. 서버에 사용자별 세션을 저장해서 관리한다.  따라서 다음과 같은 인증 과정을 거친다.
 
1. 클라이언트가 로그인을 위해 인증 정보를 서버에 전송
2. 서버는 메모리에 사용자에 해당하는 세션값을 생성하여 저장하고, 세션 아이디를 쿠키로 전달
3. 클라이언트는 다음 요청부터 쿠키에 세션 아이디를 함께 서버에 전달
4. 서버는 쿠키로 전달된 세션 아이디를 서버 메모리에서 검색한 후 적절한 응답
 

[출처] https://velog.io/@jellyjw/JWT%EC%99%80-session

 
 
이러한 인증 방식은 서버에서 인증 과정을 처리하므로 안전하지만, 서버의 메모리 내부에 세션값(session ID)가 적재되므로 클라이언트의 수가 늘어나면 서버 메모리의 부하도 늘어난다는 단점을 가진다. 또한 서비스의 규모가 확대되어 서버를 여러대로 확장해야 할 경우 세션을 분산시키는 처리를 따로 해줘야 하는 번거로움도 존재한다. 쿠키는 단일 도메인 및 서브 도메인에서만 작동하도록 설계되어 있으므로 여러 도메인에서 관리하기 번거롭다는 단점도 있다.
 
 

  • JWT

JWT는 다음과 같이 동작한다.
 
1. 클라이언트가 로그인을 위해 인증 정보를 서버에 전송
2. 서버는 비밀 키 값을 통해 JWT를 생성하고 클라이언트에 전달
3. 클라이언트는 로컬 혹은 브라우저에 JWT를 저장한 뒤, 서버에 요청할 때 함께 요청
4. 서버는 JWT를 통해 사용자를 식별하고 적절한 응답 수행 
 
 

[출처] https://velog.io/@jellyjw/JWT%EC%99%80-session

 
 
 
JWT는 세션인증 방식과 달리 사용자 정보를 따로 저장하는 과정이 없다. JWT 자체로 유효성 판단이 가능하기 때문에 별도로 데이터베이스나 메모리 서버가 필요하지 않다. 따라서 세션 인증 방식이 가지는 한계점을 보완할 수 있다.
 
 
 

JWT의 토큰 구성

 

 
JWT는 header, payload, signature 세 가지 부분으로 나뉜다. 
 

  • Header 

헤더에는 토큰의 종류와 Signature 생성을 위해서 어떤 알고리즘을 사용했는지 명시된다.  첫 번째로 토큰의 유형을 나타내며, 두 번째는 HMAC, SHA256 또는 RSA와 같은 해시 알고리즘을 나타낸다.
 

{
    "alg" : "HS256",
    "tpy" : "JWT"
}

 
JWT는 typ을 JWT로 사용한다.
 

  • payload

페이로드에는 내가 로그인한 유저임을 증명할 수 있는 기본적인 정보들을 삽입한다. 차후에 클라이언트가 다시 토큰을 전송하면 해독해서 DB내의 유저정보와 비교한다.  페이로드에 담는 정보의 한 조각을 클레임(Claim)으로 표현하며 이 클레임은 key-value의 한 쌍으로 구성된다. 이런 클레임은 여러 개를 삽입할 수 있다.
 
이런 클레임은 등록된(registered), 공개(public), 비공개(private)로 세 종류로 나뉜다. 
 
등록된 클레임(registered claims) 토큰 정보를 표현하기 위해 이미 JWT 표준에 정해진 종류의 데이터들을 의미한다.
 

  1. iss(Issuer): JWT를 발급한 발급자(서버)의 식별자다. 예를 들어, "https://example.com" 같은 URL이 될 수 있다.
  2. sub(Subject): JWT에 담긴 정보의 주체를 나타낸다. 일반적으로 사용자의 고유 식별자나 아이디가 들어간다.
  3. aud(Audience): JWT가 사용되는 대상자(대상 서비스나 앱)를 나타낸다. 여기에 지정한 대상자만 JWT를 사용할 수 있도록 할 수 있다.
  4. exp(Expiration Time): JWT의 만료 시간을 나타낸다. 이 시간 이후에는 JWT가 더 이상 유효하지 않다. NumericDate 형식으로 되어 있어야 한다. nbf(Not Before):  토큰활성 날짜로, JWT가 사용 가능한 시간을 나타낸다. 이 시간 이전에는 JWT를 사용할 수 없다.
  5. iat(Issued At): JWT가 발급된 시간을 나타낸다. 보통 JWT를 생성한 시간이 들어간다. ( 토큰 발급 이후의 경과시간을 측정할 수 있다 ) 
  6. jti(JWT ID): JWT의 고유 식별자다. 중복되지 않는 고유한 값이어야 한다. 주로 중복 사용을 방지하거나 JWT를 추적할 때 사용된다. 일회용 토큰에도 사용할 수 있다.

등록된 클레임들은 JWT의 표준에 따라 정의되어 있기 때문에 보내는 쪽과 받는 쪽이 같은 방식으로 해석할 수 있다.
 
공개 클레임(public claims)은 JWT의 페이로드 부분에 들어가는 정보 중 누구나 사용할 수 있는 정보를 의미한다. 이 정보들은 JWT 표준에 따라 정의되지 않았지만 필요에 따라 사용자가 임의로 추가할 수 있는 부분이다. JWT의 표준에 따라 정의하는 것이 아니라 사용자 임의로 지정하기 때문에, 중복이 되지 않도록 유의해야 한다. 따라서 일반적으로 중복방지(충돌방지)를 위해 URI 포맷을 사용한다.
공개 클레임이라는 뜻 그대로, 누구나 조회하고 사용할 수 있기 때문에 민감한 정보는 넣지 않는 게 좋다.
 
비공개 클레임(private claims)은 특정한 사람들에게만 공유되어야 하는 비공개 정보를 의미한다. 이 정보들 또한 JWT 표준에 따라 정의되지 않았으며, 주로 민감한 정보를 포함할 때 사용한다. 토큰을 발생한 서버와 토큰을 받는 클라이언트 사이에서만 공유되는 정보다. 
 

  • signature

Signature은 토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드이다. 
 
Signature 값을 생성하는 과정은 다음과 같다.
 
1. 헤더와 페이로드 값을 각각 Base64로 인코딩 
2. 인코딩한 값을 설정한 비밀키를 이용해 헤더에서 정의한 알고리즘으로 해싱
3. 이 값을 다시 Base64로 인코딩
 
이렇게 헤더와 페이로드를 통해 생성되므로 해당 토큰이 변조되었는지 여부를 확인할 수 있다.
 
 
 

JWT의 장점과 단점

 

  • 장점

세션과 달리  별도로 DB나 메모리 서버가 필요하지 않아 서버 확장성이 좋다. 또한 서버 측 부하를 낮출 수 있고 독립적으로 동작하므로 능률적인 접근 권한 관리가 가능하다. 이러한 장점은 분산/클라우드 기반 인프라 스트럭처와 적합하다.
 

  • 단점

서버가 토큰의 상태를 관리하지 않아 누군가가 훔친 토큰을 가지고 요청을 보낼 경우 대응이 어렵다. 또한 한번 발급된 token은 수정, 폐기가 불가능하다. 
 
토큰에 넣는 데이터가 많아질수록 토큰 길이가 길어지는데, 이것은 API호출 시 네트워크 대역폭 낭비로 이어질 수 있다. ( API 호출시마다 토큰 데이터를 서버에 전달해야 하므로 문제가 발생한다. ) 
 
 
 

JWT의 단점을 극복하기 위한  refresh Token과 Sliding Session 

 
위에서 기재한 대로, JWT의 가장 큰 단점은 훔친 토큰에 대한 대응 방법이 없는 것이다. 이런 단점을 극복하기 위해 refresh token이 등장했다.
 

  • refresh token

훔친 토큰으로 인한 피해시간을 줄이기 위해 인증토큰의 유효시간을 매우 짧게 발급한다. 이렇게 짧게 생성된 인증토큰은 훔친 토큰에 대한 피해는 줄일 수 있겠지만, 사용자가 다시 로그인해야 하는 불편함을 야기한다. 따라서 로그인 시 유효 시간을 길게 설정한 refresh 토큰을 함께 발급하고, 이 토큰은 사용자 정보와 매칭하여 DB에 저장한다.
 
클라이언트는 이후 요청 시 인증토큰(Access Token)과 refresh 토큰을 함께 제시하고, 서버는 인증토큰의 기간이 만료되었더라도 refresh 토큰이 유효한 경우에는 사용자가 재로그인 없이 새로운 인증토큰을 발급받을 수 있도록 한다.  만약 refresh토큰 자체가 만료된다면, 사용자가 재로그인할 수 있도록 안내한다.
 
refresh토큰을 사용하면 db를 조회하거나 무효화하는 작업 등이 필요하기 때문에 DB I/O가 줄어드는 JWT 장점이 감소할 수 있다.
 
사실 완벽한 대응책은 아니다. refresh 토큰도 훔칠 수 있기 때문이다. 그렇지만 이것은 일종의 하나의 방벽을 추가하는 것과 같다. 아이디 비밀번호도 해커가 훔치면 그만이지만, 방지책으로 암호화하고, OTP 과정을 추가하는 것과 동일한 메커니즘이다.
 

  • sliding session 

사용자가 활동할 때마다 세션의 유효 기간이 연장된다. 사용자가 활동을 할 때마다 세션의 유효 기간이 연장된다. 이렇게 하면 사용자가 계속 활동 중인 동안에는 로그아웃 되지 않는다. 반면 사용자가 일정 기간 동안 활동이 없으면 자동으로 로그아웃 된다.
 
하지만 이러한 sliding session 방식은 접속이 주로 단발성으로 이루어지는 경우 효과가 크지 않고, 인증토큰(Access Token)의 만료시간이 길게 발급되면 로그인을 전혀 하지 않아도 되는 문제점이 발생한다.
 
 
Session 인증 방식이든 JWT든 완벽하게 이상적인 인증 방식은 없다.
어떠한 방식을 사용할지는 서비스의 성격에 따라 다르다. 사용자 편의성을 높이기 위해 적절한 방식을 선택해야 한다는 점을 기억하자.
 

 

JWT  Token은 어디에 저장해야 하는가? ( XSS와 CSRF 방지 )

 
클라이언트가 서버로부터 생성된 토큰을 저장할 수 있는 저장 공간은 비공개변수, Local Storage, Session Storage, Cookie로 분류할 수 있다.
 

  • 비공개변수

브라우저가 새로고침 될 때마다 유지할 수없으므로 새로고침시마다 재접속이 필요하다. 따라서 대부분 사용하지 않는다.
 

  • Local Storage

브라우저가 새로고침 되더라도 정보들이 유지되어 사용자 편의성이 높다. ( 브라우저를 닫고 다시 열어도 정보들이 유지된다 )  하지만 JavaScript 코드를 통해 접근이 가능하므로 XSS 공격에 취약하다. 아이디가 저장된 자동 로그인등이 이에 해당한다.
 

  • Session Storage

현재 실행되고 있는 브라우저 탭에서만 유효하다. 새로고침시에 사라지진 않지만 탭을 닫을 경우 데이터가 사라진다. 입력 폼이나 일회성 로그인이 이에 해당한다. 
 

  • Cookie 

로그인 자동완성 등 유효기간을 설정하여 데이터를 저장할 수 있다. 매우 작은 데이터 용량 (4KB)를 가지며 클라이언트의 요청마다 함께 전달된다.  httpOnly(http요청일 때만 스크립트가 실행된다. 즉 사이트 내부에 악의적으로 삽입되어 있는 코드는 실행되지 않는다), Secure(https 요청만 허용한다) 옵션등을 통해 XSS를 방지할 수 있지만, CRSF는 방지할 수 없다. ( CRSF를 방지하기 위해서는 Http Request Header의 Referer과 Origin을 확인하거나 특정 함수의 API에 대해서만 HTTP 요청을 가능하게 하는 보완책을 적용해야 한다 ) 
 
 
cf. XSS와 CSRF의 차이
둘 다 Javascript 를 통해 공격자가 의도된 행위를 하는 JavaScript를 통한 공격이지만 XSS는 악성 스크립트가 클라이언트 쪽에서 실행된다면 ( 악성 스크립트가 포함된 사이트를 클릭하는 등의 행위를 통해 실행  ) CSRF는 정상적인 사용자의 요청이 서버에 전달되어 클라이언트의 의도와 관계없이 악성 스크립트가 서버에서 실행되는 차이가 있다. ( 요청자체는 정상적이지만 그 이후의 실행 내용이 악의적임 ) 
 
일반적으로 Local/Session 방식의 경우 XSS에 직접적으로 노출되고 다른 브라우저간에 통신할 수 없기 때문에 Cookie에 보안옵션을 추가하는 방식으로 구현하지만, 사실 쿠키 방식이 완벽한 것은 아니다. 어쨌든 요청마다 쿠키가 함께 전송되므로 애플리케이션의 성능 저하가 다라올 수 있다.
 
본인이 개발하는 서비스의 특성에 맞게 다양한 방면을 고려하여 선택하는 게 맞다고 본다.
 
 

JWT 를 통한 인증 예제를 작성해 보자!

 
주로  jwt 를 통한 인증 예제를 구현할 때 Spring Security를 많이 사용한다. 하지만 내가 작성한 예제에서는 굳이 사용하지 않았다. Security를 사용하면 다양한 핸들링을 자동화해줘 편하긴하지만 간단한 예제인만큼 추가해서 불필요한 설정들이 추가되어 애플리케이션이 무거워지게 해야하는 필요성을 못느꼈다.
 
정말 Java를 통한 JWT를 통한 기본적인 샘플 코드이니 본인이 구현하는 프로젝트의 성격에 맞게 변형하여 적용하면 된다.
 

개발환경

IDE :  intelliJ 
FrameWork : springboot 3.2.2
Launguage java 17

 
 
먼저 jwt를 사용하가 위해 의존성을 추가했다.
 

  • build.gradle 파일 
	//JWT
	implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

 
 

  • JWT 생성을 위한 샘플 파일 
@Component
public class JWTTokenUtil {

    public static final byte[] secret = "tokensampletokensampletokensampletokensampletokensample".getBytes();
    // 개인키 생성
    private final SecretKey key = hmacShaKeyFor(secret);


    // header 정보 생성
    public Map<String, Object> makeHeader() {
         Map<String, Object> headers = new HashMap<>();
         headers.put("alg", "HS256");
         headers.put("typ", "JWT");

         return headers;
    }

    //claims 정보 생성
    public Map<String, Object> makeClaims(RequestLogin requestLogin) {
         Map<String, Object> claims = new HashMap<>();
         claims.put("userId", requestLogin.getUserId());
         return claims;
    }

    public Date makeExpiresDate() {
        Date now = new Date();
        Date expires = new Date(now.getTime() + Duration.ofMinutes(60).toMillis());
        return expires;
    }

    // Token 생성
    public String createToken(Map<String, Object> headers, Map<String, Object> claims, Date expireDate) {
        return Jwts.builder()
                .header()
                .add(headers)
                .and()
                .claims(claims)
                .expiration(expireDate)
                .signWith(key)
                .compact();
    }

    // 검증
    public Claims verifyToken(String jws) throws SignatureException, UnsupportedJwtException{

        return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(jws).getPayload();

    }
  }

 
 
위 코드에서 claims 에 원하는 인증 정보를 담을 수 있다.
 
아래는 간단한 인증 절차를 테스트한 코드이다.

 @Test
 @DisplayName("토큰_생성및검증_성공")
    public void makeTokenAndValidCheck_1() {

            // given
            Map<String, Object> headers = new HashMap<>();
            headers.put("alg", "HS256");
            headers.put("typ", "JWT");

            Map<String, Object> claims = new HashMap<>();
            claims.put("id", "gildong");

            // 인증 유효시간 60분
            Date now = new Date();
            Date expires = new Date(now.getTime() + Duration.ofMinutes(60).toMillis());

            //when, then
            String jwsToken = createToken(headers, claims, expires);
            System.out.println(jwsToken);

            verifyToken(jwsToken);

    }

 
 

  • HttpRequest 에서 Authorization 인증값 추출 
@Component
@Slf4j
public class RequestAuthorization {

    public final String AUTHORIZATION = "Authorization";

    public Optional<String> extract(HttpServletRequest request, String authType) {
        Enumeration<String> headers = request.getHeaders(AUTHORIZATION);

        log.info("authType = {} " + authType);

        while (headers.hasMoreElements()) {
            String value = headers.nextElement();
            if (value.toLowerCase().startsWith(authType.toLowerCase())) {
                String result = value.substring(authType.length()).trim();
                log.info(" headers.value = {} " + result );
                return Optional.ofNullable(result);
            }
        }


        return Optional.empty();
    }
}

 
 
JWT 인증시  HTTP 헤더의 Authorization의 type을  Bearer로 지정했다. ( Bearer는 W3C 에서 토큰기반 인증에서 사용하도록 명시하는 표준중 하나이다.  [Authorication: <type> <credentials>]에서 type에 해당한다.) 

public String getJwsToken(HttpServletRequest httpServletRequest) {
    return requestAuthorization.extract(httpServletRequest, "Bearer")
            .orElseThrow(() -> new ApiException(ApiErrorCode.INVALID_TOKEN));

}

 
로그인이 완료된 시점에 위 코드들을 조합하여 JWT를 생성하고, 이 후 접근부터 JWT 값을 비교하여 사용자를 식별하게 작성할 수 있다.
 
 
 

OAuth 프로토콜이란?

OAuth 2.0 (Open Authorization 2.0)은 인증을  위한 개방형 표준 프로토콜이다. 
 
요즘 대다수의 웹 서비스는 외부소셜계정을 기반으로하는 인증 서비스를 제공하는데 이렇게 웹 서비스가 클라이언트를 대신하여 리소스 서버에서 제공하는 자원에 대한 접근 권한을 위임 받는 방식을 OAuth 라고 표현한다.
 
 

  • OAuth 관련 용어

1. Resource Owner
리소스 소유자를 의미한다. 여기서 리소스는 외부 소셜 서비스를 의미한다. 즉 해당되는 플랫폼에서 리소스를 소유하고 있는 사용자가 해당한다. 예를들어 내가 네이버에 계정이 있고 네이버 계정을 통해 다른 웹서비스(Third-Party)를 이용하고자 한다면, 네이버 계정있는 나 자신이 Resource Owner 에 해당한다.
 
2.Authorization Sever 
Authorization Sever은 Resource Owner를 인증하고 우리가 개발한 웹 서비스에게 Access Token을 발급해주는 서비스를 의미한다. 즉 외부 플랫폼 리소스에 접근할 수 있는지 인증하는 서버를 의미한다. 
 
3.Resource Server
구글,페이스북,카카오,네이버와 같이 사용자의 리소스를 가지는 서버를 의미한다. ( 보호되는 사용자 리소스를 가진 서버 )
 
4.Client
Resource Owner을 대신해 Authorication Sever와 Resource Server에 접근하려는 주체를 의미한다. Third-party 사이트가 가지고 있는 서비스에 해당한다.
 
 
 

  • OAuth 동작 방식
[출처] https://medium.com/@techworldwithmilan/how-does-oauth-2-0-work-bea67a760aa5

 
 
당연한 말이지만, Resource Server/Authorization Server 에 Third-party서비스를 등록해야 한다. 예를들면 네이버 연동 로그인을 사용하기 위해서는 네이버에 개발할사이트(Third-party)에 대한 정보를 입력해야한다. 
 
그 다음 과정은 다음과 같다.
 
1~3. 사용자(Resource Owner)가 Third-party사이트에서 연동 로그인 폼을 통해 소셜 웹사이트 로그인 정보를 입력한다. 
 
4~5. Third-party 서비스에서 작성된 내부 코드를 통해  Authorization Server에 사용자가 보낸 데이터를 전송한다. Authorization Server는 사용자를 인증할 수 있는 Access Token을 발급받아 Third-party 서비스로 전송해준다.
 
6~8. Third-party 서비스는 발급받은 Access Token을 Resource Server로 전달하여 사용자가 접근할 수 있는 리소스를 제공할 수 있다.