토큰기반 사용자 인증 - JWT

정민·2023년 11월 28일
1
post-thumbnail

회원 관리 기능을 구현하고 싶은데 어떤 것을 해볼까 생각하다가 벨로그에서 할 수 있는 이메일, 깃허브, 구글, 페이스북으로 회원을 관리하는 기능을 구현해 보려고 한다. 이 기능을 구현하기 위해 인증 방식, 이메일 전송, 소셜 로그인에 관해 공부해야 하는데 첫 번째로 회원을 인증하는 방식 중 하나인 JWT에 대해 알아보고 JWT를 생성하고 검증할 수 있는 라이브러리인 jjwt를 사용해보자



1. JWT(Json Web Token)는 무엇일까


JWT는 당사자 간에 정보를 JSON을 통해 안전하게 전송하기 위한 간결하고 독립적인 방법을 정의하는 국제 표준(RFC 7519)이다.
JWT는 보통 클라이언트와 서버 간의 인증에서 사용되며 인증된 클라이언트에게 서버는 대칭 또는 비대칭 암호화를 통해 서명한 JWT를 주게 되고 이후 클라이언트가 요청할 때 서버가 JWT를 통해 검증할 수 있다.

JWT의 정확한 동작 원리를 알기 위해서는 JWT의 구조를 알 필요가 있다.



2. JWT 구조


JWT는 헤더(Header), 페이로드(Payload), 서명(Signature)으로 구성되어 있으며 각각 헤더와 페이로드는 JSON 형식이며 Base64로 인코딩된다. 이 3가지가 마침표로 구분되어 JWT가 만들어진다.



1. 헤더(Header)


헤더는 토큰의 종류와 서명에 사용된 알고리즘 방식 정보가 들어있다.

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

위의 헤더는 HMAC-SHA256 암호화 알고리즘을 사용하며 JWT라는 정보를 담고 있다.



2. 페이로드(payload)


페이로드는 사용자 정보 또는 데이터가 들어갈 수 있다. 3가지의 클레임으로 구성될 수 있으며 클레임들은 필수적으로 사용하지 않아도 된다.



1. 등록된 클레임(Registered Claim)


등록된 클레임은 IANA에 JSON Web Token Claims라는 레지스트리에 등록된 클레임이다.

  • iss(Issuer) Claim : 토큰을 발급한 대상을 말한다.
  • sub(Subject) Claim : 토큰의 제목을 말한다.
  • aud(Audience) Claim : 토큰을 받는 대상자를 말한다.
  • exp(Expiration Time) Claim : 토큰의 유효기간을 말한다.
  • nbf(Not Before) Claim : 토큰이 활성화되는 시간을 말한다.
  • iat(Issued At) Claim : 토큰이 발급된 시간을 말한다.
  • jti(JWT ID) Claim : 토큰의 ID를 말한다.


2. 공개 클레임(Public Claim)


토큰을 사용하는 사람들이 정의하는 클레임으로, 충돌 방지를 위해서 다른 이름과 중복되지 않는 이름을 짓 거나 IANA에 등록하여 사용하여야 한다. 하지만 서버와 클라이언트 간에만 사용된다면 제3자가 사용할 일 이 없으므로 크게 신경 쓰지 않아도 된다.



3. 비공개 클레임(Private Claim)


토큰 공급자와 토큰 사용자 간에 협의된 클레임으로 다른 클레임과 충돌되지 않게 사용하면 된다.

{
  "sub": "1234567890", //등록된 클레임
  "name": "John Doe", // 공개 클레임
  "admin": true // 비공개 클레임
}

페이로드는 하나의 JSON에 모두 작성된다.

주의 : 토큰을 가진다면 누구나 정보를 확인 할 수 있으므로 민감한 정보는 절대 포함시키면 안된다.



3. 서명(Signature)


서명은 암호화 알고리즘을 사용해서 헤더와 페이로드를 각각 base64 인코딩한 값을 키로 암호화한다. 이 암호화 된 값을 통해 이 토큰의 무결성을 검증할 수 있다.



3. JWT가 만들어지는 과정


1. 헤더 생성

// 헤더
{
  "alg": "HS256",
  "typ": "JWT"
}

