chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
@@ -6,26 +6,45 @@ use secrecy::ExposeSecret;
|
||||
use crate::state::AppState;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use super::{
|
||||
jwt::create_token,
|
||||
jwt::{create_token, create_refresh_token, verify_token, verify_token_skip_expiry},
|
||||
password::{hash_password, verify_password},
|
||||
types::{AuthContext, LoginRequest, LoginResponse, RegisterRequest, ChangePasswordRequest, AccountPublic},
|
||||
types::{AuthContext, LoginRequest, LoginResponse, RegisterRequest, ChangePasswordRequest, AccountPublic, RefreshRequest},
|
||||
};
|
||||
|
||||
/// POST /api/v1/auth/register
|
||||
/// 注册成功后自动签发 JWT,返回与 login 一致的 LoginResponse
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> SaasResult<(StatusCode, Json<AccountPublic>)> {
|
||||
) -> SaasResult<(StatusCode, Json<LoginResponse>)> {
|
||||
if req.username.len() < 3 {
|
||||
return Err(SaasError::InvalidInput("用户名至少 3 个字符".into()));
|
||||
}
|
||||
if req.username.len() > 32 {
|
||||
return Err(SaasError::InvalidInput("用户名最多 32 个字符".into()));
|
||||
}
|
||||
let username_re = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
|
||||
if !username_re.is_match(&req.username) {
|
||||
return Err(SaasError::InvalidInput("用户名只能包含字母、数字、下划线和连字符".into()));
|
||||
}
|
||||
if !req.email.contains('@') || !req.email.contains('.') {
|
||||
return Err(SaasError::InvalidInput("邮箱格式不正确".into()));
|
||||
}
|
||||
if req.password.len() < 8 {
|
||||
return Err(SaasError::InvalidInput("密码至少 8 个字符".into()));
|
||||
}
|
||||
if req.password.len() > 128 {
|
||||
return Err(SaasError::InvalidInput("密码最多 128 个字符".into()));
|
||||
}
|
||||
if let Some(ref name) = req.display_name {
|
||||
if name.len() > 64 {
|
||||
return Err(SaasError::InvalidInput("显示名称最多 64 个字符".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let existing: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM accounts WHERE username = ?1 OR email = ?2"
|
||||
"SELECT id FROM accounts WHERE username = $1 OR email = $2"
|
||||
)
|
||||
.bind(&req.username)
|
||||
.bind(&req.email)
|
||||
@@ -44,7 +63,7 @@ pub async fn register(
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO accounts (id, username, email, password_hash, display_name, role, status, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', ?7, ?7)"
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $7)"
|
||||
)
|
||||
.bind(&account_id)
|
||||
.bind(&req.username)
|
||||
@@ -59,15 +78,39 @@ pub async fn register(
|
||||
let client_ip = addr.ip().to_string();
|
||||
log_operation(&state.db, &account_id, "account.create", "account", &account_id, None, Some(&client_ip)).await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(AccountPublic {
|
||||
id: account_id,
|
||||
username: req.username,
|
||||
email: req.email,
|
||||
display_name,
|
||||
role,
|
||||
status: "active".into(),
|
||||
totp_enabled: false,
|
||||
created_at: now,
|
||||
// 注册成功后自动签发 JWT + Refresh Token
|
||||
let permissions = get_role_permissions(&state.db, &role).await?;
|
||||
let config = state.config.read().await;
|
||||
let token = create_token(
|
||||
&account_id, &role, permissions.clone(),
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.jwt_expiration_hours,
|
||||
)?;
|
||||
let refresh_token = create_refresh_token(
|
||||
&account_id, &role, permissions,
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.refresh_token_hours,
|
||||
)?;
|
||||
drop(config);
|
||||
|
||||
store_refresh_token(
|
||||
&state.db, &account_id, &refresh_token,
|
||||
state.jwt_secret.expose_secret(), 168,
|
||||
).await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(LoginResponse {
|
||||
token,
|
||||
refresh_token,
|
||||
account: AccountPublic {
|
||||
id: account_id,
|
||||
username: req.username,
|
||||
email: req.email,
|
||||
display_name,
|
||||
role,
|
||||
status: "active".into(),
|
||||
totp_enabled: false,
|
||||
created_at: now,
|
||||
},
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -80,7 +123,7 @@ pub async fn login(
|
||||
let row: Option<(String, String, String, String, String, String, bool, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, username, email, display_name, role, status, totp_enabled, created_at
|
||||
FROM accounts WHERE username = ?1 OR email = ?1"
|
||||
FROM accounts WHERE username = $1 OR email = $1"
|
||||
)
|
||||
.bind(&req.username)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -94,7 +137,7 @@ pub async fn login(
|
||||
}
|
||||
|
||||
let (password_hash,): (String,) = sqlx::query_as(
|
||||
"SELECT password_hash FROM accounts WHERE id = ?1"
|
||||
"SELECT password_hash FROM accounts WHERE id = $1"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
@@ -110,7 +153,7 @@ pub async fn login(
|
||||
.ok_or_else(|| SaasError::Totp("此账号已启用双因素认证,请提供 TOTP 码".into()))?;
|
||||
|
||||
let (totp_secret,): (Option<String>,) = sqlx::query_as(
|
||||
"SELECT totp_secret FROM accounts WHERE id = ?1"
|
||||
"SELECT totp_secret FROM accounts WHERE id = $1"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
@@ -120,6 +163,12 @@ pub async fn login(
|
||||
SaasError::Internal("TOTP 已启用但密钥丢失,请联系管理员".into())
|
||||
})?;
|
||||
|
||||
// 解密 TOTP secret (兼容旧的明文格式)
|
||||
let config = state.config.read().await;
|
||||
let enc_key = config.totp_encryption_key()
|
||||
.map_err(|e| SaasError::Internal(e.to_string()))?;
|
||||
let secret = super::totp::decrypt_totp_for_login(&secret, &enc_key)?;
|
||||
|
||||
if !super::totp::verify_totp_code(&secret, code) {
|
||||
return Err(SaasError::Totp("TOTP 码错误或已过期".into()));
|
||||
}
|
||||
@@ -132,16 +181,28 @@ pub async fn login(
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.jwt_expiration_hours,
|
||||
)?;
|
||||
let refresh_token = create_refresh_token(
|
||||
&id, &role, permissions,
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.refresh_token_hours,
|
||||
)?;
|
||||
drop(config);
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE accounts SET last_login_at = ?1 WHERE id = ?2")
|
||||
sqlx::query("UPDATE accounts SET last_login_at = $1 WHERE id = $2")
|
||||
.bind(&now).bind(&id)
|
||||
.execute(&state.db).await?;
|
||||
let client_ip = addr.ip().to_string();
|
||||
log_operation(&state.db, &id, "account.login", "account", &id, None, Some(&client_ip)).await?;
|
||||
|
||||
store_refresh_token(
|
||||
&state.db, &id, &refresh_token,
|
||||
state.jwt_secret.expose_secret(), 168,
|
||||
).await?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
token,
|
||||
refresh_token,
|
||||
account: AccountPublic {
|
||||
id, username, email, display_name, role, status, totp_enabled, created_at,
|
||||
},
|
||||
@@ -149,17 +210,92 @@ pub async fn login(
|
||||
}
|
||||
|
||||
/// POST /api/v1/auth/refresh
|
||||
/// 使用 refresh_token 换取新的 access + refresh token 对
|
||||
/// refresh_token 一次性使用,使用后立即失效
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Extension(ctx): axum::extract::Extension<AuthContext>,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
// 1. 验证 refresh token 签名 (跳过过期检查,但有 7 天窗口限制)
|
||||
let claims = verify_token_skip_expiry(&req.refresh_token, state.jwt_secret.expose_secret())?;
|
||||
|
||||
// 2. 确认是 refresh 类型 token
|
||||
if claims.token_type != "refresh" {
|
||||
return Err(SaasError::AuthError("无效的 refresh token".into()));
|
||||
}
|
||||
|
||||
let jti = claims.jti.as_deref()
|
||||
.ok_or_else(|| SaasError::AuthError("refresh token 缺少 jti".into()))?;
|
||||
|
||||
// 3. 从 DB 查找 refresh token,确保未被使用
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT account_id FROM refresh_tokens WHERE jti = $1 AND used_at IS NULL AND expires_at > $2"
|
||||
)
|
||||
.bind(jti)
|
||||
.bind(&chrono::Utc::now().to_rfc3339())
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let token_account_id = row
|
||||
.ok_or_else(|| SaasError::AuthError("refresh token 已使用、已过期或不存在".into()))?
|
||||
.0;
|
||||
|
||||
// 4. 验证 token 中的 account_id 与 DB 中的一致
|
||||
if token_account_id != claims.sub {
|
||||
return Err(SaasError::AuthError("refresh token 账号不匹配".into()));
|
||||
}
|
||||
|
||||
// 5. 标记旧 refresh token 为已使用 (一次性)
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE refresh_tokens SET used_at = $1 WHERE jti = $2")
|
||||
.bind(&now).bind(jti)
|
||||
.execute(&state.db).await?;
|
||||
|
||||
// 6. 获取最新角色权限
|
||||
let (role,): (String,) = sqlx::query_as(
|
||||
"SELECT role FROM accounts WHERE id = $1 AND status = 'active'"
|
||||
)
|
||||
.bind(&claims.sub)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| SaasError::AuthError("账号不存在或已禁用".into()))?;
|
||||
|
||||
let permissions = get_role_permissions(&state.db, &role).await?;
|
||||
|
||||
// 7. 创建新的 access token + refresh token
|
||||
let config = state.config.read().await;
|
||||
let token = create_token(
|
||||
&ctx.account_id, &ctx.role, ctx.permissions.clone(),
|
||||
let new_access = create_token(
|
||||
&claims.sub, &role, permissions.clone(),
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.jwt_expiration_hours,
|
||||
)?;
|
||||
Ok(Json(serde_json::json!({ "token": token })))
|
||||
let new_refresh = create_refresh_token(
|
||||
&claims.sub, &role, permissions.clone(),
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.refresh_token_hours,
|
||||
)?;
|
||||
drop(config);
|
||||
|
||||
// 8. 存储新 refresh token 到 DB
|
||||
let new_claims = verify_token(&new_refresh, state.jwt_secret.expose_secret())?;
|
||||
let new_jti = new_claims.jti.unwrap_or_default();
|
||||
let new_id = uuid::Uuid::new_v4().to_string();
|
||||
let refresh_expires = (chrono::Utc::now() + chrono::Duration::hours(168)).to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_tokens (id, account_id, jti, token_hash, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)"
|
||||
)
|
||||
.bind(&new_id).bind(&claims.sub).bind(&new_jti)
|
||||
.bind(sha256_hex(&new_refresh)).bind(&refresh_expires).bind(&now)
|
||||
.execute(&state.db).await?;
|
||||
|
||||
// 9. 清理过期/已使用的 refresh tokens (异步, 不阻塞)
|
||||
cleanup_expired_refresh_tokens(&state.db).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"token": new_access,
|
||||
"refresh_token": new_refresh,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/auth/me — 返回当前认证用户的公开信息
|
||||
@@ -170,7 +306,7 @@ pub async fn me(
|
||||
let row: Option<(String, String, String, String, String, String, bool, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, username, email, display_name, role, status, totp_enabled, created_at
|
||||
FROM accounts WHERE id = ?1"
|
||||
FROM accounts WHERE id = $1"
|
||||
)
|
||||
.bind(&ctx.account_id)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -196,7 +332,7 @@ pub async fn change_password(
|
||||
|
||||
// 获取当前密码哈希
|
||||
let (password_hash,): (String,) = sqlx::query_as(
|
||||
"SELECT password_hash FROM accounts WHERE id = ?1"
|
||||
"SELECT password_hash FROM accounts WHERE id = $1"
|
||||
)
|
||||
.bind(&ctx.account_id)
|
||||
.fetch_one(&state.db)
|
||||
@@ -210,7 +346,7 @@ pub async fn change_password(
|
||||
// 更新密码
|
||||
let new_hash = hash_password(&req.new_password)?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE accounts SET password_hash = ?1, updated_at = ?2 WHERE id = ?3")
|
||||
sqlx::query("UPDATE accounts SET password_hash = $1, updated_at = $2 WHERE id = $3")
|
||||
.bind(&new_hash)
|
||||
.bind(&now)
|
||||
.bind(&ctx.account_id)
|
||||
@@ -223,9 +359,9 @@ pub async fn change_password(
|
||||
Ok(Json(serde_json::json!({"ok": true, "message": "密码修改成功"})))
|
||||
}
|
||||
|
||||
pub(crate) async fn get_role_permissions(db: &sqlx::SqlitePool, role: &str) -> SaasResult<Vec<String>> {
|
||||
pub(crate) async fn get_role_permissions(db: &sqlx::PgPool, role: &str) -> SaasResult<Vec<String>> {
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT permissions FROM roles WHERE id = ?1"
|
||||
"SELECT permissions FROM roles WHERE id = $1"
|
||||
)
|
||||
.bind(role)
|
||||
.fetch_optional(db)
|
||||
@@ -252,7 +388,7 @@ pub fn check_permission(ctx: &AuthContext, permission: &str) -> SaasResult<()> {
|
||||
|
||||
/// 记录操作日志
|
||||
pub async fn log_operation(
|
||||
db: &sqlx::SqlitePool,
|
||||
db: &sqlx::PgPool,
|
||||
account_id: &str,
|
||||
action: &str,
|
||||
target_type: &str,
|
||||
@@ -263,7 +399,7 @@ pub async fn log_operation(
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO operation_logs (account_id, action, target_type, target_id, details, ip_address, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)"
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(action)
|
||||
@@ -276,3 +412,45 @@ pub async fn log_operation(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 存储 refresh token 到 DB
|
||||
async fn store_refresh_token(
|
||||
db: &sqlx::PgPool,
|
||||
account_id: &str,
|
||||
refresh_token: &str,
|
||||
secret: &str,
|
||||
refresh_hours: i64,
|
||||
) -> SaasResult<()> {
|
||||
let claims = verify_token(refresh_token, secret)?;
|
||||
let jti = claims.jti.unwrap_or_default();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let expires_at = (chrono::Utc::now() + chrono::Duration::hours(refresh_hours)).to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_tokens (id, account_id, jti, token_hash, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)"
|
||||
)
|
||||
.bind(&id).bind(account_id).bind(&jti)
|
||||
.bind(sha256_hex(refresh_token)).bind(&expires_at).bind(&now)
|
||||
.execute(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清理过期和已使用的 refresh tokens
|
||||
async fn cleanup_expired_refresh_tokens(db: &sqlx::PgPool) -> SaasResult<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
// 删除过期超过 30 天的已使用 token (减少 DB 膨胀)
|
||||
sqlx::query(
|
||||
"DELETE FROM refresh_tokens WHERE (used_at IS NOT NULL AND used_at < $1) OR (expires_at < $1)"
|
||||
)
|
||||
.bind(&now)
|
||||
.execute(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SHA-256 hex digest
|
||||
fn sha256_hex(input: &str) -> String {
|
||||
use sha2::{Sha256, Digest};
|
||||
hex::encode(Sha256::digest(input.as_bytes()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user