iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Rust

大家一起跟Rust當好朋友吧!系列 第 29

Day 29: 簡單的安全防護 - 保護你的創作平台

  • 分享至 

  • xImage
  •  

今天我們為個人部落格加入簡單的的安全機制:

  1. JWT 認證:只讓管理員(你)能操作內容
  2. 速率限制:防止 API 被濫用
  3. CORS 設定:支援前端跨域請求

🔐 JWT 認證系統

1. 新增依賴

# Cargo.toml
[dependencies]
# 現有依賴...

# JWT 認證
jsonwebtoken = "9"
# 密碼雜湊
bcrypt = "0.18"
# 時間處理
time = "0.3"

2. JWT 服務

建立 src/auth.rs

use std::env;
use anyhow::{Context, Result};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
    pub sub: String, // 用戶 ID
    pub exp: usize,  // 過期時間
    pub role: String, // 角色
}

#[derive(Debug, Serialize, Deserialize,ToSchema)]
pub struct LoginRequest {
    pub username: String,
    pub password: String,
}

#[derive(Debug, Serialize, Deserialize,ToSchema)]
pub struct LoginResponse {
    pub token: String,
    pub expires_in: usize,
}

#[derive(Clone)]
pub struct JwtService {
    encoding_key: EncodingKey,
    decoding_key: DecodingKey,
    validation: Validation,
}

impl JwtService {
    pub fn new() -> Result<Self> {
        let secret = env::var("JWT_SECRET")
            .context("請設定 JWT_SECRET 環境變數")?;

        let mut validation = Validation::new(Algorithm::HS256);
        validation.validate_exp = true;

        Ok(Self {
            encoding_key: EncodingKey::from_secret(secret.as_bytes()),
            decoding_key: DecodingKey::from_secret(secret.as_bytes()),
            validation,
        })
    }

    pub fn generate_token(&self, user_id: &str) -> Result<String> {
        let exp = Utc::now() + Duration::hours(24);

        let claims = Claims {
            sub: user_id.to_string(),
            exp: exp.timestamp() as usize,
            role: "admin".to_string(),
        };

        encode(&Header::default(), &claims, &self.encoding_key)
            .context("JWT token 生成失敗")
    }

    pub fn verify_token(&self, token: &str) -> Result<Claims> {
        let token_data = decode::<Claims>(token, &self.decoding_key, &self.validation)
            .context("JWT token 驗證失敗")?;

        Ok(token_data.claims)
    }
}

3. 認證中介層

建立 src/auth_middleware.rs

use axum::{
    extract::{Request, State as AxumState},
    http::header::AUTHORIZATION,
    middleware::Next,
    response::Response,
};
use crate::{error::AppError, state::AppState};

pub async fn auth_middleware(
    AxumState(app_state): AxumState<AppState>,
    mut request: Request,
    next: Next,
) -> Result<Response, AppError> {

    let auth_header = request
        .headers()
        .get(AUTHORIZATION)
        .and_then(|header| header.to_str().ok())
        .ok_or_else(|| AppError::Unauthorized("缺少認證資訊".to_string()))?;

    let token = auth_header
        .strip_prefix("Bearer ")
        .ok_or_else(|| AppError::Unauthorized("無效的認證格式".to_string()))?;

    let claims = app_state
        .jwt_service
        .verify_token(token)
        .map_err(|_| AppError::Unauthorized("無效的認證 token".to_string()))?;

    // 將用戶資訊加入請求
    request.extensions_mut().insert(claims);

    Ok(next.run(request).await)
}

4. 登入 Handler

建立 src/auth_handlers.rs

use crate::{
    auth::{Claims, LoginRequest, LoginResponse},
    error::AppError,
    state::AppState,
};
use axum::{extract::{State, Extension}, Json};
use std::env;

/// 管理員登入取得 JWT
#[utoipa::path(
    post,
    path = "/api/admin/login",
    tag = "admin",
    request_body = LoginRequest,
    responses(
        (status = 200, description = "登入成功,回傳 JWT", body = LoginResponse),
        (status = 401, description = "用戶名或密碼錯誤")
    )
)]
pub async fn admin_login(
    State(app_state): State<AppState>,
    Json(login_request): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
    // 簡單的管理員驗證
    let admin_username = env::var("ADMIN_USERNAME")
        .unwrap_or_else(|_| "admin".to_string());
    let admin_password = env::var("ADMIN_PASSWORD")
        .unwrap_or_else(|_| "admin123".to_string());

    // 驗證用戶名和密碼
    if login_request.username != admin_username || login_request.password != admin_password {
        return Err(AppError::Unauthorized("用戶名或密碼錯誤".to_string()));
    }

    // 生成 JWT token
    let token = app_state
        .jwt_service
        .generate_token(&admin_username)
        .map_err(|_| AppError::InternalServerError("Token 生成失敗".to_string()))?;

    Ok(Json(LoginResponse {
        token,
        expires_in: 24 * 3600, // 24 小時
    }))
}

