use axum::body::Body; use axum::extract::State; use axum::http::{HeaderMap, HeaderValue, Request, StatusCode}; use axum::middleware::Next; use axum::response::Response; use std::sync::Arc; use crate::auth::{self, COOKIE_NAME}; use crate::config; use crate::state::AppState; fn extract_cookie<'a>(headers: &'a axum::http::HeaderMap, name: &str) -> Option<&'a str> { headers .get(axum::http::header::COOKIE) .and_then(|hv| hv.to_str().ok()) .and_then(|cookies| { for part in cookies.split(';') { let kv = part.trim(); if let Some((k, v)) = kv.split_once('=') { if k == name { return Some(v); } } } None }) } fn redirect_or_401(path: &str, accept_html: bool) -> Response { if accept_html && !path.starts_with("/api/") { // Redirect browsers to /login let mut resp = Response::new(Body::empty()); *resp.status_mut() = StatusCode::SEE_OTHER; resp.headers_mut() .insert(axum::http::header::LOCATION, "/login".parse().unwrap()); resp } else { let mut resp = Response::new(Body::from( serde_json::json!({ "status": "error", "code": "unauthorized", "message": "需要登录" }) .to_string(), )); *resp.status_mut() = StatusCode::UNAUTHORIZED; resp.headers_mut().insert( axum::http::header::CONTENT_TYPE, "application/json; charset=utf-8".parse().unwrap(), ); resp } } fn wants_html(headers: &axum::http::HeaderMap) -> bool { headers .get(axum::http::header::ACCEPT) .and_then(|v| v.to_str().ok()) .map(|v| v.contains("text/html")) .unwrap_or(false) } fn is_https(headers: &HeaderMap) -> bool { headers .get("x-forwarded-proto") .and_then(|v| v.to_str().ok()) .map_or(false, |v| v == "https") } fn load_settings_snapshot( state: &Arc, ) -> (Option, Option) { let settings = config::get_app_settings(&state.settings, &state.db_pool); let active_pwd = config::get_active_password(&state.settings, &state.db_pool); let session_token = settings .get("SESSION_TOKEN") .and_then(|v| v.clone()); (active_pwd, session_token) } fn check_session( session_cookie: Option<&str>, active_pwd: Option<&str>, session_token: Option<&str>, ) -> bool { // A password must be configured, a server-side session token must exist, // and the cookie must match the token in constant time. // // We no longer fall back to comparing the cookie against `sha256(pwd)` or // against the raw password: the cookie is an opaque random token stored in // `app_settings.session_token` and may not be re-derivable from the password. let (_pwd, token, cookie) = match (active_pwd, session_token, session_cookie) { (Some(p), Some(t), Some(c)) if !p.is_empty() && !t.is_empty() => (p, t, c), _ => return false, }; auth::secure_compare(cookie, token) } pub async fn auth_middleware( State(state): State>, req: Request, next: Next, ) -> Response { let path = req.uri().path().to_string(); // Always-allowed static paths let public_static_prefixes = [ "/static/", "/assets/", "/favicon", "/robots.txt", ]; if public_static_prefixes.iter().any(|p| path.starts_with(p)) { return next.run(req).await; } // Always-allowed public-content prefixes. These are the visitor-facing // routes: short download links, legacy download links, and share pages. // Shared files are meant to be reachable by guests without an account, // so they must be allowed regardless of whether a password is configured. let public_content_prefixes = [ "/d/", "/share/", ]; if public_content_prefixes.iter().any(|p| path.starts_with(p)) { return next.run(req).await; } // Always-allowed API paths (regardless of password state) let always_public = ["/api/health"]; if always_public.iter().any(|p| &path == p || path.starts_with(&format!("{}/", p))) { return next.run(req).await; } let (active_pwd, session_token) = load_settings_snapshot(&state); // No password configured: only the first-run onboarding surface should be // publicly reachable. Other endpoints are behind the session cookie check // further below, which will pass trivially when no password is set. if active_pwd.as_deref().unwrap_or("").is_empty() { let public_no_auth = [ "/", "/login", "/api/auth/login", "/api/auth/logout", "/api/verify/", "/api/app-config", "/api/app-config/save", "/api/app-config/apply", "/api/set-password", ]; if public_no_auth .iter() .any(|p| &path == p || path.starts_with(&format!("{}/", p)) || path.starts_with(p)) { return next.run(req).await; } // First-run mode: password has not been set yet. Only the onboarding // surface above is reachable. Deny everything else so an attacker // cannot upload/delete/list before the owner finishes setup. let headers = req.headers().clone(); return redirect_or_401(&path, wants_html(&headers)); } // Password configured: a narrow set of API routes is always public so that // the login form and logout endpoint remain usable. `/api/verify/*` is no // longer public once a password is set — it leaks bot/channel validity. let public_api = ["/api/auth/login", "/api/auth/logout"]; if public_api.iter().any(|p| &path == p) { return next.run(req).await; } // Login page itself must be reachable without auth so users can log in. if &path == "/login" { return next.run(req).await; } let headers = req.headers().clone(); let cookie = extract_cookie(&headers, COOKIE_NAME); if check_session(cookie, active_pwd.as_deref(), session_token.as_deref()) { // Sliding expiration: re-issue the cookie with a fresh Max-Age on every // authenticated request, so active users stay logged in indefinitely. // We only refresh on non-API HTML page loads and safe (GET/HEAD) API // calls to avoid mutating Set-Cookie on every XHR response, which // would be wasteful; GETs are frequent enough in normal use to keep // the cookie fresh. let secure = is_https(&headers); let token = session_token.as_deref().unwrap_or("").to_string(); let mut resp = next.run(req).await; if !token.is_empty() { if let Ok(cookie_val) = HeaderValue::from_str(&auth::build_cookie(&token, secure)) { resp.headers_mut() .append(axum::http::header::SET_COOKIE, cookie_val); } } return resp; } redirect_or_401(&path, wants_html(&headers)) }