Skip to main content

yggdrasil/api/
auth.rs

1//! 认证相关的 Dioxus server function 与辅助函数。
2//!
3//! 提供注册、登录、登出、获取当前用户等接口,
4//! 通过 HttpOnly Cookie 维护会话,首个注册用户自动成为 admin。
5//! 也可通过 `ADMIN_*` 环境变量在启动时创建/同步初始管理员(env 为凭据源,
6//! 每次启动覆盖密码并确保 admin 角色),免去首次注册步骤——见 `sync_admin_from_env`。
7//! 所有 server function 均在 `#[server(Name, "/api")]` 下注册,供客户端与服务端调用。
8//! 仅在 `feature = "server"` 启用的服务端构建中执行数据库操作与 Cookie 写入。
9
10#![allow(clippy::unused_unit, deprecated)]
11
12use dioxus::prelude::*;
13#[cfg(feature = "server")]
14use http::header::{HeaderValue, SET_COOKIE};
15
16#[cfg(feature = "server")]
17use crate::api::error::AppError;
18#[cfg(feature = "server")]
19use crate::auth::session::get_session_from_ctx;
20#[cfg(feature = "server")]
21use crate::auth::{password, session};
22#[cfg(feature = "server")]
23use crate::db::pool::get_conn;
24use crate::models::user::PublicUser;
25#[cfg(feature = "server")]
26use crate::models::user::{SessionUser, UserRole};
27
28#[cfg(feature = "server")]
29fn validate_username(username: &str) -> Result<(), String> {
30    if username.len() < 3 || username.len() > 50 {
31        return Err("用户名长度必须在 3-50 字符之间".to_string());
32    }
33    if !username.chars().all(|c| c.is_alphanumeric() || c == '_') {
34        return Err("用户名只能包含字母、数字和下划线".to_string());
35    }
36    Ok(())
37}
38
39#[cfg(feature = "server")]
40pub(crate) fn validate_email(email: &str) -> Result<(), String> {
41    if !crate::utils::server::EMAIL_REGEX.is_match(email) {
42        return Err("邮箱格式不正确".to_string());
43    }
44    Ok(())
45}
46
47#[cfg(feature = "server")]
48/// 密码长度上限(字节):纵深防御,避免 Argon2 对超大输入做无意义的线性哈希扫描
49/// (Argon2 本身的 memory_cost 才是主要开销,这里只是零成本兜底)。
50const MAX_PASSWORD_BYTES: usize = 256;
51
52#[cfg(feature = "server")]
53pub(crate) fn validate_password(password: &str) -> Result<(), String> {
54    if password.len() < 8 {
55        return Err("密码长度至少 8 位".to_string());
56    }
57    if password.len() > MAX_PASSWORD_BYTES {
58        return Err("密码长度不能超过 256 位".to_string());
59    }
60    Ok(())
61}
62
63#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64/// 认证接口统一响应结构。
65pub struct AuthResponse {
66    /// 操作是否成功。
67    pub success: bool,
68    /// 提示信息。
69    pub message: String,
70    /// 登录成功后的会话 token(已废弃,实际通过 Cookie 传递)。
71    pub token: Option<String>,
72}
73
74/// 用户注册。
75///
76/// 校验用户名、邮箱、密码,首个注册用户自动设为 admin;
77/// 已有 admin 时返回 "Registration is closed"。
78/// Dioxus server function,注册在 `/api` 路径下。
79#[server(Register, "/api")]
80pub async fn register(
81    username: String,
82    email: String,
83    password: String,
84) -> Result<AuthResponse, ServerFnError> {
85    // 服务端构建时先进行严格限流检查。
86    #[cfg(feature = "server")]
87    {
88        if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
89            let headers = ctx.parts_mut().headers.clone();
90            let ip = crate::api::rate_limit::get_client_ip(&headers).await;
91            if let Err(msg) = crate::api::rate_limit::check_strict_limit(&ip) {
92                return Ok(AuthResponse {
93                    success: false,
94                    message: msg,
95                    token: None,
96                });
97            }
98        }
99    }
100
101    if let Err(e) = validate_username(&username) {
102        return Ok(AuthResponse {
103            success: false,
104            message: e,
105            token: None,
106        });
107    }
108    if let Err(e) = validate_email(&email) {
109        return Ok(AuthResponse {
110            success: false,
111            message: e,
112            token: None,
113        });
114    }
115    if let Err(e) = validate_password(&password) {
116        return Ok(AuthResponse {
117            success: false,
118            message: e,
119            token: None,
120        });
121    }
122
123    let client = get_conn().await.map_err(AppError::db_conn)?;
124
125    // Argon2 是 memory-hard 计算,必须在 spawn_blocking 中执行,避免阻塞 Tokio worker。
126    let pw_for_hash = password.clone();
127    let password_hash = tokio::task::spawn_blocking(move || password::hash_password(&pw_for_hash))
128        .await
129        .map_err(|_| AppError::Internal("密码处理任务失败"))?
130        .map_err(|_| AppError::Internal("密码处理失败"))?;
131
132    // 使用 INSERT ON CONFLICT 原子性地完成“首个用户成为 admin”的竞争。
133    // 若已有 admin 或用户名/邮箱冲突,RETURNING 将返回空。
134    let result = client
135        .query_opt(
136            "INSERT INTO users (username, email, password_hash, role)
137             VALUES ($1, $2, $3, 'admin')
138             ON CONFLICT DO NOTHING
139             RETURNING id",
140            &[&username, &email, &password_hash],
141        )
142        .await
143        .map_err(AppError::query)?;
144
145    if result.is_some() {
146        return Ok(AuthResponse {
147            success: true,
148            message: "注册成功".to_string(),
149            token: None,
150        });
151    }
152
153    // 插入失败:区分是已有 admin 还是用户名/邮箱冲突。
154    let admin_exists: bool = client
155        .query_one(
156            "SELECT EXISTS (SELECT 1 FROM users WHERE role = 'admin')",
157            &[],
158        )
159        .await
160        .map_err(AppError::query)?
161        .get(0);
162
163    let message = if admin_exists {
164        "Registration is closed".to_string()
165    } else {
166        "用户名或邮箱已存在".to_string()
167    };
168
169    Ok(AuthResponse {
170        success: false,
171        message,
172        token: None,
173    })
174}
175
176/// 用户登录。
177///
178/// 验证用户名/邮箱与密码,生成会话并写入 HttpOnly Cookie;
179/// 同一用户活跃会话数超过 `MAX_SESSIONS_PER_USER` 时删除最早会话。
180/// Dioxus server function,注册在 `/api` 路径下。
181#[server(Login, "/api")]
182pub async fn login(username: String, password: String) -> Result<AuthResponse, ServerFnError> {
183    // 服务端构建时先进行严格限流检查。
184    #[cfg(feature = "server")]
185    {
186        if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
187            let headers = ctx.parts_mut().headers.clone();
188            let ip = crate::api::rate_limit::get_client_ip(&headers).await;
189            if let Err(msg) = crate::api::rate_limit::check_strict_limit(&ip) {
190                return Ok(AuthResponse {
191                    success: false,
192                    message: msg,
193                    token: None,
194                });
195            }
196        }
197    }
198
199    let mut client = get_conn().await.map_err(AppError::db_conn)?;
200
201    let row = match client
202        .query_opt(
203            "SELECT id, username, email, password_hash, role, created_at FROM users WHERE username = $1 OR email = $1",
204            &[&username],
205        )
206        .await
207    {
208        Ok(Some(row)) => row,
209        Ok(None) => {
210            // 用户不存在时也执行一次 Argon2 verify,抹平「用户不存在」与
211            // 「密码错误」的响应时序差,防止通过响应时间枚举账号(L2)。
212            // 用固定合法哈希做 verify(必然失败),耗时与真实校验一致。
213            const DUMMY_HASH: &str =
214                "$argon2id$v=19$m=19456,t=2,p=1$j3rNaAXzdExYaL94WBWtfg$n1S75LUQKaYJwaRl5bkFF/f/N1tLfRYR/7TuQxKP94c";
215            let dummy_pw = password.clone();
216            let _ = tokio::task::spawn_blocking(move || {
217                crate::auth::password::verify_password(&dummy_pw, DUMMY_HASH)
218            })
219            .await;
220            return Ok(AuthResponse {
221                success: false,
222                message: "Invalid credentials".to_string(),
223                token: None,
224            });
225        }
226        Err(e) => {
227            return Err(AppError::query(e).into());
228        }
229    };
230
231    let password_hash: String = row.get("password_hash");
232    // Argon2 校验同样在 spawn_blocking 中执行。
233    let pw_for_verify = password.clone();
234    let hash_for_verify = password_hash.clone();
235    let valid = tokio::task::spawn_blocking(move || {
236        password::verify_password(&pw_for_verify, &hash_for_verify)
237    })
238    .await
239    .map_err(|_| AppError::Internal("密码处理任务失败"))?
240    .map_err(|_| AppError::Internal("密码处理失败"))?;
241
242    if !valid {
243        return Ok(AuthResponse {
244            success: false,
245            message: "Invalid credentials".to_string(),
246            token: None,
247        });
248    }
249
250    // blocked / 未知角色即使密码正确也不能建立会话;统一返回通用凭据错误,
251    // 避免泄露账号当前状态。
252    let role_str: String = row.get("role");
253    if !matches!(UserRole::from_str(&role_str), Some(UserRole::Admin)) {
254        return Ok(AuthResponse {
255            success: false,
256            message: "Invalid credentials".to_string(),
257            token: None,
258        });
259    }
260
261    let user_id: i32 = row.get("id");
262    let token = session::generate_token();
263    let token_hash = session::hash_token(&token);
264    let expires_at = session::default_expiry();
265
266    let max_sessions = crate::api::settings::runtime_security_settings()
267        .await
268        .max_sessions_per_user
269        .max(1) as i64;
270
271    // 用事务 + 对 users 行加 FOR UPDATE 锁,串行化同一用户的并发登录,
272    // 避免 COUNT→DELETE→INSERT 之间的竞态导致超出上限(M1)。
273    let tx = client.transaction().await.map_err(AppError::query)?;
274    // 锁住该用户行,并发登录在此排队。
275    tx.execute("SELECT 1 FROM users WHERE id = $1 FOR UPDATE", &[&user_id])
276        .await
277        .map_err(AppError::query)?;
278
279    let session_count: i64 = tx
280        .query_one(
281            "SELECT COUNT(*) FROM sessions WHERE user_id = $1 AND expires_at > NOW()",
282            &[&user_id],
283        )
284        .await
285        .map_err(AppError::query)?
286        .get(0);
287
288    if session_count >= max_sessions {
289        tx.execute(
290            "DELETE FROM sessions WHERE id IN (
291                SELECT id FROM sessions
292                WHERE user_id = $1 AND expires_at > NOW()
293                ORDER BY created_at ASC
294                LIMIT 1
295            )",
296            &[&user_id],
297        )
298        .await
299        .map_err(AppError::query)?;
300    }
301
302    tx.execute(
303        "INSERT INTO sessions (user_id, token_hash, user_agent, expires_at) VALUES ($1, $2, $3, $4)",
304        &[&user_id, &token_hash, &None::<String>, &expires_at],
305    )
306    .await
307    .map_err(AppError::query)?;
308
309    tx.commit().await.map_err(AppError::query)?;
310
311    let cookie = session::session_cookie(
312        &token,
313        session::SESSION_MAX_AGE_SECS,
314        crate::api::settings::runtime_security_settings()
315            .await
316            .cookie_secure,
317    );
318    // 通过 Dioxus FullstackContext 设置 HttpOnly Cookie 响应头。
319    if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
320        if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
321            ctx.add_response_header(SET_COOKIE, value);
322        }
323    }
324
325    Ok(AuthResponse {
326        success: true,
327        message: "登录成功".to_string(),
328        token: None,
329    })
330}
331
332/// 用户登出。
333///
334/// 清空客户端 session Cookie,并删除数据库中对应会话记录。
335/// Dioxus server function,注册在 `/api` 路径下。
336#[server(Logout, "/api")]
337pub async fn logout() -> Result<AuthResponse, ServerFnError> {
338    let token = get_session_from_ctx();
339
340    let client = get_conn().await.map_err(AppError::db_conn)?;
341
342    // 设置过期时间为 0 的 Cookie,通知浏览器清除会话。
343    let cookie = session::session_cookie(
344        "",
345        0,
346        crate::api::settings::runtime_security_settings()
347            .await
348            .cookie_secure,
349    );
350    if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
351        if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
352            ctx.add_response_header(SET_COOKIE, value);
353        }
354    }
355
356    if let Some(t) = token {
357        let token_hash = session::hash_token(&t);
358        crate::cache::invalidate_session_user(&token_hash).await;
359        client
360            .execute("DELETE FROM sessions WHERE token_hash = $1", &[&token_hash])
361            .await
362            .map_err(AppError::query)?;
363    }
364
365    Ok(AuthResponse {
366        success: true,
367        message: "登出成功".to_string(),
368        token: None,
369    })
370}
371
372#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
373/// 当前用户查询响应。
374pub struct CurrentUserResponse {
375    /// 当前已登录用户的公开信息;未登录时为 `None`。
376    pub user: Option<PublicUser>,
377}
378
379#[cfg(feature = "server")]
380/// 根据会话 token 查询对应用户(不含密码哈希,供会话缓存使用)。
381///
382/// 优先命中内存缓存,避免每次请求都执行完整用户查询;未命中时回查数据库并回填缓存。
383/// 缓存命中后仍按 token 校验 `sessions.expires_at`、用户角色与世代号,
384/// 确保过期、撤销或被封禁的会话不会在 TTL 内继续有效。
385pub async fn get_user_by_token(token: &str) -> Result<Option<SessionUser>, ServerFnError> {
386    let token_hash = session::hash_token(token);
387
388    if let Some(cached) = crate::cache::get_session_user(&token_hash).await {
389        // 缓存命中后同时校验 session 是否存在/未过期、角色与世代号。
390        let session_state: Option<(i32, String)> = get_conn()
391            .await
392            .map_err(AppError::db_conn)?
393            .query_opt(
394                "SELECT u.session_generation, u.role
395                 FROM sessions s
396                 JOIN users u ON s.user_id = u.id
397                 WHERE s.token_hash = $1 AND s.expires_at > NOW()",
398                &[&token_hash],
399            )
400            .await
401            .map_err(AppError::query)?
402            .map(|r| (r.get(0), r.get(1)));
403
404        match session_state {
405            Some((generation, role))
406                if generation == cached.session_generation
407                    && role == "admin"
408                    && matches!(cached.role, UserRole::Admin) =>
409            {
410                return Ok(Some(cached));
411            }
412            _ => {
413                // session 不存在/已过期、世代不匹配或角色已禁用:逐出缓存并视为未登录。
414                crate::cache::invalidate_session_user(&token_hash).await;
415                return Ok(None);
416            }
417        }
418    }
419
420    let client = get_conn().await.map_err(AppError::db_conn)?;
421
422    let row = client
423        .query_opt(
424            "SELECT u.id, u.username, u.email, u.display_name, u.avatar_url, u.role, u.created_at, u.session_generation
425             FROM sessions s
426             JOIN users u ON s.user_id = u.id
427             WHERE s.token_hash = $1 AND s.expires_at > NOW()",
428            &[&token_hash],
429        )
430        .await
431        .map_err(AppError::query)?;
432
433    let user = match row {
434        Some(row) => {
435            let role_str: String = row.get("role");
436            let role = match UserRole::from_str(&role_str) {
437                Some(UserRole::Admin) => UserRole::Admin,
438                _ => return Ok(None),
439            };
440            Some(SessionUser {
441                id: row.get("id"),
442                username: row.get("username"),
443                email: row.get("email"),
444                display_name: row.get("display_name"),
445                avatar_url: row.get("avatar_url"),
446                role,
447                created_at: row.get("created_at"),
448                session_generation: row.get("session_generation"),
449            })
450        }
451        None => None,
452    };
453
454    if let Some(ref u) = user {
455        crate::cache::set_session_user(&token_hash, u.clone()).await;
456    }
457
458    Ok(user)
459}
460
461/// 获取当前登录用户的公开信息。
462///
463/// Dioxus server function,注册在 `/api` 路径下。
464#[server(GetCurrentUser, "/api")]
465pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
466    let token = match get_session_from_ctx() {
467        Some(t) => t,
468        None => return Ok(CurrentUserResponse { user: None }),
469    };
470
471    let user = get_user_by_token(&token).await?.map(PublicUser::from);
472
473    Ok(CurrentUserResponse { user })
474}
475
476#[cfg(feature = "server")]
477/// 获取当前登录用户并要求其为 admin,否则返回 401/403。
478///
479/// 供其它服务端接口内部调用。
480pub async fn get_current_admin_user() -> Result<SessionUser, AppError> {
481    let token = get_session_from_ctx().ok_or(AppError::Unauthorized("未登录"))?;
482
483    let session_user = get_user_by_token(&token)
484        .await
485        .map_err(AppError::query)?
486        .ok_or(AppError::Unauthorized("会话已过期"))?;
487
488    if session_user.role != UserRole::Admin {
489        return Err(AppError::Forbidden("权限不足"));
490    }
491
492    Ok(session_user)
493}
494
495/// 初始管理员配置(来自 `ADMIN_*` 环境变量)。
496///
497/// 不实现 `Debug`,避免日志意外泄露密码。
498#[cfg(feature = "server")]
499struct AdminEnvConfig {
500    username: String,
501    email: String,
502    password: String,
503}
504
505/// 从环境变量读取初始管理员配置(`ADMIN_USERNAME` / `ADMIN_EMAIL` / `ADMIN_PASSWORD`)。
506///
507/// 三个变量任一缺失或为空 → 返回 `None`(特性关闭)。
508/// 用户名/邮箱会 trim;密码原样保留(允许含空格)。
509#[cfg(feature = "server")]
510fn parse_admin_env() -> Option<AdminEnvConfig> {
511    let username = std::env::var("ADMIN_USERNAME").ok()?;
512    let username = username.trim().to_string();
513    if username.is_empty() {
514        return None;
515    }
516    let email = std::env::var("ADMIN_EMAIL").ok()?;
517    let email = email.trim().to_string();
518    if email.is_empty() {
519        return None;
520    }
521    let password = std::env::var("ADMIN_PASSWORD").ok()?;
522    if password.is_empty() {
523        return None;
524    }
525    Some(AdminEnvConfig {
526        username,
527        email,
528        password,
529    })
530}
531
532/// `ADMIN_*` 初始管理员环境变量是否全部就位(即启动同步特性是否激活)。
533///
534/// 激活时每次启动都会用 env 覆盖邮箱与密码——个人信息页据此显示提示横幅。
535#[cfg(feature = "server")]
536pub(crate) fn admin_env_active() -> bool {
537    parse_admin_env().is_some()
538}
539
540/// 启动时按 `ADMIN_*` 环境变量同步初始管理员(env 是超级管理员的凭据源)。
541///
542/// 语义(与 `BACKUP_*` 播种「仅首次生效」不同:这里**每次启动**都生效):
543/// 1. 三个变量任一缺失/为空 → 跳过(特性关闭,向后兼容)。
544/// 2. 变量值非法(不过 `validate_*` 校验)→ warn + 跳过,不阻断启动。
545/// 3. `ADMIN_USERNAME` 已存在 → 用 `ADMIN_PASSWORD` 覆盖其密码哈希
546///    (密码一致时跳过重哈希,避免每次启动改写行;覆盖时 bump
547///    `session_generation`,旧会话全部失效需重新登录);
548///    确保 role='admin'(恢复被降级/封禁的用户,触发 018 迁移的角色触发器);
549///    邮箱同步为 `ADMIN_EMAIL`(被其他用户占用时仅告警跳过邮箱)。
550/// 4. `ADMIN_USERNAME` 不存在 → 尝试创建为 admin。库中已存在其他 admin 时
551///    创建失败(`idx_one_admin` 部分唯一索引保证全库仅一个 admin),warn 提示。
552///
553/// 失败只告警不阻断启动(与 BACKUP_* 播种一致)。
554#[cfg(feature = "server")]
555pub(crate) async fn sync_admin_from_env(client: &tokio_postgres::Client) -> Result<(), AppError> {
556    let Some(cfg) = parse_admin_env() else {
557        return Ok(());
558    };
559    if let Err(e) = validate_username(&cfg.username) {
560        tracing::warn!(
561            "ADMIN_USERNAME={:?} 非法,跳过初始管理员同步: {e}",
562            cfg.username
563        );
564        return Ok(());
565    }
566    if let Err(e) = validate_email(&cfg.email) {
567        tracing::warn!("ADMIN_EMAIL={:?} 非法,跳过初始管理员同步: {e}", cfg.email);
568        return Ok(());
569    }
570    if let Err(e) = validate_password(&cfg.password) {
571        tracing::warn!("ADMIN_PASSWORD 不合法({e}),跳过初始管理员同步");
572        return Ok(());
573    }
574
575    // Argon2 是 memory-hard 计算,必须在 spawn_blocking 中执行(与注册路径一致)。
576    let pw = cfg.password.clone();
577    let password_hash = tokio::task::spawn_blocking(move || password::hash_password(&pw))
578        .await
579        .map_err(|_| AppError::Internal("初始管理员密码哈希任务失败"))?
580        .map_err(|_| AppError::Internal("初始管理员密码哈希失败"))?;
581
582    let row = client
583        .query_opt(
584            "SELECT id, email, role, password_hash FROM users WHERE username = $1",
585            &[&cfg.username],
586        )
587        .await
588        .map_err(AppError::query)?;
589
590    let Some(row) = row else {
591        // 用户名不存在:尝试创建为 admin(仅当库中尚无 admin 时成功)。
592        let created = client
593            .query_opt(
594                "INSERT INTO users (username, email, password_hash, role)
595                 VALUES ($1, $2, $3, 'admin')
596                 ON CONFLICT DO NOTHING
597                 RETURNING id",
598                &[&cfg.username, &cfg.email, &password_hash],
599            )
600            .await
601            .map_err(AppError::query)?;
602        if created.is_some() {
603            tracing::info!(
604                "初始管理员已从环境变量创建: username={}(登录成功后可从 .env 移除 ADMIN_* 变量)",
605                cfg.username
606            );
607        } else {
608            tracing::warn!(
609                "无法创建初始管理员 username={}(用户名/邮箱已被占用,或库中已存在其他 admin——全库仅允许一个 admin)。\
610                 可将 ADMIN_USERNAME 改为现有 admin 的用户名以同步其凭据",
611                cfg.username
612            );
613        }
614        return Ok(());
615    };
616
617    // 用户名已存在:env 覆盖密码、确保 admin 角色、同步邮箱。
618    let user_id: i32 = row.get("id");
619    let stored_hash: String = row.get("password_hash");
620    let stored_email: String = row.get("email");
621    let role: String = row.get("role");
622
623    // 密码与 env 一致时跳过重哈希(一次 Argon2 verify,代价可接受)。
624    let pw_for_verify = cfg.password.clone();
625    let same_password = tokio::task::spawn_blocking(move || {
626        password::verify_password(&pw_for_verify, &stored_hash)
627    })
628    .await
629    .map_err(|_| AppError::Internal("初始管理员密码校验任务失败"))?
630    .unwrap_or(false);
631    if !same_password {
632        // 凭据变更 → bump session_generation,旧会话全部失效,需用新密码重新登录。
633        client
634            .execute(
635                "UPDATE users SET password_hash = $1, session_generation = session_generation + 1
636                 WHERE id = $2",
637                &[&password_hash, &user_id],
638            )
639            .await
640            .map_err(AppError::query)?;
641        tracing::info!("初始管理员密码已从环境变量覆盖: username={}", cfg.username);
642    }
643
644    if role != "admin" {
645        // 018 迁移的角色变更触发器会在此 bump session_generation(降级期间的会话本已失效)。
646        client
647            .execute("UPDATE users SET role = 'admin' WHERE id = $1", &[&user_id])
648            .await
649            .map_err(AppError::query)?;
650        tracing::info!("初始管理员角色已恢复为 admin: username={}", cfg.username);
651    }
652
653    if stored_email != cfg.email {
654        let email_taken: bool = client
655            .query_one(
656                "SELECT EXISTS (SELECT 1 FROM users WHERE email = $1 AND id <> $2)",
657                &[&cfg.email, &user_id],
658            )
659            .await
660            .map_err(AppError::query)?
661            .get(0);
662        if email_taken {
663            tracing::warn!(
664                "ADMIN_EMAIL={:?} 已被其他用户占用,跳过邮箱同步(密码/角色不受影响)",
665                cfg.email
666            );
667        } else {
668            client
669                .execute(
670                    "UPDATE users SET email = $1 WHERE id = $2",
671                    &[&cfg.email, &user_id],
672                )
673                .await
674                .map_err(AppError::query)?;
675        }
676    }
677
678    Ok(())
679}
680
681#[cfg(all(test, feature = "server"))]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn validate_username_valid() {
687        assert!(validate_username("admin").is_ok());
688        assert!(validate_username("user_123").is_ok());
689        assert!(validate_username("abc").is_ok());
690    }
691
692    #[test]
693    fn validate_username_too_short() {
694        assert!(validate_username("ab").is_err());
695    }
696
697    #[test]
698    fn validate_username_too_long() {
699        assert!(validate_username(&"a".repeat(51)).is_err());
700    }
701
702    #[test]
703    fn validate_username_max_length() {
704        assert!(validate_username(&"a".repeat(50)).is_ok());
705    }
706
707    #[test]
708    fn validate_username_special_chars() {
709        assert!(validate_username("user name").is_err());
710        assert!(validate_username("user@name").is_err());
711        assert!(validate_username("user-name").is_err());
712    }
713
714    #[test]
715    fn validate_username_unicode() {
716        assert!(validate_username("用户名").is_ok());
717    }
718
719    #[test]
720    fn validate_email_valid() {
721        assert!(validate_email("user@example.com").is_ok());
722        assert!(validate_email("a.b+c@domain.co").is_ok());
723    }
724
725    #[test]
726    fn validate_email_invalid() {
727        assert!(validate_email("notanemail").is_err());
728        assert!(validate_email("@domain.com").is_err());
729        assert!(validate_email("user@").is_err());
730        assert!(validate_email("user@.com").is_err());
731        assert!(validate_email("").is_err());
732    }
733
734    #[test]
735    fn validate_password_valid() {
736        assert!(validate_password("12345678").is_ok());
737        assert!(validate_password("a very long password with spaces").is_ok());
738    }
739
740    #[test]
741    fn validate_password_too_short() {
742        assert!(validate_password("1234567").is_err());
743    }
744
745    #[test]
746    fn validate_password_exactly_8() {
747        assert!(validate_password("12345678").is_ok());
748    }
749
750    #[test]
751    fn validate_password_empty() {
752        assert!(validate_password("").is_err());
753    }
754
755    #[test]
756    fn validate_password_too_long() {
757        assert!(validate_password(&"a".repeat(257)).is_err());
758        assert!(validate_password(&"a".repeat(256)).is_ok());
759    }
760
761    /// 在给定环境变量集下运行闭包,结束后恢复原值(env 是进程全局状态,需 serial 隔离)。
762    fn with_env(vars: &[(&str, Option<&str>)], f: impl FnOnce()) {
763        let saved: Vec<(String, Option<String>)> = vars
764            .iter()
765            .map(|(k, _)| (k.to_string(), std::env::var(k).ok()))
766            .collect();
767        for (k, v) in vars {
768            match v {
769                Some(v) => std::env::set_var(k, v),
770                None => std::env::remove_var(k),
771            }
772        }
773        f();
774        for (k, v) in saved {
775            match v {
776                Some(v) => std::env::set_var(k, v),
777                None => std::env::remove_var(k),
778            }
779        }
780    }
781
782    #[test]
783    #[serial_test::serial]
784    fn parse_admin_env_none_when_all_unset() {
785        with_env(
786            &[
787                ("ADMIN_USERNAME", None),
788                ("ADMIN_EMAIL", None),
789                ("ADMIN_PASSWORD", None),
790            ],
791            || assert!(parse_admin_env().is_none()),
792        );
793    }
794
795    #[test]
796    #[serial_test::serial]
797    fn parse_admin_env_none_when_any_missing() {
798        // 只缺 ADMIN_PASSWORD。
799        with_env(
800            &[
801                ("ADMIN_USERNAME", Some("admin")),
802                ("ADMIN_EMAIL", Some("admin@example.com")),
803                ("ADMIN_PASSWORD", None),
804            ],
805            || assert!(parse_admin_env().is_none()),
806        );
807        // 只缺 ADMIN_EMAIL。
808        with_env(
809            &[
810                ("ADMIN_USERNAME", Some("admin")),
811                ("ADMIN_EMAIL", None),
812                ("ADMIN_PASSWORD", Some("strongpass123")),
813            ],
814            || assert!(parse_admin_env().is_none()),
815        );
816        // 只缺 ADMIN_USERNAME。
817        with_env(
818            &[
819                ("ADMIN_USERNAME", None),
820                ("ADMIN_EMAIL", Some("admin@example.com")),
821                ("ADMIN_PASSWORD", Some("strongpass123")),
822            ],
823            || assert!(parse_admin_env().is_none()),
824        );
825    }
826
827    #[test]
828    #[serial_test::serial]
829    fn parse_admin_env_none_on_empty_values() {
830        with_env(
831            &[
832                ("ADMIN_USERNAME", Some("admin")),
833                ("ADMIN_EMAIL", Some("admin@example.com")),
834                ("ADMIN_PASSWORD", Some("")),
835            ],
836            || assert!(parse_admin_env().is_none()),
837        );
838        with_env(
839            &[
840                ("ADMIN_USERNAME", Some("   ")),
841                ("ADMIN_EMAIL", Some("admin@example.com")),
842                ("ADMIN_PASSWORD", Some("strongpass123")),
843            ],
844            || assert!(parse_admin_env().is_none()),
845        );
846    }
847
848    #[test]
849    #[serial_test::serial]
850    fn parse_admin_env_some_when_all_set() {
851        with_env(
852            &[
853                ("ADMIN_USERNAME", Some("  admin  ")),
854                ("ADMIN_EMAIL", Some(" admin@example.com ")),
855                ("ADMIN_PASSWORD", Some("  strong pass 123  ")),
856            ],
857            || {
858                let cfg = parse_admin_env().expect("三个变量齐全应返回 Some");
859                // 用户名/邮箱 trim,密码原样保留。
860                assert_eq!(cfg.username, "admin");
861                assert_eq!(cfg.email, "admin@example.com");
862                assert_eq!(cfg.password, "  strong pass 123  ");
863            },
864        );
865    }
866}