JWT 認(rèn)證 + 頻率限制完整方案

?? 目錄


架構(gòu)概述

設(shè)計(jì)理念

簡單、有效、易維護(hù)

本方案采用 頻率限制 + 動(dòng)態(tài)驗(yàn)證碼 的組合策略,無需復(fù)雜的請求簽名驗(yàn)證,既保證了安全性,又降低了實(shí)現(xiàn)復(fù)雜度。

核心特點(diǎn)

  • ?? 簡單高效:無需客戶端簽名,實(shí)現(xiàn)簡單
  • ?? 安全可靠:多層頻率限制 + 動(dòng)態(tài)驗(yàn)證碼
  • ?? 統(tǒng)一接口:Web 和移動(dòng)端使用同一接口
  • ?? 用戶友好:正常情況無需驗(yàn)證碼
  • ??? 防護(hù)全面:防暴力破解、防撞庫、防 DDoS

認(rèn)證流程

用戶請求登錄
    ↓
┌─────────────────────────────────────┐
│  第一層:IP 級(jí)別頻率限制              │
│  - 每分鐘最多 10 次                  │
│  - 防止單個(gè) IP 暴力破解              │
└─────────────────────────────────────┘
    ↓
┌─────────────────────────────────────┐
│  第二層:用戶名級(jí)別頻率限制           │
│  - 每 5 分鐘最多 20 次               │
│  - 防止針對特定賬號(hào)的攻擊            │
└─────────────────────────────────────┘
    ↓
┌─────────────────────────────────────┐
│  第三層:動(dòng)態(tài)驗(yàn)證碼檢查               │
│  - 失敗 3 次后要求驗(yàn)證碼             │
│  - 新 IP 首次登錄要求驗(yàn)證碼(可選)   │
└─────────────────────────────────────┘
    ↓
┌─────────────────────────────────────┐
│  第四層:用戶名密碼驗(yàn)證               │
│  - 數(shù)據(jù)庫查詢驗(yàn)證                    │
│  - 密碼哈希對比                      │
└─────────────────────────────────────┘
    ↓
生成 JWT Token
    ↓
返回給客戶端

核心設(shè)計(jì)原則

為什么不需要請求簽名?

場景 簽名是否必要 原因
登錄接口 ? 不需要 公開接口,頻率限制已足夠
已認(rèn)證 API ? 不需要 JWT 本身就是簽名
支付接口 ? 需要 防止金額篡改
敏感操作 ? 需要 如修改密碼、刪除賬號(hào)

安全性保證

HTTPS 傳輸加密
    +
多層頻率限制
    +
動(dòng)態(tài)驗(yàn)證碼
    +
JWT 簽名驗(yàn)證
    =
足夠的安全性

頻率限制策略

三層限流設(shè)計(jì)

1. IP 級(jí)別限制

目的: 防止單個(gè) IP 發(fā)起暴力破解攻擊

限制規(guī)則:
- 時(shí)間窗口:60 秒
- 最大請求:10 次
- 超限后:返回 429 狀態(tài)碼,提示剩余等待時(shí)間

適用場景:

  • 防止單點(diǎn)攻擊
  • 防止 DDoS 攻擊
  • 保護(hù)服務(wù)器資源

2. 用戶名級(jí)別限制

目的: 防止分布式攻擊針對特定賬號(hào)

限制規(guī)則:
- 時(shí)間窗口:300 秒(5 分鐘)
- 最大請求:20 次
- 超限后:返回 429 狀態(tài)碼,提示剩余等待時(shí)間

適用場景:

  • 防止撞庫攻擊
  • 防止分布式暴力破解
  • 保護(hù)特定賬號(hào)安全

3. 失敗次數(shù)記錄

目的: 觸發(fā)動(dòng)態(tài)驗(yàn)證碼機(jī)制

記錄規(guī)則:
- 記錄:IP + 用戶名組合的失敗次數(shù)
- 觸發(fā):失敗 3 次后要求驗(yàn)證碼
- 清除:登錄成功后清除記錄

限流算法:滑動(dòng)窗口

// 偽代碼
struct RateLimit {
    count: u32,           // 當(dāng)前窗口內(nèi)的請求次數(shù)
    window_start: Instant, // 窗口開始時(shí)間
}

fn check_rate_limit(key: &str, max: u32, window: Duration) -> Result<()> {
    let limit = get_or_create(key);
    
    if limit.window_start.elapsed() >= window {
        // 窗口過期,重置
        limit.count = 1;
        limit.window_start = now();
        Ok(())
    } else if limit.count >= max {
        // 超過限制
        Err(RateLimitExceeded)
    } else {
        // 增加計(jì)數(shù)
        limit.count += 1;
        Ok(())
    }
}

動(dòng)態(tài)驗(yàn)證碼機(jī)制

觸發(fā)條件

