Step 02: JWT 인증 시스템
core 90 min

JWT 인증 시스템

JWT로 사용자 인증 및 권한 관리를 구현합니다.

Execute this step

Run from project root:
cargo run --bin web

Step 2: JWT 인증 시스템

학습 목표

  • JWT로 토큰 기반 인증 구현하기
  • 미들웨어로 보호된 라우트 만들기
  • 사용자 권한 관리하기

Cargo.toml에 추가

[dependencies]
jsonwebtoken = "9"
bcrypt = "0.15"

사용자 테이블 마이그레이션

sqlx migrate add create_users_table

migrations/xxx_create_users_table.sql:

1CREATE TYPE user_role AS ENUM ('admin', 'librarian', 'member');
2
3CREATE TABLE users (
4    id SERIAL PRIMARY KEY,
5    username VARCHAR(50) UNIQUE NOT NULL,
6    email VARCHAR(255) UNIQUE NOT NULL,
7    password_hash VARCHAR(255) NOT NULL,
8    role user_role NOT NULL DEFAULT 'member',
9    created_at TIMESTAMP NOT NULL DEFAULT NOW()
10);
11
12CREATE INDEX idx_users_username ON users(username);
sqlx migrate run

JWT 구현

src/auth.rs 생성:

1use chrono::{Duration, Utc};
2use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
3use serde::{Deserialize, Serialize};
4use crate::{LibraryError, Result};
5
6#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
7pub enum UserRole {
8    Admin,
9    Librarian,
10    Member,
11}
12
13#[derive(Debug, Serialize, Deserialize)]
14pub struct Claims {
15    pub sub: i32,        // user id
16    pub username: String,
17    pub role: UserRole,
18    pub exp: i64,        // expiration
19}
20
21pub struct Auth {
22    secret: String,
23}
24
25impl Auth {
26    pub fn new(secret: String) -> Self {
27        Auth { secret }
28    }
29    
30    pub fn generate_token(
31        &self,
32        user_id: i32,
33        username: &str,
34        role: UserRole,
35    ) -> Result<String> {
36        let expiration = Utc::now()
37            .checked_add_signed(Duration::hours(24))
38            .unwrap()
39            .timestamp();
40        
41        let claims = Claims {
42            sub: user_id,
43            username: username.to_string(),
44            role,
45            exp: expiration,
46        };
47        
48        encode(
49            &Header::default(),
50            &claims,
51            &EncodingKey::from_secret(self.secret.as_bytes()),
52        )
53        .map_err(|e| LibraryError::AuthError(e.to_string()))
54    }
55    
56    pub fn verify_token(&self, token: &str) -> Result<Claims> {
57        decode::<Claims>(
58            token,
59            &DecodingKey::from_secret(self.secret.as_bytes()),
60            &Validation::default(),
61        )
62        .map(|data| data.claims)
63        .map_err(|e| LibraryError::AuthError(e.to_string()))
64    }
65}
66
67pub fn hash_password(password: &str) -> Result<String> {
68    bcrypt::hash(password, bcrypt::DEFAULT_COST)
69        .map_err(|e| LibraryError::AuthError(e.to_string()))
70}
71
72pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
73    bcrypt::verify(password, hash)
74        .map_err(|e| LibraryError::AuthError(e.to_string()))
75}

사용자 데이터베이스

src/db.rs에 추가:

1use crate::auth::{UserRole, hash_password, verify_password};
2
3#[derive(Debug, FromRow)]
4pub struct UserRow {
5    pub id: i32,
6    pub username: String,
7    pub email: String,
8    pub password_hash: String,
9    pub role: String,
10}
11
12impl Database {
13    pub async fn create_user(
14        &self,
15        username: &str,
16        email: &str,
17        password: &str,
18        role: &UserRole,
19    ) -> Result<i32> {
20        let password_hash = hash_password(password)?;
21        let role_str = match role {
22            UserRole::Admin => "admin",
23            UserRole::Librarian => "librarian",
24            UserRole::Member => "member",
25        };
26        
27        let row = sqlx::query!(
28            r#"
29            INSERT INTO users (username, email, password_hash, role)
30            VALUES ($1, $2, $3, $4::user_role)
31            RETURNING id
32            "#,
33            username,
34            email,
35            password_hash,
36            role_str
37        )
38        .fetch_one(&self.pool)
39        .await
40        .map_err(|e| LibraryError::DatabaseError(e.to_string()))?;
41        
42        Ok(row.id)
43    }
44    
45    pub async fn find_user_by_username(
46        &self,
47        username: &str,
48    ) -> Result<Option<UserRow>> {
49        let row = sqlx::query_as::<_, UserRow>(
50            "SELECT * FROM users WHERE username = $1"
51        )
52        .bind(username)
53        .fetch_optional(&self.pool)
54        .await
55        .map_err(|e| LibraryError::DatabaseError(e.to_string()))?;
56        
57        Ok(row)
58    }
59    
60    pub async fn verify_user(
61        &self,
62        username: &str,
63        password: &str,
64    ) -> Result<(i32, UserRole)> {
65        let user = self.find_user_by_username(username)
66            .await?
67            .ok_or(LibraryError::Unauthorized)?;
68        
69        if !verify_password(password, &user.password_hash)? {
70            return Err(LibraryError::Unauthorized);
71        }
72        
73        let role = match user.role.as_str() {
74            "admin" => UserRole::Admin,
75            "librarian" => UserRole::Librarian,
76            _ => UserRole::Member,
77        };
78        
79        Ok((user.id, role))
80    }
81}

인증 미들웨어

src/middleware.rs 생성:

