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:
t8y2
2026-05-05 22:22:04 +08:00
parent 8cc4dfb268
commit 0f40488bf1
10 changed files with 460 additions and 140 deletions
+82 -8
View File
@@ -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
View File
@@ -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))
+1 -1
View File
@@ -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>,