條件 說明 推薦度
失敗 3 次 IP + 用戶名組合失敗 3 次 ? 必須
新 IP 登錄 該 IP 首次訪問系統(tǒng) ?? 可選
異常行為 短時(shí)間內(nèi)嘗試多個(gè)賬號(hào) ?? 可選
高風(fēng)險(xiǎn) IP 來自黑名單 IP 段 ?? 可選

驗(yàn)證碼類型

1. 圖片驗(yàn)證碼(推薦)

// 使用 captcha crate
use captcha::Captcha;
use captcha::filters::{Noise, Wave};

fn generate_captcha() -> (String, Vec<u8>) {
    let mut captcha = Captcha::new();
    captcha
        .add_chars(4)
        .apply_filter(Noise::new(0.1))
        .apply_filter(Wave::new(2.0, 20.0))
        .view(220, 120);
    
    let captcha_text = captcha.chars_as_string();
    let captcha_image = captcha.as_png().unwrap();
    
    (captcha_text, captcha_image)
}

2. 滑動(dòng)驗(yàn)證碼(可選)

適用于移動(dòng)端,用戶體驗(yàn)更好。

驗(yàn)證碼存儲(chǔ)

// 使用 Redis 存儲(chǔ)驗(yàn)證碼
// Key: captcha:{captcha_id}
// Value: captcha_text
// TTL: 300 秒(5 分鐘)

async fn save_captcha(captcha_id: &str, captcha_text: &str) {
    redis_client
        .set_ex(
            format!("captcha:{}", captcha_id),
            captcha_text,
            300
        )
        .await
        .unwrap();
}

async fn verify_captcha(captcha_id: &str, user_input: &str) -> bool {
    let stored = redis_client
        .get(format!("captcha:{}", captcha_id))
        .await
        .ok();
    
    if let Some(text) = stored {
        // 驗(yàn)證后刪除(一次性使用)
        redis_client
            .del(format!("captcha:{}", captcha_id))
            .await
            .ok();
        
        // 不區(qū)分大小寫
        text.to_lowercase() == user_input.to_lowercase()
    } else {
        false
    }
}

后端實(shí)現(xiàn)(Rust + Axum)

完整代碼

// Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
bcrypt = "0.15"
uuid = { version = "1", features = ["v4"] }
captcha = "0.0.9"
redis = { version = "0.24", features = ["tokio-comp"] }
// src/main.rs
use axum::{
    Router,
    routing::{get, post},
    extract::State,
    http::{HeaderMap, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::collections::HashMap;
use tokio::sync::RwLock;
use std::time::{Duration, Instant};

// ============ 數(shù)據(jù)結(jié)構(gòu) ============

#[derive(Clone)]
struct AppState {
    rate_limiter: RateLimiter,
    captcha_service: CaptchaService,
    auth_service: AuthService,
}

#[derive(Deserialize)]
struct LoginRequest {
    username: String,
    password: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    captcha: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    captcha_id: Option<String>,
}

#[derive(Serialize)]
struct TokenResponse {
    access_token: String,
    refresh_token: String,
    expires_in: u64,
    token_type: String,
}

#[derive(Serialize)]
struct ErrorResponse {
    code: String,
    message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    retry_after: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    captcha_required: Option<bool>,
}

// ============ 錯(cuò)誤處理 ============

#[derive(Debug)]
enum AppError {
    RateLimitExceeded { message: String, retry_after: u64 },
    CaptchaRequired,
    InvalidCaptcha,
    InvalidCredentials,
    InternalError,
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, code, message, retry_after, captcha_required) = match self {
            AppError::RateLimitExceeded { message, retry_after } => (
                StatusCode::TOO_MANY_REQUESTS,
                "RATE_LIMIT_EXCEEDED",
                message,
                Some(retry_after),
                None,
            ),
            AppError::CaptchaRequired => (
                StatusCode::BAD_REQUEST,
                "CAPTCHA_REQUIRED",
                "需要驗(yàn)證碼".to_string(),
                None,
                Some(true),
            ),
            AppError::InvalidCaptcha => (
                StatusCode::BAD_REQUEST,
                "INVALID_CAPTCHA",
                "驗(yàn)證碼錯(cuò)誤".to_string(),
                None,
                None,
            ),
            AppError::InvalidCredentials => (
                StatusCode::UNAUTHORIZED,
                "INVALID_CREDENTIALS",
                "用戶名或密碼錯(cuò)誤".to_string(),
                None,
                None,
            ),
            AppError::InternalError => (
                StatusCode::INTERNAL_SERVER_ERROR,
                "INTERNAL_ERROR",
                "服務(wù)器內(nèi)部錯(cuò)誤".to_string(),
                None,
                None,
            ),
        };

        let body = Json(ErrorResponse {
            code: code.to_string(),
            message,
            retry_after,
            captcha_required,
        });

        (status, body).into_response()
    }
}

