212 lines
7.1 KiB
Rust
212 lines
7.1 KiB
Rust
use std::sync::Arc;
|
|
|
|
use argon2::password_hash::rand_core::OsRng;
|
|
use argon2::password_hash::SaltString;
|
|
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
|
use axum::extract::State;
|
|
use axum::http::{Request, StatusCode};
|
|
use axum::middleware::Next;
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::Json;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::state::WebState;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct LoginRequest {
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ChangePasswordRequest {
|
|
pub old_password: String,
|
|
pub new_password: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct AuthCheckResponse {
|
|
pub authenticated: bool,
|
|
pub required: bool,
|
|
pub setup_required: bool,
|
|
}
|
|
|
|
const MAX_ATTEMPTS: u32 = 5;
|
|
const LOCKOUT_SECS: u64 = 60;
|
|
|
|
pub async fn login(State(state): State<Arc<WebState>>, Json(body): Json<LoginRequest>) -> Result<Response, StatusCode> {
|
|
let hash_guard = state.password_hash.read().await;
|
|
let hash_str = match hash_guard.as_deref() {
|
|
Some(h) => h.to_string(),
|
|
None => {
|
|
return Ok((StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response());
|
|
}
|
|
};
|
|
drop(hash_guard);
|
|
|
|
// Check rate limit
|
|
{
|
|
let rl = state.login_rate_limit.lock().await;
|
|
if let Some(locked_until) = rl.locked_until {
|
|
if locked_until > std::time::Instant::now() {
|
|
let remaining = (locked_until - std::time::Instant::now()).as_secs();
|
|
return Ok((
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
Json(serde_json::json!({"error": format!("请 {remaining} 秒后再试")})),
|
|
)
|
|
.into_response());
|
|
}
|
|
}
|
|
}
|
|
|
|
let parsed_hash = PasswordHash::new(&hash_str).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
if Argon2::default().verify_password(body.password.as_bytes(), &parsed_hash).is_err() {
|
|
let mut rl = state.login_rate_limit.lock().await;
|
|
rl.fail_count += 1;
|
|
if rl.fail_count >= MAX_ATTEMPTS {
|
|
rl.locked_until = Some(std::time::Instant::now() + std::time::Duration::from_secs(LOCKOUT_SECS));
|
|
rl.fail_count = 0;
|
|
}
|
|
return Err(StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
// Success — reset rate limit
|
|
{
|
|
let mut rl = state.login_rate_limit.lock().await;
|
|
rl.fail_count = 0;
|
|
rl.locked_until = None;
|
|
}
|
|
|
|
let token = uuid::Uuid::new_v4().to_string();
|
|
state.sessions.write().await.insert(token.clone());
|
|
|
|
let cookie = format!("dbx_session={token}; Path=/; HttpOnly; SameSite=Lax");
|
|
Ok((StatusCode::OK, [("set-cookie", cookie.as_str())], Json(serde_json::json!({"ok": true}))).into_response())
|
|
}
|
|
|
|
pub async fn setup(State(state): State<Arc<WebState>>, Json(body): Json<LoginRequest>) -> Result<Response, StatusCode> {
|
|
// Only allow setup when no password is configured
|
|
if state.password_hash.read().await.is_some() {
|
|
return Err(StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
if body.password.is_empty() {
|
|
return Err(StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
let salt = SaltString::generate(&mut OsRng);
|
|
let hash = Argon2::default()
|
|
.hash_password(body.password.as_bytes(), &salt)
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
|
.to_string();
|
|
|
|
// Save to database
|
|
state.app.storage.save_password_hash(&hash).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
|
|
// Update in-memory state
|
|
*state.password_hash.write().await = Some(hash);
|
|
|
|
// Auto-login: create session
|
|
let token = uuid::Uuid::new_v4().to_string();
|
|
state.sessions.write().await.insert(token.clone());
|
|
|
|
let cookie = format!("dbx_session={token}; Path=/; HttpOnly; SameSite=Lax");
|
|
Ok((StatusCode::OK, [("set-cookie", cookie.as_str())], Json(serde_json::json!({"ok": true}))).into_response())
|
|
}
|
|
|
|
pub async fn check(State(state): State<Arc<WebState>>, req: Request<axum::body::Body>) -> Json<AuthCheckResponse> {
|
|
let has_password = state.password_hash.read().await.is_some();
|
|
if !has_password {
|
|
return Json(AuthCheckResponse { authenticated: false, required: false, setup_required: true });
|
|
}
|
|
let authenticated = match extract_session_token(&req) {
|
|
Some(token) => state.sessions.read().await.contains(&token),
|
|
None => false,
|
|
};
|
|
Json(AuthCheckResponse { authenticated, required: true, setup_required: false })
|
|
}
|
|
|
|
pub async fn change_password(
|
|
State(state): State<Arc<WebState>>,
|
|
Json(body): Json<ChangePasswordRequest>,
|
|
) -> Result<Response, StatusCode> {
|
|
let hash_guard = state.password_hash.read().await;
|
|
let hash_str = match hash_guard.as_deref() {
|
|
Some(h) => h.to_string(),
|
|
None => return Err(StatusCode::BAD_REQUEST),
|
|
};
|
|
drop(hash_guard);
|
|
|
|
if body.new_password.is_empty() {
|
|
return Err(StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
let parsed_hash = PasswordHash::new(&hash_str).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
if Argon2::default().verify_password(body.old_password.as_bytes(), &parsed_hash).is_err() {
|
|
return Err(StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
let salt = SaltString::generate(&mut OsRng);
|
|
let new_hash = Argon2::default()
|
|
.hash_password(body.new_password.as_bytes(), &salt)
|
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
|
.to_string();
|
|
|
|
state.app.storage.save_password_hash(&new_hash).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
*state.password_hash.write().await = Some(new_hash);
|
|
|
|
Ok((StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response())
|
|
}
|
|
|
|
pub async fn logout(State(state): State<Arc<WebState>>, req: Request<axum::body::Body>) -> Response {
|
|
if let Some(token) = extract_session_token(&req) {
|
|
state.sessions.write().await.remove(&token);
|
|
}
|
|
let cookie = "dbx_session=; Path=/; HttpOnly; Max-Age=0";
|
|
(StatusCode::OK, [("set-cookie", cookie)], Json(serde_json::json!({"ok": true}))).into_response()
|
|
}
|
|
|
|
fn extract_session_token<B>(req: &Request<B>) -> Option<String> {
|
|
let cookie_header = req.headers().get("cookie")?.to_str().ok()?;
|
|
for pair in cookie_header.split(';') {
|
|
let pair = pair.trim();
|
|
if let Some(value) = pair.strip_prefix("dbx_session=") {
|
|
if !value.is_empty() {
|
|
return Some(value.to_string());
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub async fn auth_middleware(
|
|
State(state): State<Arc<WebState>>,
|
|
req: Request<axum::body::Body>,
|
|
next: Next,
|
|
) -> Response {
|
|
// No password set — allow everything
|
|
if state.password_hash.read().await.is_none() {
|
|
return next.run(req).await;
|
|
}
|
|
|
|
// Auth endpoints are always accessible
|
|
let path = req.uri().path();
|
|
if path.starts_with("/api/auth/") {
|
|
return next.run(req).await;
|
|
}
|
|
|
|
// Non-API requests (static files) are always accessible
|
|
if !path.starts_with("/api/") {
|
|
return next.run(req).await;
|
|
}
|
|
|
|
// Check session token
|
|
if let Some(token) = extract_session_token(&req) {
|
|
if state.sessions.read().await.contains(&token) {
|
|
return next.run(req).await;
|
|
}
|
|
}
|
|
|
|
StatusCode::UNAUTHORIZED.into_response()
|
|
}
|