use crate::AuthService; use crate::errors::{AuthError, AuthResult}; use crate::events::{Labels, label}; use crate::handlers::challenge::PowProofRequest; use crate::models::users::user_events::UserEventType; use crate::models::users::user_mfa::{self, MfaStatus, MfaType}; use crate::models::users::users; use crate::session::Session; use crate::share::codes::{consume_email_code, store_email_code}; use crate::share::crypto::{now_secs, random_alnum, random_numeric_code}; use crate::share::password::{self, PasswordCheck}; use crate::share::rate::{attempts_load, attempts_reset, attempts_track, rate_limit_once}; use crate::share::validate; use sea_orm::sea_query::{Expr, ExprTrait, Func}; use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; pub const SESSION_LOGIN_MFA: &str = "login_mfa"; pub const HASH_KEY_SHA256_V1: &str = "sha256-v1"; pub const LOGIN_ATTEMPTS_MAX: u64 = 5; pub const LOGIN_ATTEMPTS_WINDOW_SECS: u64 = 300; pub const MFA_CHALLENGE_TTL_SECS: u64 = 300; #[derive(Clone, Deserialize, Serialize, ToSchema)] pub struct UserLoginRequest { pub identity: String, pub password_hash: String, pub pow_payload: PowProofRequest, } impl std::fmt::Debug for UserLoginRequest { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("UserLoginRequest") .field("identity", &self.identity) .field("password_hash", &"") .field("pow_payload", &self.pow_payload) .finish() } } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct UserLoginResponse { pub user_id: i64, pub username: String, pub displayname: Option, pub mfa_required: bool, pub challenge: Option, } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct LoginCodeRequest { pub email: String, pub pow_payload: PowProofRequest, } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct LoginCodeResponse { pub expires_in_secs: u64, } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct LoginCodeVerifyRequest { pub email: String, pub email_code: String, pub pow_payload: PowProofRequest, } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct LoginVerifyRequest { pub challenge: String, pub code: String, } #[derive(Clone, Debug, Deserialize, Serialize)] pub struct LoginMfaChallenge { pub user_id: i64, pub challenge: String, pub expires_at: u64, } pub(crate) fn login_code_key(user_id: i64) -> String { format!("auth:code:login:{user_id}") } impl AuthService { #[tracing::instrument(skip_all, fields(request_id = %request_id, client_ip = ?session.client_ip().ok().flatten()))] pub async fn login( &self, session: Session, request: UserLoginRequest, request_id: String, ) -> AuthResult { self.challenge_verify_pow( session.clone(), &request.pow_payload, request.identity.as_bytes(), request_id.clone(), ) .await?; let identity = request.identity.trim().to_lowercase(); let found = self.find_user_by_identity(&request.identity).await?; let client_ip = session.client_ip().ok().flatten().unwrap_or_default(); // IP 维计数与账号维并行:攻击者无法用 5 次失败锁死任意已知用户名, // 只能锁自己的 IP;账号维只累计已验证失败的次数,拦住单账号分布式慢速猜测。 let ip_key = format!( "auth:rate:login:ip:{}:{}", client_ip, &crate::share::crypto::sha256_hex(identity.as_bytes())[..16] ); let ip_allowed = attempts_track( self.cache(), &ip_key, LOGIN_ATTEMPTS_MAX * 10, LOGIN_ATTEMPTS_WINDOW_SECS, Some(request_id.as_str()), ) .await?; let attempts_key = match found.as_ref() { Some(user) => format!("auth:rate:login:attempts:{}", user.id), None => { // identity 截断后哈希,防任意长度输入撑大 Redis key。 let identity_digest = crate::share::crypto::sha256_hex(identity.as_bytes()); format!( "auth:rate:login:attempts:anon:{client_ip}:{}", &identity_digest[..16] ) } }; // 账号维只读不写:本次尝试本身不能计入阀门,否则 5 次请求就能锁死任意已知账号。 let account_locked = attempts_load(self.cache(), &attempts_key, Some(request_id.as_str())) .await? >= LOGIN_ATTEMPTS_MAX; if !ip_allowed || account_locked { if account_locked && let Some(locked) = found.as_ref() && let Err(error) = self .audit( &session, locked.id, Some(locked.id), UserEventType::FailedLoginAttempt, None, &request_id, ) .await { tracing::warn!(%error, %request_id, "failed to write the locked-login audit event"); } return Err(AuthError::RateLimited { retry_after_secs: LOGIN_ATTEMPTS_WINDOW_SECS, }); } let user = found.ok_or(AuthError::InvalidCredentials)?; match password::verify( request.password_hash.clone(), user.password_hash.clone(), Some(request_id.as_str()), ) .await? { PasswordCheck::Match => {} PasswordCheck::LegacyMatch => { let upgraded = password::hash(request.password_hash.clone(), Some(request_id.as_str())) .await?; users::Entity::update_many() .col_expr(users::Column::PasswordHash, Expr::value(upgraded)) .col_expr( users::Column::PasswordUpdatedAt, Expr::value(chrono::Utc::now()), ) .filter(users::Column::Id.eq(user.id)) .exec(self.db().write()) .await?; } PasswordCheck::Mismatch => { attempts_track( self.cache(), &attempts_key, LOGIN_ATTEMPTS_MAX, LOGIN_ATTEMPTS_WINDOW_SECS, Some(request_id.as_str()), ) .await?; self.audit( &session, user.id, Some(user.id), UserEventType::FailedLoginAttempt, None, &request_id, ) .await?; return Err(AuthError::InvalidCredentials); } } self.assert_login_allowed(&session, &user, &request_id) .await?; attempts_reset(self.cache(), &attempts_key, Some(request_id.as_str())).await?; let response = self.complete_login(session, user, &request_id).await?; self.emit_labeled( label::AUTH_LOGIN_CREATE, Labels::new() .with(label::USER_ID, response.user_id) .with(label::REQUEST_ID, &request_id), serde_json::json!({ "user_id": response.user_id, "username": &response.username, "mfa_required": response.mfa_required, }), Some(request_id.as_str()), ) .await; Ok(response) } #[tracing::instrument(skip_all, fields(request_id = %request_id, client_ip = ?session.client_ip().ok().flatten()))] pub async fn login_code( &self, session: Session, request: LoginCodeRequest, request_id: String, ) -> AuthResult { self.challenge_verify_pow( session.clone(), &request.pow_payload, request.email.as_bytes(), request_id.clone(), ) .await?; let email = validate::validate_email(&request.email)?; let user = self .find_user_by_email(&email) .await? .ok_or(AuthError::InvalidCredentials)?; self.assert_login_allowed(&session, &user, &request_id) .await?; rate_limit_once( self.cache(), &format!("auth:rate:login:{}", user.id), self.auth().email_code_rate_limit_secs, Some(request_id.as_str()), ) .await?; let ttl = self.auth().verify_code_ttl_secs; let code = random_numeric_code(self.auth().verify_code_len); store_email_code( self.cache(), &login_code_key(user.id), &code, ttl, Some(request_id.as_str()), ) .await?; let minutes = std::cmp::max(ttl.div_ceil(60), 1); let text = format!( "Your GitDataAi sign-in code is {code}. It expires in {minutes} minute(s); never share it with anyone." ); let html = format!( "

