러스트(Rust) 실전 프로젝트 예제 따라하기 시리즈 - 4편: 인증(Authorization)과 JWT를 통한 안전한 REST API 구현하기

이전 글에서는 데이터베이스를 연동한 간단한 To-Do 리스트 REST API를 구현하며, 러스트 생태계를 활용한 웹 개발의 기초를 다졌습니다. 이번 글에서는 인증(Authorization)과 토큰 기반 인증(JWT)을 적용해, 좀 더 실전적이고 안전한 REST API를 만들어봅니다. 이를 통해 사용자가 로그인하고, 발급받은 토큰(JWT)을 사용해 권한이 필요한 API에 접근하는 패턴을 익힐 수 있습니다.

etc-image-0

이번 프로젝트의 주요 목표는 다음과 같습니다.

  • JWT 발급 및 검증: 사용자가 로그인 시도 시 JWT를 발급하고, 이후 요청 시 해당 토큰을 헤더에 담아 접근 권한을 확인하는 패턴 구현
  • 비밀번호 해싱(BCrypt) 및 보안 처리: 사용자 정보(아이디, 비밀번호) 관리 시 평문 비밀번호 대신 해싱 처리
  • Actix-web 미들웨어를 통한 인증 적용: 요청 핸들러에 도달하기 전 JWT 검사 로직을 적용해 안전한 접근 제어
  • C++ 대비 장점: C++로 인증 로직 구현 시 라이브러리 선택, 메모리 관리, 예외 처리, 스레드 안전성 등 다양한 복잡도가 증가. 러스트는 타입 시스템과 async/await, 풍부한 크레이트 덕분에 명확하고 안전한 인증 로직을 쉽게 구현 가능.

프로젝트 개요

이번 예제는 이전의 To-Do API를 바탕으로 다음을 추가합니다.

  • 사용자 모델(User): DB에 users 테이블 추가 (username, password_hash 컬럼)
  • 회원가입/로그인 라우트:
    • POST /api/register → username, password 받으면 DB에 저장(비밀번호는 해시 후 저장)
    • POST /api/login → username, password 검증 후 JWT 발급
  • JWT 인증 적용:
    • GET /api/todos → JWT 토큰 확인 후 접근 허용
    • 미들웨어로 JWT 검증 로직 구현

이번 글에서는 간결성을 위해 완벽한 인증 시스템 대신 핵심 개념에 집중하겠습니다.

의존성 추가

이전에 사용한 actix-web, sqlx, serde 외에 jsonwebtoken 크레이트를 사용하여 JWT를 발급/검증하고, bcrypt 크레이트로 비밀번호 해싱을 처리합니다.

[dependencies]
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
sqlx = { version = "0.6", features = ["sqlite", "runtime-tokio-native-tls", "macros"] }
bcrypt = "0.12"
jsonwebtoken = "8"

DB 마이그레이션 업데이트

users 테이블을 추가하는 마이그레이션을 생성합니다. 이전 프로젝트에서 사용했던 migrations 디렉토리를 재활용한다고 가정합니다.

migrations/20230102000000_create_users.sql:

CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

todos 테이블은 기존과 동일하다고 가정하고, 새 마이그레이션을 추가한 뒤 MIGRATOR.run(&pool)로 자동 적용할 수 있습니다.

JWT 발급 로직 구현

토큰 발급에 사용할 시크릿 키를 설정합니다. 간단히 코드 내 상수로 두지만, 실전에서는 환경 변수를 사용하세요.

src/auth.rs (새 파일):

use serde::{Serialize, Deserialize};
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};
use anyhow::Result;
use std::time::{SystemTime, UNIX_EPOCH, Duration};

const SECRET_KEY: &[u8] = b"secret_key_for_jwt"; // 실전에서는 환경 변수로 관리

#[derive(Serialize, Deserialize)]
struct Claims {
    sub: String,
    exp: usize,
}

pub fn create_jwt(username: &str) -> Result<String> {
    let expiration = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 3600; // 1시간 유효
    let claims = Claims {
        sub: username.to_string(),
        exp: expiration as usize,
    };

    let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET_KEY))?;
    Ok(token)
}

pub fn decode_jwt(token: &str) -> Result<String> {
    let data = decode::<Claims>(
        token,
        &DecodingKey::from_secret(SECRET_KEY),
        &Validation::default()
    )?;
    Ok(data.claims.sub)
}
  • create_jwt: username을 subject로 하는 JWT 발급
  • decode_jwt: 토큰 디코딩 및 검증. 유효하지 않거나 만료되면 에러 발생

