use axum::Json; use axum::extract::State; 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, Json(req): Json, ) -> Result, StatusCode> { // 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"); } // 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::() 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, 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; } } StatusCode::OK } pub async fn me( State(state): State, headers: HeaderMap, ) -> Result, 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 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(), })) } 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 ")) } /// Revoke all sessions for the current user pub async fn revoke_all_sessions(State(state): State, 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; } }; 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"); // 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 } } } /// List all active sessions (admin only) #[derive(serde::Serialize)] pub struct SessionListResponse { pub sessions: Vec, } #[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 async fn list_active_sessions( State(state): State, ) -> Result, 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 })?; 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, })) }