gitdataai/libs/service/auth/login.rs

125 lines
5.2 KiB
Rust

use crate::AppService;
use crate::error::AppError;
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<String>,
}
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),
);
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::<String>(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::<String, String, ()>(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.renew();
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(())
}
}