use axum::{ Json, extract::State, http::{HeaderMap, StatusCode}, }; 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"; #[utoipa::path( post, path = "/api/v1/auth/login", tag = "auth", request_body = LoginRequest, responses( (status = 200, description = "Login successful", body = LoginResponse), (status = 400, description = "Bad request"), (status = 401, description = "Invalid credentials"), (status = 500, description = "Internal server error"), ), security() )] 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: invalid password"); } else { tracing::warn!(username = %req.username, "login failed: unknown user"); } // Record failed login attempt in audit log if let Err(e) = pinakes_core::audit::record_action( &state.storage, None, pinakes_core::model::AuditAction::LoginFailed, Some(format!("username: {}", req.username)), ) .await { tracing::warn!(error = %e, "failed to record failed login audit"); } return Err(StatusCode::UNAUTHORIZED); } // At this point we know the user exists and password is valid let user = user.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; // Generate session token using unbiased uniform distribution #[expect(clippy::expect_used)] let token: String = { use rand::seq::IndexedRandom; const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let mut rng = rand::rng(); (0..48) .map(|_| *CHARSET.choose(&mut rng).expect("non-empty charset") 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(config.accounts.session_expiry_hours as i64), 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 if let Err(e) = pinakes_core::audit::record_action( &state.storage, None, pinakes_core::model::AuditAction::LoginSuccess, Some(format!("username: {username}, role: {role}")), ) .await { tracing::warn!(error = %e, "failed to record login audit"); } Ok(Json(LoginResponse { token, username, role: role.to_string(), })) } #[utoipa::path( post, path = "/api/v1/auth/logout", tag = "auth", responses( (status = 200, description = "Logged out"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] pub async fn logout( State(state): State, headers: HeaderMap, ) -> StatusCode { let Some(token) = extract_bearer_token(&headers) else { return StatusCode::UNAUTHORIZED; }; // 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 Err(e) = pinakes_core::audit::record_action( &state.storage, None, pinakes_core::model::AuditAction::Logout, Some(format!("username: {user}")), ) .await { tracing::warn!(error = %e, "failed to record logout audit"); } StatusCode::OK } #[utoipa::path( get, path = "/api/v1/auth/me", tag = "auth", responses( (status = 200, description = "Current user info", body = UserInfoResponse), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] 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, })) } 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 ")) } /// Refresh the current session, extending its expiry by the configured /// duration. #[utoipa::path( post, path = "/api/v1/auth/refresh", tag = "auth", responses( (status = 200, description = "Session refreshed"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] pub async fn refresh( State(state): State, headers: HeaderMap, ) -> Result, StatusCode> { let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?; let config = state.config.read().await; let expiry_hours = config.accounts.session_expiry_hours as i64; drop(config); let new_expires_at = chrono::Utc::now() + chrono::Duration::hours(expiry_hours); match state.storage.extend_session(token, new_expires_at).await { Ok(Some(expires)) => { Ok(Json(serde_json::json!({ "expires_at": expires.to_rfc3339() }))) }, Ok(None) => Err(StatusCode::UNAUTHORIZED), Err(e) => { tracing::error!(error = %e, "failed to extend session"); Err(StatusCode::INTERNAL_SERVER_ERROR) }, } } /// Revoke all sessions for the current user #[utoipa::path( post, path = "/api/v1/auth/revoke-all", tag = "auth", responses( (status = 200, description = "All sessions revoked"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] 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 if let Err(e) = pinakes_core::audit::record_action( &state.storage, None, pinakes_core::model::AuditAction::Logout, Some(format!("revoked all sessions for username: {username}")), ) .await { tracing::warn!(error = %e, "failed to record session revocation audit"); } 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, utoipa::ToSchema)] pub struct SessionListResponse { pub sessions: Vec, } #[derive(serde::Serialize, utoipa::ToSchema)] pub struct SessionInfo { pub username: String, pub role: String, pub created_at: String, pub last_accessed: String, pub expires_at: String, } #[utoipa::path( get, path = "/api/v1/auth/sessions", tag = "auth", responses( (status = 200, description = "Active sessions", body = SessionListResponse), (status = 401, description = "Unauthorized"), (status = 403, description = "Forbidden"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] 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, })) }