Skip to content
Clone
main
600 lines 19.9 KB Rust
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", &"<redacted>")
.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<String>,
pub mfa_required: bool,
pub challenge: Option<String>,
}
#[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<UserLoginResponse> {
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<LoginCodeResponse> {
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!(
"<p>Your GitDataAi sign-in code is <strong>{code}</strong>. It expires in {minutes} minute(s); never share it with anyone.</p>"
);
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<UserLoginResponse> {
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<UserLoginResponse> {
let challenge = session
.get::<LoginMfaChallenge>(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<UserLoginResponse> {
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<UserLoginResponse> {
session.renew();
session.set_user_id(user.id)?;
session.set_security_version(user.security_version)?;
self.bind_auth_session(
session,
user,
session.get::<bool>("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<Option<user_mfa::Model>> {
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<Option<users::Model>> {
let trimmed = identity.trim();
let lower = trimmed.to_lowercase();
let id = trimmed.parse::<i64>().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<Option<users::Model>> {
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<i64>,
event_type: UserEventType,
details: Option<serde_json::Value>,
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
}
}