JWT
JWT 는 현대 서버 간 통신 인증의 주류 방식 중 하나로, 경량이고 stateless 한 특징을 가지고 있습니다.
소개
JWT 는 전체 이름이 JSON Web Tokens 이며, JWT 에 대한 자세한 내용은 [jwt.io](JSON Web Token Introduction - jwt.io) 에서 확인할 수 있습니다. 이는 개방적이고 안전하며 컴팩트하고 JSON 객체를 매체로 하여 서비스 양측 간에 정보를 전송하는 방식으로, 보안성이 높고 내용 변조 방지, 낮은 소비라는 특징이 있습니다.
구조
RFC 표준에서 JWT 는 다음 세 부분으로 구성됩니다.
- Header 헤더
- Payload 페이로드
- Signature 서명
그리고 각 부분은 점 . 으로 구분되며 최종적으로 하나의 문자열로 구성되며 형식은 header.payload.signature 입니다. 이것이 JWT 토큰의 표준 구조이며,接下来 각 구조의 역할을 하나씩 설명하겠습니다.
TIP
base64 와 base64URL 은 동일한 인코딩 방식이 아니며, 후자는 웹 URL 과 호환되며 이스케이프 처리되었습니다.
헤더
헤더는 일부 기본 정보만 선언하며 일반적으로 두 부분으로 구성됩니다. 토큰 유형과 서명에 사용되는 암호화 알고리즘입니다. 예를 들어 다음과 같습니다.
{
"alg": "HS256",
"typ": "JWT"
}위 정보는 대략 토큰 유형이 JWT 이며 서명 부분에 사용된 암호화 알고리즘이 HS256 임을 의미합니다. 마지막으로 JSON 객체를 Base64Url 로 인코딩하여 문자열로 만듭니다. 이 문자열이 JWT 의 헤더입니다.
페이로드
JWT 의 두 번째 부분은 페이로드 부분으로 주로 클레임 (claims) 부분을 포함합니다. 클레임 부분은 일반적으로 엔티티에 대한 데이터입니다. 예를 들어 사용자입니다. 클레임 유형에는 총 세 가지가 있습니다.
registered:Registered claims는 일부 미리 정의된 클레임을 나타내며, 강제 사항은 아니지만 여전히 사용이 권장됩니다. 예를 들어iss(발행자),exp(만료 시간),aud(대상) 등이 있습니다.public:Public claims는 JWT 를 사용하는 사람이 임의로 정의할 수 있으며 다른 클레임 부분과 충돌을 피해야 합니다.private claims: 이 부분의 클레임도 사용자 정의이며 일반적으로 서비스 양측 간에 일부 정보를 공유하는 데 사용됩니다.
페이로드 예제는 다음과 같습니다.
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}이 JSON 객체는 Base64Url 로 인코딩되어 문자열로 만들어 JWT 의 두 번째 부분을 구성합니다.
DANGER
페이로드 부분도 보호되며 변조 방지가 있지만 이 부분은 공개적으로 읽을 수 있으므로 JWT 내에 민감한 정보를 저장하지 마십시오.
서명
인코딩된 헤더와 인코딩된 페이로드 부분을 얻은 후 헤더에서 지정한 서명 알고리즘을 통해 앞의 두 부분 내용과 키를 사용하여 암호화 서명할 수 있습니다. 따라서 JWT 내용이 변경되면 복호화 시 얻은 서명이 모두 달라지며, 개인 키를 사용하면 JWT 발행자를 검증할 수도 있습니다.
sign = HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)예를 들어 다음과 같습니다.
Header
{
"alg": "HS256",
"typ": "JWT"
}
Payload
{
"alg": "HS256",
"typ": "JWT"
}
Verify Signature
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your secret
)최종적으로 얻은 출력은 세 개의 base64Url 문자열로 구성되고 . 으로 구분된 문자열이며 대략 다음과 같습니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
cThIIoDvwdueQB468K5xDc5633seEFoqwxjF_xSJyQQ작동 원리
인증에서 사용자가 자격 증명으로 성공적으로 로그인하면 JSON Web 토큰이 반환됩니다. 토큰은 자격 증명이므로 보안 문제가 발생하지 않도록 매우 주의해야 합니다. 일반적으로 토큰은 필요한 시간보다 길게 보관하지 않아야 합니다. 그런 다음 사용자가 보호된 라우트와 리소스에 액세스하려면 요청을 시작할 때 토큰을携带해야 하며, 일반적으로 요청 헤더의 Authorization 헤더에 있는 Bearer schema 에 있습니다. 예를 들어 다음과 같습니다.
Authorization: Bearer <token>서버는 JWT 를 받은 후 유효성을 검사합니다. 예를 내용 변조, 토큰 만료 등이 있으며, 검증이 통과되면 리소스에 정상적으로 액세스할 수 있습니다. JWT 에 일부 기본 정보를携带할 수 있지만 여전히 정보가 너무 크지 않도록 권장합니다.
JWT 라이브러리
공식 저장소: golang-jwt/jwt: Community maintained clone of https://github.com/dgrijalva/jwt-go
공식 문서: jwt package - github.com/golang-jwt/jwt/v4 - Go Packages
이 라이브러리는 파싱 및 검증과 JWT 생성 및 서명을 지원합니다. 현재 지원되는 서명 알고리즘은 HMAC SHA, RSA, RSA-PSS 및 ECDSA 이지만 직접 훅을 추가할 수도 있습니다.
설치
go get -u github.com/golang-jwt/jwt/v4가져오기
import "github.com/golang-jwt/jwt/v4"서명 알고리즘 선택
사용 가능한 서명 알고리즘은 여러 가지가 있으며, 사용하기 전에 차이점을 이해하여 서명 알고리즘을 더 잘 선택해야 합니다. 이들 간의 가장 큰 차이점은 대칭 암호화와 비대칭 암호화입니다.
가장 간단한 대칭 암호화 알고리즘 HSA 는 모든 []byte 가 유효한 키로 사용될 수 있게 하여 계산 속도가 약간 빠릅니다. 생산자와 소비자 양측이 모두 신뢰할 수 있을 때 대칭 암호화 알고리즘의 효율성이 가장 높습니다. 그러나 서명과 검증에 동일한 키를 사용하므로 검증을 위한 키를 쉽게 배포할 수 없습니다. 서명 키도 동일하므로 서명이 유출되면 JWT 의 보안성은 무의미해집니다.
비대칭 암호화 서명 방법 (예: RSA) 은 토큰 서명과 검증에 서로 다른 키를 사용하여 개인 키로 토큰을 생성할 수 있게 하며, 공개 키를 사용하는 사람도 정상적으로 액세스할 수 있습니다.
서명 알고리즘마다 필요한 키 유형도 다릅니다. 다음은 일반적인 서명 알고리즘 유형입니다.
HMAC: 대칭 암호화로, 서명과 검증에[]byte유형의 값이 필요합니다. (HS256,HS384,HS512)RSA: 비대칭 암호화로, 서명에*rsa.PrivateKey유형의 값이 필요하고 검증에*rsa.PublicKey유형의 값이 필요합니다. (RS256,RS384,RS512)ECDSA: 비대칭 암호화로, 서명에*ecdsa.PrivateKey유형의 값이 필요하고 검증에*ecdsa.PublicKey유형의 값이 필요합니다. (ES256,ES384,ES512)EdDSA: 비대칭 암호화로, 서명에ed25519.PrivateKey유형의 값이 필요하고 검증에ed25519.PublicKey유형의 값이 필요합니다. (Ed25519)
예제
다음은 JWT 생성 및 서명, 파싱 및 검증에 대한 일부 예제를 보여줍니다.
type Token struct {
Raw string // 원본 Token 문자열, 파싱 시작 시 이 필드 채움
Method SigningMethod // 서명에 사용된 방법
Header map[string]interface{} // JWT 의 헤더 부분
Claims Claims // JWT 의 payload 부분
Signature string // JWT 의 서명 부분, 파싱 시작 시 이 필드 채움
Valid bool // JWT 가 유효한지 여부
}Token 구조체는 JWT Token 을 나타내며, 필드 사용은 주로 JWT 가 어떻게 생성/서명되거나 파싱/검증되는지에 따라 다릅니다.
type RegisteredClaims struct {
// the `iss` (Issuer) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1
Issuer string `json:"iss,omitempty"`
// the `sub` (Subject) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2
Subject string `json:"sub,omitempty"`
// the `aud` (Audience) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
Audience ClaimStrings `json:"aud,omitempty"`
// the `exp` (Expiration Time) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4
ExpiresAt *NumericDate `json:"exp,omitempty"`
// the `nbf` (Not Before) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5
NotBefore *NumericDate `json:"nbf,omitempty"`
// the `iat` (Issued At) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6
IssuedAt *NumericDate `json:"iat,omitempty"`
// the `jti` (JWT ID) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7
ID string `json:"jti,omitempty"`
}이는 라이브러리에서 제공하는 미리 정의된 Claims 로, 목적에 맞게 적절히 사용할 수 있습니다.
예 1. HMAC 생성 및 서명
func TestHmac(t *testing.T) {
// hmac 의 키 유형은 바이트 배열입니다.
secret := []byte("my secret")
// HS256 알고리즘 사용, jwt.MapClaims 는 payload 입니다.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"id": 123456,
"name": "jack",
})
fmt.Printf("%+v\n", *token)
// 서명
signedString, err := token.SignedString(secret)
fmt.Println(signedString, err)
}출력:
{Raw: Method:0xc000008150 Header:map[alg:HS256 typ:JWT] Claims:map[id:123456 name:jack] Signature: Valid:false}
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.
QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M
<nil>예 2. 미리 정의된 Claims 사용
mySigningKey := []byte("AllYourBase")
// Claims 생성
claims := &jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Unix(1516239022, 0)),
Issuer: "test",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
ss, err := token.SignedString(mySigningKey)
fmt.Printf("%v %v", ss, err)출력:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpc3MiOiJ0ZXN0IiwiZXhwIjoxNTE2MjM5MDIyfQ.
0XN_1Tpp9FszFOonIBpwha0c_SfnNI22DhTnjMshPg8
<nil>예 3. 사용자 정의 Claims
type MyClaims struct {
User string `json:"user"`
jwt.RegisteredClaims
}
func TestCustomClaims(t *testing.T) {
// 키 생성
secret := []byte("my secret")
// Claims 생성
claims := MyClaims{
User: "114514",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "Server",
},
}
// Token 생성
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// 서명
signedString, err := token.SignedString(secret)
fmt.Println(signedString, err)
}출력:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyIjoiMTE0NTE0IiwiaXNzIjoiU2VydmVyIiwiZXhwIjoxNjczMDg1Nzk2LCJuYmYiOjE2NzMwODIxOTYsImlhdCI6MTY3MzA4MjE5Nn0.
PdPXdQBbDuYtE4ENXzoAcrW-dBSxqsufeYXCT5zTwVI
<nil>TIP
사용자 정의 Claims 에 표준 Claims 를 임베드할 때는 다음을 확인해야 합니다.
임베드된 표준 Claims 는 비포인터 유형이어야 합니다.
포인터 유형인 경우, 전달하기 전에 적절한 메모리를 할당해야 하며 그렇지 않으면 panic 이 발생합니다.
예 4. HMAC 파싱 및 검증 Token
func TestParse(t *testing.T) {
secret := []byte("my secret")
// HS256 알고리즘으로 생성 및 서명되어 token 이 생성되었다고 가정
tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M"
// token 문자열과 검증 훅 함수를 전달하며, 반환 값은 Token 구조체입니다.
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 서명 알고리즘이 일치하는지 검증
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("일치하지 않는 서명 알고리즘: %s", token.Header["alg"])
}
// 검증 키 반환
return secret, nil
})
if err != nil {
fmt.Println(token, err)
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
fmt.Println(claims)
} else {
fmt.Println(err)
}
}출력:
map[id:123456 name:jack]예 5. 오류 처리
func TestProcess(t *testing.T) {
secret := []byte("my secret")
// HS256 알고리즘으로 생성 및 서명되어 token 이 생성되었다고 가정
tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M"
// token 문자열과 검증 훅 함수를 전달하며, 반환 값은 Token 구조체입니다.
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 서명 알고리즘이 일치하는지 검증
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("일치하지 않는 서명 알고리즘: %s", token.Header["alg"])
}
// 검증 키 반환
return secret, nil
})
if token.Valid {
fmt.Println("token 유효")
} else if errors.Is(err, jwt.ErrTokenMalformed) {
fmt.Println("전달된 문자열이 token 도 아닙니다...")
} else if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) {
fmt.Println("token 이 만료되었거나 아직生效하지 않았습니다")
} else {
fmt.Println("token 처리 이상...")
}
}출력:
token 유효예 6. 사용자 정의 Claims 파싱
Token 생성 시 사용자 정의 Claims 를 사용했다면, 파싱 시 Claims 를 map 이 아닌 사용자 정의 Claims 로 직접 변환하려면 사용자 정의 Claims 를 전달해야 합니다.
func TestCustomClaimsParse(t *testing.T) {
secret := []byte("my secret")
tokenstring := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiMTE0NTE0IiwiaXNzIjoiU2VydmVyIiwiZXhwIjoxNjczMDg4MDU2LCJuYmYiOjE2NzMwODQ0NTYsImlhdCI6MTY3MzA4NDQ1Nn0.T245aoDeL2x19X8_JZde0EmZ2TDyIgr1u3ddKFjQmgw"
token, err := jwt.ParseWithClaims(tokenstring, &MyClaims{}, func(token *jwt.Token) (interface{}, error) {
return secret, nil
}, jwt.WithValidMethods([]string{"HS256"})) // option 을 사용하여 검증
// 타입 단언
if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
fmt.Println(claims)
} else {
fmt.Println(err)
}
}출력:
&{114514 {Server [] 2023-01-07 18:40:56 +0800 CST 2023-01-07 17:40:56 +0800 CST 2023-01-07 17:40:56 +0800 CST }}예 7. RSA 서명 및 파싱
RSA 는 분산 아키텍처에서 많이 사용되며, 대략적인 과정은 다음과 같습니다.
- 인증 센터에서 키 쌍을 생성하고 개인 키로 jwt 에 서명하며, jwt 는 클라이언트에 반환되고 공개 키는 비즈니스 서비스가 보유합니다.
- 클라이언트는 jwt 를携带하여 비즈니스 서비스에 요청을 시작하며, 비즈니스 모듈은 공개 키로 jwt 를 파싱하며 인증 센터에 액세스할 필요가 없습니다.
- 인증이 통과되면 비즈니스 정보를 반환합니다.
- 인증이 실패하면 실패 정보를 반환합니다.
func TestRsa(t *testing.T) {
// 키 쌍 생성
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
publicKey := &privateKey.PublicKey
if err != nil {
fmt.Println(err)
return
}
// claims
claims := MyClaims{
User: "114514",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "Server",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
// 개인 키 암호화
signedString, err := token.SignedString(privateKey)
fmt.Println(signedString, err)
// 공개 키 복호화
token, err = jwt.ParseWithClaims(signedString, &MyClaims{}, func(token *jwt.Token) (interface{}, error) {
return publicKey, nil
})
if err != nil {
fmt.Println(err)
} else if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
fmt.Println(claims)
}
}