mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-24 01:01:37 +00:00
feat(web): add password setup page, change password, and settings tabs
- First-time visitors see a password setup page instead of open access - Password persisted to SQLite (env var DBX_PASSWORD takes priority) - Add change password in settings dialog (Security tab, web only) - Split settings dialog into Editor and Security tabs - Argon2 hashing for all password storage
This commit is contained in:
+82
-8
@@ -1,6 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
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;
|
||||
@@ -15,22 +17,31 @@ 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 password_hash = match &state.password_hash {
|
||||
Some(h) => h,
|
||||
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
|
||||
{
|
||||
@@ -47,7 +58,7 @@ pub async fn login(State(state): State<Arc<WebState>>, Json(body): Json<LoginReq
|
||||
}
|
||||
}
|
||||
|
||||
let parsed_hash = PasswordHash::new(password_hash).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
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;
|
||||
@@ -73,15 +84,78 @@ pub async fn login(State(state): State<Arc<WebState>>, Json(body): Json<LoginReq
|
||||
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> {
|
||||
if state.password_hash.is_none() {
|
||||
return Json(AuthCheckResponse { authenticated: true, required: false });
|
||||
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 })
|
||||
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 {
|
||||
@@ -111,7 +185,7 @@ pub async fn auth_middleware(
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// No password set — allow everything
|
||||
if state.password_hash.is_none() {
|
||||
if state.password_hash.read().await.is_none() {
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
|
||||
+9
-5
@@ -46,16 +46,18 @@ async fn main() {
|
||||
Arc::new(AppState::new(storage))
|
||||
};
|
||||
|
||||
// Password hash
|
||||
let password_hash = std::env::var("DBX_PASSWORD").ok().map(|pw| {
|
||||
// Password hash: env var takes priority, then database
|
||||
let password_hash = if let Some(pw) = std::env::var("DBX_PASSWORD").ok() {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default().hash_password(pw.as_bytes(), &salt).expect("Failed to hash password").to_string()
|
||||
});
|
||||
Some(Argon2::default().hash_password(pw.as_bytes(), &salt).expect("Failed to hash password").to_string())
|
||||
} else {
|
||||
app_state.storage.load_password_hash().await.unwrap_or(None)
|
||||
};
|
||||
|
||||
let web_state = Arc::new(WebState {
|
||||
app: app_state,
|
||||
data_dir,
|
||||
password_hash,
|
||||
password_hash: RwLock::new(password_hash),
|
||||
sessions: RwLock::new(HashSet::new()),
|
||||
sse_channels: RwLock::new(HashMap::new()),
|
||||
login_rate_limit: tokio::sync::Mutex::new(state::LoginRateLimit { fail_count: 0, locked_until: None }),
|
||||
@@ -69,6 +71,8 @@ async fn main() {
|
||||
// Auth
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/check", get(auth::check))
|
||||
.route("/auth/setup", post(auth::setup))
|
||||
.route("/auth/change-password", post(auth::change_password))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
// Connection
|
||||
.route("/connection/test", post(routes::connection::test_connection))
|
||||
|
||||
@@ -12,7 +12,7 @@ pub struct LoginRateLimit {
|
||||
pub struct WebState {
|
||||
pub app: Arc<AppState>,
|
||||
pub data_dir: PathBuf,
|
||||
pub password_hash: Option<String>,
|
||||
pub password_hash: RwLock<Option<String>>,
|
||||
pub sessions: RwLock<HashSet<String>>,
|
||||
pub sse_channels: RwLock<HashMap<String, broadcast::Sender<String>>>,
|
||||
pub login_rate_limit: Mutex<LoginRateLimit>,
|
||||
|
||||
Reference in New Issue
Block a user