// ============ 頻率限制器 ============

#[derive(Clone)]
struct RateLimiter {
    ip_limits: Arc<RwLock<HashMap<String, (u32, Instant)>>>,
    username_limits: Arc<RwLock<HashMap<String, (u32, Instant)>>>,
    failed_attempts: Arc<RwLock<HashMap<String, u32>>>,
}

impl RateLimiter {
    fn new() -> Self {
        Self {
            ip_limits: Arc::new(RwLock::new(HashMap::new())),
            username_limits: Arc::new(RwLock::new(HashMap::new())),
            failed_attempts: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    async fn check_login_attempt(
        &self,
        ip: &str,
        username: &str,
    ) -> Result<(), AppError> {
        // 1. IP 級(jí)別限制:每分鐘最多 10 次
        self.check_limit(
            &self.ip_limits,
            ip,
            10,
            Duration::from_secs(60),
            "IP 請求過于頻繁,請稍后再試",
        )
        .await?;

        // 2. 用戶名級(jí)別限制:每 5 分鐘最多 20 次
        self.check_limit(
            &self.username_limits,
            username,
            20,
            Duration::from_secs(300),
            "該賬號(hào)登錄嘗試過多,請稍后再試",
        )
        .await?;

        Ok(())
    }

    async fn check_limit(
        &self,
        limits: &Arc<RwLock<HashMap<String, (u32, Instant)>>>,
        key: &str,
        max_attempts: u32,
        window: Duration,
        error_msg: &str,
    ) -> Result<(), AppError> {
        let mut map = limits.write().await;
        let (count, start_time) = map
            .entry(key.to_string())
            .or_insert((0, Instant::now()));

        if start_time.elapsed() >= window {
            // 窗口過期,重置
            *count = 1;
            *start_time = Instant::now();
            Ok(())
        } else if *count >= max_attempts {
            // 超過限制
            let retry_after = window.as_secs() - start_time.elapsed().as_secs();
            Err(AppError::RateLimitExceeded {
                message: error_msg.to_string(),
                retry_after,
            })
        } else {
            // 增加計(jì)數(shù)
            *count += 1;
            Ok(())
        }
    }

    async fn get_failed_attempts(&self, ip: &str, username: &str) -> u32 {
        let key = format!("{}:{}", ip, username);
        let map = self.failed_attempts.read().await;
        *map.get(&key).unwrap_or(&0)
    }

    async fn record_failed_attempt(&self, ip: &str, username: &str) {
        let key = format!("{}:{}", ip, username);
        let mut map = self.failed_attempts.write().await;
        *map.entry(key).or_insert(0) += 1;
    }

    async fn clear_failed_attempts(&self, ip: &str, username: &str) {
        let key = format!("{}:{}", ip, username);
        self.failed_attempts.write().await.remove(&key);
    }
}

// ============ 驗(yàn)證碼服務(wù) ============

#[derive(Clone)]
struct CaptchaService {
    store: Arc<RwLock<HashMap<String, (String, Instant)>>>,
}

impl CaptchaService {
    fn new() -> Self {
        Self {
            store: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    async fn generate(&self) -> (String, Vec<u8>) {
        use captcha::Captcha;
        use captcha::filters::{Noise, Wave};

        let mut captcha = Captcha::new();
        captcha
            .add_chars(4)
            .apply_filter(Noise::new(0.1))
            .apply_filter(Wave::new(2.0, 20.0))
            .view(220, 120);

        let captcha_text = captcha.chars_as_string();
        let captcha_image = captcha.as_png().unwrap();

        // 生成唯一 ID
        let captcha_id = uuid::Uuid::new_v4().to_string();

        // 存儲(chǔ)驗(yàn)證碼(5 分鐘有效期)
        let mut store = self.store.write().await;
        store.insert(captcha_id.clone(), (captcha_text.clone(), Instant::now()));

        // 清理過期驗(yàn)證碼
        store.retain(|_, (_, time)| time.elapsed() < Duration::from_secs(300));

        (captcha_id, captcha_image)
    }

    async fn verify(&self, captcha_id: &str, user_input: &str) -> Result<(), AppError> {
        let mut store = self.store.write().await;

        if let Some((text, time)) = store.remove(captcha_id) {
            // 檢查是否過期
            if time.elapsed() >= Duration::from_secs(300) {
                return Err(AppError::InvalidCaptcha);
            }

            // 不區(qū)分大小寫比較
            if text.to_lowercase() == user_input.to_lowercase() {
                Ok(())
            } else {
                Err(AppError::InvalidCaptcha)
            }
        } else {
            Err(AppError::InvalidCaptcha)
        }
    }
}

// ============ 認(rèn)證服務(wù) ============

#[derive(Clone)]
struct AuthService;

#[derive(Serialize)]
struct User {
    id: String,
    username: String,
    roles: Vec<String>,
}

impl AuthService {
    async fn authenticate(&self, username: &str, password: &str) -> Result<User, AppError> {
        // 實(shí)際應(yīng)該查詢數(shù)據(jù)庫
        // 這里簡化處理
        if username == "admin" && password == "admin123" {
            Ok(User {
                id: "1".to_string(),
                username: username.to_string(),
                roles: vec!["admin".to_string()],
            })
        } else {
            Err(AppError::InvalidCredentials)
        }
    }
}

// ============ JWT 生成 ============

use jsonwebtoken::{encode, EncodingKey, Header};

#[derive(Serialize)]
struct Claims {
    sub: String,
    username: String,
    roles: Vec<String>,
    exp: usize,
}

fn generate_jwt(user: &User) -> Result<TokenResponse, AppError> {
    let expiration = chrono::Utc::now()
        .checked_add_signed(chrono::Duration::hours(1))
        .unwrap()
        .timestamp() as usize;

    let claims = Claims {
        sub: user.id.clone(),
        username: user.username.clone(),
        roles: user.roles.clone(),
        exp: expiration,
    };

    let secret = "your-secret-key"; // 實(shí)際應(yīng)該從環(huán)境變量讀取
    let token = encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret.as_ref()),
    )
    .map_err(|_| AppError::InternalError)?;

    // 生成 refresh token(簡化處理)
    let refresh_token = uuid::Uuid::new_v4().to_string();

    Ok(TokenResponse {
        access_token: token,
        refresh_token,
        expires_in: 3600,
        token_type: "Bearer".to_string(),
    })
}

// ============ 路由處理 ============

async fn jwt_login(
    headers: HeaderMap,
    State(state): State<Arc<AppState>>,
    Json(req): Json<LoginRequest>,
) -> Result<Json<TokenResponse>, AppError> {
    // 1. 獲取客戶端 IP
    let ip = get_client_ip(&headers);

    // 2. 頻率限制
    state
        .rate_limiter
        .check_login_attempt(&ip, &req.username)
        .await?;

    // 3. 檢查是否需要驗(yàn)證碼
    let failed_count = state
        .rate_limiter
        .get_failed_attempts(&ip, &req.username)
        .await;

    if failed_count >= 3 {
        // 需要驗(yàn)證碼
        if req.captcha.is_none() || req.captcha_id.is_none() {
            return Err(AppError::CaptchaRequired);
        }

        // 驗(yàn)證驗(yàn)證碼
        state
            .captcha_service
            .verify(&req.captcha_id.unwrap(), &req.captcha.unwrap())
            .await?;
    }

    // 4. 驗(yàn)證用戶名密碼
    match state
        .auth_service
        .authenticate(&req.username, &req.password)
        .await
    {
        Ok(user) => {
            // 成功,清除失敗記錄
            state
                .rate_limiter
                .clear_failed_attempts(&ip, &req.username)
                .await;

            // 生成 JWT
            let token = generate_jwt(&user)?;

            Ok(Json(token))
        }
        Err(_) => {
            // 失敗,記錄失敗次數(shù)
            state
                .rate_limiter
                .record_failed_attempt(&ip, &req.username)
                .await;

            Err(AppError::InvalidCredentials)
        }
    }
}

async fn get_captcha(
    State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
    let (captcha_id, captcha_image) = state.captcha_service.generate().await;

    // 將圖片轉(zhuǎn)為 base64
    let base64_image = base64::encode(&captcha_image);

    Ok(Json(serde_json::json!({
        "captcha_id": captcha_id,
        "captcha_image": format!("data:image/png;base64,{}", base64_image),
    })))
}

fn get_client_ip(headers: &HeaderMap) -> String {
    headers
        .get("X-Real-IP")
        .or_else(|| headers.get("X-Forwarded-For"))
        .and_then(|v| v.to_str().ok())
        .map(|s| s.split(',').next().unwrap_or(s).trim())
        .unwrap_or("unknown")
        .to_string()
}

// ============ 主函數(shù) ============

#[tokio::main]
async fn main() {
    let state = Arc::new(AppState {
        rate_limiter: RateLimiter::new(),
        captcha_service: CaptchaService::new(),
        auth_service: AuthService,
    });

    let app = Router::new()
        .route("/api/jwt/token", post(jwt_login))
        .route("/api/captcha", get(get_captcha))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:8080")
        .await
        .unwrap();

    println!("?? Server running on http://127.0.0.1:8080");
    axum::serve(listener, app).await.unwrap();
}

前端實(shí)現(xiàn)(React)

API 客戶端

// src/api/auth.ts
import ky from 'ky';

const api = ky.create({
  prefixUrl: '/api',
  timeout: 10000,
  retry: 0,
});

export interface LoginRequest {
  username: string;
  password: string;
  captcha?: string;
  captcha_id?: string;
}

export interface TokenResponse {
  access_token: string;
  refresh_token: string;
  expires_in: number;
  token_type: string;
}

export interface ErrorResponse {
  code: string;
  message: string;
  retry_after?: number;
  captcha_required?: boolean;
}

export interface CaptchaResponse {
  captcha_id: string;
  captcha_image: string; // base64 格式
}

export async function login(data: LoginRequest): Promise<TokenResponse> {
  try {
    return await api.post('jwt/token', { json: data }).json<TokenResponse>();
  } catch (error: any) {
    if (error.response) {
      const errorData: ErrorResponse = await error.response.json();
      throw errorData;
    }
    throw error;
  }
}

export async function getCaptcha(): Promise<CaptchaResponse> {
  return await api.get('captcha').json<CaptchaResponse>();
}

登錄組件

// src/pages/login/index.tsx
import React, { useState } from 'react';
import { Form, Input, Button, message, Image } from 'antd';
import { UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons';
import { login, getCaptcha, type ErrorResponse } from '@/api/auth';
import { useNavigate } from 'react-router-dom';

export default function LoginPage() {
  const [form] = Form.useForm();
  const navigate = useNavigate();
  const [loading, setLoading] = useState(false);
  const [captchaRequired, setCaptchaRequired] = useState(false);
  const [captchaData, setCaptchaData] = useState<{
    id: string;
    image: string;
  } | null>(null);

  // 獲取驗(yàn)證碼
  const fetchCaptcha = async () => {
    try {
      const data = await getCaptcha();
      setCaptchaData({
        id: data.captcha_id,
        image: data.captcha_image,
      });
      setCaptchaRequired(true);
    } catch (error) {
      message.error('獲取驗(yàn)證碼失敗');
    }
  };

  // 刷新驗(yàn)證碼
  const refreshCaptcha = () => {
    form.setFieldValue('captcha', '');
    fetchCaptcha();
  };

  // 提交登錄
  const handleSubmit = async (values: any) => {
    setLoading(true);

    try {
      const loginData: any = {
        username: values.username,
        password: values.password,
      };

      // 如果需要驗(yàn)證碼
      if (captchaRequired && captchaData) {
        loginData.captcha = values.captcha;
        loginData.captcha_id = captchaData.id;
      }

      const response = await login(loginData);

      // 存儲(chǔ) token
      localStorage.setItem('access_token', response.access_token);
      localStorage.setItem('refresh_token', response.refresh_token);

      message.success('登錄成功');
      navigate('/');
    } catch (error: any) {
      const err = error as ErrorResponse;

      if (err.code === 'CAPTCHA_REQUIRED') {
        // 需要驗(yàn)證碼
        message.warning(err.message);
        await fetchCaptcha();
      } else if (err.code === 'INVALID_CAPTCHA') {
        // 驗(yàn)證碼錯(cuò)誤,刷新驗(yàn)證碼
        message.error(err.message);
        refreshCaptcha();
      } else if (err.code === 'RATE_LIMIT_EXCEEDED') {
        // 頻率限制
        message.error(`${err.message},請 ${err.retry_after} 秒后再試`);
      } else if (err.code === 'INVALID_CREDENTIALS') {
        // 用戶名或密碼錯(cuò)誤
        message.error(err.message);
        // 可能需要驗(yàn)證碼
        if (err.captcha_required) {
          await fetchCaptcha();
        }
      } else {
        message.error(err.message || '登錄失敗');
      }
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="login-container">
      <div className="login-box">
        <h1>管理后臺(tái)登錄</h1>

        <Form
          form={form}
          onFinish={handleSubmit}
          autoComplete="off"
        >
          <Form.Item
            name="username"
            rules={[{ required: true, message: '請輸入用戶名' }]}
          >
            <Input
              prefix={<UserOutlined />}
              placeholder="用戶名"
              size="large"
            />
          </Form.Item>

          <Form.Item
            name="password"
            rules={[{ required: true, message: '請輸入密碼' }]}
          >
            <Input.Password
              prefix={<LockOutlined />}
              placeholder="密碼"
              size="large"
            />
          </Form.Item>

          {captchaRequired && captchaData && (
            <Form.Item
              name="captcha"
              rules={[{ required: true, message: '請輸入驗(yàn)證碼' }]}
            >
              <div style={{ display: 'flex', gap: '10px' }}>
                <Input
                  prefix={<SafetyOutlined />}
                  placeholder="驗(yàn)證碼"
                  size="large"
                  style={{ flex: 1 }}
                />
                <Image
                  src={captchaData.image}
                  alt="驗(yàn)證碼"
                  style={{ height: 40, cursor: 'pointer' }}
                  preview={false}
                  onClick={refreshCaptcha}
                />
              </div>
            </Form.Item>
          )}

          <Form.Item>
            <Button
              type="primary"
              htmlType="submit"
              size="large"
              loading={loading}
              block
            >
              登錄
            </Button>
          </Form.Item>
        </Form>
      </div>
    </div>
  );
}

移動(dòng)端實(shí)現(xiàn)(React Native)

API 客戶端

// src/api/auth.ts
import * as SecureStore from 'expo-secure-store';

const API_BASE_URL = 'https://api.example.com/api';

export interface LoginRequest {
  username: string;
  password: string;
  captcha?: string;
  captcha_id?: string;
}

export interface TokenResponse {
  access_token: string;
  refresh_token: string;
  expires_in: number;
  token_type: string;
}

export interface ErrorResponse {
  code: string;
  message: string;
  retry_after?: number;
  captcha_required?: boolean;
}

export async function login(data: LoginRequest): Promise<TokenResponse> {
  const response = await fetch(`${API_BASE_URL}/jwt/token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(data),
  });

  const result = await response.json();

  if (!response.ok) {
    throw result as ErrorResponse;
  }

  return result;
}

export async function getCaptcha(): Promise<{ captcha_id: string; captcha_image: string }> {
  const response = await fetch(`${API_BASE_URL}/captcha`);
  return await response.json();
}

// Token 存儲(chǔ)
export async function saveTokens(accessToken: string, refreshToken: string) {
  await SecureStore.setItemAsync('access_token', accessToken);
  await SecureStore.setItemAsync('refresh_token', refreshToken);
}

export async function getAccessToken(): Promise<string | null> {
  return await SecureStore.getItemAsync('access_token');
}

export async function clearTokens() {
  await SecureStore.deleteItemAsync('access_token');
  await SecureStore.deleteItemAsync('refresh_token');
}

登錄組件

// src/screens/LoginScreen.tsx
import React, { useState } from 'react';
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  Image,
  Alert,
  StyleSheet,
} from 'react-native';
import { login, getCaptcha, saveTokens, type ErrorResponse } from '../api/auth';

export default function LoginScreen({ navigation }: any) {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [captcha, setCaptcha] = useState('');
  const [captchaData, setCaptchaData] = useState<{
    id: string;
    image: string;
  } | null>(null);
  const [loading, setLoading] = useState(false);

  // 獲取驗(yàn)證碼
  const fetchCaptcha = async () => {
    try {
      const data = await getCaptcha();
      setCaptchaData({
        id: data.captcha_id,
        image: data.captcha_image,
      });
    } catch (error) {
      Alert.alert('錯(cuò)誤', '獲取驗(yàn)證碼失敗');
    }
  };

  // 刷新驗(yàn)證碼
  const refreshCaptcha = () => {
    setCaptcha('');
    fetchCaptcha();
  };

  // 提交登錄
  const handleLogin = async () => {
    if (!username || !password) {
      Alert.alert('提示', '請輸入用戶名和密碼');
      return;
    }

    if (captchaData && !captcha) {
      Alert.alert('提示', '請輸入驗(yàn)證碼');
      return;
    }

    setLoading(true);

    try {
      const loginData: any = {
        username,
        password,
      };

      if (captchaData) {
        loginData.captcha = captcha;
        loginData.captcha_id = captchaData.id;
      }

      const response = await login(loginData);

      // 存儲(chǔ) token
      await saveTokens(response.access_token, response.refresh_token);

      Alert.alert('成功', '登錄成功', [
        { text: '確定', onPress: () => navigation.replace('Home') },
      ]);
    } catch (error: any) {
      const err = error as ErrorResponse;

      if (err.code === 'CAPTCHA_REQUIRED') {
        Alert.alert('提示', err.message);
        await fetchCaptcha();
      } else if (err.code === 'INVALID_CAPTCHA') {
        Alert.alert('錯(cuò)誤', err.message);
        refreshCaptcha();
      } else if (err.code === 'RATE_LIMIT_EXCEEDED') {
        Alert.alert('錯(cuò)誤', `${err.message},請 ${err.retry_after} 秒后再試`);
      } else {
        Alert.alert('錯(cuò)誤', err.message || '登錄失敗');
      }
    } finally {
      setLoading(false);
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>管理后臺(tái)</Text>

      <TextInput
        style={styles.input}
        placeholder="用戶名"
        value={username}
        onChangeText={setUsername}
        autoCapitalize="none"
      />

      <TextInput
        style={styles.input}
        placeholder="密碼"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />

      {captchaData && (
        <View style={styles.captchaContainer}>
          <TextInput
            style={[styles.input, styles.captchaInput]}
            placeholder="驗(yàn)證碼"
            value={captcha}
            onChangeText={setCaptcha}
          />
          <TouchableOpacity onPress={refreshCaptcha}>
            <Image
              source={{ uri: captchaData.image }}
              style={styles.captchaImage}
            />
          </TouchableOpacity>
        </View>
      )}

      <TouchableOpacity
        style={[styles.button, loading && styles.buttonDisabled]}
        onPress={handleLogin}
        disabled={loading}
      >
        <Text style={styles.buttonText}>
          {loading ? '登錄中...' : '登錄'}
        </Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    padding: 20,
    backgroundColor: '#f5f5f5',
  },
  title: {
    fontSize: 32,
    fontWeight: 'bold',
    textAlign: 'center',
    marginBottom: 40,
  },
  input: {
    backgroundColor: 'white',
    padding: 15,
    borderRadius: 8,
    marginBottom: 15,
    fontSize: 16,
  },
  captchaContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 10,
  },
  captchaInput: {
    flex: 1,
  },
  captchaImage: {
    width: 110,
    height: 50,
    borderRadius: 8,
  },
  button: {
    backgroundColor: '#1890ff',
    padding: 15,
    borderRadius: 8,
    marginTop: 10,
  },
  buttonDisabled: {
    backgroundColor: '#ccc',
  },
  buttonText: {
    color: 'white',
    textAlign: 'center',
    fontSize: 16,
    fontWeight: 'bold',
  },
});

部署配置

Nginx 配置

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    # 限流配置(Nginx 層面的額外保護(hù))
    limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
    limit_req_status 429;

    # 登錄接口限流
    location /api/jwt/token {
        limit_req zone=login_limit burst=3 nodelay;
        
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # 其他 API 接口
    location /api/ {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Systemd 服務(wù)

# /etc/systemd/system/api-server.service
[Unit]
Description=API Server
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/api-server
ExecStart=/opt/api-server/target/release/api-server
Restart=always
RestartSec=5

# 環(huán)境變量
Environment="JWT_SECRET=your-secret-key"
Environment="RUST_LOG=info"

[Install]
WantedBy=multi-user.target

Docker 部署

# Dockerfile
FROM rust:1.75 as builder

WORKDIR /app
COPY . .
RUN cargo build --release

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/api-server /usr/local/bin/

EXPOSE 8080

CMD ["api-server"]
# docker-compose.yml
version: '3.8'

services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      - JWT_SECRET=your-secret-key
      - RUST_LOG=info
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    restart: unless-stopped

安全性最佳實(shí)踐

1. HTTPS 強(qiáng)制

# 強(qiáng)制 HTTPS
server {
    listen 80;
    server_name api.example.com;
    return 301 https://$server_name$request_uri;
}

2. 密碼安全

use bcrypt::{hash, verify, DEFAULT_COST};

// 注冊時(shí)哈希密碼
async fn register_user(username: &str, password: &str) -> Result<()> {
    let hashed = hash(password, DEFAULT_COST)?;
    // 存儲(chǔ) hashed 到數(shù)據(jù)庫
    Ok(())
}

// 登錄時(shí)驗(yàn)證密碼
async fn verify_password(password: &str, hash: &str) -> bool {
    verify(password, hash).unwrap_or(false)
}

3. JWT 密鑰管理

// 從環(huán)境變量讀取密鑰
use std::env;

fn get_jwt_secret() -> String {
    env::var("JWT_SECRET")
        .expect("JWT_SECRET must be set")
}

// 使用強(qiáng)密鑰
// 生成方式:openssl rand -base64 32

4. 安全響應(yīng)頭

use tower_http::set_header::SetResponseHeaderLayer;
use axum::http::header;

let app = Router::new()
    // ... 路由
    .layer(SetResponseHeaderLayer::if_not_present(
        header::X_CONTENT_TYPE_OPTIONS,
        HeaderValue::from_static("nosniff"),
    ))
    .layer(SetResponseHeaderLayer::if_not_present(
        header::X_FRAME_OPTIONS,
        HeaderValue::from_static("DENY"),
    ))
    .layer(SetResponseHeaderLayer::if_not_present(
        header::STRICT_TRANSPORT_SECURITY,
        HeaderValue::from_static("max-age=31536000; includeSubDomains"),
    ));

5. 日志記錄

use tracing::{info, warn};

async fn jwt_login(...) -> Result<...> {
    let ip = get_client_ip(&headers);
    
    match authenticate(...).await {
        Ok(user) => {
            info!(
                username = %user.username,
                ip = %ip,
                "Login successful"
            );
            // ...
        }
        Err(_) => {
            warn!(
                username = %req.username,
                ip = %ip,
                "Login failed"
            );
            // ...
        }
    }
}

6. 監(jiān)控告警

// 使用 Prometheus 監(jiān)控
use prometheus::{Counter, Histogram, Registry};

lazy_static! {
    static ref LOGIN_ATTEMPTS: Counter = Counter::new(
        "login_attempts_total",
        "Total login attempts"
    ).unwrap();
    
    static ref LOGIN_FAILURES: Counter = Counter::new(
        "login_failures_total",
        "Total login failures"
    ).unwrap();
    
    static ref RATE_LIMIT_HITS: Counter = Counter::new(
        "rate_limit_hits_total",
        "Total rate limit hits"
    ).unwrap();
}

// 在登錄邏輯中記錄指標(biāo)
LOGIN_ATTEMPTS.inc();
if login_failed {
    LOGIN_FAILURES.inc();
}

常見問題

Q1: 頻率限制會(huì)影響正常用戶嗎?

A: 不會(huì)。限制規(guī)則設(shè)置得很寬松:

  • IP 限制:每分鐘 10 次(正常用戶不會(huì)這么頻繁)
  • 用戶名限制:每 5 分鐘 20 次(足夠正常使用)

Q2: 驗(yàn)證碼會(huì)影響用戶體驗(yàn)嗎?

A: 不會(huì)。采用動(dòng)態(tài)驗(yàn)證碼策略:

  • 正常登錄:無需驗(yàn)證碼
  • 失敗 3 次后:才要求驗(yàn)證碼
  • 登錄成功后:清除失敗記錄

Q3: 如何防止分布式攻擊?

A: 多層防護(hù):

  • IP 限制:防止單點(diǎn)攻擊
  • 用戶名限制:防止分布式攻擊特定賬號(hào)
  • 動(dòng)態(tài)驗(yàn)證碼:異常情況觸發(fā)

Q4: 內(nèi)存存儲(chǔ)會(huì)丟失數(shù)據(jù)嗎?

A: 生產(chǎn)環(huán)境建議使用 Redis:

use redis::AsyncCommands;

async fn check_rate_limit(
    redis: &redis::Client,
    key: &str,
    max: u32,
    window: u64,
) -> Result<(), AppError> {
    let mut conn = redis.get_async_connection().await?;
    
    let count: u32 = conn.incr(key, 1).await?;
    
    if count == 1 {
        conn.expire(key, window as usize).await?;
    }
    
    if count > max {
        Err(AppError::RateLimitExceeded { ... })
    } else {
        Ok(())
    }
}

Q5: 如何處理 CDN 或代理后的真實(shí) IP?

A: 正確讀取 IP:

fn get_client_ip(headers: &HeaderMap) -> String {
    // 1. 優(yōu)先讀取 X-Real-IP(Nginx 設(shè)置)
    if let Some(ip) = headers.get("X-Real-IP") {
        return ip.to_str().unwrap_or("unknown").to_string();
    }
    
    // 2. 讀取 X-Forwarded-For(可能有多個(gè) IP)
    if let Some(ips) = headers.get("X-Forwarded-For") {
        if let Ok(ips_str) = ips.to_str() {
            // 取第一個(gè) IP(客戶端真實(shí) IP)
            if let Some(first_ip) = ips_str.split(',').next() {
                return first_ip.trim().to_string();
            }
        }
    }
    
    "unknown".to_string()
}

Q6: 如何測試頻率限制?

A: 使用測試腳本:

#!/bin/bash
# test_rate_limit.sh

for i in {1..15}; do
  echo "Request $i:"
  curl -X POST http://localhost:8080/api/jwt/token \
    -H "Content-Type: application/json" \
    -d '{"username":"admin","password":"wrong"}' \
    -w "\nHTTP Status: %{http_code}\n\n"
  sleep 1
done

Q7: 如何清理過期數(shù)據(jù)?

A: 定期清理任務(wù):

use tokio::time::{interval, Duration};

async fn cleanup_task(state: Arc<AppState>) {
    let mut interval = interval(Duration::from_secs(300)); // 每 5 分鐘
    
    loop {
        interval.tick().await;
        
        // 清理過期的限流記錄
        state.rate_limiter.cleanup().await;
        
        // 清理過期的驗(yàn)證碼
        state.captcha_service.cleanup().await;
    }
}

// 在 main 函數(shù)中啟動(dòng)
tokio::spawn(cleanup_task(state.clone()));

總結(jié)

? 方案優(yōu)勢

  1. 簡單高效

    • 無需復(fù)雜的簽名驗(yàn)證
    • 實(shí)現(xiàn)和維護(hù)成本低
    • 客戶端無需額外邏輯
  2. 安全可靠

    • 多層頻率限制
    • 動(dòng)態(tài)驗(yàn)證碼機(jī)制
    • HTTPS 傳輸加密
    • JWT 簽名驗(yàn)證
  3. 用戶友好

    • 正常情況無需驗(yàn)證碼
    • 異常情況才觸發(fā)保護(hù)
    • 清晰的錯(cuò)誤提示
  4. 易于擴(kuò)展

    • 可以添加更多限流策略
    • 可以集成第三方驗(yàn)證碼服務(wù)
    • 可以添加設(shè)備指紋識(shí)別

?? 適用場景

  • ? Web 管理后臺(tái)
  • ? 移動(dòng)端 APP
  • ? 小程序
  • ? 第三方集成
  • ? 微服務(wù)架構(gòu)

?? 參考資源

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容