JWT(JSON Web Token)의 개념부터 구현까지 알아보기

JWT(JSON Web Token)의 개념부터 구현까지 알아보기

JWT(JSON Web Token)

JWT 는 유저를 인증하고 식별하기 위한 토큰(Token)기반 인증이다. RFC 7519 에 자세한 명세가 나와있다. 토큰은 세션과는 달리 서버가 아닌 클라이언트에 저장되기 때문에 메모리나 스토리지 등을 통해 세션을 관리했던 서버의 부담을 덜 수 있다. JWT 가 가지는 핵심적인 특징이 있다면, 토큰 자체에 사용자의 권한 정보나 서비스를 사용하기 위한 정보가 포함(Self-contained)된다는 것이다. 데이터가 많아지면 토큰이 커질 수 있으며 토큰이 한 번 발급된 이후 사용자의 정보를 바꾸더라도 토큰을 재발급하지 않는 이상 반영되지 않는다.

 

JWT 를 사용하면 RESTful 과 같은 무상태(Stateless)인 환경에서 사용자 데이터를 주고 받을 수 있게된다. 세션(Session)을 사용하게 될 경우에는 쿠키 등을 통해 식별하고 서버에 세션을 저장했지만 JWT 와 같은 토큰을 클라이언트에 저장하고 요청시 단순히 HTTP 헤더에 토큰을 첨부하는 것만으로도 단순하게 데이터를 요청하고 응답을 받아올 수 있다.

 

일반적으로 JWT 를 사용하면 아래와 같은 순서로 진행된다.

  1. 클라이언트 사용자가 아이디, 패스워드를 통해 웹서비스 인증.
  2. 서버에서 서명된(Signed) JWT 를 생성하여 클라이언트에 응답으로 돌려주기.
  3. 클라이언트가 서버에 데이터를 추가적으로 요구할 때 JWT 를 HTTP Header 에 첨부.
  4. 서버에서 클라이언트로부터 온 JWT 를 검증.

JWT 는 JSON 데이터를 Base64 URL-safe Encode 를 통해 인코딩하여 직렬화한 것이 포함되며 토큰 내부에는 위변조 방지를 위해 개인키를 통한 전자서명도 있다. 따라서 사용자가 JWT 를 서버로 전송하면 서버는 서명을 검증하는 과정을 거치게 되며 검증이 완료되면 요청한 응답을 돌려준다.

Base64 URL-safe Encode 는 일반적인 Base64 Encode 에서 URL 에서 오류없이 사용하도록 '+', '/' 를 각각 '-', '_' 로 표현한 것이다.

구조

JWT 의 구조를 살펴보자. JWT는 Header, Payload, Signature 로 구성된다. 또한 각 요소는 . 으로 구분된다. Header 에는 JWT 에서 사용할 타입과 해시 알고리즘의 종류가 담겨있으며 Payload 는 서버에서 첨부한 사용자 권한 정보와 데이터가 담겨있다. 마지막으로 Signature 에는 Header, Payload 를 Base64 URL-safe Encode 를 한 이후 Header 에 명시된 해시함수를 적용하고, 개인키(Private Key)로 서명한 전자서명이 담겨있다. 전자서명 알고리즘으로 타원 곡선 암호화(ECDSA)를 사용한다고 가정하면,

Sig = ECDSA(SHA256(B64(Header).B64(Payload)), PrivateKey)

이를 JWT 로 표현하려면, 다음과 같이 되는데, 위에서 만든 전자서명도 Base64 URL-safe Encode 로 처리해서 합쳐줄 필요가 있다. 여기서 만든 전자서명은 Header, Payload 가 변조되었는지 확인하기 위해 사용되는 중요 정보이며 JWT 를 신뢰할 수 있는 토큰으로 사용할 수 있는 근거가 된다.

JWT = B64(Header).B64(Payload).B64(Sig)

전자서명에는 비대칭 암호화 알고리즘을 사용하므로 암호화를 위한 키와 복호화를 위한 키가 다르다. 암호화(전자서명)에는 개인키를, 복호화(검증)에는 공개키를 사용한다.

 

