1use 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
35const SESSION_TTL: Duration = Duration::from_secs(12 * 3600);
37const COOKIE: &str = "dtmrs_session";
38
39const TOKEN_CACHE_TTL: Duration = Duration::from_secs(10);
44
45pub struct Auth {
46 user: String,
48 password: String,
49 token: String,
52 sessions: Mutex<HashMap<String, Instant>>,
54 managed: Mutex<(std::collections::HashSet<String>, Instant)>,
57 store: Option<dtmrs_store::Store>,
59}
60
61impl Auth {
62 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 managed: Mutex::new((
81 std::collections::HashSet::new(),
82 Instant::now() - TOKEN_CACHE_TTL * 2,
83 )),
84 store: None,
85 }))
86 }
87
88 pub fn with_store(mut self: Arc<Self>, store: dtmrs_store::Store) -> Arc<Self> {
91 if let Some(me) = Arc::get_mut(&mut self) {
93 me.store = Some(store);
94 }
95 self
96 }
97
98 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 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 pub fn has_login(&self) -> bool {
139 !self.password.is_empty()
140 }
141
142 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 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 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); 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
208fn 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
230fn 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
240pub 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 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 if auth.token_ok(t) {
263 return next.run(req).await;
264 }
265 let ip = client_ip(&req);
267 if auth.managed_ok(t, &ip).await {
268 return next.run(req).await;
269 }
270 }
271 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 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#[derive(serde::Serialize)]
343pub struct TokenView {
344 pub id: String,
346 pub name: String,
347 pub create_time: i64,
348 pub last_used: i64,
350 pub use_count: i64,
351 pub last_ip: String,
352 pub revoked: i64,
353 pub revealable: bool,
355}
356
357fn 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 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 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 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(); 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
468use 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 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
524pub fn seal_token(plain: &str) -> String {
526 let Some(key) = token_key() else {
527 return String::new();
528 };
529 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
546pub 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
563pub 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}