헤더를 base64 인코딩 : ewogICJhbGciOiAiSFMyNTYiLAogICJ0eXAiOiAiSldUIgp9



2. 페이로드 생성

// 페이로드
{
  "iss": "server"
  "aud": "user1"
  "sub": "subject"
  "iat": 1701008141 
  "exp": 1701008441
  "userId": 1
  "email": "user1@example.com"
}

페이로드를 base64 인코딩 : ewogICJpc3MiOiAic2VydmVyIgogICJhdWQiOiAidXNlcjEiCiAgInN1YiI6ICJzdWJqZWN0IgogICJpYXQiOiAxNzAxMDA4MTQxIAogICJleHAiOiAxNzAxMDA4NDQxCiAgInVzZXJJZCI6IDEKICAiZW1haWwiOiAidXNlcjFAZXhhbXBsZS5jb20iCn0



3. 서명하기

헤더와 페이로드를 마침표로 구분: ewogICJhbGciOiAiSFMyNTYiLAogICJ0eXAiOiAiSldUIgp9.ewogICJpc3MiOiAic2VydmVyIgogICJhdWQiOiAidXNlcjEiCiAgInN1YiI6ICJzdWJqZWN0IgogICJpYXQiOiAxNzAxMDA4MTQxIAogICJleHAiOiAxNzAxMDA4NDQxCiAgInVzZXJJZCI6IDEKICAiZW1haWwiOiAidXNlcjFAZXhhbXBsZS5jb20iCn0

암호화 결과:
_YIWNJdMwXo1GwUuQ-k3i9f_gdHnPkr2CVQ2EW9qyO4



4. JWT 조합하기.

헤더, 페이로드, 서명을 마침표로 구분한다.

완성된 JWT:
ewogICJhbGciOiAiSFMyNTYiLAogICJ0eXAiOiAiSldUIgp9.ewogICJpc3MiOiAic2VydmVyIgogICJhdWQiOiAidXNlcjEiCiAgInN1YiI6ICJzdWJqZWN0IgogICJpYXQiOiAxNzAxMDA4MTQxIAogICJleHAiOiAxNzAxMDA4NDQxCiAgInVzZXJJZCI6IDEKICAiZW1haWwiOiAidXNlcjFAZXhhbXBsZS5jb20iCn0._YIWNJdMwXo1GwUuQ-k3i9f_gdHnPkr2CVQ2EW9qyO4

사용자는 인증과정을 거치고 난 후 JWT를 받고 이 후의 요청에서 사용자는 JWT를 서버에 제공하여 자신을 인증하는데 서버는 JWT에서 서명부분을 검증하여 무결성을 확인하고 사용자를 신뢰할 수 있게 된다.

  • 시간은 JWT 표준에서 권장하는 POSIX 시간 형식이다.
  • base64 인코딩시 = 또는 ==이 나오는 경우 URL Safe하게 되지 않아서 제거 해야한다.


4. jjwt 라이브러리를 통한 JWT 사용


위의 JWT가 만들어지는 과정을 직접 구현할 수도 있겠지만 좀더 편하고 쉽게 구현하기위해 jjwt 라이브러리를 통해 JWT를 구현해볼 생각이다.



암호화 키 생성

Date fiveMinutesLater = new Date(new Date().getTime() + (5 * 60 * 1000));
SecretKey key = Jwts.SIG.HS256.key().build();

토큰의 유효기간을 5분으로 하기위해서 5분뒤의 시간을 구했다.
예시에서는 HMAC-SHA256 암호화 알고리즘을 사용한다.



JWT 생성

String jwt = Jwts.builder()
                .header().type("JWT").and()
                .issuer("server")
                .audience().add("user1").and()
                .subject("subject")
                .expiration(fiveMinutesLater)
                .notBefore(new Date()) 
                .issuedAt(new Date()) 
                .id("JWT ID")
                .claim("email", "user@example.com")
                .claim("userId", "user1")
                .signWith(key)
                .compact();