* 아래 섹션부터는 구현 영역이다. 구현에는 관심없다면 마치며로 넘어가자.

구성요소

struct Token

먼저, Token 의 구조체를 선언하면 다음과 같은데,  JWT 에는 Header, Payload, Signature, 그리고 전자서명에 사용할 알고리즘(HMAC, ECDSA 등)을 추상화한 SigningMethod 인터페이스를 충족하는 인스턴스를 하나 가지고 있는 것을 볼 수 있다. SigningMethod 는 전자서명에 쓰일 알고리즘을 표현해놓은 것이다. SigningString 은 Header, Payload 를 Base64 URL-safe Encode 로 처리하고 합친 것을 말한다. 

// Token 에 전자서명에 사용할 알고리즘을 포함한다.
type Token struct {
	Header        Header
	Claims        Claims
	Signature     string
	SigningString string
	Method        SigningMethod
}

interface SigningMethod

전자서명 알고리즘을 표현하는 SigningMethod 에는 알고리즘의 이름을 반환하는 Alg(), 검증에 쓰일 Verify(), 서명을 생성할 때 필요한 Sign() 메서드가 필요하다. 또한 내부적으로 전자서명에 사용할 알고리즘에 알맞는 개인키공개키를 가지고 있는 것을 전제로 한다. 따라서 해당 인터페이스를 따르는 구조체는 전자서명을 생성할 때 사용하게 된다.

// SigningMethod 인터페이스는 서명 알고리즘을 정의할 때 사용한다.
type SigningMethod interface {
	Alg() string                                 // 알고리즘
	Verify(signingString, signature string) bool // 검증
	Sign(signingString string) string            // 서명
}

struct Header

Header토큰의 타입(일반적으로 'JWT')와 해시 알고리즘의 종류가 담겨있다. 여기서는 ES256(P-256 + SHA256)을 사용해보자. 블록체인에서 지갑의 주소 생성과 트랜잭션 검증을 위해 사용했던 알고리즘인 ECDSA(Elliptic Curve Digital Signature Algorithm)을 사용해볼 것이다. Header 는 아래와 같고, 이후에 사용하게 된다.

// Header 에는 typ, alg 로 구성된다.
type Header struct {
	Typ string `json:"typ"`
	Alg string `json:"alg"`
}

Payload

Claims사용자의 데이터나 권한이 담겨있다. 일반적으로 JSON 형태의 데이터라면 상관없다. 클레임(Claim)이라 표현하며 Key/Value 형태로 가지고 있다. Payload 에 저장되는 정보에 따라 Registered Clamis, Public Clamis, Private Claims 로 구분된다. 이러한 클레임의 종류는 RFC 7519#Section 4 에서 찾아볼 수 있지만, 일반적으로는 Registered Clamis 보다는 보다 자유로운 Private Claims 의 사용이 많을 것이다.

// Claims 는 Key/Value 형태로 된 값을 가진다.
type Claims map[string]interface{}

Signature

Signature 는 Header, Payload 를 대상으로 Base64 URL-safe Encode 를 적용하고, 해싱 한 뒤, 이를 대상으로 비밀키로 서명한 것이다. 비밀키와 공개키는 ECDSA 를 사용하여 생성하게 될 것이다. 비밀키로 서명하게 되면 공개키를 통해 검증을 거치게 될 것이다.

 

이러한 구조를 사용하여 JWT 를 생성하면 아래와 같은 모양이 나타나게 된다.

eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOiIxNTg2MzY0MzI3IiwiaXNzIjoiamluaG8uc2hpbiJ9.fWynQLZcHUgeFvFOWT8x-kdRyPmibeMRh4np81Rf9OuXVkbkFCmpdsdbDVWx_QLjdTzAnyBZHPqzKhY1gQDegA

요청과 라우트 구성하기

자, 이제 JWT의 자세한 내용을 구현하기 전에 어떻게 사용할 건지부터 알아보자. 우리는 Go 언어로 웹 서버를 하나 만들고, 토큰을 발행하고 검증하는 작업을 거치게 될 것이다. 먼저 SigningMethod 를 구현한 서명 알고리즘인 SigningMethodEs256 은 토큰에 사용할 전자서명을 생성하고 검증하기 위한 키페어(Key Pair)를 가지고 있어야 하는데, KeyParECDSA 는 ECDSA 에서 사용할 개인키(Private Key)공개키(Public Key)를 의미하며 이를 주입한다.