/// 取得當前管理員資訊
#[utoipa::path(
    get,
    path = "/api/admin/info",
    tag = "admin",
    security(("bearer_auth" = [])),
    responses(
        (status = 200, description = "當前管理員資訊", body = serde_json::Value),
        (status = 401, description = "未授權")
    )
)]
pub async fn admin_info(Extension(claims): Extension<Claims>) -> Json<serde_json::Value> {
    Json(serde_json::json!({
        "user_id": claims.sub,
        "role": claims.role,
        "expires_at": claims.exp
    }))
}

🚦 速率限制

建立 src/rate_limit.rs

use std::{
    collections::HashMap,
    net::IpAddr,
    sync::{Arc, Mutex},
    time::{Duration, Instant},
};
use axum::{
    extract::{ConnectInfo, Request},
    http::StatusCode,
    middleware::Next,
    response::Response,
};

#[derive(Debug)]
struct RateLimitEntry {
    count: u32,
    window_start: Instant,
}

type RateLimitStore = Arc<Mutex<HashMap<IpAddr, RateLimitEntry>>>;

#[derive(Clone)]
pub struct RateLimiter {
    store: RateLimitStore,
    max_requests: u32,
}

impl RateLimiter {
    pub fn new(max_requests: u32) -> Self {
        Self {
            store: Arc::new(Mutex::new(HashMap::new())),
            max_requests,
        }
    }

    fn check_rate_limit(&self, ip: IpAddr) -> bool {
        let mut store = self.store.lock().unwrap();
        let now = Instant::now();
        let window_duration = Duration::from_secs(60); // 1 分鐘窗口

        match store.get_mut(&ip) {
            Some(entry) => {
                // 如果窗口已過期,重置計數
                if now.duration_since(entry.window_start) >= window_duration {
                    entry.count = 1;
                    entry.window_start = now;
                    true
                } else if entry.count >= self.max_requests {
                    false // 超過限制
                } else {
                    entry.count += 1;
                    true
                }
            }
            None => {
                // 新 IP
                store.insert(
                    ip,
                    RateLimitEntry {
                        count: 1,
                        window_start: now,
                    },
                );
                true
            }
        }
    }
}

pub async fn rate_limit_middleware(
    ConnectInfo(addr): ConnectInfo<std::net::SocketAddr>,
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    static RATE_LIMITER: std::sync::OnceLock<RateLimiter> = std::sync::OnceLock::new();
    let limiter = RATE_LIMITER.get_or_init(|| RateLimiter::new(60)); // 每分鐘 60 次

    if !limiter.check_rate_limit(addr.ip()) {
        return Err(StatusCode::TOO_MANY_REQUESTS);
    }

    Ok(next.run(request).await)
}

🌐 CORS 配置

更新 src/app.rs

use crate::{rate_limit::rate_limit_middleware, routes, state::AppState};
use axum::{middleware, Router};
use std::env;
use tower_http::cors::{Any, CorsLayer};
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::docs::ApiDoc;
use crate::auth_middleware::auth_middleware;

pub fn build_app(app_state: AppState) -> Router {
    let cors = if env::var("ENVIRONMENT").unwrap_or_default() == "production" {
        let allowed_origins = env::var("CORS_ORIGINS")
            .unwrap_or_else(|_| "https://yourdomain.com".to_string())
            .split(',')
            .filter_map(|origin| origin.parse().ok())
            .collect::<Vec<_>>();

        CorsLayer::new()
            .allow_origin(allowed_origins)
            .allow_methods([
                http::Method::GET,
                http::Method::POST,
                http::Method::PUT,
                http::Method::DELETE,
            ])
            .allow_headers([
                http::header::AUTHORIZATION,
                http::header::CONTENT_TYPE,
            ])
    } else {
        // 開發環境:寬鬆設定
        CorsLayer::new()
            .allow_origin(Any)
            .allow_methods(Any)
            .allow_headers(Any)
    };

    let public = routes::public_router();
    let admin = routes::admin_router();
    let admin_protected = admin.layer(middleware::from_fn_with_state(app_state.clone(), auth_middleware));

    // Swagger UI 與 OpenAPI 提供路由
    let swagger_router = Router::new().merge(
        SwaggerUi::new("/docs").url("/api-docs/openapi.json", ApiDoc::openapi()),
    );

    Router::new()
        .merge(swagger_router)
        .merge(public)
        .nest("/api/admin", admin_protected)
        .layer(middleware::from_fn(rate_limit_middleware))
        .layer(cors)
        .with_state(app_state)
}

