treewide: fix various UI bugs; optimize crypto dependencies & format
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
This commit is contained in:
parent
764aafa88d
commit
3ccddce7fd
178 changed files with 58285 additions and 54241 deletions
|
|
@ -1,271 +1,286 @@
|
|||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
};
|
||||
|
||||
use crate::dto::{LoginRequest, LoginResponse, UserInfoResponse};
|
||||
use crate::state::AppState;
|
||||
use crate::{
|
||||
dto::{LoginRequest, LoginResponse, UserInfoResponse},
|
||||
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";
|
||||
/// 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>,
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, StatusCode> {
|
||||
// Limit input sizes to prevent DoS
|
||||
if req.username.len() > 255 || req.password.len() > 1024 {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
// Limit input sizes to prevent DoS
|
||||
if req.username.len() > 255 || req.password.len() > 1024 {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
let config = state.config.read().await;
|
||||
if !config.accounts.enabled {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let user = config
|
||||
.accounts
|
||||
.users
|
||||
.iter()
|
||||
.find(|u| u.username == req.username);
|
||||
|
||||
// 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),
|
||||
};
|
||||
|
||||
let parsed_hash = argon2::password_hash::PasswordHash::new(hash_to_verify)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let password_valid = argon2::Argon2::default()
|
||||
.verify_password(req.password.as_bytes(), &parsed_hash)
|
||||
.is_ok();
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
let config = state.config.read().await;
|
||||
if !config.accounts.enabled {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let user = config
|
||||
.accounts
|
||||
.users
|
||||
.iter()
|
||||
.find(|u| u.username == req.username);
|
||||
|
||||
// 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),
|
||||
};
|
||||
|
||||
let parsed_hash = argon2::password_hash::PasswordHash::new(hash_to_verify)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let password_valid = argon2::Argon2::default()
|
||||
.verify_password(req.password.as_bytes(), &parsed_hash)
|
||||
.is_ok();
|
||||
|
||||
// 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
|
||||
let token: String = (0..48)
|
||||
.map(|_| {
|
||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
|
||||
abcdefghijklmnopqrstuvwxyz\
|
||||
0123456789";
|
||||
let idx = (rand::random::<u32>() as usize) % CHARSET.len();
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
let role = user.role;
|
||||
let username = user.username.clone();
|
||||
|
||||
// Create session in database
|
||||
let now = chrono::Utc::now();
|
||||
let session_data = pinakes_core::storage::SessionData {
|
||||
session_token: token.clone(),
|
||||
user_id: None, // Could be set if we had user IDs
|
||||
username: username.clone(),
|
||||
role: role.to_string(),
|
||||
created_at: now,
|
||||
expires_at: now + chrono::Duration::hours(24), // 24 hour sessions
|
||||
last_accessed: now,
|
||||
};
|
||||
|
||||
if let Err(e) = state.storage.create_session(&session_data).await {
|
||||
tracing::error!(error = %e, "failed to create session in database");
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
tracing::info!(username = %username, role = %role, "login successful");
|
||||
|
||||
// Record successful login in audit log
|
||||
// Record failed login attempt in audit log
|
||||
let _ = pinakes_core::audit::record_action(
|
||||
&state.storage,
|
||||
None,
|
||||
pinakes_core::model::AuditAction::LoginSuccess,
|
||||
Some(format!("username: {}, role: {}", username, role)),
|
||||
&state.storage,
|
||||
None,
|
||||
pinakes_core::model::AuditAction::LoginFailed,
|
||||
Some(format!("username: {}", req.username)),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
token,
|
||||
username,
|
||||
role: role.to_string(),
|
||||
}))
|
||||
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
|
||||
let token: String = (0..48)
|
||||
.map(|_| {
|
||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
|
||||
abcdefghijklmnopqrstuvwxyz\
|
||||
0123456789";
|
||||
let idx = (rand::random::<u32>() as usize) % CHARSET.len();
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
let role = user.role;
|
||||
let username = user.username.clone();
|
||||
|
||||
// Create session in database
|
||||
let now = chrono::Utc::now();
|
||||
let session_data = pinakes_core::storage::SessionData {
|
||||
session_token: token.clone(),
|
||||
user_id: None, // Could be set if we had user IDs
|
||||
username: username.clone(),
|
||||
role: role.to_string(),
|
||||
created_at: now,
|
||||
expires_at: now + chrono::Duration::hours(24), // 24 hour sessions
|
||||
last_accessed: now,
|
||||
};
|
||||
|
||||
if let Err(e) = state.storage.create_session(&session_data).await {
|
||||
tracing::error!(error = %e, "failed to create session in database");
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
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,
|
||||
role: role.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> StatusCode {
|
||||
if let Some(token) = extract_bearer_token(&headers) {
|
||||
// Get username before deleting session
|
||||
let username = match state.storage.get_session(token).await {
|
||||
Ok(Some(session)) => Some(session.username),
|
||||
_ => None,
|
||||
};
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> StatusCode {
|
||||
if let Some(token) = extract_bearer_token(&headers) {
|
||||
// Get username before deleting session
|
||||
let username = match state.storage.get_session(token).await {
|
||||
Ok(Some(session)) => Some(session.username),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Delete session from database
|
||||
if let Err(e) = state.storage.delete_session(token).await {
|
||||
tracing::error!(error = %e, "failed to delete session from database");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Delete session from database
|
||||
if let Err(e) = state.storage.delete_session(token).await {
|
||||
tracing::error!(error = %e, "failed to delete session from database");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
StatusCode::OK
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
pub async fn me(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<UserInfoResponse>, StatusCode> {
|
||||
let config = state.config.read().await;
|
||||
if !config.accounts.enabled {
|
||||
// When accounts are not enabled, return a default admin user
|
||||
return Ok(Json(UserInfoResponse {
|
||||
username: "admin".to_string(),
|
||||
role: "admin".to_string(),
|
||||
}));
|
||||
}
|
||||
drop(config);
|
||||
let config = state.config.read().await;
|
||||
if !config.accounts.enabled {
|
||||
// When accounts are not enabled, return a default admin user
|
||||
return Ok(Json(UserInfoResponse {
|
||||
username: "admin".to_string(),
|
||||
role: "admin".to_string(),
|
||||
}));
|
||||
}
|
||||
drop(config);
|
||||
|
||||
let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
let session = state
|
||||
.storage
|
||||
.get_session(token)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
let session = state
|
||||
.storage
|
||||
.get_session(token)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
Ok(Json(UserInfoResponse {
|
||||
username: session.username.clone(),
|
||||
role: session.role.clone(),
|
||||
}))
|
||||
Ok(Json(UserInfoResponse {
|
||||
username: session.username.clone(),
|
||||
role: session.role.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
}
|
||||
|
||||
/// Revoke all sessions for the current user
|
||||
pub async fn revoke_all_sessions(State(state): State<AppState>, headers: HeaderMap) -> StatusCode {
|
||||
let token = match extract_bearer_token(&headers) {
|
||||
Some(t) => t,
|
||||
None => return StatusCode::UNAUTHORIZED,
|
||||
};
|
||||
pub async fn revoke_all_sessions(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> StatusCode {
|
||||
let token = match extract_bearer_token(&headers) {
|
||||
Some(t) => t,
|
||||
None => return StatusCode::UNAUTHORIZED,
|
||||
};
|
||||
|
||||
// Get current session to find username
|
||||
let session = match state.storage.get_session(token).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => return StatusCode::UNAUTHORIZED,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to get session");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
};
|
||||
// Get current session to find username
|
||||
let session = match state.storage.get_session(token).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => return StatusCode::UNAUTHORIZED,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to get session");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||
},
|
||||
};
|
||||
|
||||
let username = session.username.clone();
|
||||
let username = session.username.clone();
|
||||
|
||||
// Delete all sessions for this user
|
||||
match state.storage.delete_user_sessions(&username).await {
|
||||
Ok(count) => {
|
||||
tracing::info!(username = %username, count = count, "revoked all user sessions");
|
||||
// Delete all sessions for this user
|
||||
match state.storage.delete_user_sessions(&username).await {
|
||||
Ok(count) => {
|
||||
tracing::info!(username = %username, count = count, "revoked all user sessions");
|
||||
|
||||
// Record in audit log
|
||||
let _ = pinakes_core::audit::record_action(
|
||||
&state.storage,
|
||||
None,
|
||||
pinakes_core::model::AuditAction::Logout,
|
||||
Some(format!("revoked all sessions for username: {}", username)),
|
||||
)
|
||||
.await;
|
||||
// Record in audit log
|
||||
let _ = pinakes_core::audit::record_action(
|
||||
&state.storage,
|
||||
None,
|
||||
pinakes_core::model::AuditAction::Logout,
|
||||
Some(format!("revoked all sessions for username: {}", username)),
|
||||
)
|
||||
.await;
|
||||
|
||||
StatusCode::OK
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to revoke sessions");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
StatusCode::OK
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to revoke sessions");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// List all active sessions (admin only)
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct SessionListResponse {
|
||||
pub sessions: Vec<SessionInfo>,
|
||||
pub sessions: Vec<SessionInfo>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct SessionInfo {
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub created_at: String,
|
||||
pub last_accessed: String,
|
||||
pub expires_at: String,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub created_at: String,
|
||||
pub last_accessed: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
pub async fn list_active_sessions(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<SessionListResponse>, StatusCode> {
|
||||
// Get all active sessions
|
||||
let sessions = state
|
||||
.storage
|
||||
.list_active_sessions(None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to list active sessions");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
// Get all active sessions
|
||||
let sessions =
|
||||
state
|
||||
.storage
|
||||
.list_active_sessions(None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to list active sessions");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let session_infos = sessions
|
||||
.into_iter()
|
||||
.map(|s| SessionInfo {
|
||||
username: s.username,
|
||||
role: s.role,
|
||||
created_at: s.created_at.to_rfc3339(),
|
||||
last_accessed: s.last_accessed.to_rfc3339(),
|
||||
expires_at: s.expires_at.to_rfc3339(),
|
||||
})
|
||||
.collect();
|
||||
let session_infos = sessions
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
SessionInfo {
|
||||
username: s.username,
|
||||
role: s.role,
|
||||
created_at: s.created_at.to_rfc3339(),
|
||||
last_accessed: s.last_accessed.to_rfc3339(),
|
||||
expires_at: s.expires_at.to_rfc3339(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(SessionListResponse {
|
||||
sessions: session_infos,
|
||||
}))
|
||||
Ok(Json(SessionListResponse {
|
||||
sessions: session_infos,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue