feat(auth): 添加 Refresh Token 双 Token 机制

- 添加 /api/refresh-token 接口支持 Token 续期
- 登录接口返回 access_token 和 refresh_token
- 新增 refresh_tokens 表存储 refresh_token
- 部署脚本添加数据库备份和迁移功能
- deploy.sh 添加 4 项 API 测试
- 更新 AGENTS.md 文档
This commit is contained in:
2026-04-19 16:01:41 +08:00
parent 52991dcfd5
commit e14c85436b
11 changed files with 872 additions and 98 deletions

View File

@@ -1,52 +1,54 @@
use chrono::Utc;
use chrono::{Utc, Duration};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use crate::error::AppError;
use crate::models::{Claims, TempTokenClaims};
use actix_web::{
Error, HttpMessage, body::MessageBody, dev::ServiceRequest, dev::ServiceResponse,
middleware::Next,
}; // 新增:用于包装中间件函数
};
// 常量
const ACCESS_TOKEN_EXPIRE_HOURS: i64 = 24;
const REFRESH_TOKEN_EXPIRE_DAYS: i64 = 7;
// 中间件函数:泛型 B 约束为 MessageBody返回 Result<ServiceResponse<B>, Error>
pub async fn jwt_middleware<B: MessageBody>(
req: ServiceRequest,
next: Next<B>,
) -> Result<ServiceResponse<B>, Error> {
// (保持原有逻辑不变)
let auth_header = req
.headers()
.get("Authorization")
.ok_or_else(|| actix_web::error::ErrorUnauthorized("缺少Authorization头"))?
.ok_or_else(|| AppError::Unauthorized("请先登录".to_string()))?
.to_str()
.map_err(|_| actix_web::error::ErrorUnauthorized("Authorization格式无效"))?;
.map_err(|_| AppError::Unauthorized("Authorization格式无效".to_string()))?;
let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
actix_web::error::ErrorUnauthorized("Authorization格式应为 Bearer <token>")
AppError::Unauthorized("请使用有效的登录凭证".to_string())
})?;
let jwt_secret = std::env::var("JWT_SECRET")
.map_err(|_| actix_web::error::ErrorInternalServerError("服务器配置JWT密钥"))?;
.map_err(|_| AppError::Internal("服务器配置错误".to_string()))?;
let claims = verify_token(token, &jwt_secret)
.map_err(|e| actix_web::error::ErrorUnauthorized(format!("无效的token: {}", e)))?;
.map_err(|e| AppError::Unauthorized(format!("登录已过期,请重新登录: {}", e)))?;
req.extensions_mut().insert(claims);
next.call(req).await
}
// 生成JWT的函数
// 生成 access_token
pub fn generate_token(
user_id: i32,
openid: &str,
user_type: i32,
secret: &str,
) -> Result<String, String> {
// 设置过期时间:当前时间 + 24小时86400秒
let now = Utc::now();
let exp = (now + chrono::Duration::hours(24)).timestamp();
let exp = (now + Duration::hours(ACCESS_TOKEN_EXPIRE_HOURS)).timestamp();
let iat = now.timestamp();
// 构建Claims
let claims = Claims {
exp,
iat,
@@ -55,15 +57,51 @@ pub fn generate_token(
user_type,
};
// 生成token
encode(
&Header::new(Algorithm::HS256), // 使用HS256算法
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(secret.as_bytes()), // 签名密钥
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| format!("生成JWT失败: {}", e))
}
// 生成 refresh_token简单 base64 编码的随机字符串)
pub fn generate_refresh_token(user_id: i32, secret: &str) -> Result<String, String> {
let now = Utc::now();
let exp = (now + Duration::days(REFRESH_TOKEN_EXPIRE_DAYS)).timestamp();
let payload = format!("{}:{}:{}", user_id, exp, secret);
let token = BASE64.encode(payload.as_bytes());
Ok(token)
}
// 解析 refresh_token返回 (user_id, expires_at)
pub fn verify_refresh_token(token: &str, secret: &str) -> Result<(i32, i64), String> {
let decoded = BASE64.decode(token)
.map_err(|e| format!("Refresh token 格式错误: {}", e))?;
let payload = String::from_utf8(decoded)
.map_err(|_| "Refresh token 解析失败".to_string())?;
let parts: Vec<&str> = payload.split(':').collect();
if parts.len() != 3 {
return Err("Refresh token 结构错误".to_string());
}
let user_id: i32 = parts[0].parse()
.map_err(|_| "Refresh token user_id 解析失败".to_string())?;
let exp: i64 = parts[1].parse()
.map_err(|_| "Refresh token exp 解析失败".to_string())?;
let now = Utc::now().timestamp();
if now > exp {
return Err("Refresh token 已过期".to_string());
}
Ok((user_id, exp))
}
// 验证并解析JWT的函数
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, String> {
// 验证配置指定算法默认会检查exp等字段

View File

@@ -1,4 +1,4 @@
use sqlx::{PgPool, postgres::PgPoolOptions};
use sqlx::{PgPool, postgres::PgPoolOptions, FromRow};
use std::env;
use std::error::Error;
use chrono::Utc;
@@ -476,3 +476,89 @@ pub async fn set_weather_favorite(
Ok(())
}
// ===== Refresh Token DB 函数 =====
#[derive(Debug, FromRow)]
pub struct RefreshToken {
pub id: i32,
pub user_id: i32,
pub token: String,
pub expires_at: chrono::DateTime<chrono::Utc>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
pub async fn create_refresh_token(
pool: &PgPool,
user_id: i32,
token: &str,
expires_at: chrono::DateTime<chrono::Utc>,
) -> Result<(), AppError> {
let query = r#"
INSERT INTO refresh_tokens (user_id, token, expires_at)
VALUES ($1, $2, $3)
"#;
sqlx::query(query)
.bind(user_id)
.bind(token)
.bind(expires_at)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("创建 refresh_token 失败: {}", e)))?;
Ok(())
}
pub async fn verify_refresh_token(
pool: &PgPool,
token: &str,
) -> Result<RefreshToken, AppError> {
let query = r#"
SELECT id, user_id, token, expires_at, created_at
FROM refresh_tokens
WHERE token = $1 AND expires_at > NOW()
"#;
sqlx::query_as::<_, RefreshToken>(query)
.bind(token)
.fetch_optional(pool)
.await
.map_err(|e| AppError::Database(format!("验证 refresh_token 失败: {}", e)))?
.ok_or_else(|| AppError::Unauthorized("Refresh token 无效或已过期".to_string()))
}
pub async fn delete_refresh_token(pool: &PgPool, user_id: i32) -> Result<(), AppError> {
let query = r#"DELETE FROM refresh_tokens WHERE user_id = $1"#;
sqlx::query(query)
.bind(user_id)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("删除 refresh_token 失败: {}", e)))?;
Ok(())
}
pub async fn delete_specific_refresh_token(pool: &PgPool, token: &str) -> Result<(), AppError> {
let query = r#"DELETE FROM refresh_tokens WHERE token = $1"#;
sqlx::query(query)
.bind(token)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("删除 refresh_token 失败: {}", e)))?;
Ok(())
}
pub async fn cleanup_expired_refresh_tokens(pool: &PgPool) -> Result<u64, AppError> {
let query = r#"DELETE FROM refresh_tokens WHERE expires_at <= NOW()"#;
let result = sqlx::query(query)
.execute(pool)
.await
.map_err(|e| AppError::Database(format!("清理过期 refresh_token 失败: {}", e)))?;
Ok(result.rows_affected())
}

