pinakes: import in parallel; various UI improvements

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I1eb47cd79cd4145c56af966f6756fe1d6a6a6964
This commit is contained in:
raf 2026-02-03 10:31:20 +03:00
commit 116fe7b059
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
42 changed files with 4316 additions and 316 deletions

View file

@ -5,6 +5,12 @@ use axum::http::{HeaderMap, StatusCode};
use crate::dto::{LoginRequest, LoginResponse, UserInfoResponse};
use crate::state::AppState;
/// Dummy password hash to use for timing-safe comparison when user doesn't exist.
/// This is a valid argon2 hash that will always fail verification but takes
/// similar time to verify as a real hash, preventing timing attacks that could
/// reveal whether a username exists.
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk";
pub async fn login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
@ -25,27 +31,47 @@ pub async fn login(
.iter()
.find(|u| u.username == req.username);
let user = match user {
Some(u) => u,
None => {
tracing::warn!(username = %req.username, "login failed: unknown user");
return Err(StatusCode::UNAUTHORIZED);
}
// Always perform password verification to prevent timing attacks.
// If the user doesn't exist, we verify against a dummy hash to ensure
// consistent response times regardless of whether the username exists.
use argon2::password_hash::PasswordVerifier;
let (hash_to_verify, user_found) = match user {
Some(u) => (&u.password_hash as &str, true),
None => (DUMMY_HASH, false),
};
// Verify password using argon2
use argon2::password_hash::PasswordVerifier;
let hash = &user.password_hash;
let parsed_hash = argon2::password_hash::PasswordHash::new(hash)
let parsed_hash = argon2::password_hash::PasswordHash::new(hash_to_verify)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let valid = argon2::Argon2::default()
let password_valid = argon2::Argon2::default()
.verify_password(req.password.as_bytes(), &parsed_hash)
.is_ok();
if !valid {
tracing::warn!(username = %req.username, "login failed: invalid password");
// Authentication fails if user wasn't found OR password was invalid
if !user_found || !password_valid {
// Log different messages for debugging but return same error
if !user_found {
tracing::warn!(username = %req.username, "login failed: unknown user");
} else {
tracing::warn!(username = %req.username, "login failed: invalid password");
}
// Record failed login attempt in audit log
let _ = pinakes_core::audit::record_action(
&state.storage,
None,
pinakes_core::model::AuditAction::LoginFailed,
Some(format!("username: {}", req.username)),
)
.await;
return Err(StatusCode::UNAUTHORIZED);
}
// At this point we know the user exists and password is valid
let user = user.expect("user should exist at this point");
// Generate session token
use rand::Rng;
let token: String = rand::rng()
@ -72,6 +98,15 @@ pub async fn login(
tracing::info!(username = %username, role = %role, "login successful");
// Record successful login in audit log
let _ = pinakes_core::audit::record_action(
&state.storage,
None,
pinakes_core::model::AuditAction::LoginSuccess,
Some(format!("username: {}, role: {}", username, role)),
)
.await;
Ok(Json(LoginResponse {
token,
username,
@ -81,8 +116,24 @@ pub async fn login(
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> StatusCode {
if let Some(token) = extract_bearer_token(&headers) {
let sessions = state.sessions.read().await;
let username = sessions.get(token).map(|s| s.username.clone());
drop(sessions);
let mut sessions = state.sessions.write().await;
sessions.remove(token);
drop(sessions);
// Record logout in audit log
if let Some(user) = username {
let _ = pinakes_core::audit::record_action(
&state.storage,
None,
pinakes_core::model::AuditAction::Logout,
Some(format!("username: {}", user)),
)
.await;
}
}
StatusCode::OK
}