func main() {
	es256 := NewSigningMethodEs256(NewKeyPairECDSA())
	// ...
    
	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

/auth/login

아래는 포스트 맨에서 /auth/login 라우트를 테스트한 결과다. 이 라우트에서는 토큰을 발급하는 것이 주요 사안이다. Header 를 살펴보면, Access_token 에 JWT 토큰 값이 나와있는 것을 볼 수 있다. 그 외에 별도의 요청 파라매터는 넣지 않았다. 토큰의 발급이 주요 내용이므로 유저에 대한 인증은 생략할 것이기 때문이다.

프로덕트라면 유저를 인증하는 과정이 따로 필요하지만, 지금은 생략했다. JWT 를 발급하는 처리를 한다. access_token 헤더를 추가하여 발급해보자. jwt.SignedString() 는 서명된(Signed) JWT 문자열을 반환하게 된다. es256.Verify() 는 토큰을 검증한다.

http.HandleFunc("/auth/login", func(w http.ResponseWriter, r *http.Request) {
    // 인증 및 클레임 생성...
    user := Claims{"email": "pronist@naver.com"}

    // 토큰 생성
    token := New(es256, user)
    // 토큰 검증
    if verified := es256.Verify(token.SigningString, token.Signature); verified {
        // 토큰 발급
        w.Header().Set("access_token", token.SignedString())
    } else {
        http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
    }
})

/user

HTTP Header 에 Authorization 을 설정하고 요청을 보낸 결과다. 응답으로 토큰에 설정한 Payload 를 응답해주는 모습을 볼 수 있다. Payload 는 사실 토큰만 있더라도 얻을 수 있는데, 그저 Base64 URL-safe Encode 한 것을 Decode 해주면 되는 것이다.

여기에서는 Authorization 헤더로 넘어온 토큰을 Parser 를 사용하여 파싱한 뒤, 검증하고, Claims 를 반환하여 준다. 만약 토큰이 올바르게 검증이 안 된 경우 401 을 응답으로 던져준다. 편의상 일부 에러처리는 제외했다.

http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
    // Authorization 헤더 얻기
    raw := r.Header.Get("Authorization")
    var parser Parser

    // 토큰 파싱
    token := parser.Parse(es256, raw)
    // 토큰 검증
    if verified := es256.Verify(token.SigningString, token.Signature); verified {
        c, err := json.Marshal(token.Claims)
        if err != nil {
            panic(err)
        }
        if _, err = w.Write(c); err != nil {
            panic(err)
        }
    } else {
        http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
    }
})

구현

struct KeyPairECDSA

이 녀석은 ECDSA 에서 사용할 개인키/공개키 페어를 가지고 있고, 초기화한다.

// KeyPairECDSA 는 ECDSA 를 위한 키페어를 의미한다.
type KeyPairECDSA struct {
	PrivateKey *ecdsa.PrivateKey // 개인키
	PublicKey  []byte            // 공개키
}

// NewKeyPairECDSA 새로운 NewKeyPairECDSA 를 만든다.
func NewKeyPairECDSA() *KeyPairECDSA {
	var err error
	var keyPairECDSA KeyPairECDSA

	// 개인키 생성
	curve := elliptic.P256()
	keyPairECDSA.PrivateKey, err = ecdsa.GenerateKey(curve, rand.Reader)
	if err != nil {
		panic(err)
	}

	// 공개키 생성
	keyPairECDSA.PublicKey = append(
		keyPairECDSA.PrivateKey.PublicKey.X.Bytes(),
		keyPairECDSA.PrivateKey.PublicKey.Y.Bytes()...)

	return &keyPairECDSA
}

struct SigningMethodEs256

SigningMethodEs256 은 ES256 을 사용하여 전자서명을 생성하기 위한 구조체며 SingingMethod 인터페이스를 충족한다.