📝 更新路由

更新 src/routes/mod.rs

use axum::{routing::get, Router};
use axum::routing::{delete, post, put};
use crate::auth_handler::{admin_info, admin_login};
use crate::auth_middleware::auth_middleware;
use crate::state::AppState;


pub mod blog;
pub mod health;
pub mod posts;
pub mod tags;
pub mod comments;

pub fn public_router() -> Router<AppState> {
    Router::new()
        .route("/", get(blog::blog_info))
        .route("/health", get(health::health_check))
        .merge(posts::create_post_routes())
        .merge(tags::create_tag_routes())
        .merge(comments::create_comment_routes())
        // 登入端點為公開
        .route("/api/admin/login", post(admin_login))
}

pub fn admin_router() -> Router<AppState> {
    Router::new()
        .route("/info", get(admin_info))
        // posts 管理端點
        .route("/posts", post(posts::create_post))
        .route("/posts/{id}", get(posts::get_post_for_admin))
        .route("/posts/{id}", put(posts::update_post))
        .route("/posts/{id}", delete(posts::delete_post))
        .route("/posts/search", get(posts::admin_search_posts))
        // tags 管理端點
        .route("/tags", post(tags::create_tag))
        .route("/tags/{id}", get(tags::get_tag_by_id))
        .route("/tags/{id}", put(tags::update_tag))
        .route("/tags/{id}", delete(tags::delete_tag))
        // comments 管理端點
        .route("/comments", get(comments::get_admin_comments))
        .route("/comments/{id}", put(comments::update_comment_status))
        .route("/comments/{id}", delete(comments::delete_comment))
}


🔧 更新應用狀態

更新 src/state.rs

use sea_orm::DatabaseConnection;
use crate::cache::CacheConfig;
use crate::cache::post::PostCache;
use crate::cache::tag::TagCache;
use crate::config::Config;
use crate::auth::JwtService;

#[derive(Clone)]
pub struct AppState {
    pub db: DatabaseConnection,
    pub config: Config,
    pub post_cache: PostCache,
    pub tag_cache: TagCache,
    pub jwt_service: JwtService,
}

impl AppState {
    pub fn new(db: DatabaseConnection, config: Config, jwt_service: JwtService) -> Self {

        let cache_config = CacheConfig::default();

        Self {
            db,
            config ,
            post_cache: PostCache::new(&cache_config),
            tag_cache: TagCache::new(&cache_config),
            jwt_service,
        }
    }
}

🔐 環境變數設定

更新 .env.example

# JWT 配置
JWT_SECRET=your-super-secret-jwt-key
JWT_EXPIRY_HOURS=24

# 管理員帳號
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123

# CORS 配置
CORS_ORIGINS=http://localhost:3000,https://yourdomain.com
ENVIRONMENT=development

# 其他現有配置...

在需要驗證的API上面加上 bearer_auth


#[utoipa::path(
    post,
    path = "/api/admin/posts",
    tag = "admin",
    security(("bearer_auth" = [])), // 像這樣
    request_body = CreatePostRequest,
    responses(
        (status = 201, description = "文章建立成功", body = PostResponse),
        (status = 400, description = "請求資料錯誤"),
        (status = 409, description = "Slug 已存在")
    )
)]
pub async fn create_post(
    State(app_state): State<AppState>,
    Json(req): Json<CreatePostRequest>,
) -> Result<(StatusCode, Json<PostResponse>), AppError> {
    req.validate().map_err(|e| AppError::ValidationError(e.to_string()))?;

    let post = PostService::create_post(&app_state.db, req).await?;

    // 新增文章後清理相關快取
    app_state.post_cache.invalidate_all().await;
    app_state.tag_cache.invalidate_all().await;

    info!("📝 文章建立成功,已清理相關快取");

    Ok((StatusCode::CREATED, Json(post)))
}



🚀 今天的收穫

簡潔而有效的安全系統:

JWT 認證

  • ✅ 簡單的 token 生成和驗證
  • ✅ 中介層保護管理員功能
  • ✅ 24 小時 token 有效期

速率限制

  • ✅ 基於 IP 的請求限制
  • ✅ 每分鐘 60 次請求限制
  • ✅ 自動重置計數器

CORS 設定

  • ✅ 開發環境寬鬆設定
  • ✅ 生產環境白名單機制
  • ✅ 支援前端跨域請求

個人部落格特色

  • ✅ 單一管理員模式,簡單夠用就好

今天就先到此結束拉!明天就是最後一天嘞!!

我們明天見!


上一篇
Day 28: 優化使用者體驗 - 快取策略與效能提升
下一篇
Day 30: 總結、打包 (Docker)
系列文
大家一起跟Rust當好朋友吧!30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言