1use actix_web::{dev::ServiceRequest, Error, HttpMessage};
2use actix_web_httpauth::extractors::bearer::BearerAuth;
3use crate::auth::{Auth, Claims, UserRole};
4
5pub async fn validator(
6    req: ServiceRequest,
7    credentials: BearerAuth,
8) -> Result<ServiceRequest, (Error, ServiceRequest)> {
9    let auth = Auth::new(std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string()));
10    
11    match auth.verify_token(credentials.token()) {
12        Ok(claims) => {
13            req.extensions_mut().insert(claims);
14            Ok(req)
15        }
16        Err(_) => Err((actix_web::error::ErrorUnauthorized("Invalid token"), req)),
17    }
18}
19
20pub fn require_role(required: UserRole) -> impl Fn(&Claims) -> bool {
21    move |claims: &Claims| match required {
22        UserRole::Admin => claims.role == UserRole::Admin,
23        UserRole::Librarian => {
24            claims.role == UserRole::Admin || claims.role == UserRole::Librarian
25        }
26        UserRole::Member => true,
27    }
28}

인증 라우트

src/bin/web.rs에 추가:

1use actix_web_httpauth::middleware::HttpAuthentication;
2use book_manager::auth::{Auth, UserRole};
3use book_manager::middleware::validator;
4
5#[derive(Deserialize)]
6struct RegisterRequest {
7    username: String,
8    email: String,
9    password: String,
10}
11
12#[derive(Deserialize)]
13struct LoginRequest {
14    username: String,
15    password: String,
16}
17
18#[derive(Serialize)]
19struct AuthResponse {
20    token: String,
21    username: String,
22    role: UserRole,
23}
24
25async fn register(
26    data: web::Data<AppState>,
27    req: web::Json<RegisterRequest>,
28) -> Result<HttpResponse, LibraryError> {
29    let user_id = data.db.create_user(
30        &req.username,
31        &req.email,
32        &req.password,
33        &UserRole::Member,
34    ).await?;
35    
36    let token = data.auth.generate_token(user_id, &req.username, UserRole::Member)?;
37    
38    Ok(HttpResponse::Created().json(ApiResponse::success(AuthResponse {
39        token,
40        username: req.username.clone(),
41        role: UserRole::Member,
42    })))
43}
44
45async fn login(
46    data: web::Data<AppState>,
47    req: web::Json<LoginRequest>,
48) -> Result<HttpResponse, LibraryError> {
49    let (user_id, role) = data.db.verify_user(&req.username, &req.password).await?;
50    let token = data.auth.generate_token(user_id, &req.username, role.clone())?;
51    
52    Ok(HttpResponse::Ok().json(ApiResponse::success(AuthResponse {
53        token,
54        username: req.username.clone(),
55        role,
56    })))
57}
58
59// 보호된 라우트 예시
60async fn borrow_book_protected(
61    data: web::Data<AppState>,
62    path: web::Path<i32>,
63    req: web::Json<BorrowRequest>,
64    claims: web::ReqData<Claims>,
65) -> Result<HttpResponse, LibraryError> {
66    let id = path.into_inner();
67    
68    // 사용자 정보 사용 가능
69    let borrower = &claims.username;
70    
71    data.db.borrow_book(id, borrower, &req.due_date).await?;
72    Ok(HttpResponse::Ok().json(ApiResponse::success("대여 완료")))
73}
74
75#[actix_web::main]
76async fn main() -> std::io::Result<()> {
77    dotenv().ok();
78    
79    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
80    let jwt_secret = env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
81    
82    let db = Database::new(&database_url).await.expect("DB connection failed");
83    let auth = Auth::new(jwt_secret);
84    
85    let app_state = web::Data::new(AppState { db, auth });
86    
87    let auth_middleware = HttpAuthentication::bearer(validator);
88    
89    HttpServer::new(move || {
90        App::new()
91            .app_data(app_state.clone())
92            // 공개 라우트
93            .route("/health", web::get().to(health_check))
94            .route("/register", web::post().to(register))
95            .route("/login", web::post().to(login))
96            // 보호된 라우트
97            .service(
98                web::scope("/api")
99                    .wrap(auth_middleware)
100                    .route("/books", web::get().to(list_books))
101                    .route("/books/{id}", web::get().to(get_book))
102                    .route("/books", web::post().to(create_book))
103                    .route("/books/{id}/borrow", web::post().to(borrow_book_protected))
104                    .route("/books/{id}/return", web::post().to(return_book))
105            )
106    })
107    .bind(("127.0.0.1", 8080))?
108    .run()
109    .await
110}

테스트

1# 회원가입
2curl -X POST http://localhost:8080/register \
3  -H "Content-Type: application/json" \
4  -d '{"username":"test","email":"test@example.com","password":"password123"}'
5
6# 로그인
7curl -X POST http://localhost:8080/login \
8  -H "Content-Type: application/json" \
9  -d '{"username":"test","password":"password123"}'
10
11# 토큰으로 API 호출
12curl http://localhost:8080/api/books \
13  -H "Authorization: Bearer <TOKEN>"

체크리스트

  • [ ] JWT 토큰 생성 및 검증을 구현했습니다
  • [ ] 사용자 회원가입/로그인을 구현했습니다
  • [ ] 미들웨어로 보호된 라우트를 만들었습니다
  • [ ] 비밀번호를 bcrypt로 해시했습니다
  • [ ] 권한 기반 접근 제어를 구현했습니다

다음 단계

Step 3에서는 Docker로 애플리케이션을 컨테이너화하고 배포합니다. 축하합니다!

Did you find this helpful? Give it a cheer!