Skip to content
Clone
a06673b
141 lines 4.4 KB Rust
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<Option<db::user::users::Model>, 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<LoginResp, AuthError> {
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))
}