Skip to main content

yggdrasil/api/
auth.rs

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