Implement compression quota refunds and admin manual subscription
This commit is contained in:
596
src/api/auth.rs
Normal file
596
src/api/auth.rs
Normal file
@@ -0,0 +1,596 @@
|
||||
use crate::auth;
|
||||
use crate::api::envelope::Envelope;
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::services::mail;
|
||||
use crate::state::AppState;
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/register", post(register))
|
||||
.route("/login", post(login))
|
||||
.route("/send-verification", post(send_verification))
|
||||
.route("/verify-email", post(verify_email))
|
||||
.route("/forgot-password", post(forgot_password))
|
||||
.route("/reset-password", post(reset_password))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegisterRequest {
|
||||
email: String,
|
||||
password: String,
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RegisterResponse {
|
||||
user: UserView,
|
||||
token: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LoginRequest {
|
||||
email: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LoginResponse {
|
||||
token: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
user: UserView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UserView {
|
||||
id: Uuid,
|
||||
email: String,
|
||||
username: String,
|
||||
role: String,
|
||||
email_verified: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct UserRow {
|
||||
id: Uuid,
|
||||
email: String,
|
||||
username: String,
|
||||
password_hash: String,
|
||||
role: String,
|
||||
is_active: bool,
|
||||
email_verified_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<Json<Envelope<RegisterResponse>>, AppError> {
|
||||
validate_email(&req.email)?;
|
||||
validate_username(&req.username)?;
|
||||
validate_password(&req.password)?;
|
||||
|
||||
let password_hash = hash_password(&req.password)?;
|
||||
|
||||
let user = sqlx::query_as::<_, UserRow>(
|
||||
r#"
|
||||
INSERT INTO users (email, username, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING
|
||||
id,
|
||||
email,
|
||||
username,
|
||||
password_hash,
|
||||
role::text AS role,
|
||||
is_active,
|
||||
email_verified_at
|
||||
"#,
|
||||
)
|
||||
.bind(req.email.to_lowercase())
|
||||
.bind(&req.username)
|
||||
.bind(password_hash)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(map_unique_violation)?;
|
||||
|
||||
let (token, _expires_at) =
|
||||
auth::issue_jwt(&state.config.jwt_secret, state.config.jwt_expiry_hours, user.id, &user.role)?;
|
||||
|
||||
let verification_token = generate_token();
|
||||
let token_hash = sha256_hex(&verification_token);
|
||||
let expires_at_db = Utc::now() + Duration::hours(24);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO email_verifications (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(token_hash)
|
||||
.bind(expires_at_db)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?;
|
||||
|
||||
let verification_url = format!(
|
||||
"{}/verify-email?token={}",
|
||||
state.config.public_base_url, verification_token
|
||||
);
|
||||
|
||||
mail::send_verification_email(&state, &user.email, &user.username, &verification_url)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?;
|
||||
|
||||
let body = RegisterResponse {
|
||||
user: UserView {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
email_verified: user.email_verified_at.is_some(),
|
||||
},
|
||||
token,
|
||||
message: "注册成功,验证邮件已发送至您的邮箱".to_string(),
|
||||
};
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: body,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<Envelope<LoginResponse>>, AppError> {
|
||||
let identity = req.email.trim();
|
||||
if identity.is_empty() {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名不能为空"));
|
||||
}
|
||||
|
||||
let user = if identity.contains('@') {
|
||||
validate_email(identity)?;
|
||||
sqlx::query_as::<_, UserRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
username,
|
||||
password_hash,
|
||||
role::text AS role,
|
||||
is_active,
|
||||
email_verified_at
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
"#,
|
||||
)
|
||||
.bind(identity.to_lowercase())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
} else {
|
||||
validate_username(identity)?;
|
||||
sqlx::query_as::<_, UserRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
username,
|
||||
password_hash,
|
||||
role::text AS role,
|
||||
is_active,
|
||||
email_verified_at
|
||||
FROM users
|
||||
WHERE username = $1
|
||||
"#,
|
||||
)
|
||||
.bind(identity)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
}
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?
|
||||
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "账号或密码错误"))?;
|
||||
|
||||
if !user.is_active {
|
||||
return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用"));
|
||||
}
|
||||
|
||||
verify_password(&req.password, &user.password_hash)?;
|
||||
|
||||
let (token, expires_at) =
|
||||
auth::issue_jwt(&state.config.jwt_secret, state.config.jwt_expiry_hours, user.id, &user.role)?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: LoginResponse {
|
||||
token,
|
||||
expires_at,
|
||||
user: UserView {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
email_verified: user.email_verified_at.is_some(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MessageResponse {
|
||||
message: String,
|
||||
}
|
||||
|
||||
async fn send_verification(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
||||
let claims = auth::require_jwt(&state.config.jwt_secret, &headers)?;
|
||||
|
||||
// Rate limit: 1 per minute per user
|
||||
let key = format!("rate:send_verification:{}:{}", claims.sub, Utc::now().format("%Y%m%d%H%M"));
|
||||
let mut redis = state.redis.clone();
|
||||
let count: i64 = redis::cmd("INCR")
|
||||
.arg(&key)
|
||||
.query_async(&mut redis)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "Redis 限流失败").with_source(err))?;
|
||||
if count == 1 {
|
||||
let _: () = redis::cmd("EXPIRE")
|
||||
.arg(&key)
|
||||
.arg(60)
|
||||
.query_async(&mut redis)
|
||||
.await
|
||||
.unwrap_or(());
|
||||
}
|
||||
if count > 1 {
|
||||
return Err(AppError::new(ErrorCode::RateLimited, "发送过于频繁,请稍后再试"));
|
||||
}
|
||||
|
||||
let user = sqlx::query_as::<_, UserRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
username,
|
||||
password_hash,
|
||||
role::text AS role,
|
||||
is_active,
|
||||
email_verified_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?
|
||||
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "用户不存在或未登录"))?;
|
||||
|
||||
if user.email_verified_at.is_some() {
|
||||
return Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: MessageResponse {
|
||||
message: "邮箱已验证,无需重复验证".to_string(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
let verification_token = generate_token();
|
||||
let token_hash = sha256_hex(&verification_token);
|
||||
let expires_at_db = Utc::now() + Duration::hours(24);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO email_verifications (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(token_hash)
|
||||
.bind(expires_at_db)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?;
|
||||
|
||||
let verification_url = format!(
|
||||
"{}/verify-email?token={}",
|
||||
state.config.public_base_url, verification_token
|
||||
);
|
||||
|
||||
mail::send_verification_email(&state, &user.email, &user.username, &verification_url)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: MessageResponse {
|
||||
message: "验证邮件已发送,请查收".to_string(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VerifyEmailRequest {
|
||||
token: String,
|
||||
}
|
||||
|
||||
async fn verify_email(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<VerifyEmailRequest>,
|
||||
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
||||
if req.token.trim().is_empty() {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "token 不能为空"));
|
||||
}
|
||||
|
||||
let token_hash = sha256_hex(&req.token);
|
||||
let now = Utc::now();
|
||||
|
||||
let updated = sqlx::query(
|
||||
r#"
|
||||
WITH v AS (
|
||||
SELECT user_id
|
||||
FROM email_verifications
|
||||
WHERE token_hash = $1
|
||||
AND verified_at IS NULL
|
||||
AND expires_at > $2
|
||||
LIMIT 1
|
||||
),
|
||||
u AS (
|
||||
UPDATE users
|
||||
SET email_verified_at = $2
|
||||
WHERE id = (SELECT user_id FROM v)
|
||||
AND email_verified_at IS NULL
|
||||
RETURNING id
|
||||
)
|
||||
UPDATE email_verifications
|
||||
SET verified_at = $2
|
||||
WHERE token_hash = $1
|
||||
AND verified_at IS NULL
|
||||
AND expires_at > $2
|
||||
AND user_id IN (SELECT id FROM u)
|
||||
"#,
|
||||
)
|
||||
.bind(token_hash)
|
||||
.bind(now)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "邮箱验证失败").with_source(err))?;
|
||||
|
||||
if updated.rows_affected() == 0 {
|
||||
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
|
||||
}
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: MessageResponse {
|
||||
message: "邮箱验证成功".to_string(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ForgotPasswordRequest {
|
||||
email: String,
|
||||
}
|
||||
|
||||
async fn forgot_password(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ForgotPasswordRequest>,
|
||||
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
||||
validate_email(&req.email)?;
|
||||
|
||||
let user = sqlx::query_as::<_, UserRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
username,
|
||||
password_hash,
|
||||
role::text AS role,
|
||||
is_active,
|
||||
email_verified_at
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
"#,
|
||||
)
|
||||
.bind(req.email.to_lowercase())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||||
|
||||
if let Some(user) = user {
|
||||
let reset_token = generate_token();
|
||||
let token_hash = sha256_hex(&reset_token);
|
||||
let expires_at_db = Utc::now() + Duration::hours(1);
|
||||
|
||||
let _ = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO password_resets (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(token_hash)
|
||||
.bind(expires_at_db)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
let reset_url = format!(
|
||||
"{}/reset-password?token={}",
|
||||
state.config.public_base_url, reset_token
|
||||
);
|
||||
|
||||
let _ = mail::send_password_reset_email(&state, &user.email, &user.username, &reset_url).await;
|
||||
}
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: MessageResponse {
|
||||
message: "如果该邮箱已注册,您将收到重置邮件".to_string(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResetPasswordRequest {
|
||||
token: String,
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
async fn reset_password(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ResetPasswordRequest>,
|
||||
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
||||
if req.token.trim().is_empty() {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "token 不能为空"));
|
||||
}
|
||||
validate_password(&req.new_password)?;
|
||||
|
||||
let token_hash = sha256_hex(&req.token);
|
||||
let now = Utc::now();
|
||||
|
||||
let user_id: Option<Uuid> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT user_id
|
||||
FROM password_resets
|
||||
WHERE token_hash = $1
|
||||
AND used_at IS NULL
|
||||
AND expires_at > $2
|
||||
"#,
|
||||
)
|
||||
.bind(&token_hash)
|
||||
.bind(now)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "重置密码失败").with_source(err))?;
|
||||
|
||||
let Some(user_id) = user_id else {
|
||||
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
|
||||
};
|
||||
|
||||
let password_hash = hash_password(&req.new_password)?;
|
||||
|
||||
let mut tx = state
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||
|
||||
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
||||
.bind(password_hash)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新密码失败").with_source(err))?;
|
||||
|
||||
sqlx::query("UPDATE password_resets SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL")
|
||||
.bind(token_hash)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新重置记录失败").with_source(err))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: MessageResponse {
|
||||
message: "密码重置成功".to_string(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn validate_email(email: &str) -> Result<(), AppError> {
|
||||
if email.trim().is_empty() || !email.contains('@') {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱格式不正确"));
|
||||
}
|
||||
if email.len() > 255 {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱过长"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_username(username: &str) -> Result<(), AppError> {
|
||||
if username.trim().is_empty() {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "用户名不能为空"));
|
||||
}
|
||||
if username.len() > 50 {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "用户名过长"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_password(password: &str) -> Result<(), AppError> {
|
||||
if password.len() < 8 {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "密码至少 8 位"));
|
||||
}
|
||||
if password.len() > 128 {
|
||||
return Err(AppError::new(ErrorCode::InvalidRequest, "密码过长"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> Result<String, AppError> {
|
||||
let salt = argon2::password_hash::SaltString::generate(&mut rand::rngs::OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))?
|
||||
.to_string()
|
||||
.pipe(Ok)
|
||||
}
|
||||
|
||||
fn verify_password(password: &str, password_hash: &str) -> Result<(), AppError> {
|
||||
let parsed = PasswordHash::new(password_hash)
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希格式错误").with_source(err))?;
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.map_err(|_| AppError::new(ErrorCode::Unauthorized, "账号或密码错误"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn sha256_hex(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn map_unique_violation(err: sqlx::Error) -> AppError {
|
||||
if let sqlx::Error::Database(db_err) = &err {
|
||||
if let Some(code) = db_err.code() {
|
||||
if code == "23505" {
|
||||
return AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名已存在");
|
||||
}
|
||||
}
|
||||
}
|
||||
AppError::new(ErrorCode::Internal, "数据库操作失败").with_source(err)
|
||||
}
|
||||
|
||||
trait Pipe: Sized {
|
||||
fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
|
||||
f(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Pipe for T {}
|
||||
Reference in New Issue
Block a user