use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use axum::body::Body;
use axum::extract::State;
use axum::http::{header, Request, StatusCode};
use axum::middleware::Next;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::Form;
use serde::Deserialize;
const SESSION_TTL: Duration = Duration::from_secs(12 * 3600);
const COOKIE: &str = "dtmrs_session";
const TOKEN_CACHE_TTL: Duration = Duration::from_secs(10);
pub struct Auth {
user: String,
password: String,
token: String,
sessions: Mutex<HashMap<String, Instant>>,
managed: Mutex<(std::collections::HashSet<String>, Instant)>,
store: Option<dtmrs_store::Store>,
}
impl Auth {
pub fn from_env() -> Option<Arc<Self>> {
let password = std::env::var("DTMRS_ADMIN_PASSWORD").unwrap_or_default();
let token = std::env::var("DTMRS_AUTH_TOKEN").unwrap_or_default();
if password.is_empty() && token.is_empty() {
return None;
}
Some(Arc::new(Self {
user: std::env::var("DTMRS_ADMIN_USER").unwrap_or_else(|_| "admin".into()),
password,
token,
sessions: Mutex::new(HashMap::new()),
managed: Mutex::new((
std::collections::HashSet::new(),
Instant::now() - TOKEN_CACHE_TTL * 2,
)),
store: None,
}))
}
pub fn with_store(mut self: Arc<Self>, store: dtmrs_store::Store) -> Arc<Self> {
if let Some(me) = Arc::get_mut(&mut self) {
me.store = Some(store);
}
self
}
pub async fn managed_ok(&self, presented: &str, ip: &str) -> bool {
let Some(store) = self.store.clone() else {
return false;
};
let hash = dtmrs_store::hash_token(presented);
let need_refresh = {
let g = self.managed.lock().unwrap();
g.1.elapsed() >= TOKEN_CACHE_TTL
};
if need_refresh {
if let Ok(list) = store.active_token_hashes().await {
let mut g = self.managed.lock().unwrap();
*g = (list.into_iter().collect(), Instant::now());
}
}
let hit = {
let g = self.managed.lock().unwrap();
g.0.contains(&hash)
};
if hit {
let (s, h, ip) = (store, hash.clone(), ip.to_string());
tokio::spawn(async move {
let _ = s.touch_token(&h, &ip).await;
});
}
hit
}
pub fn invalidate_cache(&self) {
let mut g = self.managed.lock().unwrap();
g.1 = Instant::now() - TOKEN_CACHE_TTL * 2;
}
pub fn has_login(&self) -> bool {
!self.password.is_empty()
}
pub fn token_ok(&self, presented: &str) -> bool {
if self.token.is_empty() {
return false;
}
let (a, b) = (presented.as_bytes(), self.token.as_bytes());
let mut diff = a.len() ^ b.len();
for i in 0..a.len().max(b.len()) {
diff |= usize::from(a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(1));
}
diff == 0
}
pub fn bearer(v: &str) -> Option<&str> {
let v = v.trim();
v.strip_prefix("Bearer ")
.or_else(|| v.strip_prefix("bearer "))
.map(str::trim)
}
fn matches(&self, user: &str, password: &str) -> bool {
let a = user.as_bytes();
let b = self.user.as_bytes();
let c = password.as_bytes();
let d = self.password.as_bytes();
let mut diff = (a.len() ^ b.len()) | (c.len() ^ d.len());
for i in 0..a.len().max(b.len()) {
diff |= usize::from(a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(1));
}
for i in 0..c.len().max(d.len()) {
diff |= usize::from(c.get(i).copied().unwrap_or(0) ^ d.get(i).copied().unwrap_or(1));
}
diff == 0
}
fn issue(&self) -> String {
use rand::Rng;
let raw: [u8; 32] = rand::thread_rng().gen();
let token: String = raw.iter().map(|b| format!("{b:02x}")).collect();
let mut s = self.sessions.lock().unwrap();
let now = Instant::now();
s.retain(|_, exp| *exp > now); s.insert(token.clone(), now + SESSION_TTL);
token
}
fn valid(&self, token: &str) -> bool {
let mut s = self.sessions.lock().unwrap();
match s.get(token) {
Some(exp) if *exp > Instant::now() => true,
Some(_) => {
s.remove(token);
false
}
None => false,
}
}
fn revoke(&self, token: &str) {
self.sessions.lock().unwrap().remove(token);
}
}
fn client_ip(req: &Request<Body>) -> String {
req.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(|v| v.trim().to_string())
.unwrap_or_else(|| "-".into())
}
fn cookie_of(req: &Request<Body>) -> Option<String> {
req.headers()
.get(header::COOKIE)?
.to_str()
.ok()?
.split(';')
.filter_map(|kv| kv.split_once('='))
.find(|(k, _)| k.trim() == COOKIE)
.map(|(_, v)| v.trim().to_string())
}
fn is_https(req_headers: &axum::http::HeaderMap) -> bool {
req_headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|v| v.eq_ignore_ascii_case("https"))
.unwrap_or(false)
}
pub async fn guard(
State(auth): State<Arc<Auth>>,
req: Request<Body>,
next: Next,
) -> Response {
let path = req.uri().path();
if path == "/health" || path == "/login" || path == "/logout" {
return next.run(req).await;
}
let presented = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(Auth::bearer)
.map(str::to_string);
if let Some(t) = &presented {
if auth.token_ok(t) {
return next.run(req).await;
}
let ip = client_ip(&req);
if auth.managed_ok(t, &ip).await {
return next.run(req).await;
}
}
if cookie_of(&req).is_some_and(|t| auth.valid(&t)) {
return next.run(req).await;
}
let wants_html = req
.headers()
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("text/html"));
if wants_html {
Redirect::to("/login").into_response()
} else {
(StatusCode::UNAUTHORIZED, "需要登录").into_response()
}
}
#[derive(Deserialize)]
pub struct LoginForm {
user: String,
password: String,
}
pub async fn login_page() -> Html<&'static str> {
Html(include_str!("login.html"))
}
pub async fn login_submit(
State(auth): State<Arc<Auth>>,
headers: axum::http::HeaderMap,
Form(f): Form<LoginForm>,
) -> Response {
if !auth.has_login() || !auth.matches(&f.user, &f.password) {
return (
StatusCode::UNAUTHORIZED,
Html(include_str!("login.html").replace(
"<!--ERR-->",
r#"<p class="err">用户名或密码不对</p>"#,
)),
)
.into_response();
}
let token = auth.issue();
let secure = if is_https(&headers) { "; Secure" } else { "" };
let cookie = format!(
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}{secure}",
SESSION_TTL.as_secs()
);
([(header::SET_COOKIE, cookie)], Redirect::to("/console")).into_response()
}
pub async fn logout(State(auth): State<Arc<Auth>>, req: Request<Body>) -> Response {
if let Some(t) = cookie_of(&req) {
auth.revoke(&t);
}
(
[(
header::SET_COOKIE,
format!("{COOKIE}=; Path=/; HttpOnly; Max-Age=0"),
)],
Redirect::to("/login"),
)
.into_response()
}
#[derive(serde::Serialize)]
pub struct TokenView {
pub id: String,
pub name: String,
pub create_time: i64,
pub last_used: i64,
pub use_count: i64,
pub last_ip: String,
pub revoked: i64,
pub revealable: bool,
}
fn require_session(auth: &Auth, req: &Request<Body>) -> bool {
cookie_of(req).is_some_and(|t| auth.valid(&t))
}
pub async fn tokens_list(
State((auth, store)): State<(Arc<Auth>, dtmrs_store::Store)>,
req: Request<Body>,
) -> Response {
if !require_session(&auth, &req) {
return (StatusCode::FORBIDDEN, "令牌管理只能在管理台里操作").into_response();
}
match store.list_tokens().await {
Ok(list) => axum::Json(
list.into_iter()
.map(|t| TokenView {
id: t.token_hash.chars().take(12).collect(),
name: t.name,
create_time: t.create_time,
last_used: t.last_used,
use_count: t.use_count,
last_ip: t.last_ip,
revoked: t.revoked,
revealable: !t.secret.is_empty(),
})
.collect::<Vec<_>>(),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
#[derive(Deserialize)]
pub struct CreateTokenReq {
#[serde(default)]
pub name: String,
}
pub async fn tokens_create(
State((auth, store)): State<(Arc<Auth>, dtmrs_store::Store)>,
req: Request<Body>,
) -> Response {
if !require_session(&auth, &req) {
return (StatusCode::FORBIDDEN, "令牌管理只能在管理台里操作").into_response();
}
let body = axum::body::to_bytes(req.into_body(), 64 * 1024)
.await
.unwrap_or_default();
let name = serde_json::from_slice::<CreateTokenReq>(&body)
.map(|r| r.name)
.unwrap_or_default();
let name = if name.trim().is_empty() {
"未命名".to_string()
} else {
name.trim().to_string()
};
use rand::Rng;
let raw: [u8; 24] = rand::thread_rng().gen();
let token: String = raw.iter().map(|b| format!("{b:02x}")).collect();
let sealed = seal_token(&token);
match store
.create_token(&dtmrs_store::hash_token(&token), &name, &sealed)
.await
{
Ok(()) => {
auth.invalidate_cache();
axum::Json(serde_json::json!({ "token": token, "name": name })).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
#[derive(Deserialize)]
pub struct RevokeReq {
pub id: String,
}
pub async fn tokens_revoke(
State((auth, store)): State<(Arc<Auth>, dtmrs_store::Store)>,
req: Request<Body>,
) -> Response {
if !require_session(&auth, &req) {
return (StatusCode::FORBIDDEN, "令牌管理只能在管理台里操作").into_response();
}
let body = axum::body::to_bytes(req.into_body(), 64 * 1024)
.await
.unwrap_or_default();
let Ok(r) = serde_json::from_slice::<RevokeReq>(&body) else {
return (StatusCode::BAD_REQUEST, "缺少 id").into_response();
};
let full = match store.list_tokens().await {
Ok(list) => list.into_iter().find(|t| t.token_hash.starts_with(&r.id)),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let Some(t) = full else {
return (StatusCode::NOT_FOUND, "没有这个令牌").into_response();
};
match store.revoke_token(&t.token_hash).await {
Ok(done) => {
auth.invalidate_cache(); axum::Json(serde_json::json!({ "revoked": done })).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
fn token_key() -> Option<LessSafeKey> {
let raw = std::env::var("DTMRS_TOKEN_KEY").ok()?;
if raw.is_empty() {
return None;
}
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(raw.as_bytes());
let key = h.finalize();
UnboundKey::new(&AES_256_GCM, &key).ok().map(LessSafeKey::new)
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn unhex(s: &str) -> Option<Vec<u8>> {
(s.len() % 2 == 0)
.then(|| {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect::<Option<Vec<u8>>>()
})
.flatten()
}
pub fn seal_token(plain: &str) -> String {
let Some(key) = token_key() else {
return String::new();
};
use rand::Rng;
let nonce_bytes: [u8; NONCE_LEN] = rand::thread_rng().gen();
let mut buf = plain.as_bytes().to_vec();
if key
.seal_in_place_append_tag(
Nonce::assume_unique_for_key(nonce_bytes),
Aad::empty(),
&mut buf,
)
.is_err()
{
return String::new();
}
format!("{}{}", hex(&nonce_bytes), hex(&buf))
}
pub fn open_token(sealed: &str) -> Option<String> {
if sealed.is_empty() {
return None;
}
let key = token_key()?;
let raw = unhex(sealed)?;
if raw.len() <= NONCE_LEN {
return None;
}
let (n, ct) = raw.split_at(NONCE_LEN);
let nonce = Nonce::try_assume_unique_for_key(n).ok()?;
let mut buf = ct.to_vec();
let plain = key.open_in_place(nonce, Aad::empty(), &mut buf).ok()?;
String::from_utf8(plain.to_vec()).ok()
}
pub async fn tokens_reveal(
State((auth, store)): State<(Arc<Auth>, dtmrs_store::Store)>,
req: Request<Body>,
) -> Response {
if !require_session(&auth, &req) {
return (StatusCode::FORBIDDEN, "令牌管理只能在管理台里操作").into_response();
}
let body = axum::body::to_bytes(req.into_body(), 64 * 1024)
.await
.unwrap_or_default();
let Ok(r) = serde_json::from_slice::<RevokeReq>(&body) else {
return (StatusCode::BAD_REQUEST, "缺少 id").into_response();
};
let found = match store.list_tokens().await {
Ok(list) => list.into_iter().find(|t| t.token_hash.starts_with(&r.id)),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let Some(t) = found else {
return (StatusCode::NOT_FOUND, "没有这个令牌").into_response();
};
match open_token(&t.secret) {
Some(plain) => axum::Json(serde_json::json!({ "token": plain })).into_response(),
None => (
StatusCode::GONE,
"这个令牌的明文没有保存(生成时没配 DTMRS_TOKEN_KEY,或者密钥已更换)",
)
.into_response(),
}
}