Go에서 PASETO를 사용한 API 보안 강화
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Go에서 PASETO를 사용한 더욱 안전한 API 구축
웹 API 보안 환경은 끊임없이 진화하고 있습니다. 수년 동안 JSON Web Tokens(JWT)는 정보 간의 송수에 편리하고 자체 포함된 방식을 제공하며 지배적인 역할을 해왔습니다. 그러나 만연함이 커짐에 따라 구현의 복잡성, 알고리즘 민첩성 및 부적절한 검증과 관련된 위험에 대한 인식도 높아졌습니다. 개발자로서 우리는 애플리케이션을 보호하기 위해 더 강력하고 개발자 친화적인 솔루션을 끊임없이 모색합니다. 이러한 추구는 보안을 단순화하면서 전반적인 복원력을 향상시키는 대안을 탐색하도록 많은 사람들을 이끌었습니다. 그러한 유망한 대안 중 하나인 PASETO(Platform-Agnostic Security Tokens)가 상당한 주목을 받고 있습니다. 이 문서는 Go에서 PASETO를 사용하여 API 인증을 구현하는 것에 대해 깊이 파고들어 전통적인 JWT에 비해 더 안전하고 간단한 접근 방식을 제공하는 방법을 보여줍니다.
안전한 토큰의 기둥 이해하기
실질적인 구현에 들어가기 전에 논의의 기초가 되는 핵심 개념에 대한 근본적인 이해를 확립해 봅시다. JWT 경험을 통해 익숙한 것들이 많을 수 있지만, PASETO 생태계 내에서의 고유한 뉘앙스를 파악하는 것이 중요합니다.
PASETO(Platform-Agnostic Security Tokens): 핵심적으로 PASETO는 JWT의 안전한 대안입니다. JWT와 달리 PASETO는 기본적으로 안전합니다. 즉, 암호화 및 토큰 구조에 대한 모범 사례를 내장하여 일반적인 보안 구성 오류 가능성을 줄입니다. 항상 서명(또는 암호화 및 서명)되어 있어 변조를 방지하고 무결성을 보장합니다. PASETO에는 local
(암호화)과 public
(서명)의 두 가지 주요 형식이 있습니다. 주로 API 인증에 public
토큰에 중점을 둘 것입니다. 서버는 일반적으로 토큰 내용을 클라이언트에 비공개로 유지할 필요 없이 클라이언트의 신원을 확인해야 하기 때문입니다.
대칭 키 암호화: PASETO local
토큰에서 사용되며, 동일한 키가 암호화 및 복호화에 모두 사용됩니다. 이는 토큰 발행자와 소비자 간에 동일한 엔티티이거나 안전하게 공유되는 비밀 키가 있는 경우에 적합합니다.
비대칭 키 암호화: PASETO public
토큰(그리고 우리가 사용할 것)에 사용되며, 수학적으로 연결된 키 쌍, 즉 공개 키와 개인 키를 포함합니다. 개인 키는 토큰 서명에 사용되고 공개 키는 서명 검증에 사용됩니다. 이는 발행자가 토큰에 서명하고 여러 소비자(공개 키만 소유)가 위조할 수 없이 검증해야 하는 시나리오에 이상적입니다. 이는 API 인증에 완벽하게 매핑되며, 서버가 토큰에 서명하고 클라이언트 애플리케이션이 이를 확인합니다(암묵적으로 서버에 다시 보내 검증).
클레임: JWT와 PASETO 토큰 모두 페이로드, 즉 토큰의 주체에 대한 주장(assertion)을 나타내는 "클레임" 집합을 전달합니다. 일반적으로 JSON 객체이며 iss
(발행자), sub
(주제), exp
(만료 시간)와 같은 표준 클레임 및 사용자 정의 애플리케이션별 클레임을 포함할 수 있습니다.
푸터: PASETO의 고유한 기능은 선택적 푸터입니다. 이를 통해 임의의, 암호화되지 않은, 서명되지 않은 데이터를 토큰에 추가할 수 있습니다. 푸터에 민감한 데이터를 피하는 것이 좋지만, 암호화 무결성 검사의 일부가 될 필요가 없는 컨텍스트 정보(예: 키 ID)에 유용할 수 있습니다.
Go에서 API 인증을 위해 PASETO 구현하기
API 인증을 위해 PASETO를 사용하는 핵심 아이디어는 간단합니다. 사용자가 성공적으로 인증하면(예: 올바른 사용자 이름과 암호 제공), 서버는 해당 신원 정보가 포함된 PASETO public
토큰을 발행합니다. 그런 다음 이 토큰은 클라이언트로 다시 전송됩니다. 후속 API 요청을 위해 클라이언트는 이 PASETO를 Authorization
헤더에 포함합니다. 그런 다음 서버는 공개 키를 사용하여 PASETO의 서명을 검증하고 클레임에서 사용자 신원을 추출하여 요청을 승인합니다.
실질적인 Go 구현을 살펴보겠습니다.
먼저 Go용으로 강력한 PASETO 라이브러리가 필요합니다. @o1egl의 paseto
패키지는 인기 있고 잘 유지 관리되는 선택입니다. 설치합니다.
go get github.com/o1egl/paseto
다음으로 인증 시스템의 구조를 고려해 보겠습니다. 키 쌍을 생성하고, 토큰을 발행하고, 토큰을 검증하는 함수가 필요합니다.
1. 비대칭 키 생성
public
PASETO 토큰의 경우 Ed25519 비대칭 키 쌍이 필요합니다. 개인 키를 안전하게 저장하고 공개 키를 검증용으로 배포하는 것이 중요합니다. 시연 목적으로 메모리에서 생성하겠습니다. 프로덕션 환경에서는 이러한 키가 보안 저장소에서 로드됩니다.
package main import ( "crypto/rand" "fmt" "golang.org/x/crypto/ed25519" ) // generateKeyPair generates an Ed25519 public/private key pair. func generateKeyPair() (ed25519.PublicKey, ed25519.PrivateKey, error) { publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { return nil, nil, fmt.Errorf("failed to generate Ed25519 key pair: %w", err) } return publicKey, privateKey, nil } // In a real application, you would load these from environment variables or a key management system. // For demonstration, let's keep them global. var ( appPrivateKey ed25519.PrivateKey appPublicKey ed25519.PublicKey ) func init() { var err error appPublicKey, appPrivateKey, err = generateKeyPair() if err != nil { panic(fmt.Sprintf("failed to initialize application keys: %v", err)) } fmt.Println("Keys generated successfully.") }
2. PASETO 토큰 발행
사용자가 로그인하면 토큰을 생성합니다. 이 토큰에는 UserEmail
및 UserID
와 같은 클레임이 포함되며 만료 시간이 있습니다.
package main import ( "fmt" "time" "github.com/o1egl/paseto" ) // UserClaims defines the structure for our token claims. type UserClaims struct { paseto.JSONToken UserEmail string `json:"user_email"` UserID string `json:"user_id"` } // issueToken creates a new PASETO public token. func issueToken(userID, userEmail string, duration time.Duration) (string, error) { // Create a new PASETO V2 public builder v2 := paseto.NewV2() // Prepare claims now := time.Now() exp := now.Add(duration) claims := UserClaims{ JSONToken: paseto.JSONToken{ IssuedAt: now, Expiration: exp, NotBefore: now, }, UserID: userID, UserEmail: userEmail, } // Sign the token with the private key token, err := v2.Sign(appPrivateKey, claims, "some-optional-footer") // Footer is optional if err != nil { return "", fmt.Errorf("failed to sign PASETO token: %w", err) } return token, nil }
3. PASETO 토큰 확인 및 클레임 추출
보호된 모든 API 호출에 대해 서버는 토큰을 수신하고, 공개 키를 사용하여 유효성을 검증한 다음, 클레임을 추출합니다.
package main import ( "fmt" "time" "github.com/o1egl/paseto" ) // verifyToken verifies a PASETO public token and extracts its claims. func verifyToken(token string) (*UserClaims, error) { v2 := paseto.NewV2() claims := &UserClaims{} footer := "" // If you used a footer, you'd specify it here. // Verify the token with the public key err := v2.Verify(token, appPublicKey, claims, footer) if err != nil { return nil, fmt.Errorf("failed to verify PASETO token: %w", err) } // PASETO library automatically checks expiration and nbf by default. // You can add additional checks if needed, e.g., for custom claims. return claims, nil }
4. API 엔드포인트에 통합 (Go의 net/http
사용 예시)
흐름을 시연하기 위해 간단한 HTTP 서버를 설정해 보겠습니다.
package main import ( "encoding/json" "fmt" "log" "net/http" "time" ) // Login request body type LoginRequest struct { Username string `json:"username"` Password string `json:"password"` } // Login response body type LoginResponse struct { Token string `json:"token"` } // Authenticate simulates a login process and issues a token. func Authenticate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // In a real app, validate credentials against a database if req.Username != "testuser" || req.Password != "password123" { http.Error(w, "Invalid credentials", http.StatusUnauthorized) return } // Issue a PASETO token token, err := issueToken("user-123", req.Username+"@example.com", 24*time.Hour) if err != nil { log.Printf("Error issuing token: %v", err) http.Error(w, "Failed to issue token", http.StatusInternalServerError) return } resp := LoginResponse{Token: token} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // AuthMiddleware is a middleware to protect API endpoints. func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" { http.Error(w, "Authorization header required", http.StatusUnauthorized) return } // Expecting "Bearer <PASETO_TOKEN>" if len(authHeader) < 7 || authHeader[:7] != "Bearer " { http.Error(w, "Invalid Authorization header format", http.StatusUnauthorized) return } pasetoToken := authHeader[7:] claims, err := verifyToken(pasetoToken) if err != nil { log.Printf("PASETO verification failed: %v", err) http.Error(w, "Invalid or expired token", http.StatusUnauthorized) return } // Token is valid, you can now use claims.UserID and claims.UserEmail // to identify the user and perform authorization checks. // For example, store user info in request context for downstream handlers. log.Printf("User %s (%s) authenticated successfully.", claims.UserID, claims.UserEmail) // Proceed to the next handler next.ServeHTTP(w, r) } } // ProtectedEndpoint is an example of an API that requires authentication. func ProtectedEndpoint(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Welcome to the protected area!") } func main() { http.HandleFunc("/login", Authenticate) http.HandleFunc("/protected", AuthMiddleware(ProtectedEndpoint)) fmt.Println("Server listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) }
이 예제를 실행하는 방법:
- 코드를
main.go
로 저장합니다. go mod init myapp
(아직 초기화되지 않은 경우)을 실행합니다.go mod tidy
을 실행합니다.go run main.go
를 실행합니다.
그런 다음 curl
로 테스트할 수 있습니다.
1. 토큰을 받기 위해 로그인:
curl -X POST -H "Content-Type: application/json" -d '{"username": "testuser", "password": "password123"}' http://localhost:8080/login
그러면 PASETO 토큰이 포함된 JSON 객체가 반환됩니다. 토큰을 복사합니다.
2. 토큰과 함께 보호된 엔드포인트에 액세스:
(받은 토큰으로 <YOUR_PASETO_TOKEN>
을 대체)
curl -H "Authorization: Bearer <YOUR_PASETO_TOKEN>" http://localhost:8080/protected
"Welcome to the protected area!"라는 메시지가 표시되어야 합니다. 토큰을 생략하거나 잘못된 토큰을 보내면 Unauthorized
오류가 발생합니다.
JWT 대신 PASETO를 사용하는 이유?
paseto
라이브러리는 설계상 JWT 구현에서 종종 간과되고 선택 사항인 여러 보안 모범 사례를 강제합니다.
- 기본적으로 안전: PASETO는 특정 버전(예: V1, V2, V3, V4)을 명시적으로 정의하며, 각 버전은 특정 강력한 암호화 알고리즘(예: V2는 공개 키에 Ed25519, 로컬 토큰에 XChacha20-Poly1305 사용)과 연결됩니다. 이렇게 하면
none
과 같이 안전하지 않은 알고리즘이 실수로 사용될 수 있는 "알고리즘 민첩성" 문제가 제거됩니다. - 변조 방지: 모든 PASETO 토큰은 암호화 방식으로 서명되거나(공개 토큰) 암호화 및 서명됩니다(로컬 토큰). 토큰을 무효화하지 않고 쉽게 디코딩하고 다시 인코딩할 수 있는 암호화되지 않은 페이로드는 없습니다.
- 단순성 및 예측 가능성: PASETO 사양은 JWT보다 더 간결하고 모호하지 않아 구현 오류가 줄어들고 보안 보장이 명확해집니다.
- 암호화 민첩성 취약성 없음: PASETO의 버전 관리는 공격자가 약한 알고리즘을 사용하도록 검증자를 속이는 공격(예: RSA에서 공개 키를 비밀 키로 사용하는 HMAC로 전환)을 완전히 방지합니다.
애플리케이션 시나리오
PASETO는 다양한 API 인증 시나리오에 탁월한 선택입니다.
- 마이크로서비스 통신: 서비스 간에 사용자 컨텍스트 또는 권한 부여 데이터를 안전하게 전송합니다.
- 웹 API 인증: 설명된 주요 사용 사례로, 클라이언트는 토큰을 얻고 후속 요청을 인증하는 데 사용합니다.
- 서버 간 인증:
local
PASETO 토큰은 비밀 키를 공유하는 신뢰할 수 있는 서비스 간의 안전한 통신에 사용할 수 있습니다. - 비밀번호 없는 인증: PASETO 토큰은 이메일 또는 SMS를 통해 전송되는 안전하고 시간 제한이 있는 로그인 토큰 역할을 할 수 있습니다.
맺음말
PASETO는 암호화 강점과 개발자 편의성 사이의 섬세한 균형을 맞추면서 인증된 토큰에 대한 신선하고 보안 우선적인 접근 방식을 제공합니다. Go API 프로젝트에서 PASETO를 채택함으로써 토큰 기반 인증과 관련된 공격 표면을 크게 줄이고 더 복원력 있고 신뢰할 수 있는 애플리케이션을 구축할 수 있습니다. 모범 사례를 내장한 의견 중심적인 디자인은 특히 "기본적으로 안전한" 원칙을 우선시하는 개발자에게 JWT에 대한 매력적인 대안이 됩니다. 제공된 코드 예시는 Go에서 PASETO를 구현하는 것이 간단하며, 성숙한 암호화 기본 요소와 잘 설계된 라이브러리를 활용하여 API 통신을 보호한다는 것을 보여줍니다.
Go 애플리케이션에 PASETO를 선택하는 것은 본질적으로 더 안전하고 유지 관리 가능한 시스템을 구축하고, 현대적인 보안 문제를 염두에 두고 설계된 토큰 표준을 채택하는 것입니다.