회원가입/로그인 라우트

src/routes/auth.rs (새 파일):

use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;
use sqlx::SqlitePool;
use anyhow::Result;
use bcrypt::{hash, verify};
use crate::auth::create_jwt;

#[derive(Deserialize)]
struct RegisterInfo {
    username: String,
    password: String,
}

pub async fn register(pool: web::Data<SqlitePool>, info: web::Json<RegisterInfo>) -> impl Responder {
    let hashed = match hash(&info.password, 10) {
        Ok(h) => h,
        Err(_) => return HttpResponse::InternalServerError().body("Error hashing password"),
    };

    match sqlx::query!("INSERT INTO users (username, password_hash) VALUES (?, ?)", info.username, hashed)
        .execute(pool.get_ref()).await {
        Ok(_) => HttpResponse::Ok().body("User registered"),
        Err(e) => {
            eprintln!("Error: {:?}", e);
            HttpResponse::BadRequest().body("Username already exists or DB error")
        }
    }
}

#[derive(Deserialize)]
struct LoginInfo {
    username: String,
    password: String,
}

pub async fn login(pool: web::Data<SqlitePool>, info: web::Json<LoginInfo>) -> impl Responder {
    let row = match sqlx::query!("SELECT password_hash FROM users WHERE username = ?", info.username)
        .fetch_one(pool.get_ref()).await {
        Ok(r) => r,
        Err(_) => return HttpResponse::Unauthorized().body("Invalid username or password"),
    };

    if verify(&info.password, &row.password_hash).unwrap_or(false) {
        match create_jwt(&info.username) {
            Ok(token) => HttpResponse::Ok().json(serde_json::json!({"token": token})),
            Err(_) => HttpResponse::InternalServerError().body("Error creating token"),
        }
    } else {
        HttpResponse::Unauthorized().body("Invalid username or password")
    }
}

회원가입: 비밀번호 해싱 후 DB 저장, 중복 유저 처리
로그인: 비밀번호 검증 후 JWT 발급

JWT 미들웨어 구현

토큰 검증을 위한 미들웨어를 하나 만들고, 인증이 필요한 라우트에 적용합니다.

src/middleware/auth_middleware.rs (새 파일):

use actix_web::{HttpMessage, HttpRequest, HttpResponse, dev::{Service, ServiceRequest, ServiceResponse, Transform}, Error};
use futures_util::future::{ok, Ready};
use std::rc::Rc;
use crate::auth::decode_jwt;

pub struct AuthMiddleware;

impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
where
    S: Service<ServiceRequest, Response=ServiceResponse<B>, Error=Error> + 'static,
    S::Future: 'static,
{
    type Transform = AuthMiddlewareService<S>;
    type Error = Error;
    type InitError = ();
    fn new_transform(&self, service: S) -> Result<Self::Transform, Self::InitError> {
        Ok(AuthMiddlewareService {
            service: Rc::new(service)
        })
    }
}

pub struct AuthMiddlewareService<S> {
    service: Rc<S>,
}

impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
where
    S: Service<ServiceRequest, Response=ServiceResponse<B>, Error=Error> + 'static
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Future = futures_util::future::LocalBoxFuture<'static, Result<ServiceResponse<B>, Error>>;

    fn poll_ready(&self, cx: &mut std::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
        self.service.poll_ready(cx)
    }

    fn call(&self, req: ServiceRequest) -> Self::Future {
        let svc = self.service.clone();
        Box::pin(async move {
            // 헤더에서 Authorization: Bearer <token> 추출
            if let Some(auth_header) = req.headers().get("Authorization") {
                if let Ok(auth_str) = auth_header.to_str() {
                    if let Some(token) = auth_str.strip_prefix("Bearer ") {
                        match decode_jwt(token) {
                            Ok(username) => {
                                // RequestExtensions에 username 저장
                                req.extensions_mut().insert(username);
                                return svc.call(req).await;
                            }
                            Err(_) => return Ok(req.into_response(HttpResponse::Unauthorized().body("Invalid token").into())),
                        }
                    }
                }
            }
            Ok(req.into_response(HttpResponse::Unauthorized().body("Missing or invalid Authorization header").into()))
        })
    }
}

