Axum 사용자 지정 인증 계층 구축
Ethan Miller
Product Engineer · Leapcell

소개
현대 웹 서비스의 세계에서 API 보안은 매우 중요합니다. 마이크로서비스 또는 모놀리식 애플리케이션을 구축하든, 승인된 사용자 또는 서비스만이 특정 엔드포인트에 접근할 수 있도록 보장하는 것은 기본적인 요구 사항입니다. JSON Web Token(JWT)과 API 키는 이를 달성하는 두 가지 보편적인 방법으로, 경량적이고 상태 없는(stateless) 방식의 신원 확인을 제공합니다.
Rust에서 점점 더 인기가 많아지고 있는 웹 프레임워크인 Axum은 훌륭한 빌딩 블록을 제공하지만, 사용자 지정 인증을 구현하려면 종종 미들웨어 아키텍처에 대한 더 깊은 이해가 필요합니다. 이 문서는 JWT 및 API 키 유효성 검사를 모두 처리할 수 있는 재사용 가능한 인증 "계층(Layer)"을 처음부터 만드는 과정을 안내합니다. 설계 원칙을 탐색하고, 구현 세부 사항을 살펴보고, Axum 애플리케이션에 원활하게 통합하는 방법을 시연할 것입니다.
핵심 개념 이해
코드를 살펴보기 전에, 논의의 핵심이 되는 몇 가지 주요 용어를 간략하게 정의해 보겠습니다.
- Axum 요청 처리: Axum은 서비스 체인을 통해 들어오는 HTTP 요청을 처리합니다. 각 서비스는 요청을 처리하고, 수정하거나, 체인의 다음 서비스로 전달할 수 있습니다.
- Tower Service Trait: Axum의 미들웨어 시스템의 핵심은
tower::Service
트레이트입니다. 이는 요청을 받아 응답을 반환하는 일반 비동기 작업을 정의합니다. - Tower Layer Trait:
tower::Layer
는tower::Service
인스턴스의 팩토리입니다. 내부 서비스를 래핑하여 내부 서비스가 실행되기 전이나 후에 로직을 삽입할 수 있습니다. 이것이 바로 인증 로직을 주입하는 데 사용할 것입니다. - JSON Web Token (JWT): 두 당사자 간에 전송될 클레임을 나타내는 자체 포함적이고, 컴팩트하며, URL 안전한 수단입니다. JWT는 종종 인증에 사용되며, 서버는 로그인 성공 후 클라이언트에 토큰을 발급하고, 클라이언트는 이후 요청에 이 토큰을 포함하여 자신의 신원을 증명합니다.
- API 키: API 제공자가 API에 액세스하려는 소비자에게 제공하는 고유 식별자입니다. API 키는 일반적으로 요청 헤더나 쿼리 매개변수에 전달됩니다. 더 간단하지만, 세분화된 제어가 덜 제공되며 추가 메커니즘 없이는 사용자 인증에 JWT보다 일반적으로 덜 안전합니다.
- 인증(Authentication) vs. 인가(Authorization): 인증은 "당신이 누구인가" (신원 확인)에 관한 것이고, 인가는 "무엇을 할 수 있는가" (접근 권한 결정)에 관한 것입니다. 우리의 계층은 주로 인증에 중점을 둘 것입니다.
인증 계층 구축
우리의 인증 계층은 Authorization
헤더의 JWT 또는 사용자 지정 헤더(예: X-API-Key
)의 API 키를 포함하는 들어오는 요청을 검사합니다. 유효한 자격 증명이 발견되면 요청이 진행되지만, 그렇지 않으면 승인되지 않은 응답이 반환됩니다.
인증 상태
먼저, 가능한 인증 결과 가능한 결과를 나타내는 간단한 열거형을 정의해 보겠습니다.
#[derive(Debug, Clone, PartialEq)] pub enum AuthStatus { Authenticated(String), // 사용자 ID 또는 기타 식별자를 위해 Unauthenticated, }
이 AuthStatus
는 인증 시도의 결과를 신호하는 데 사용됩니다. 인증된 경우 사용자 ID 또는 기타 관련 정보를 저장할 수 있습니다.
인증 서비스
다음으로, tower::Service
트레이트를 구현하는 사용자 지정 AuthService
를 만들겠습니다. 이 서비스는 내부 서비스를 래핑하고 인증 로직을 수행합니다.
use async_trait::async_trait; use axum::{ body::{Body, BoxBody}, extract::Request, http::{ header::{AUTHORIZATION, CONTENT_TYPE}, response::Response, StatusCode, }, response::IntoResponse, middleware::Next, }; use std::{ future::Future, pin::Pin, task::{Context, Poll}, }; use tower::{Layer, Service}; pub struct AuthService<S> { inner: S, jwt_secret: String, api_key_header_name: String, valid_api_keys: Vec<String>, } impl<S> AuthService<S> { pub fn new( inner: S, jwt_secret: String, api_key_header_name: String, valid_api_keys: Vec<String>, ) -> Self { Self { inner, jwt_secret, api_key_header_name, valid_api_keys, } } // JWT 유효성을 검사하는 헬퍼 함수 fn validate_jwt(&self, token: &str) -> Option<String> { // 실제 애플리케이션에서는 JWT를 파싱하고 유효성을 검사해야 합니다. // 데모를 위해 더미 유효성 검사를 가정해 보겠습니다. if token.starts_with("Bearer my_valid_jwt_") { // 토큰에서 사용자 ID 추출 (예: 클레임 디코딩) Some("user123".to_string()) } else { None } } // API 키 유효성을 검사하는 헬퍼 함수 fn validate_api_key(&self, api_key: &str) -> Option<String> { if self.valid_api_keys.contains(&api_key.to_string()) { // API 키의 경우, 키 자체 또는 관련 사용자/서비스 ID를 반환할 수 있습니다. Some(format!("api_user_{}", api_key)) } else { None } } } // AuthService에 tower::Service 트레이트 구현 impl<S> Service<Request> for AuthService<S> where S: Service<Request, Response = Response> + Send + 'static, S::Future: Send + 'static, { type Response = S::Response; type Error = S::Error; type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, mut request: Request) -> Self::Future { let jwt_secret = self.jwt_secret.clone(); let api_key_header_name = self.api_key_header_name.clone(); let valid_api_keys = self.valid_api_keys.clone(); // 비동기 블록을 위해 클론 // 까다로운 부분: `Service::call`은 `&mut self`를 사용하므로 `self.inner`를 클론해야 합니다. // `S`가 `Clone`을 구현하지 않으면 이 접근 방식은 `ServiceBuilder::service`가 `&&mut S`를 사용하는 것을 필요로 합니다. // 일반적인 패턴은 `S`가 `Clone`이 아닌 경우 공유 상태를 `Arc<Mutex<S>>`로 래핑하는 것입니다. // 여기서는 단순화를 위해 S가 복제 가능하다고 가정하거나 뮤터블 참조를 주의 깊게 처리합니다. // Axum의 `tower::util::ServiceFn` 또는 `middleware::from_extractor_with_state`에서는 복제가 처리됩니다. // 일반 Tower Service의 경우, 더 명시적으로 처리해야 하는 경우가 많습니다. let inner = self.inner.ready(); // 내부 서비스가 준비되었는지 확인 Box::pin(async move { let mut auth_status = AuthStatus::Unauthenticated; // 1. JWT 확인 if let Some(auth_header) = request.headers().get(AUTHORIZATION) { if let Ok(header_value) = auth_header.to_str() { if header_value.starts_with("Bearer ") { let token = &header_value[7..]; if let Some(user_id) = AuthService::<S>::new( // 이 `new`는 `validate_jwt` 헬퍼를 호출하기 위한 것일 뿐, // 실제 호출될 서비스 생성용이 아닙니다. // `validate_jwt`를 자유 함수로 만들거나 컨텍스트를 전달하는 것이 더 좋습니다. S::default(), // 더미 내부 서비스, 유효성 검사 로직에 사용되지 않음 jwt_secret.clone(), String::new(), // JWT 유효성 검사에 사용되지 않음 vec![], // JWT 유효성 검사에 사용되지 않음 ).validate_jwt(token) { auth_status = AuthStatus::Authenticated(user_id); } } } } // 2. 아직 인증되지 않은 경우 API 키 확인 if auth_status == AuthStatus::Unauthenticated { if let Some(api_key_header) = request.headers().get(&api_key_header_name) { if let Ok(key_value) = api_key_header.to_str() { if let Some(api_user) = AuthService::<S>::new( S::default(), // 더미 내부 서비스 String::new(), // API 키 유효성 검사에 사용되지 않음 api_key_header_name.clone(), valid_api_keys.clone(), ).validate_api_key(key_value) { auth_status = AuthStatus::Authenticated(api_user); } } } } match auth_status { AuthStatus::Authenticated(user_id) => { // 인증된 사용자 ID를 요청 확장 기능에 저장 request.extensions_mut().insert(user_id.clone()); inner.await?.call(request).await // 내부 서비스로 진행 } AuthStatus::Unauthenticated => { // 401 Unauthorized 응답 반환 Ok(StatusCode::UNAUTHORIZED.into_response()) } } }) } }
Service::call
및 &mut self
에 대한 중요 참고: tower::Service::call
메서드는 &mut self
를 사용합니다. 이는 AuthService
가 내부 상태(예: jwt_secret
또는 valid_api_keys
)에 의존하는 비동기 작업을 수행하고 inner
서비스(이는 &mut S
를 사용함)를 호출해야 하는 경우 주의해야 함을 의미합니다. 위 코드는 데모를 위해 clone
을 사용합니다. 실제 환경에서는 공유 상태가 뮤터블하고 비동기 작업 간에 공유되어야 하는 경우 Arc
및 Mutex
또는 RwLock
으로 래핑하는 경우가 많거나 구성의 복사본을 전달합니다. Axum의 tower::Layer::layer
도우미는 종종 이를 단순화하거나 Service
를 생성 후 불변으로 만듭니다. Layer
의 경우 생성된 Service
는 연결 또는 애플리케이션의 수명 동안 유지되므로 상태를 공유할 수 있어야 합니다.
inner.await?.call(request).await
과 함께 &mut self
로 Service::call
을 처리하는 더 관용적인 방법은 종종 tower::util::ServiceFn
을 통해 달성되거나 axum::middleware::from_fn
을 사용하여 구성할 수 있는 자체 middleware
함수로 인증 로직을 분리하는 것입니다. 그러나 명시적으로 일반 tower::Layer
및 Service
를 보여주기 위해 이 패턴을 따릅니다.
인증 계층
이제 AuthService
인스턴스를 만드는 AuthLayer
를 정의하겠습니다.
pub struct AuthLayer { jwt_secret: String, api_key_header_name: String, valid_api_keys: Vec<String>, } impl AuthLayer { pub fn new(jwt_secret: String, api_key_header_name: String, valid_api_keys: Vec<String>) -> Self { Self { jwt_secret, api_key_header_name, valid_api_keys, } } } // AuthLayer에 tower::Layer 트레이트 구현 impl<S> Layer<S> for AuthLayer { type Service = AuthService<S>; fn layer(&self, inner: S) -> Self::Service { AuthService::new( inner, self.jwt_secret.clone(), self.api_key_header_name.clone(), self.valid_api_keys.clone(), ) } }
인증된 사용자 ID 추출
인증된 사용자 ID를 핸들러에서 사용할 수 있도록 Axum 추출기를 만들 수 있습니다.
use axum::{ async_trait, extract::{FromRequestParts, State}, http::{request::Parts, StatusCode}, response::{IntoResponse, Response}, Json, }; pub struct AuthenticatedUser(pub String); #[async_trait] impl<S> FromRequestParts<S> for AuthenticatedUser where S: Send + Sync, { type Rejection = Response; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { if let Some(user_id) = parts.extensions.get::<String>() { Ok(AuthenticatedUser(user_id.clone())) } else { // 계층이 올바르게 적용되었다면 이것은 이상적으로 발생하지 않아야 하지만, 안전 장치 역할을 합니다. Err(StatusCode::UNAUTHORIZED.into_response()) } } }
Axum과 통합
마지막으로 Axum 라우터에 이 계층을 적용하는 방법을 살펴보겠습니다.
use axum::{routing::get, Router}; use tower::ServiceBuilder; // 인증이 필요한 핸들러 async fn protected_handler(AuthenticatedUser(user_id): AuthenticatedUser) -> String { format!("안녕하세요, 인증된 사용자: {}!", user_id) } // 간단한 공개 핸들러 async fn public_handler() -> &'static str { "이것은 공개 엔드포인트입니다." } #[tokio::main] async fn main() { let app = Router::new() .route("/public", get(public_handler)) .route("/protected", get(protected_handler)) .layer( ServiceBuilder::new() .layer(AuthLayer::new( "super_secret_jwt_key".to_string(), // 실제로는 설정/환경 변수에서 로드 "X-Api-Key".to_string(), vec!["my_secret_api_key".to_string(), "another_key".to_string()], )) ); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap(); }
작동 방식
- 요청
AuthLayer
: HTTP 요청이 들어오면 먼저AuthLayer
에 히트합니다. AuthLayer::layer
: 레이어는AuthService
인스턴스를 생성하고 내부 서비스(다른 레이어 또는 최종 핸들러일 수 있음)를 래핑합니다.AuthService::call
:AuthService
의call
메서드가 호출됩니다.Authorization
헤더에서 "Bearer" 토큰을 확인하고 JWT 유효성 검사를 시도합니다.- JWT 유효성 검사가 실패하거나 존재하지 않으면
X-Api-Key
헤더에서 미리 정의된 API 키를 확인합니다. - 둘 중 하나라도 유효하면, 파생된
user_id
(또는 유사한 식별자)를 요청의 확장 기능에request.extensions_mut().insert()
를 사용하여 삽입합니다. - 그런 다음
inner
서비스를 호출하여 요청이 핸들러로 계속 진행되도록 합니다. - JWT 또는 API 키가 유효하지 않으면 즉시
401 Unauthorized
응답을 반환하고 요청 체인을 단축(short-circuit)합니다.
AuthenticatedUser
추출기:protected_handler
에서AuthenticatedUser
추출기를 사용합니다. 이 추출기는 요청 확장 기능에서String
(우리의user_id
)을 검색합니다. 존재하면 핸들러가 이를 받지만, 그렇지 않으면 추출기 자체의 거부 흐름에서401 Unauthorized
를 반환하여 이중 점검 역할을 합니다.
결론
Axum의 tower::Layer
및 tower::Service
트레이트를 활용하여 JWT 및 API 키 유효성 검사를 모두 처리할 수 있는 사용자 지정 인증 계층을 성공적으로 구현했습니다. 이 접근 방식은 인증 로직을 중앙 집중화하고, 핸들러를 깔끔하게 유지하며, 코드 재사용성을 촉진합니다.
이 강력한 미들웨어 패턴은 Axum으로 안전하고 유지보수 가능한 웹 애플리케이션을 구축하는 데 기본적입니다. 잘 구조화된 애플리케이션은 명확하게 정의된 경계와 모듈식 구성 요소에 의존하며, 사용자 지정 계층은 이를 달성하기 위한 강력한 도구입니다.