Skip to main content

dtmrs_server/
auth.rs

1//! 管理台的登录保护。
2//!
3//! # 为什么保护范围是「除了 /health 之外全部」
4//!
5//! 管理台那个 HTML 页面本身没什么可保护的 —— 真正危险的是它调的接口:
6//! `abort` 能中止在途事务、`retry` 能改调度、`submit` 能凭空造事务。
7//! **只给页面加登录而把 `/api/dtmsvr/*` 敞着,等于没加。**
8//! 所以这里是全局中间件,白名单只有 `/health`(反向代理的健康检查要用)
9//! 和 `/login` 本身。
10//!
11//! # 没配密码时不启用
12//!
13//! `DTMRS_ADMIN_PASSWORD` 没设就完全不拦 —— 内网/本地开发的用法不变。
14//! 但**一旦你打算暴露到公网,这个变量就是必须的**,`main.rs` 在监听
15//! 非回环地址且没配密码时会打醒目警告。
16//!
17//! # 会话存在内存里
18//!
19//! 单进程 TC,没必要引入签名/JWT 那一套。代价是**重启后所有人要重新登录**,
20//! 对管理台来说完全可以接受。多实例部署时各实例的会话不互通,
21//! 前面挂负载均衡的话要开会话保持(或者干脆每个实例单独登录)。
22
23use std::collections::HashMap;
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, Instant};
26
27use axum::body::Body;
28use axum::extract::State;
29use axum::http::{header, Request, StatusCode};
30use axum::middleware::Next;
31use axum::response::{Html, IntoResponse, Redirect, Response};
32use axum::Form;
33use serde::Deserialize;
34
35/// 会话有效期。管理台是低频操作,给长一点省得老登录
36const SESSION_TTL: Duration = Duration::from_secs(12 * 3600);
37const COOKIE: &str = "dtmrs_session";
38
39pub struct Auth {
40    user: String,
41    password: String,
42    /// token -> 过期时刻
43    sessions: Mutex<HashMap<String, Instant>>,
44}
45
46impl Auth {
47    /// 密码为空返回 `None` —— 调用方据此决定「不启用认证」
48    pub fn from_env() -> Option<Arc<Self>> {
49        let password = std::env::var("DTMRS_ADMIN_PASSWORD").unwrap_or_default();
50        if password.is_empty() {
51            return None;
52        }
53        Some(Arc::new(Self {
54            user: std::env::var("DTMRS_ADMIN_USER").unwrap_or_else(|_| "admin".into()),
55            password,
56            sessions: Mutex::new(HashMap::new()),
57        }))
58    }
59
60    /// ⚠ 定长时间比较。管理台密码通常不长,朴素的 `==` 会随前缀匹配长度
61    /// 提前返回,理论上能被逐字节试出来
62    fn matches(&self, user: &str, password: &str) -> bool {
63        let a = user.as_bytes();
64        let b = self.user.as_bytes();
65        let c = password.as_bytes();
66        let d = self.password.as_bytes();
67        let mut diff = (a.len() ^ b.len()) | (c.len() ^ d.len());
68        for i in 0..a.len().max(b.len()) {
69            diff |= usize::from(a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(1));
70        }
71        for i in 0..c.len().max(d.len()) {
72            diff |= usize::from(c.get(i).copied().unwrap_or(0) ^ d.get(i).copied().unwrap_or(1));
73        }
74        diff == 0
75    }
76
77    fn issue(&self) -> String {
78        use rand::Rng;
79        let raw: [u8; 32] = rand::thread_rng().gen();
80        let token: String = raw.iter().map(|b| format!("{b:02x}")).collect();
81        let mut s = self.sessions.lock().unwrap();
82        let now = Instant::now();
83        s.retain(|_, exp| *exp > now); // 顺手清过期的,省得无限涨
84        s.insert(token.clone(), now + SESSION_TTL);
85        token
86    }
87
88    fn valid(&self, token: &str) -> bool {
89        let mut s = self.sessions.lock().unwrap();
90        match s.get(token) {
91            Some(exp) if *exp > Instant::now() => true,
92            Some(_) => {
93                s.remove(token);
94                false
95            }
96            None => false,
97        }
98    }
99
100    fn revoke(&self, token: &str) {
101        self.sessions.lock().unwrap().remove(token);
102    }
103}
104
105fn cookie_of(req: &Request<Body>) -> Option<String> {
106    req.headers()
107        .get(header::COOKIE)?
108        .to_str()
109        .ok()?
110        .split(';')
111        .filter_map(|kv| kv.split_once('='))
112        .find(|(k, _)| k.trim() == COOKIE)
113        .map(|(_, v)| v.trim().to_string())
114}
115
116/// 走反向代理时协议看 `X-Forwarded-Proto`;直连 http 的话不能加 Secure,
117/// 否则浏览器根本不会回传这个 cookie
118fn is_https(req_headers: &axum::http::HeaderMap) -> bool {
119    req_headers
120        .get("x-forwarded-proto")
121        .and_then(|v| v.to_str().ok())
122        .map(|v| v.eq_ignore_ascii_case("https"))
123        .unwrap_or(false)
124}
125
126/// 全局中间件:除 `/health` 和 `/login` 外都要求已登录。
127///
128/// 浏览器来的(Accept 含 text/html)跳转到登录页;
129/// 接口调用返回 401,不做跳转 —— 让 curl / SDK 拿到明确的状态码。
130pub async fn guard(
131    State(auth): State<Arc<Auth>>,
132    req: Request<Body>,
133    next: Next,
134) -> Response {
135    let path = req.uri().path();
136    if path == "/health" || path == "/login" || path == "/logout" {
137        return next.run(req).await;
138    }
139    if cookie_of(&req).is_some_and(|t| auth.valid(&t)) {
140        return next.run(req).await;
141    }
142    let wants_html = req
143        .headers()
144        .get(header::ACCEPT)
145        .and_then(|v| v.to_str().ok())
146        .is_some_and(|v| v.contains("text/html"));
147    if wants_html {
148        Redirect::to("/login").into_response()
149    } else {
150        (StatusCode::UNAUTHORIZED, "需要登录").into_response()
151    }
152}
153
154#[derive(Deserialize)]
155pub struct LoginForm {
156    user: String,
157    password: String,
158}
159
160pub async fn login_page() -> Html<&'static str> {
161    Html(include_str!("login.html"))
162}
163
164pub async fn login_submit(
165    State(auth): State<Arc<Auth>>,
166    headers: axum::http::HeaderMap,
167    Form(f): Form<LoginForm>,
168) -> Response {
169    if !auth.matches(&f.user, &f.password) {
170        // 不区分「用户名不存在」和「密码错误」—— 那等于告诉对方用户名猜对了
171        return (
172            StatusCode::UNAUTHORIZED,
173            Html(include_str!("login.html").replace(
174                "<!--ERR-->",
175                r#"<p class="err">用户名或密码不对</p>"#,
176            )),
177        )
178            .into_response();
179    }
180    let token = auth.issue();
181    let secure = if is_https(&headers) { "; Secure" } else { "" };
182    let cookie = format!(
183        "{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}{secure}",
184        SESSION_TTL.as_secs()
185    );
186    ([(header::SET_COOKIE, cookie)], Redirect::to("/console")).into_response()
187}
188
189pub async fn logout(State(auth): State<Arc<Auth>>, req: Request<Body>) -> Response {
190    if let Some(t) = cookie_of(&req) {
191        auth.revoke(&t);
192    }
193    (
194        [(
195            header::SET_COOKIE,
196            format!("{COOKIE}=; Path=/; HttpOnly; Max-Age=0"),
197        )],
198        Redirect::to("/login"),
199    )
200        .into_response()
201}