// SigningMethodEs256 을 사용하면 ES256 알고리즘을 사용하여 토큰을 만들 수 있다.
type SigningMethodEs256 struct {
	Name         string
	KeyPairECDSA *KeyPairECDSA
}

NewSigningMethodEs256() *SigningMethodEs256

이름을 정해주고 개인키/공개키 키페어를 지정해주자.

func NewSigningMethodEs256(keypair *KeyPairECDSA) *SigningMethodEs256 {
	return &SigningMethodEs256{Name: "ES256", KeyPairECDSA: keypair}
}

SigningMethodEs256.Sign(string) (string, error)

signingString 은 Header, Payload 를 Base64 URL-safe Encode 로 처리한 문자열이 넘어오게 되며 이를 SHA256 해시로 해싱하고 개인키로 서명한다. 이후 서명을 다시 인코딩하여 반환한다.

func (m *SigningMethodEs256) Sign(signingString string) string {
	// 개인키로 서명하고 전자서명 만들기
	hash := sha256.Sum256([]byte(signingString))
	r, s, err := ecdsa.Sign(rand.Reader, m.KeyPairECDSA.PrivateKey, hash[:])
	if err != nil {
		panic(err)
	}

	sig := append(r.Bytes(), s.Bytes()...)

	// 만들어진 전자서명도 base64 Url-safe Encode
	return base64.RawURLEncoding.EncodeToString(sig)
}

SigningMethodEs256.Verify(string, string) (bool, error)

마지막으로 SigningMethodEs256.Verify() 는 Es256 알고리즘으로 서명된 전자서명을 검증하기 위해 사용되며 서명에서 r, s 값을 구하고 기존의 공개키에서 x, y 값을 구하여 검증하는 과정을 거친다.

func (m *SigningMethodEs256) Verify(signingString, signature string) bool {
	// 검증을 위해 r, s 값 구하기
	var r, s big.Int

	sig, err := base64.RawURLEncoding.DecodeString(signature)
	if err != nil {
		panic(err)
	}

	sigLen := len(sig)
	r.SetBytes(sig[:sigLen/2])
	s.SetBytes(sig[sigLen/2:])

	// 기존의 공개키에서 검증을 위한 x, y 값 구하기
	var x, y big.Int
	curve := elliptic.P256()

	keyLen := len(m.KeyPairECDSA.PublicKey)
	x.SetBytes(m.KeyPairECDSA.PublicKey[:keyLen/2])
	y.SetBytes(m.KeyPairECDSA.PublicKey[keyLen/2:])

	pubKey := ecdsa.PublicKey{Curve: curve, X: &x, Y: &y}

	hash := sha256.Sum256([]byte(signingString))

	return ecdsa.Verify(&pubKey, hash[:], &r, &s)
}

struct Token

New(SigningMethod, Claims) *Token

이 함수는 새로운 토큰을 만드는 것이 목적이며, Header 를 정의하고 SigningMethod 의 인스턴스를 가지고 있게된다. SigningMethod.Alg() 는 알고리즘의 이름을 반환하므로 ES256 을 반환한다. 그 외에 ClaimsToken 의 요소들에 있는 것을 초기화한다. 서명도 여기서 바로 한다. 서명에 대한 코드를 보려면 SigningMethodEs256.Sign() 을 살펴보자.

// New 새로운 Jwt 를 만든다.
func New(m SigningMethod, c Claims) *Token {
	token := Token{Header: Header{Typ: "JWT", Alg: m.Alg()}, Claims: c, Method: m}

	// Header, payload 를 JSON 문자열로 만들기
	header, err := json.Marshal(token.Header)
	if err != nil {
		panic(err)
	}
	claims, err := json.Marshal(token.Claims)
	if err != nil {
		panic(err)
	}

	// 서명 얻기
	token.SigningString = strings.Join([]string{
		// Header, Payload 를 JSON base64 Url-safe Encode
		base64.RawURLEncoding.EncodeToString(header), base64.RawURLEncoding.EncodeToString(claims),
	}, ".")

	token.Signature = token.Method.Sign(token.SigningString)

	return &token
}

Token.SignedString() string

이 메서드는 Header.Payload.Signature 구조를 가지고 있는 문자열을 반환한다.

