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
39pub struct Auth {
40 user: String,
41 password: String,
42 sessions: Mutex<HashMap<String, Instant>>,
44}
45
46impl Auth {
47 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 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); 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
116fn 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
126pub 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 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}