?? 目錄
- 架構(gòu)概述
- 核心設(shè)計(jì)原則
- 頻率限制策略
- 動(dòng)態(tài)驗(yàn)證碼機(jī)制
- 后端實(shí)現(xiàn)(Rust + Axum)
- 前端實(shí)現(xiàn)(React)
- 移動(dòng)端實(shí)現(xiàn)(React Native)
- 部署配置
- 安全性最佳實(shí)踐
- 常見問題
架構(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)勢
-
簡單高效
- 無需復(fù)雜的簽名驗(yàn)證
- 實(shí)現(xiàn)和維護(hù)成本低
- 客戶端無需額外邏輯
-
安全可靠
- 多層頻率限制
- 動(dòng)態(tài)驗(yàn)證碼機(jī)制
- HTTPS 傳輸加密
- JWT 簽名驗(yàn)證
-
用戶友好
- 正常情況無需驗(yàn)證碼
- 異常情況才觸發(fā)保護(hù)
- 清晰的錯(cuò)誤提示
-
易于擴(kuò)展
- 可以添加更多限流策略
- 可以集成第三方驗(yàn)證碼服務(wù)
- 可以添加設(shè)備指紋識(shí)別
?? 適用場景
- ? Web 管理后臺(tái)
- ? 移動(dòng)端 APP
- ? 小程序
- ? 第三方集成
- ? 微服務(wù)架構(gòu)