Jwts.builder()를 통해 JWT를 만들기 시작하며. header()로 헤더를 만들 수 있다. (alg 속성은 jjwt에서 자동으로 만들어준다) 헤더를 다 작성하면. and()로 페이로드 작성 부분으로 넘어갈 수 있다. issuer(), audience() 등 등록된 클레임은 만들어져있고 공개 클레임 또는 비공개 클레임 같은 경우 claim()으로 만들 수 있다. 이후 signWith()에 만들어둔 키를 넣어 어떤 키로 암호화하는지 정할 수 있다. 마지막으로 compact()로 JWT 토큰을 만든다.



JWT 검증

try {
      Jwts.parser()
               .verifyWith(key)
               .build()
               .parseSignedClaims(jwt)
               .getPayload().getAudience().equals("user");

      System.out.println("JWT 인증 성공!");

} catch (JwtException e) {
   throw new RuntimeException("유효하지 않은 JWT 토큰입니다.");
}

Jwts.parser()로 JwtParser 객체를 생성한다. verifyWith()로 검증할 key를 넣을 수 있다. build()를 통해 JwtParser 구성을 완료한다. 이후 parseSignedClaims()로 검증할 jwt를 넣으면 검증한 후 JWT를 Jws 타입으로 파싱하여 리턴한다. 리턴된 Jws 객체에서 getPayload().getAudience()로 현재 토큰의 대상자가 user 인지 확인해서 맞는다면 JWT 인증 성공! 을 출력했다. 이 과정에서 JWT의 검증이 실패할 때 JwtException 예외가 발생한다.



5. JWT의 장단점


로그인을 관리하는 방식으로는 세션(Stateful) 방식과 토큰(StateLess) 방식이 있다. 세션 방식 같은 경우는 서버에서 현재 로그인 한 사용자들을 세션이라는 곳에서 관리하게 된다. 이는 서버에서 실시간으로 로그인한 사용자를 관리할 수 있다는 장점을 가지고 있다. 하지만 서버에서 로그인한 사용자들을 관리해야 하기 때문에 사용자들이 늘어날수록 서버에 부하는 심해진다. 또한 여러 대의 서버를 두게 되었을 때 세션 관리가 어려워지며 모바일과 PC 등 여러 기기에서 로그인 할 수 있을 때 로그인 방식도 고려해야 한다는 단점이 있다.
토큰 방식은 토큰 자체만으로 무결성을 검증하고 사용자를 신뢰할 수 있기 때문에 앞서 말한 세션의 단점을 해결하여 서버에서 상태를 저장하지 않고 확장성이 매우 편해지게 된다. 하지만 서버에서 상태를 저장하지 않기 때문에 한 번 발급한 토큰은 유효기간이 끝날 때 까지, 서버에서 관리할 수 없게 되는 문제가 생긴다. 이는 토큰이 탈취 당했을 때 큰 문제가 생기는데 이를 해결하기 위해 서버에서는 AccessToken과 RefreshToken 두 개를 발급하는 방식과 RefreshToken을 로테이션시키는 RTR 방식이 있다.



1. AccessToken, RefreshToken


서버는 유효기간이 짧은 AccessToken과 유효기간이 긴 RefreshToken을 만들어 사용자에게 발급한다. 사용자는 AccessToken으로 서버에 요청을 하게 되며 서버는 AccessToken을 검증하여 만약 유효기간이 지났다면 RefreshToken을 요구하고 RefreshToken을 검증한 후 AccessToken을 재발급해주는 방식이다. 이 방법을 통해 AccessToken을 탈취당하더라도 짧은 시간의 유효기간으로 인해 크게 부담되지 않게 된다.



2. RTR(RefreshToken Rotation)


TR 방식은 RefreshToken을 일회용으로 두어 RefreshToken으로 AccessToken을 재발급할 때 RefreshToken도 같이 발급하는 방식이다. 이를 통해 탈취당한 RefreshToken으로 AccessToken 재요청을 할 때 여러 번의 RefreshToken 사용을 감지하게 되어 요청을 거부할 수 있게 된다.

위의 방법으로 보안이 완벽하게 해결되지는 않는다. 사용되지 않은 RefreshToken을 탈취 당할 수도 있고 AccessToken을 탈취당한다면 막을 수 있는 방법이 없기때문이다.



참고

https://hudi.blog/self-made-jwt/

https://datatracker.ietf.org/doc/html/rfc7519

https://github.com/jwtk/jjwt

0개의 댓글