Step 02: JWT 인증 시스템
0 studying now
core 90 min
JWT 인증 시스템
JWT로 사용자 인증 및 권한 관리를 구현합니다.
Execute this step
Run from project root:
cargo run --bin webStep 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로 애플리케이션을 컨테이너화하고 배포합니다. 축하합니다!