View File

@@ -1,11 +1,15 @@
use actix_web::{web, HttpResponse, Responder, post};
use chrono::{Utc, Duration};
use reqwest::Client;
use sqlx::postgres::PgPool;
use tracing::{debug, error, info, warn};
use crate::auth::generate_token;
use crate::auth::{generate_token, generate_refresh_token, verify_refresh_token};
use crate::db;
use crate::error::ErrorResponse;
use crate::models::{
AppState, ErrorResponse, TokenResponse, WeChatApiResponse, WeChatLoginRequest,
AppState, LoginResponse, RefreshTokenRequest, TokenRefreshResponse,
WeChatApiResponse, WeChatLoginRequest,
};
#[post("/api/login")]
@@ -28,11 +32,7 @@ pub async fn login(
}
Err(e) => {
error!("微信API请求失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse {
error: "请求微信服务失败".to_string(),
errcode: None,
errmsg: None,
});
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("请求微信服务失败"));
}
};
@@ -43,11 +43,7 @@ pub async fn login(
}
Err(e) => {
error!("微信API响应解析失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse {
error: "解析微信响应失败".to_string(),
errcode: None,
errmsg: None,
});
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("解析微信响应失败"));
}
};
@@ -56,11 +52,11 @@ pub async fn login(
"微信登录验证失败 - 错误码: {}, 错误信息: {:?}",
errcode, wechat_data.errmsg
);
return HttpResponse::BadRequest().json(ErrorResponse {
error: "微信登录失败".to_string(),
errcode: Some(errcode),
errmsg: wechat_data.errmsg,
});
let errmsg = wechat_data.errmsg.unwrap_or_default();
return HttpResponse::BadRequest().json(ErrorResponse::<()>::error(format!(
"微信登录失败: {} {}",
errcode, errmsg
)));
}
let openid = match wechat_data.openid {
@@ -71,11 +67,7 @@ pub async fn login(
}
None => {
error!("微信API响应中未包含openid");
return HttpResponse::InternalServerError().json(ErrorResponse {
error: "微信API未返回openid".to_string(),
errcode: None,
errmsg: None,
});
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("微信API未返回openid"));
}
};
@@ -97,11 +89,7 @@ pub async fn login(
}
Err(e) => {
error!("用户信息处理失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse {
error: "用户信息处理失败".to_string(),
errcode: Some(500),
errmsg: None,
});
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("用户信息处理失败"));
}
};
@@ -112,14 +100,93 @@ pub async fn login(
}
Err(e) => {
error!("JWT生成失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse {
error: "生成身份令牌失败".to_string(),
errcode: Some(500),
errmsg: None,
});
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("生成身份令牌失败"));
}
};
let token_response = TokenResponse::new(token);
HttpResponse::Ok().json(token_response)
let refresh_token_str = match generate_refresh_token(user_id, &app_state.jwt_secret) {
Ok(t) => t,
Err(e) => {
error!("Refresh token 生成失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("生成刷新令牌失败"));
}
};
let refresh_expires_at = Utc::now() + Duration::days(7);
if let Err(e) = db::create_refresh_token(pool.get_ref(), user_id, &refresh_token_str, refresh_expires_at).await {
error!("保存 refresh_token 失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("保存刷新令牌失败"));
}
let login_response = LoginResponse::new(token, refresh_token_str);
HttpResponse::Ok().json(login_response)
}
#[post("/api/refresh-token")]
pub async fn refresh_token(
pool: web::Data<PgPool>,
req: web::Json<RefreshTokenRequest>,
app_state: web::Data<AppState>,
) -> impl Responder {
let refresh_token = &req.refresh_token;
let refresh_token_record = match db::verify_refresh_token(pool.get_ref(), refresh_token).await {
Ok(record) => record,
Err(e) => {
warn!("Refresh token 验证失败: {}", e);
return HttpResponse::Unauthorized().json(ErrorResponse::<()>::error("Refresh token 无效或已过期"));
}
};
let user_id = refresh_token_record.user_id;
let user_query = r#"SELECT openid FROM users WHERE id = $1"#;
let openid: String = match sqlx::query_as::<_, (String,)>(user_query)
.bind(user_id)
.fetch_optional(pool.get_ref())
.await
{
Ok(Some((openid,))) => openid,
Ok(None) => {
error!("用户不存在: {}", user_id);
return HttpResponse::Unauthorized().json(ErrorResponse::<()>::error("用户不存在"));
}
Err(e) => {
error!("查询用户失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("查询用户信息失败"));
}
};
let new_access_token = match generate_token(user_id, &openid, 2, &app_state.jwt_secret) {
Ok(t) => t,
Err(e) => {
error!("生成新 JWT 失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("生成令牌失败"));
}
};
let new_refresh_token = match generate_refresh_token(user_id, &app_state.jwt_secret) {
Ok(t) => t,
Err(e) => {
error!("生成新 refresh_token 失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("生成刷新令牌失败"));
}
};
if let Err(e) = db::delete_specific_refresh_token(pool.get_ref(), refresh_token).await {
warn!("删除旧 refresh_token 失败(继续): {}", e);
}
let new_expires_at = Utc::now() + Duration::days(7);
if let Err(e) = db::create_refresh_token(pool.get_ref(), user_id, &new_refresh_token, new_expires_at).await {
error!("保存新 refresh_token 失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("保存刷新令牌失败"));
}
let response = TokenRefreshResponse {
success: true,
token: new_access_token,
refresh_token: new_refresh_token,
};
HttpResponse::Ok().json(response)
}

View File

@@ -3,6 +3,7 @@ pub mod admin;
pub mod auth;
pub mod favorites;
pub mod health;
pub mod meta;
pub mod payment;
pub mod static_files;
pub mod user;
@@ -17,8 +18,10 @@ pub static TEMPLATES_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates");
pub use admin::admin_get_user;
pub use admin::admin_update_user_payment;
pub use auth::login;
pub use auth::refresh_token;
pub use favorites::{add_favorite, get_favorites, remove_favorite};
pub use health::health_check;
pub use meta::root;
pub use static_files::serve_static_files;
pub use user::get_current_user_profile;
pub use user::save_user_profile;

View File

@@ -1,6 +1,5 @@
use actix_web::middleware::from_fn;
use actix_web::{App, HttpServer, web};
use include_dir::{Dir, include_dir};
use tracing::{error, info};
use openssl::ssl::{SslAcceptor, SslAcceptorBuilder, SslFiletype, SslMethod};
use reqwest::Client;
@@ -21,8 +20,8 @@ use handlers::{
admin_get_user, admin_update_user_payment, add_favorite, create_order,
delete_weather, generate_temp_token_handler, get_current_user_profile,
get_favorites, get_user_quota, get_weather_brief, get_weather_details,
health_check, login, mock_confirm, post_weather_data, remove_favorite,
save_user_profile, serve_static_files,
health_check, login, mock_confirm, post_weather_data, refresh_token,
remove_favorite, root, save_user_profile, serve_static_files,
};
use models::AppState;
@@ -60,10 +59,13 @@ fn create_server_config(
.app_data(web::Data::new(pool))
.app_data(web::Data::new(http_client))
.app_data(web::Data::new(app_state))
// 根路径(无需认证)
.service(root) // #[get("/")] - 返回服务信息
// 静态文件(无需认证)
.service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files)))
// API 接口
.service(login) // #[post("/api/login")]
.service(refresh_token) // #[post("/api/refresh-token")](公开接口,无需认证)
.service(get_weather_details) // #[get("/weather/details")](支持 JWT 或 temp_token公开接口
.service(health_check) // #[get("/health")](公开接口,无需认证)
// 受保护接口JWT
@@ -110,6 +112,7 @@ async fn main() -> std::io::Result<()> {
std::env::set_var("SSL_CERT_PATH", &app_config.ssl_cert_path);
std::env::set_var("RUST_LOG", &app_config.rust_log);
std::env::set_var("FREE_USER_DATA_LIMIT", app_config.free_user_data_limit.to_string());
std::env::set_var("APP_VERSION", &app_config.app_version);
}
// 初始化文件日志JSON 格式,带轮转)

View File

@@ -28,14 +28,28 @@ pub struct TempTokenClaims {
pub resource_id: i32, // 允许访问的资源ID如天气数据id
}
// 登录成功后的令牌响应结构体
/// 仅包含操作状态和令牌信息
// 登录成功后的令牌响应结构体(双 token
#[derive(Debug, Serialize, Clone)]
pub struct LoginResponse {
pub success: bool,
pub token: String,
pub refresh_token: String,
}
impl LoginResponse {
pub fn new(token: String, refresh_token: String) -> Self {
Self {
success: true,
token,
refresh_token,
}
}
}
// 兼容旧的 TokenResponse
#[derive(Debug, Serialize, Clone)]
pub struct TokenResponse {
/// 操作状态true表示登录成功false表示失败
pub success: bool,
/// 登录成功后生成的JWT令牌客户端后续请求需携带此令牌
pub token: String,
}
@@ -48,6 +62,20 @@ impl TokenResponse {
}
}
// Refresh Token 请求
#[derive(Debug, Deserialize)]
pub struct RefreshTokenRequest {
pub refresh_token: String,
}
// Token 刷新响应
#[derive(Debug, Serialize)]
pub struct TokenRefreshResponse {
pub success: bool,
pub token: String,
pub refresh_token: String,
}
#[derive(Debug, Deserialize)]
pub struct WeChatLoginRequest {
pub code: String,
@@ -60,13 +88,6 @@ pub struct WeChatApiResponse {
pub errmsg: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
pub errcode: Option<i32>,
pub errmsg: Option<String>,
}
// 定义新类型,包装 Vec<f64>(当前 crate 内的类型)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FloatVec(pub Vec<f64>);
@@ -316,6 +337,7 @@ pub struct AppState {
pub wechat_appid: String,
pub wechat_secret: String,
pub free_user_data_limit: i32,
pub app_version: String,
}
impl AppState {
@@ -330,6 +352,8 @@ impl AppState {
free_user_data_limit: std::env::var("FREE_USER_DATA_LIMIT")
.map(|v| v.parse().unwrap_or(20))
.unwrap_or(20),
app_version: std::env::var("APP_VERSION")
.unwrap_or_else(|_| "unknown".to_string()),
})
}
}