이 미들웨어는 Authorization: Bearer <token> 헤더를 검사하고, 유효한 토큰이면 req.extensions_mut().insert(username)로 요청 컨텍스트에 username을 저장합니다. 핸들러에서는 req.extensions()로 username을 확인할 수 있습니다.

인증이 필요한 라우트에 미들웨어 적용

이전 todo 라우트(/api/todos)에 인증을 적용해봅시다.

src/routes/todo.rs (새 파일):

use actix_web::{web, HttpResponse, Responder};
use sqlx::SqlitePool;
use serde::Serialize;

#[derive(Serialize)]
struct Todo {
    id: i64,
    title: String,
    created_at: String,
}

pub async fn list_todos(pool: web::Data<SqlitePool>, req: actix_web::HttpRequest) -> impl Responder {
    // username 추출
    let username = req.extensions().get::<String>().cloned().unwrap_or("Unknown".to_string());

    match sqlx::query!("SELECT id, title, created_at FROM todos")
        .fetch_all(pool.get_ref())
        .await
    {
        Ok(rows) => {
            let todos: Vec<Todo> = rows.into_iter().map(|r| Todo {
                id: r.id,
                title: r.title,
                created_at: r.created_at,
            }).collect();
            // username을 출력하거나, username 관련 로직 적용 가능
            HttpResponse::Ok().json(todos)
        }
        Err(e) => {
            eprintln!("Error listing todos: {:?}", e);
            HttpResponse::InternalServerError().body("Error listing todos.")
        }
    }
}

src/routes/mod.rs에서 라우트 설정:

mod auth;
mod todo;

use actix_web::{web};
use crate::middleware::auth_middleware::AuthMiddleware;
use self::{auth::{register, login}, todo::list_todos};

pub fn init(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::resource("/api/register").route(web::post().to(register))
    )
    .service(
        web::resource("/api/login").route(web::post().to(login))
    )
    .service(
        web::scope("/api")
            .wrap(AuthMiddleware)
            .service(
                web::resource("/todos").route(web::get().to(list_todos))
            )
    );
}

이제 /api/todos는 JWT 인증이 필요합니다. 먼저 POST /api/login으로 토큰을 얻은 뒤, 요청 시 Authorization: Bearer <토큰> 헤더를 추가해야 합니다.

테스트

  • POST /api/register로 유저 등록
    curl -X POST -H "Content-Type: application/json" -d '{"username":"test","password":"secret"}' http://127.0.0.1:8080/api/register
    
  • POST /api/login로 JWT 획득
    curl -X POST -H "Content-Type: application/json" -d '{"username":"test","password":"secret"}' http://127.0.0.1:8080/api/login
    
    응답으로 {"token":"...JWT..."}를 받는다.
  • 이제 /api/todos 접근 시
    curl -H "Authorization: Bearer <JWT_토큰>" http://127.0.0.1:8080/api/todos
    
    유효한 토큰이면 Todo 목록 반환, 아니면 401 Unauthorized.

다음 단계

이번 예제에서는 JWT 기반 인증을 통해 API 접근 제어를 구현했습니다. 다음을 고려할 수 있습니다.

  • 토큰 만료와 갱신(Refresh Token): 토큰 만료 시 재발급 로직
  • Role/Permission 관리: 관리자, 일반 사용자 역할 분리
  • 로깅, 모니터링: 요청/응답 로깅, Prometheus 메트릭, OpenTelemetry 연동
  • C++와 비교 재확인: C++로 인증/인가 로직 구현 시 포인터 관리, 메모리 안전성, 예외 처리 등 부담이 큰 반면, 러스트는 타입 안전성을 바탕으로 더 명확하고 안정적인 구현 가능.

결론

이번 글에서는 러스트 기반 REST API에 JWT 인증을 도입하며, 실전에 가까운 백엔드 서비스 구현 패턴을 체험했습니다. 비밀번호 해싱, 토큰 발급/검증, 미들웨어를 통한 접근 제어 등 백엔드 서비스 개발에 필수적인 개념들을 러스트 생태계 크레이트들로 손쉽게 구축할 수 있었습니다.

다음 글에서는 WebAssembly 연동, C++ FFI, 혹은 마이크로서비스 아키텍처 패턴, 배포 전략 등 더욱 폭넓은 주제에 도전하며 실전성을 높여갈 예정입니다.

유용한 링크와 리소스

반응형