// SignedString 은 base64 Url-safe Encode 처리된
// Header, Payload, 그리고 개인키로 서명된 전자서명으로 구성된 문자열을 반환한다.
func (t *Token) SignedString() string {
	return strings.Join([]string{t.SigningString, t.Signature}, ".")
}

JWT 의 페이로드 부분이 단순 Base64 URL-safe Encode 를 통해 처리 된 것이므로 이를 디코딩이 및 위변조가 가능하여 이러한 검증과정은 반드시 필요하다. 또한 생성된 토큰이 노출되는 일은 없어야한다. 만일을 대비해 토큰에 중요한 정보를 넣는 일은 자제해야 한다.

struct Parser

토큰을 파싱하기 위한 파서다. Parser.Parse() 에서는 SigningMethod, HTTP Authorization Header 를 통해 얻어온 Raw 한 형태의 토큰을 받아와 파싱하고 새 토큰을 만든다.

// Parser 는 토큰을 파싱하기 위한 것이다.
type Parser struct{}

// Parse 는 토큰을 하여 새로운 토큰을 반환한다.
func (p Parser) Parse(m SigningMethod, ts string) *Token {
	t := strings.Split(ts, ".")
	var token Token

	// header
	var header Header

	h, err := base64.RawURLEncoding.DecodeString(t[0])
	if err != nil {
		panic(err)
	}
	if err := json.Unmarshal(h, &header); err != nil {
		panic(err)
	}

	token.Header = header

	// Claims
	var claims Claims

	c, err := base64.RawURLEncoding.DecodeString(t[1])
	if err != nil {
		panic(err)
	}
	if err := json.Unmarshal(c, &claims); err != nil {
		panic(err)
	}

	token.Claims = claims

	// Signature
	token.Signature = t[2]
	// SigningString
	token.SigningString = strings.Join([]string{t[0], t[1]}, ".")
	// SigningMethod
	token.Method = m

	return &token
}

마치며

여끼가지 JWT(JSON Web Token)에 대해 알아보고 구현도 해보았다. JWT 가 가지고 있는 장단점을 마지막으로 정리해보면 아래와 같다.

  • JWT 는 최근 웹서비스에서 범용적으로 사용되고 있으며 규격이 정해져 있기 때문에 다양한 클라이언트(웹, 모바일 등)에서 호환성이 뛰어나다.
  • Payload 가 많아지면 토큰이 커져서 서버의 부담이 갈 수 있다.
  • 토큰이 재발급되기 전까지 사용자 정보가 갱신되더라도 적용되지 않는 문제가 있다.
  • RESTful 과 같은 무상태(Stateless) 환경에서의 통신이 용이하고 사용하기 쉽다.
  • 데이터를 자체적으로 가지고 있어서 데이터를 얻기위해 타 서비스에 다시 요청하는 횟수가 줄어들어 서버의 부담이 줄어들게 된다.
  • 토큰의 만료시간이 있는 경우 만료시간까지는 강제적으로 만료시킬 수 없으므로 노출이 되어서는 안 되며 중보정보를 넣는 일은 없어야 한다.

이 포스트에서 구현한 내용은 기존에 존재하던 jwt-go 의 구현을 같지는 않지만 일부 모방하여 만든 것이며, Go 언어에서 jwt 를 다루려면 아래의 오픈소스 프로젝트를 참고하자. 다른 언어들도 jwt 를 구현한 프로젝트들은 있기 때문에 직접 구현하는 것은 원리 파악을 위해 한 번이면 충분하다.

 

https://github.com/pronist/StudyBook

 

GitHub - pronist/StudyBook: 📕 공부의 기록, 알고리즘, 책 및 블로그(https://pronist.tistory.com) 예제코드 모

📕 공부의 기록, 알고리즘, 책 및 블로그(https://pronist.tistory.com) 예제코드 모음 - GitHub - pronist/StudyBook: 📕 공부의 기록, 알고리즘, 책 및 블로그(https://pronist.tistory.com) 예제코드 모음

github.com

더 읽을거리

쿠키(Cookie), 그리고 세션(Sessions)에 대해 알아보자

OAuth 2.0 클라이언트 만들기(feat. 깃허브)