use jwt::handlers::TokenPairResponse; use jwt::rotation::{RotationContext, issue_login_pair}; use time::OffsetDateTime; use db::conn::sea_orm::{ ActiveModelTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, }; use db::user::user_emails::Entity as EmailEntity; use db::user::users::{ActiveModel as UserActiveModel, Entity as UserEntity}; use crate::error::AuthError; use crate::features::AuthService; use crate::features::password_hash; use crate::features::register::PowProof; use crate::pow::PowService; use super::types::{LoginCmd, LoginResp, UserPublic}; async fn verify_pow(pow: &PowService, proof: &PowProof, expected_cid: Option<&str>) -> Result<(), AuthError> { let result = pow .verify_solution_once(&proof.challenge, &proof.solution, expected_cid) .await?; if !result.verified { return Err(AuthError::PowFailed("PoW solution not accepted".into())); } Ok(()) } async fn find_user_by_identifier( db: &DatabaseConnection, identifier: &str, ) -> Result, AuthError> { let ident = identifier.trim(); if ident.is_empty() { return Ok(None); } let lower = ident.to_ascii_lowercase(); if ident.contains('@') { // Try email table first. let by_email = EmailEntity::find() .filter(EmailEntity::COLUMN.lower_email.eq(lower.clone())) // VULN-011: exclude soft-deleted emails so a removed handle // cannot still be used to authenticate. .filter(EmailEntity::COLUMN.deleted_at.is_null()) .find_also_related(UserEntity) .one(db) .await?; if let Some((_em, user)) = by_email { return Ok(user); } } let by_name = UserEntity::find() .filter(UserEntity::COLUMN.lower_name.eq(lower)) .one(db) .await?; Ok(by_name) } fn build_resp(pair: TokenPairResponse, user: db::user::users::Model) -> LoginResp { LoginResp { access_token: pair.access_token, refresh_token: pair.refresh_token, token_type: pair.token_type, expires_in: pair.expires_in, user: UserPublic::from(user), } } /// Authenticate an existing user with email/username + password. /// /// Security guards: /// - PoW required (anti-bruteforce on the endpoint). /// - Unknown identifier → `InvalidCredentials` (no user enumeration). /// - Inactive, restricted, prohibit_login users are rejected. /// - Passwordless accounts (`passwd_hash` empty) MUST NOT sign in via password /// and are told to use a third-party provider. pub async fn login( cmd: LoginCmd, svc: &AuthService, expected_cid: Option<&str>, ) -> Result { verify_pow(&svc.pow, &cmd.pow, expected_cid).await?; let db = svc.db.writer(); let user = find_user_by_identifier(&db, &cmd.identifier) .await? .ok_or(AuthError::InvalidCredentials)?; if user.prohibit_login { return Err(AuthError::Forbidden("login prohibited".into())); } if user.is_restricted { return Err(AuthError::Forbidden("account restricted".into())); } if !user.is_active { return Err(AuthError::Forbidden( "account inactive, please verify your email".into(), )); } let hash = user .passwd_hash .clone() .ok_or_else(|| AuthError::BadRequest("this account is registered via a third-party provider; please sign in with that provider".into()))?; let valid = password_hash::usecase::verify_password( password_hash::PasswordInput { password: &cmd.password, }, &password_hash::PasswordHashString(hash), )?; if !valid { return Err(AuthError::InvalidCredentials); } // Issue the token pair via jwt rotation. let cache = svc.jwt.cache.read().await; let ctx = RotationContext { db: &db, settings: &svc.jwt.settings, cache: &cache, }; let pair = issue_login_pair( &ctx, user.uid, user.username.clone(), user.is_admin, user.is_restricted, user.passwd_version.unwrap_or(0), cmd.app_id.clone(), ) .await?; // Update last_login_at. let mut am: UserActiveModel = user.clone().into(); am.last_login_at = Set(Some(OffsetDateTime::now_utc())); am.updated_at = Set(OffsetDateTime::now_utc()); let _ = am.update(&db).await; // best-effort Ok(build_resp(pair.into(), user)) }