JWT(JSON Web Token)
JWT 는 유저를 인증하고 식별하기 위한 토큰(Token)기반 인증이다. RFC 7519 에 자세한 명세가 나와있다. 토큰은 세션과는 달리 서버가 아닌 클라이언트에 저장되기 때문에 메모리나 스토리지 등을 통해 세션을 관리했던 서버의 부담을 덜 수 있다. JWT 가 가지는 핵심적인 특징이 있다면, 토큰 자체에 사용자의 권한 정보나 서비스를 사용하기 위한 정보가 포함(Self-contained)된다는 것이다. 데이터가 많아지면 토큰이 커질 수 있으며 토큰이 한 번 발급된 이후 사용자의 정보를 바꾸더라도 토큰을 재발급하지 않는 이상 반영되지 않는다.
JWT 를 사용하면 RESTful 과 같은 무상태(Stateless)인 환경에서 사용자 데이터를 주고 받을 수 있게된다. 세션(Session)을 사용하게 될 경우에는 쿠키 등을 통해 식별하고 서버에 세션을 저장했지만 JWT 와 같은 토큰을 클라이언트에 저장하고 요청시 단순히 HTTP 헤더에 토큰을 첨부하는 것만으로도 단순하게 데이터를 요청하고 응답을 받아올 수 있다.
일반적으로 JWT 를 사용하면 아래와 같은 순서로 진행된다.
- 클라이언트 사용자가 아이디, 패스워드를 통해 웹서비스 인증.
- 서버에서 서명된(Signed) JWT 를 생성하여 클라이언트에 응답으로 돌려주기.
- 클라이언트가 서버에 데이터를 추가적으로 요구할 때 JWT 를 HTTP Header 에 첨부.
- 서버에서 클라이언트로부터 온 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)
전자서명에는 비대칭 암호화 알고리즘을 사용하므로 암호화를 위한 키와 복호화를 위한 키가 다르다. 암호화(전자서명)에는 개인키를, 복호화(검증)에는 공개키를 사용한다.
* 아래 섹션부터는 구현 영역이다. 구현에는 관심없다면 마치며로 넘어가자.
* 구현부는 Go 언어로 작성되었다.
구성요소
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 라우트를 테스트한 결과다. 이 라우트에서는 토큰을 발급하는 것이 주요 사안이다. 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)
}
})
조회
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
을 반환한다. 그 외에 Claims
등 Token
의 요소들에 있는 것을 초기화한다. 서명도 여기서 바로 한다. 서명에 대한 코드를 보려면 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 를 구현한 프로젝트들은 있기 때문에 직접 구현하는 것은 원리 파악을 위해 한 번이면 충분하다.