이전 글에서는 데이터베이스를 연동한 간단한 To-Do 리스트 REST API를 구현하며, 러스트 생태계를 활용한 웹 개발의 기초를 다졌습니다. 이번 글에서는 인증(Authorization)과 토큰 기반 인증(JWT)을 적용해, 좀 더 실전적이고 안전한 REST API를 만들어봅니다. 이를 통해 사용자가 로그인하고, 발급받은 토큰(JWT)을 사용해 권한이 필요한 API에 접근하는 패턴을 익힐 수 있습니다.
이번 프로젝트의 주요 목표는 다음과 같습니다.
- 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 획득
응답으로 {"token":"...JWT..."}를 받는다.curl -X POST -H "Content-Type: application/json" -d '{"username":"test","password":"secret"}' http://127.0.0.1:8080/api/login
- 이제 /api/todos 접근 시
유효한 토큰이면 Todo 목록 반환, 아니면 401 Unauthorized.curl -H "Authorization: Bearer <JWT_토큰>" http://127.0.0.1:8080/api/todos
다음 단계
이번 예제에서는 JWT 기반 인증을 통해 API 접근 제어를 구현했습니다. 다음을 고려할 수 있습니다.
- 토큰 만료와 갱신(Refresh Token): 토큰 만료 시 재발급 로직
- Role/Permission 관리: 관리자, 일반 사용자 역할 분리
- 로깅, 모니터링: 요청/응답 로깅, Prometheus 메트릭, OpenTelemetry 연동
- C++와 비교 재확인: C++로 인증/인가 로직 구현 시 포인터 관리, 메모리 안전성, 예외 처리 등 부담이 큰 반면, 러스트는 타입 안전성을 바탕으로 더 명확하고 안정적인 구현 가능.
결론
이번 글에서는 러스트 기반 REST API에 JWT 인증을 도입하며, 실전에 가까운 백엔드 서비스 구현 패턴을 체험했습니다. 비밀번호 해싱, 토큰 발급/검증, 미들웨어를 통한 접근 제어 등 백엔드 서비스 개발에 필수적인 개념들을 러스트 생태계 크레이트들로 손쉽게 구축할 수 있었습니다.
다음 글에서는 WebAssembly 연동, C++ FFI, 혹은 마이크로서비스 아키텍처 패턴, 배포 전략 등 더욱 폭넓은 주제에 도전하며 실전성을 높여갈 예정입니다.
유용한 링크와 리소스
- jsonwebtoken 문서: https://docs.rs/jsonwebtoken
- bcrypt 문서: https://docs.rs/bcrypt
- Actix-web: https://actix.rs/
- SQLx: https://docs.rs/sqlx