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
39/// 托管令牌的缓存刷新间隔。**作废最长这么久才生效** ——
40/// 认证在热路径上(几万 QPS),每请求查一次库会让认证开销盖过事务本身。
41///
42/// 多实例部署时每个实例各自刷新,所以延迟是各自独立的、不会叠加。
43const TOKEN_CACHE_TTL: Duration = Duration::from_secs(10);
44
45pub struct Auth {
46    /// 管理台登录用。为空表示不提供登录页(只用 token 认证)
47    user: String,
48    password: String,
49    /// 业务端用的静态令牌。**服务之间调用不该走登录表单+cookie**,
50    /// 那是给浏览器设计的。为空表示不接受 token 认证
51    token: String,
52    /// 会话 token -> 过期时刻
53    sessions: Mutex<HashMap<String, Instant>>,
54    /// 托管令牌:存储层是权威,这里只是热路径上的缓存。
55    /// `(有效哈希集合, 上次刷新时刻)`
56    managed: Mutex<(std::collections::HashSet<String>, Instant)>,
57    /// 拿托管令牌要用的存储句柄。`None` 表示只用 env 里那个静态 token
58    store: Option<dtmrs_store::Store>,
59}
60
61impl Auth {
62    /// 密码为空返回 `None` —— 调用方据此决定「不启用认证」
63    /// 两个都没配返回 `None` —— 调用方据此决定「不启用认证」。
64    ///
65    /// 两种凭据是**并列**的,满足任一即放行:
66    /// - `DTMRS_ADMIN_PASSWORD`:浏览器登录管理台,拿会话 cookie
67    /// - `DTMRS_AUTH_TOKEN`:业务服务/SDK 带 `Authorization: Bearer <token>`
68    pub fn from_env() -> Option<Arc<Self>> {
69        let password = std::env::var("DTMRS_ADMIN_PASSWORD").unwrap_or_default();
70        let token = std::env::var("DTMRS_AUTH_TOKEN").unwrap_or_default();
71        if password.is_empty() && token.is_empty() {
72            return None;
73        }
74        Some(Arc::new(Self {
75            user: std::env::var("DTMRS_ADMIN_USER").unwrap_or_else(|_| "admin".into()),
76            password,
77            token,
78            sessions: Mutex::new(HashMap::new()),
79            // 初始时刻设成很久以前,保证第一次校验必定去刷一遍
80            managed: Mutex::new((
81                std::collections::HashSet::new(),
82                Instant::now() - TOKEN_CACHE_TTL * 2,
83            )),
84            store: None,
85        }))
86    }
87
88    /// 接上存储,启用「管理台可增删的托管令牌」。
89    /// 不接的话只有 `DTMRS_AUTH_TOKEN` 那个静态令牌有效
90    pub fn with_store(mut self: Arc<Self>, store: dtmrs_store::Store) -> Arc<Self> {
91        // 刚构造出来还没共享,这里一定能拿到独占引用
92        if let Some(me) = Arc::get_mut(&mut self) {
93            me.store = Some(store);
94        }
95        self
96    }
97
98    /// 校验托管令牌。缓存过期就先刷一遍。
99    ///
100    /// 命中之后**顺手记一次使用**(异步 spawn,失败只吞掉)——
101    /// 统计信息不值得让一次正常的业务调用失败或变慢。
102    pub async fn managed_ok(&self, presented: &str, ip: &str) -> bool {
103        let Some(store) = self.store.clone() else {
104            return false;
105        };
106        let hash = dtmrs_store::hash_token(presented);
107
108        let need_refresh = {
109            let g = self.managed.lock().unwrap();
110            g.1.elapsed() >= TOKEN_CACHE_TTL
111        };
112        if need_refresh {
113            if let Ok(list) = store.active_token_hashes().await {
114                let mut g = self.managed.lock().unwrap();
115                *g = (list.into_iter().collect(), Instant::now());
116            }
117        }
118        let hit = {
119            let g = self.managed.lock().unwrap();
120            g.0.contains(&hash)
121        };
122        if hit {
123            let (s, h, ip) = (store, hash.clone(), ip.to_string());
124            tokio::spawn(async move {
125                let _ = s.touch_token(&h, &ip).await;
126            });
127        }
128        hit
129    }
130
131    /// 作废之后立刻让缓存失效,省得等最多 10 秒
132    pub fn invalidate_cache(&self) {
133        let mut g = self.managed.lock().unwrap();
134        g.1 = Instant::now() - TOKEN_CACHE_TTL * 2;
135    }
136
137    /// 配了密码才提供登录页
138    pub fn has_login(&self) -> bool {
139        !self.password.is_empty()
140    }
141
142    /// 校验业务端的 Bearer token。定长时间比较,理由同 `matches`
143    pub fn token_ok(&self, presented: &str) -> bool {
144        if self.token.is_empty() {
145            return false;
146        }
147        let (a, b) = (presented.as_bytes(), self.token.as_bytes());
148        let mut diff = a.len() ^ b.len();
149        for i in 0..a.len().max(b.len()) {
150            diff |= usize::from(a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(1));
151        }
152        diff == 0
153    }
154
155    /// 从 `Authorization: Bearer xxx` 里取 token。gRPC 侧的 metadata 同名,复用
156    pub fn bearer(v: &str) -> Option<&str> {
157        let v = v.trim();
158        v.strip_prefix("Bearer ")
159            .or_else(|| v.strip_prefix("bearer "))
160            .map(str::trim)
161    }
162
163    /// ⚠ 定长时间比较。管理台密码通常不长,朴素的 `==` 会随前缀匹配长度
164    /// 提前返回,理论上能被逐字节试出来
165    fn matches(&self, user: &str, password: &str) -> bool {
166        let a = user.as_bytes();
167        let b = self.user.as_bytes();
168        let c = password.as_bytes();
169        let d = self.password.as_bytes();
170        let mut diff = (a.len() ^ b.len()) | (c.len() ^ d.len());
171        for i in 0..a.len().max(b.len()) {
172            diff |= usize::from(a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(1));
173        }
174        for i in 0..c.len().max(d.len()) {
175            diff |= usize::from(c.get(i).copied().unwrap_or(0) ^ d.get(i).copied().unwrap_or(1));
176        }
177        diff == 0
178    }
179
180    fn issue(&self) -> String {
181        use rand::Rng;
182        let raw: [u8; 32] = rand::thread_rng().gen();
183        let token: String = raw.iter().map(|b| format!("{b:02x}")).collect();
184        let mut s = self.sessions.lock().unwrap();
185        let now = Instant::now();
186        s.retain(|_, exp| *exp > now); // 顺手清过期的,省得无限涨
187        s.insert(token.clone(), now + SESSION_TTL);
188        token
189    }
190
191    fn valid(&self, token: &str) -> bool {
192        let mut s = self.sessions.lock().unwrap();
193        match s.get(token) {
194            Some(exp) if *exp > Instant::now() => true,
195            Some(_) => {
196                s.remove(token);
197                false
198            }
199            None => false,
200        }
201    }
202
203    fn revoke(&self, token: &str) {
204        self.sessions.lock().unwrap().remove(token);
205    }
206}
207
208/// 取调用方 IP。走反代时真实 IP 在 `X-Forwarded-For` 的第一段;
209/// **不能信任它做安全判断**,这里只用于展示「最近谁在用这个令牌」
210fn client_ip(req: &Request<Body>) -> String {
211    req.headers()
212        .get("x-forwarded-for")
213        .and_then(|v| v.to_str().ok())
214        .and_then(|v| v.split(',').next())
215        .map(|v| v.trim().to_string())
216        .unwrap_or_else(|| "-".into())
217}
218
219fn cookie_of(req: &Request<Body>) -> Option<String> {
220    req.headers()
221        .get(header::COOKIE)?
222        .to_str()
223        .ok()?
224        .split(';')
225        .filter_map(|kv| kv.split_once('='))
226        .find(|(k, _)| k.trim() == COOKIE)
227        .map(|(_, v)| v.trim().to_string())
228}
229
230/// 走反向代理时协议看 `X-Forwarded-Proto`;直连 http 的话不能加 Secure,
231/// 否则浏览器根本不会回传这个 cookie
232fn is_https(req_headers: &axum::http::HeaderMap) -> bool {
233    req_headers
234        .get("x-forwarded-proto")
235        .and_then(|v| v.to_str().ok())
236        .map(|v| v.eq_ignore_ascii_case("https"))
237        .unwrap_or(false)
238}
239
240/// 全局中间件:除 `/health` 和 `/login` 外都要求已登录。
241///
242/// 浏览器来的(Accept 含 text/html)跳转到登录页;
243/// 接口调用返回 401,不做跳转 —— 让 curl / SDK 拿到明确的状态码。
244pub async fn guard(
245    State(auth): State<Arc<Auth>>,
246    req: Request<Body>,
247    next: Next,
248) -> Response {
249    let path = req.uri().path();
250    if path == "/health" || path == "/login" || path == "/logout" {
251        return next.run(req).await;
252    }
253    // 业务端:Authorization: Bearer <token>
254    let presented = req
255        .headers()
256        .get(header::AUTHORIZATION)
257        .and_then(|v| v.to_str().ok())
258        .and_then(Auth::bearer)
259        .map(str::to_string);
260    if let Some(t) = &presented {
261        // 先比 env 里的静态令牌(引导用,不查库)
262        if auth.token_ok(t) {
263            return next.run(req).await;
264        }
265        // 再看管理台发的托管令牌(走 10 秒 TTL 缓存,不是每次查库)
266        let ip = client_ip(&req);
267        if auth.managed_ok(t, &ip).await {
268            return next.run(req).await;
269        }
270    }
271    // 浏览器:会话 cookie
272    if cookie_of(&req).is_some_and(|t| auth.valid(&t)) {
273        return next.run(req).await;
274    }
275    let wants_html = req
276        .headers()
277        .get(header::ACCEPT)
278        .and_then(|v| v.to_str().ok())
279        .is_some_and(|v| v.contains("text/html"));
280    if wants_html {
281        Redirect::to("/login").into_response()
282    } else {
283        (StatusCode::UNAUTHORIZED, "需要登录").into_response()
284    }
285}
286
287#[derive(Deserialize)]
288pub struct LoginForm {
289    user: String,
290    password: String,
291}
292
293pub async fn login_page() -> Html<&'static str> {
294    Html(include_str!("login.html"))
295}
296
297pub async fn login_submit(
298    State(auth): State<Arc<Auth>>,
299    headers: axum::http::HeaderMap,
300    Form(f): Form<LoginForm>,
301) -> Response {
302    if !auth.has_login() || !auth.matches(&f.user, &f.password) {
303        // 不区分「用户名不存在」和「密码错误」—— 那等于告诉对方用户名猜对了
304        return (
305            StatusCode::UNAUTHORIZED,
306            Html(include_str!("login.html").replace(
307                "<!--ERR-->",
308                r#"<p class="err">用户名或密码不对</p>"#,
309            )),
310        )
311            .into_response();
312    }
313    let token = auth.issue();
314    let secure = if is_https(&headers) { "; Secure" } else { "" };
315    let cookie = format!(
316        "{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}{secure}",
317        SESSION_TTL.as_secs()
318    );
319    ([(header::SET_COOKIE, cookie)], Redirect::to("/console")).into_response()
320}
321
322pub async fn logout(State(auth): State<Arc<Auth>>, req: Request<Body>) -> Response {
323    if let Some(t) = cookie_of(&req) {
324        auth.revoke(&t);
325    }
326    (
327        [(
328            header::SET_COOKIE,
329            format!("{COOKIE}=; Path=/; HttpOnly; Max-Age=0"),
330        )],
331        Redirect::to("/login"),
332    )
333        .into_response()
334}
335
336// ==================== 令牌管理接口 ====================
337//
338// ⚠ 这几个接口**只给管理台用**,必须走会话 cookie。
339// 允许用 token 去增删 token 会形成提权链:一个泄漏的业务令牌可以自己
340// 再签发一批,作废原来那个也没用。所以这里额外要求「必须是会话登录」。
341
342#[derive(serde::Serialize)]
343pub struct TokenView {
344    /// 只回哈希的前 12 位,够用来在列表里认人,又不至于把完整哈希摊出去
345    pub id: String,
346    pub name: String,
347    pub create_time: i64,
348    /// 0 = 从没用过
349    pub last_used: i64,
350    pub use_count: i64,
351    pub last_ip: String,
352    pub revoked: i64,
353    /// 明文是否保存着(能不能事后复制)
354    pub revealable: bool,
355}
356
357/// 只有会话 cookie 能过 —— 见本节开头的提权说明
358fn require_session(auth: &Auth, req: &Request<Body>) -> bool {
359    cookie_of(req).is_some_and(|t| auth.valid(&t))
360}
361
362pub async fn tokens_list(
363    State((auth, store)): State<(Arc<Auth>, dtmrs_store::Store)>,
364    req: Request<Body>,
365) -> Response {
366    if !require_session(&auth, &req) {
367        return (StatusCode::FORBIDDEN, "令牌管理只能在管理台里操作").into_response();
368    }
369    match store.list_tokens().await {
370        Ok(list) => axum::Json(
371            list.into_iter()
372                .map(|t| TokenView {
373                    id: t.token_hash.chars().take(12).collect(),
374                    name: t.name,
375                    create_time: t.create_time,
376                    last_used: t.last_used,
377                    use_count: t.use_count,
378                    last_ip: t.last_ip,
379                    revoked: t.revoked,
380                    revealable: !t.secret.is_empty(),
381                })
382                .collect::<Vec<_>>(),
383        )
384        .into_response(),
385        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
386    }
387}
388
389#[derive(Deserialize)]
390pub struct CreateTokenReq {
391    #[serde(default)]
392    pub name: String,
393}
394
395pub async fn tokens_create(
396    State((auth, store)): State<(Arc<Auth>, dtmrs_store::Store)>,
397    req: Request<Body>,
398) -> Response {
399    if !require_session(&auth, &req) {
400        return (StatusCode::FORBIDDEN, "令牌管理只能在管理台里操作").into_response();
401    }
402    let body = axum::body::to_bytes(req.into_body(), 64 * 1024)
403        .await
404        .unwrap_or_default();
405    let name = serde_json::from_slice::<CreateTokenReq>(&body)
406        .map(|r| r.name)
407        .unwrap_or_default();
408    let name = if name.trim().is_empty() {
409        "未命名".to_string()
410    } else {
411        name.trim().to_string()
412    };
413
414    // 24 字节 = 192 位熵,爆破不可行
415    use rand::Rng;
416    let raw: [u8; 24] = rand::thread_rng().gen();
417    let token: String = raw.iter().map(|b| format!("{b:02x}")).collect();
418
419    let sealed = seal_token(&token);
420    match store
421        .create_token(&dtmrs_store::hash_token(&token), &name, &sealed)
422        .await
423    {
424        Ok(()) => {
425            auth.invalidate_cache();
426            // ⚠ 明文**只在这里返回一次**,库里只有哈希。丢了只能重新生成
427            axum::Json(serde_json::json!({ "token": token, "name": name })).into_response()
428        }
429        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
430    }
431}
432
433#[derive(Deserialize)]
434pub struct RevokeReq {
435    pub id: String,
436}
437
438pub async fn tokens_revoke(
439    State((auth, store)): State<(Arc<Auth>, dtmrs_store::Store)>,
440    req: Request<Body>,
441) -> Response {
442    if !require_session(&auth, &req) {
443        return (StatusCode::FORBIDDEN, "令牌管理只能在管理台里操作").into_response();
444    }
445    let body = axum::body::to_bytes(req.into_body(), 64 * 1024)
446        .await
447        .unwrap_or_default();
448    let Ok(r) = serde_json::from_slice::<RevokeReq>(&body) else {
449        return (StatusCode::BAD_REQUEST, "缺少 id").into_response();
450    };
451    // 前端拿到的是哈希前缀,这里要还原成完整哈希
452    let full = match store.list_tokens().await {
453        Ok(list) => list.into_iter().find(|t| t.token_hash.starts_with(&r.id)),
454        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
455    };
456    let Some(t) = full else {
457        return (StatusCode::NOT_FOUND, "没有这个令牌").into_response();
458    };
459    match store.revoke_token(&t.token_hash).await {
460        Ok(done) => {
461            auth.invalidate_cache(); // 立刻生效,不用等 10 秒
462            axum::Json(serde_json::json!({ "revoked": done })).into_response()
463        }
464        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
465    }
466}
467
468// ==================== 令牌明文的加密存储 ====================
469//
470// # 为什么不是「只存哈希」
471//
472// 只存哈希意味着令牌**只能在生成那一刻看一次**,丢了就得重发并更新所有
473// 用它的服务。但「只显示一次」防的其实**不是能进管理台的人** ——
474// 那个人本来就能生成新令牌,看旧的并没有额外授权。
475//
476// 它真正防的只有一件事:**数据库层面的泄漏**(备份、只读副本、导出的 dump、
477// 共用同一个库的 DBA)。而 TC 存储和业务库共用是很常见的部署。
478//
479// 所以这里两个都要:**密文进库、密钥进配置文件**。
480// dump 出去的库单独没用,而管理台随时能复制。
481//
482// # 密钥来源
483//
484// `DTMRS_TOKEN_KEY`,**没配就退化成「只显示一次」**(secret 存空串)——
485// 已有部署不会因为升级而报错,只是没有「事后复制」这个能力。
486//
487// ⚠ 刻意**不从 `DTMRS_ADMIN_PASSWORD` 派生**:那样改管理台密码就会让
488// 所有令牌解不开,是个很难排查的耦合。
489//
490// # 热路径不解密
491//
492// 校验走的还是哈希(见 `managed_ok`),密文只在管理台点「复制」时才解一次。
493
494use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
495
496fn token_key() -> Option<LessSafeKey> {
497    let raw = std::env::var("DTMRS_TOKEN_KEY").ok()?;
498    if raw.is_empty() {
499        return None;
500    }
501    // 任意长度的口令 → 32 字节密钥
502    use sha2::{Digest, Sha256};
503    let mut h = Sha256::new();
504    h.update(raw.as_bytes());
505    let key = h.finalize();
506    UnboundKey::new(&AES_256_GCM, &key).ok().map(LessSafeKey::new)
507}
508
509fn hex(b: &[u8]) -> String {
510    b.iter().map(|x| format!("{x:02x}")).collect()
511}
512
513fn unhex(s: &str) -> Option<Vec<u8>> {
514    (s.len() % 2 == 0)
515        .then(|| {
516            (0..s.len())
517                .step_by(2)
518                .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
519                .collect::<Option<Vec<u8>>>()
520        })
521        .flatten()
522}
523
524/// 加密令牌明文。没配密钥返回空串 —— 调用方据此退化成「只显示一次」
525pub fn seal_token(plain: &str) -> String {
526    let Some(key) = token_key() else {
527        return String::new();
528    };
529    // 每条一个随机 nonce,和密文一起存
530    use rand::Rng;
531    let nonce_bytes: [u8; NONCE_LEN] = rand::thread_rng().gen();
532    let mut buf = plain.as_bytes().to_vec();
533    if key
534        .seal_in_place_append_tag(
535            Nonce::assume_unique_for_key(nonce_bytes),
536            Aad::empty(),
537            &mut buf,
538        )
539        .is_err()
540    {
541        return String::new();
542    }
543    format!("{}{}", hex(&nonce_bytes), hex(&buf))
544}
545
546/// 解开密文。密钥换了、密文损坏、或本来就没存 → `None`
547pub fn open_token(sealed: &str) -> Option<String> {
548    if sealed.is_empty() {
549        return None;
550    }
551    let key = token_key()?;
552    let raw = unhex(sealed)?;
553    if raw.len() <= NONCE_LEN {
554        return None;
555    }
556    let (n, ct) = raw.split_at(NONCE_LEN);
557    let nonce = Nonce::try_assume_unique_for_key(n).ok()?;
558    let mut buf = ct.to_vec();
559    let plain = key.open_in_place(nonce, Aad::empty(), &mut buf).ok()?;
560    String::from_utf8(plain.to_vec()).ok()
561}
562
563/// 管理台点「复制」时用。**只认会话 cookie**,理由同其它令牌管理接口
564pub async fn tokens_reveal(
565    State((auth, store)): State<(Arc<Auth>, dtmrs_store::Store)>,
566    req: Request<Body>,
567) -> Response {
568    if !require_session(&auth, &req) {
569        return (StatusCode::FORBIDDEN, "令牌管理只能在管理台里操作").into_response();
570    }
571    let body = axum::body::to_bytes(req.into_body(), 64 * 1024)
572        .await
573        .unwrap_or_default();
574    let Ok(r) = serde_json::from_slice::<RevokeReq>(&body) else {
575        return (StatusCode::BAD_REQUEST, "缺少 id").into_response();
576    };
577    let found = match store.list_tokens().await {
578        Ok(list) => list.into_iter().find(|t| t.token_hash.starts_with(&r.id)),
579        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
580    };
581    let Some(t) = found else {
582        return (StatusCode::NOT_FOUND, "没有这个令牌").into_response();
583    };
584    match open_token(&t.secret) {
585        Some(plain) => axum::Json(serde_json::json!({ "token": plain })).into_response(),
586        None => (
587            StatusCode::GONE,
588            "这个令牌的明文没有保存(生成时没配 DTMRS_TOKEN_KEY,或者密钥已更换)",
589        )
590            .into_response(),
591    }
592}