JWT in Action: Go에서 안전한 인증 및 권한 부여
Lukas Schneider
DevOps Engineer · Leapcell

JWT에 대한 심층적인 설명: 원리, 형식, 특징 및 Go 프로젝트에서의 응용
JWT란 무엇인가
JWT는 JSON 웹 토큰의 약자로, 교차 도메인 인증 솔루션입니다. 웹 애플리케이션에서 중요한 역할을 하며 안전하고 편리한 인증 및 정보 전송을 가능하게 합니다.
JWT를 사용하여 해결되는 문제
기존 사용자 인증 프로세스의 제한 사항
기존 인증은 클라이언트 측 쿠키와 서버 측 세션에 의존합니다. 이는 단일 서버 애플리케이션에서는 잘 작동합니다. 그러나 다중 서버 배포의 경우 세션 공유 문제가 있습니다. 예를 들어 여러 서버가 함께 작동하는 대규모 분산 시스템에서 각 서버는 독립적인 세션을 유지 관리합니다. 사용자가 서버 간에 전환할 때 로그인 상태가 일치하지 않을 수 있습니다. 동시에 쿠키는 도메인을 기반으로 설정되고 다른 도메인은 쿠키를 직접 공유할 수 없기 때문에 쿠키 + 세션을 통해 여러 도메인에서 싱글 사인온을 달성할 수 없어 다중 비즈니스 시스템에서 통합 인증 및 액세스 제어가 제한됩니다.
JWT의 장점
JWT는 애플리케이션을 stateless하게 만들어 세션 공유의 필요성을 없애줍니다. 자체 구조 내에 사용자 정보를 포함합니다. 서버는 세션을 저장할 필요가 없습니다. 대신 각 요청에 대해 JWT의 유효성을 확인하여 사용자의 ID를 확인할 수 있습니다. 분산 시스템에서 JWT는 서버 확장을 용이하게 하며 서버의 수와 분포에 영향을 받지 않습니다.
JWT 형식
올바른 JWT 형식은 다음과 같습니다.
eyJhbGciOiJIUzI1NiIsInR5c.eyJ1c2VybmFtZaYjiJ9._eCVNYFYnMXwpgGX9Iu412EQSOFuEGl2c
보시다시피 JWT 문자열은 Header, Payload, Signature의 세 부분으로 구성되어 있으며 점으로 연결되어 있습니다.
Header
Header는 토큰 유형과 암호화 알고리즘의 두 부분으로 구성된 JSON 객체입니다. 예:
{ "typ": "JWT",// 일반적으로 "JWT" "alg": "HS256"// 다양한 암호화 알고리즘 지원 }
위의 JSON 객체를 Base64URL 알고리즘을 사용하여 문자열로 변환하여 JWT의 Header 부분을 얻습니다. JWT 인코딩은 표준 Base64가 아닌 Base64Url을 사용한다는 점에 유의해야 합니다. 이는 Base64에서 생성된 문자열에 URL에 있는 세 개의 특수 기호(+, /, =)가 있을 수 있기 때문입니다. 그리고 URL에서 토큰을 전달할 수 있습니다 (예 : test.com?token = xxx). Base64URL 알고리즘은 Base64 알고리즘에서 생성된 문자열을 기반으로 =을 생략하고 +를 -로 바꾸고 /를 _로 바꿉니다. 이렇게 하면 생성된 문자열이 문제없이 URL에서 전달될 수 있습니다.
Payload
JWT의 Payload 부분은 Header와 마찬가지로 실제로 필요한 데이터를 저장하는 데 사용되는 JSON 객체입니다. JWT 표준은 다음과 같은 7개의 선택적 필드를 제공합니다.
- iss(issuer): 발급자, 값은 대소문자를 구분하는 문자열 또는 Uri입니다.
- sub(subject): 주체, 사용자를 식별하는 데 사용됩니다.
- exp(expiration time): 만료 시간입니다.
- aud(audience): 대상입니다.
- iat(issued at): 발행 시간입니다.
- nbf(not before): JWT가 유효하지 않은 시간입니다.
- jti(JWT ID): 식별자입니다.
표준 필드 외에도 비즈니스 요구 사항을 충족하기 위해 필요에 따라 개인 필드를 정의할 수 있습니다. 예:
{ iss:"admin",// 표준 필드 jti:"test",// 표준 필드 username:"leapcell",// 사용자 정의 필드 "gender":"male", "avatar":"https://avatar.leapcell.jpg" }
위의 JSON 객체를 Base64URL 알고리즘을 사용하여 문자열로 변환하여 JWT의 Payload 부분을 얻습니다.
Signature
Signature는 JWT의 서명입니다. 생성 방법은 다음과 같습니다. Base64URL 알고리즘을 사용하여 Header와 Payload를 인코딩하고 점으로 연결한 다음 비밀 키 (secretKey)와 Header에 지정된 암호화 방법을 사용하여 암호화하여 최종적으로 Signature를 생성합니다. 서명의 역할은 JWT가 전송 중에 변조되지 않았는지 확인하는 것입니다. 서버는 서명을 확인하여 JWT의 무결성과 진위성을 확인할 수 있습니다.
JWT의 특징
- 보안 권장 사항: JWT 도난 가능성을 방지하기 위해 HTTPS 프로토콜을 사용하는 것이 가장 좋습니다. HTTP 프로토콜에서는 데이터 전송이 일반 텍스트로 이루어지기 때문에 쉽게 가로채고 변조할 수 있습니다. HTTPS는 암호화된 전송을 통해 JWT의 보안을 효과적으로 보호할 수 있습니다.
- 무효화 메커니즘의 제한: JWT 발행 시간이 만료된 경우를 제외하고는 이미 생성된 JWT를 무효화할 다른 방법은 서버 측에서 알고리즘을 변경하지 않는 한 없습니다. 즉, JWT가 발행된 후 유효 기간 내에 도난당한 경우 악의적으로 사용될 수 있습니다.
- 민감한 정보의 저장: JWT가 암호화되지 않은 경우 민감한 정보를 저장해서는 안 됩니다. 민감한 정보를 저장해야 하는 경우 다시 암호화하는 것이 가장 좋습니다. JWT 자체는 디코딩할 수 있기 때문입니다. 민감한 정보가 포함되어 있고 암호화되지 않은 경우 보안 위험이 있습니다.
- 만료 시간 설정: 도난당한 경우 유효하게 유지되는 것을 방지하기 위해 JWT에 짧은 만료 시간을 설정하여 잠재적 손실을 줄이는 것이 좋습니다. 짧은 만료 시간은 JWT가 도난당한 후 위험을 줄일 수 있습니다. 도난당하더라도 유효 시간은 제한됩니다.
- 비즈니스 정보의 저장: JWT의 Payload는 일부 비즈니스 정보를 저장할 수도 있어 데이터베이스 쿼리를 줄일 수 있습니다. 예를 들어 기본 사용자 정보를 Payload에 저장할 수 있습니다. 요청이 있을 때마다 서버는 데이터베이스를 다시 쿼리하지 않고 JWT에서 직접 이 정보를 가져와 시스템의 성능과 응답 속도를 향상시킬 수 있습니다.
JWT 사용
서버가 JWT를 발행한 후 클라이언트로 보냅니다. 클라이언트가 브라우저인 경우 쿠키 또는 localStorage에 저장할 수 있습니다. APP인 경우 sqlite 데이터베이스에 저장할 수 있습니다. 그런 다음 각 인터페이스 요청에 대해 JWT가 전달됩니다. 쿼리, 쿠키, 헤더 또는 본문과 같이 서버 측으로 전달하는 방법은 여러 가지가 있습니다. 요컨대 서버로 데이터를 전달할 수 있는 모든 방법을 사용할 수 있습니다. 그러나 보다 표준화된 접근 방식은 다음 형식으로 헤더 Authorization을 통해 업로드하는 것입니다.
Authorization: Bearer <token>
HTTP 요청 헤더에서 JWT를 전달하는 이러한 방식은 일반적인 인증 사양을 준수하며 서버가 통합 인증 처리를 수행하는 데 편리합니다.
Go 프로젝트에서 JWT 사용
JWT 생성
github.com/golang-jwt/jwt
라이브러리를 사용하여 JWT를 생성하거나 구문 분석하는 데 도움이 됩니다. NewWithClaims()
메서드를 사용하여 Token 객체를 생성한 다음 Token 객체의 메서드를 사용하여 JWT 문자열을 생성할 수 있습니다. 예:
package main import ( "fmt" "time" "github.com/golang-jwt/jwt" ) func main() { hmacSampleSecret := []byte("123")// 비밀 키, 유출되어서는 안 됨 // 토큰 객체 생성 token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "foo": "bar", "nbf": time.Date(2015, 10, 10, 12, 0, 0, 0, time.UTC).Unix(), }) // jwt 문자열 생성 tokenString, err := token.SignedString(hmacSampleSecret) fmt.Println(tokenString, err) }
New()
메서드를 사용하여 Token 객체를 생성한 다음 JWT 문자열을 생성할 수도 있습니다. 예:
package main import ( "fmt" "time" "github.com/golang-jwt/jwt" ) func main() { hmacSampleSecret := []byte("123") token := jwt.New(jwt.SigningMethodHS256) // New 메서드를 통해 생성할 때 데이터를 전달할 수 없으므로 token.Claims에 값을 할당하여 데이터를 정의할 수 있음 token.Claims = jwt.MapClaims{ "foo": "bar", "nbf": time.Date(2015, 10, 10, 12, 0, 0, 0, time.UTC).Unix(), } tokenString, err := token.SignedString(hmacSampleSecret) fmt.Println(tokenString, err) }
위의 예에서 JWT의 Payload에 있는 데이터는 jwt.MapClaims
데이터 구조를 통해 정의됩니다. jwt.MapClaims
를 사용하는 것 외에도 사용자 정의 구조를 사용할 수도 있습니다. 그러나 이 구조는 다음 인터페이스를 구현해야 합니다.
type Claims interface { Valid() error }
다음은 사용자 정의 데이터 구조를 구현하는 예입니다.
package main import ( "fmt" "github.com/golang-jwt/jwt" ) type CustomerClaims struct { Username string `json:"username"` Gender string `json:"gender"` Avatar string `json:"avatar"` Email string `json:"email"` } func (c CustomerClaims) Valid() error { return nil } func main() { // 비밀 키 hmacSampleSecret := []byte("123") token := jwt.New(jwt.SigningMethodHS256) token.Claims = CustomerClaims{ Username: "Leapcell", Gender: "male", Avatar: "https://avatar.leapcell.jpg", Email: "admin@test.org", } tokenString, err := token.SignedString(hmacSampleSecret) fmt.Println(tokenString, err) }
JWT 표준에 정의된 필드를 사용자 정의 구조에서 사용하려면 다음과 같이 할 수 있습니다.
type CustomerClaims struct { *jwt.StandardClaims// 표준 필드 Username string `json:"username"` Gender string `json:"gender"` Avatar string `json:"avatar"` Email string `json:"email"` }
JWT 구문 분석
구문 분석은 생성의 역 연산입니다. 토큰을 구문 분석하여 헤더, 페이로드를 얻고 서명을 통해 데이터가 변조되었는지 확인합니다. 다음은 구체적인 구현입니다.
package main import ( "fmt" "github.com/golang-jwt/jwt" ) type CustomerClaims struct { Username string `json:"username"` Gender string `json:"gender"` Avatar string `json:"avatar"` Email string `json:"email"` jwt.StandardClaims } func main() { var hmacSampleSecret = []byte("111") // 이전 예에서 생성된 토큰 tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IuWwj-aYjiIsImdlbmRlciI6IueUtyIsImF2YXRhciI6Imh0dHBzOi8vMS5qcGciLCJlbWFpbCI6InRlc3RAMTYzLmNvbSJ9.mJlWv5lblREwgnP6wWg-P75VC1FqQTs8iOdOzX6Efqk" token, err := jwt.ParseWithClaims(tokenString, &CustomerClaims{}, func(t *jwt.Token) (interface{}, error) { return hmacSampleSecret, nil }) if err!= nil { fmt.Println(err) return } claims := token.Claims.(*CustomerClaims) fmt.Println(claims) }
Gin 프로젝트에서 JWT 사용
Gin 프레임워크에서 로그인 인증은 일반적으로 미들웨어를 통해 구현됩니다. github.com/appleboy/gin-jwt
라이브러리는 github.com/golang-jwt/jwt
의 구현을 통합하고 해당 미들웨어 및 컨트롤러를 정의했습니다. 다음은 구체적인 예입니다.
package main import ( "log" "net/http" "time" jwt "github.com/appleboy/gin-jwt/v2" "github.com/gin-gonic/gin" ) // 로그인에 사용할 사용자 이름과 비밀번호를 받기 위해 사용 type login struct { Username string `form:"username" json:"username" binding:"required"` Password string `form:"password" json:"password" binding:"required"` } var identityKey = "id" // jwt의 페이로드에 있는 데이터 type User struct { UserName string FirstName string LastName string } func main() { // Gin 미들웨어 정의 authMiddleware, err := jwt.New(&jwt.GinJWTMiddleware{ Realm: "test zone", // 식별 SigningAlgorithm: "HS256", // 암호화 알고리즘 Key: []byte("secret key"), // 비밀 키 Timeout: time.Hour, MaxRefresh: time.Hour, // 최대 새로 고침 확장 시간 IdentityKey: identityKey, // 쿠키의 id 지정 PayloadFunc: func(data interface{}) jwt.MapClaims { // 페이로드, 반환된 jwt의 페이로드에 있는 데이터를 정의할 수 있음 if v, ok := data.(*User); ok { return jwt.MapClaims{ identityKey: v.UserName, } } return jwt.MapClaims{} }, IdentityHandler: func(c *gin.Context) interface{} { claims := jwt.ExtractClaims(c) return &User{ UserName: claims[identityKey].(string), } }, Authenticator: Authenticator, // 로그인 검증 로직을 여기에 작성할 수 있음 Authorizator: func(data interface{}, c *gin.Context) bool { // 사용자가 토큰을 통해 제한된 인터페이스를 요청할 때 이 로직이 실행됨 if v, ok := data.(*User); ok && v.UserName == "admin" { return true } return false }, Unauthorized: func(c *gin.Context, code int, message string) { // 오류가 있을 때 응답 c.JSON(code, gin.H{ "code": code, "message": message, }) }, // 토큰을 가져올 위치를 지정합니다. 형식은: "<source>:<name>". 여러 개인 경우 쉼표로 구분합니다. TokenLookup: "header: Authorization, query: token, cookie: jwt", TokenHeadName: "Bearer", TimeFunc: time.Now, }) if err!= nil { log.Fatal("JWT Error:" + err.Error()) } r := gin.Default() // 로그인 인터페이스 r.POST("/login", authMiddleware.LoginHandler) auth := r.Group("/auth") // 로그아웃 auth.POST("/logout", authMiddleware.LogoutHandler) // 토큰 갱신, 토큰의 유효 기간 연장 auth.POST("/refresh_token", authMiddleware.RefreshHandler) auth.Use(authMiddleware.MiddlewareFunc()) // 미들웨어 적용 { auth.GET("/hello", helloHandler) } if err := http.ListenAndServe(":8005", r); err!= nil { log.Fatal(err) } } func Authenticator(c *gin.Context) (interface{}, error) { var loginVals login if err := c.ShouldBind(&loginVals); err!= nil { return "", jwt.ErrMissingLoginValues } userID := loginVals.Username password := loginVals.Password if (userID == "admin" && password == "admin") || (userID == "test" && password == "test") { return &User{ UserName: userID, LastName: "Leapcell", FirstName: "Admin", }, nil } return nil, jwt.ErrFailedAuthentication } // /hello 라우트 처리를 위한 컨트롤러 func helloHandler(c *gin.Context) { claims := jwt.ExtractClaims(c) user, _ := c.Get(identityKey) c.JSON(200, gin.H{ "userID": claims[identityKey], "userName": user.(*User).UserName, "text": "Hello World.", }) }
서버를 실행한 후 curl 명령을 통해 로그인 요청을 보냅니다. 예:
curl http://localhost:8005/login -d "username=admin&password=admin"
응답 결과는 다음과 같이 토큰을 반환합니다.
{"code":200,"expire":"2021-12-16T17:33:39+08:00","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2Mzk2NDcyMTksImlkIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTYzOTY0MzYxOX0.HITgUPDqli-RrO2zN_PfS4mISWc6l6eA_v8VOjlPonI"}
Leapcell: Golang 호스팅을 위한 최고의 서버리스 플랫폼
마지막으로 Golang 서비스를 배포하는 데 가장 적합한 플랫폼인 **Leapcell**을 추천합니다.
1. 다중 언어 지원
- JavaScript, Python, Go 또는 Rust로 개발합니다.
2. 무제한 프로젝트를 무료로 배포
- 사용량에 대해서만 비용을 지불합니다. 요청도 없고 요금도 없습니다.
3. 최고의 비용 효율성
- 유휴 요금 없이 사용한 만큼 지불합니다.
- 예: 25달러로 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
4. 간소화된 개발자 경험
- 손쉬운 설정을 위한 직관적인 UI
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅
5. 손쉬운 확장성 및 고성능
- 높은 동시성을 쉽게 처리하기 위한 자동 확장
- 운영 오버헤드가 없어 구축에만 집중할 수 있습니다.
Leapcell Twitter: https://x.com/LeapcellHQ