use crate::error::AppError; use crate::AppService; use argon2::password_hash::{PasswordHasher, SaltString}; use argon2::{Argon2, PasswordHash, PasswordVerifier}; use models::users::{user_activity_log, user_password}; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct LoginParams { pub username: String, pub password: String, pub captcha: String, pub totp_code: Option, } impl AppService { pub const TOTP_KEY: &'static str = "totp_key"; pub async fn auth_login(&self, params: LoginParams, context: Session) -> Result<(), AppError> { self.auth_check_captcha(&context, params.captcha).await?; let password = self.auth_rsa_decode(&context, params.password).await?; let user = match self .utils_find_user_by_username(params.username.clone()) .await { Ok(user) => user, Err(_) => { match self.utils_find_user_by_email(params.username.clone()).await { Ok(user) => user, Err(_) => { // Timing-safe: perform dummy Argon2 hash to normalize response time // so attackers cannot distinguish "user not found" from "wrong password" let _ = Argon2::default().hash_password( password.as_bytes(), &SaltString::generate(&mut rsa::rand_core::OsRng::default()), ); return Err(AppError::UserNotFound); } } } }; let user_password = user_password::Entity::find() .filter(user_password::Column::User.eq(user.uid)) .one(&self.db) .await .ok() .flatten() .ok_or(AppError::UserNotFound)?; let password_hash = PasswordHash::new(&user_password.password_hash).map_err(|_| AppError::UserNotFound)?; if let Err(_e) = Argon2::default().verify_password(password.as_bytes(), &password_hash) { tracing::warn!(username = %params.username, ip = ?context.ip_address(), "Login failed: invalid password"); return Err(AppError::UserNotFound); } // 2FA check: if user has TOTP enabled, require verification before completing login let needs_totp_verification = context .get::(Self::TOTP_KEY) .ok() .flatten() .is_some(); if needs_totp_verification { // Second step: user submitted TOTP code along with credentials if let Some(ref totp_code) = params.totp_code { if !self.auth_2fa_verify_login(&context, totp_code).await? { tracing::warn!(username = %params.username, ip = ?context.ip_address(), "Login failed: invalid 2FA code"); return Err(AppError::InvalidTwoFactorCode); } } else { tracing::warn!(username = %params.username, ip = ?context.ip_address(), "Login failed: 2FA code required but not provided"); return Err(AppError::InvalidTwoFactorCode); } } else if self.auth_2fa_status_by_uid(user.uid).await?.is_enabled { // First step: user has 2FA enabled but hasn't provided code yet // Store a reference in Redis/session so the next request can identify this user let user_uid = user.uid; let totp_session_key = uuid::Uuid::new_v4().to_string(); context .insert(Self::TOTP_KEY, totp_session_key.clone()) .ok(); if let Ok(mut conn) = self.cache.conn().await { use redis::AsyncCommands; let _: Option<()> = conn .set_ex::(totp_session_key, user_uid.to_string(), 300) .await .ok(); } tracing::info!(username = %params.username, ip = ?context.ip_address(), "Login 2FA triggered"); return Err(AppError::TwoFactorRequired); } let mut arch = user.clone().into_active_model(); arch.last_sign_in_at = Set(Some(chrono::Utc::now())); arch.update(&self.db) .await .map_err(|_| AppError::UserNotFound)?; let _ = user_activity_log::ActiveModel { user_uid: Set(Some(user.uid)), action: Set("login".to_string()), ip_address: Set(context.ip_address()), user_agent: Set(context.user_agent()), details: Set(Some(serde_json::json!({ "method": "password", "username": user.username, })) .into()), created_at: Set(chrono::Utc::now()), ..Default::default() } .insert(&self.db) .await; context.set_user(user.uid); context.remove(Self::RSA_PRIVATE_KEY); context.remove(Self::RSA_PUBLIC_KEY); tracing::info!(user_uid = %user.uid, username = %user.username, ip = ?context.ip_address(), "User logged in successfully"); Ok(()) } }