Your GitDataAi sign-in code is {code}. It expires in {minutes} minute(s); never share it with anyone.

" ); self.publish_mail( &user.email, "Your GitDataAi sign-in code", &text, &html, Some(request_id.as_str()), ) .await?; let response = LoginCodeResponse { expires_in_secs: ttl, }; self.emit_labeled( label::AUTH_VERIFICATION_TOKEN_CREATE, Labels::new() .with(label::USER_ID, user.id) .with(label::REQUEST_ID, &request_id), &response, Some(request_id.as_str()), ) .await; Ok(response) } #[tracing::instrument(skip_all, fields(request_id = %request_id, client_ip = ?session.client_ip().ok().flatten()))] pub async fn login_code_verify( &self, session: Session, request: LoginCodeVerifyRequest, request_id: String, ) -> AuthResult { self.challenge_verify_pow( session.clone(), &request.pow_payload, request.email.as_bytes(), request_id.clone(), ) .await?; let email = validate::validate_email(&request.email)?; let user = self .find_user_by_email(&email) .await? .ok_or(AuthError::InvalidCredentials)?; consume_email_code( self.cache(), &login_code_key(user.id), &request.email_code, self.auth().max_verify_attempts as i64, Some(request_id.as_str()), ) .await?; self.assert_login_allowed(&session, &user, &request_id) .await?; let response = self.complete_login(session, user, &request_id).await?; self.emit_labeled( label::AUTH_LOGIN_CREATE, Labels::new() .with(label::USER_ID, response.user_id) .with(label::REQUEST_ID, &request_id), serde_json::json!({ "user_id": response.user_id, "username": &response.username, "mfa_required": response.mfa_required, }), Some(request_id.as_str()), ) .await; Ok(response) } #[tracing::instrument(skip_all, fields(request_id = %request_id, client_ip = ?session.client_ip().ok().flatten()))] pub async fn login_verify( &self, session: Session, request: LoginVerifyRequest, request_id: String, ) -> AuthResult { let challenge = session .get::(SESSION_LOGIN_MFA)? .ok_or(AuthError::Expired)?; if challenge.challenge != request.challenge.trim() { let _ = session.remove(SESSION_LOGIN_MFA); return Err(AuthError::MalformedRequest); } if challenge.expires_at <= now_secs() { let _ = session.remove(SESSION_LOGIN_MFA); return Err(AuthError::Expired); } let attempts_key = format!("auth:rate:login:mfa:{}", challenge.user_id); if !attempts_track( self.cache(), &attempts_key, LOGIN_ATTEMPTS_MAX, LOGIN_ATTEMPTS_WINDOW_SECS, Some(request_id.as_str()), ) .await? { let _ = session.remove(SESSION_LOGIN_MFA); return Err(AuthError::RateLimited { retry_after_secs: LOGIN_ATTEMPTS_WINDOW_SECS, }); } let user = users::Entity::find_by_id(challenge.user_id) .filter(users::Column::DeletedAt.is_null()) .one(self.db().read()) .await? .ok_or(AuthError::Unauthenticated)?; self.assert_login_allowed(&session, &user, &request_id) .await?; self.consume_second_factor(&user, &request.code).await?; session.set("mfa_verified", true)?; let _ = session.remove(SESSION_LOGIN_MFA); attempts_reset(self.cache(), &attempts_key, Some(request_id.as_str())).await?; let response = self.bind_session(&session, &user, &request_id).await?; self.emit_labeled( label::AUTH_LOGIN_CREATE, Labels::new() .with(label::USER_ID, response.user_id) .with(label::REQUEST_ID, &request_id), serde_json::json!({ "user_id": response.user_id, "username": &response.username, "mfa_required": response.mfa_required, }), Some(request_id.as_str()), ) .await; Ok(response) } async fn complete_login( &self, session: Session, user: users::Model, request_id: &str, ) -> AuthResult { if self.enabled_mfa(user.id).await?.is_some() { let challenge = LoginMfaChallenge { user_id: user.id, challenge: random_alnum(32), expires_at: now_secs() + MFA_CHALLENGE_TTL_SECS, }; let token = challenge.challenge.clone(); session.set(SESSION_LOGIN_MFA, &challenge)?; return Ok(UserLoginResponse { user_id: user.id, username: user.username.clone(), displayname: user.displayname.clone(), mfa_required: true, challenge: Some(token), }); } session.set("mfa_verified", false)?; self.bind_session(&session, &user, request_id).await } async fn bind_session( &self, session: &Session, user: &users::Model, request_id: &str, ) -> AuthResult { session.renew(); session.set_user_id(user.id)?; session.set_security_version(user.security_version)?; self.bind_auth_session( session, user, session.get::("mfa_verified")?.unwrap_or(false), Some(request_id), ) .await?; users::Entity::update_many() .col_expr(users::Column::LastLoginAt, Expr::value(chrono::Utc::now())) .filter(users::Column::Id.eq(user.id)) .exec(self.db().write()) .await?; crate::services::me::invalidate_me_cache(self.cache(), user.id).await; self.audit( session, user.id, Some(user.id), UserEventType::Login, None, request_id, ) .await?; Ok(UserLoginResponse { user_id: user.id, username: user.username.clone(), displayname: user.displayname.clone(), mfa_required: false, challenge: None, }) } async fn consume_second_factor(&self, user: &users::Model, code: &str) -> AuthResult<()> { let record = self .enabled_mfa(user.id) .await? .ok_or(AuthError::MfaRequired)?; if record.mfa_type == MfaType::Totp && record.secret_ciphertext.is_some() { match crate::share::mfa::consume_totp( self.db(), &record, self.auth().totp_encryption_key.as_deref(), code, None, ) .await { Ok(_) => return Ok(()), Err(AuthError::TotpCodeInvalid) => {} Err(error) => return Err(error), } } self.consume_recovery_code(user.id, code).await } async fn consume_recovery_code(&self, user_id: i64, code: &str) -> AuthResult<()> { crate::share::mfa::consume_recovery(self.db(), user_id, code, None).await } async fn enabled_mfa(&self, user_id: i64) -> AuthResult> { Ok(user_mfa::Entity::find() .filter(user_mfa::Column::UserId.eq(user_id)) .filter(user_mfa::Column::MfaStatus.eq(MfaStatus::Enabled)) .filter(user_mfa::Column::RevokedAt.is_null()) .one(self.db().read()) .await?) } async fn assert_login_allowed( &self, session: &Session, user: &users::Model, request_id: &str, ) -> AuthResult<()> { let reason = if user.deleted_at.is_some() { Some("deleted") } else if !user.allow_use { Some("disabled") } else if user.forbid_login && user .endtime_forbid_login_at .is_none_or(|until| until > chrono::Utc::now()) { Some("forbidden") } else { None }; let Some(reason) = reason else { return Ok(()); }; self.audit( session, user.id, Some(user.id), UserEventType::FailedLoginAttempt, Some(serde_json::json!({ "reason": reason, "forbid_login": user.forbid_login, "forbid_login_reason": user.forbid_login_reason, })), request_id, ) .await?; Err(AuthError::InvalidCredentials) } async fn find_user_by_identity(&self, identity: &str) -> AuthResult> { let trimmed = identity.trim(); let lower = trimmed.to_lowercase(); let id = trimmed.parse::().ok(); Ok(users::Entity::find() .filter( Condition::any() .add(users::Column::Username.eq(trimmed.to_string())) .add(users::Column::Lowername.eq(lower.clone())) .add(Expr::expr(Func::lower(Expr::col(users::Column::Email))).eq(lower)) .add_option(id.map(|id| users::Column::Id.eq(id))), ) .one(self.db().read()) .await?) } async fn find_user_by_email(&self, email: &str) -> AuthResult> { Ok(users::Entity::find() .filter(Expr::expr(Func::lower(Expr::col(users::Column::Email))).eq(email)) .one(self.db().read()) .await?) } async fn audit( &self, session: &Session, user_id: i64, actor_user_id: Option, event_type: UserEventType, details: Option, request_id: &str, ) -> AuthResult<()> { crate::share::audit::entry( self.db(), user_id, actor_user_id, event_type, details, Some(request_id), session.client_ip().ok().flatten(), session.user_agent().ok().flatten(), ) .await } }