use async_trait::async_trait; use chrono::{offset::Local, Duration}; use loco_oauth2::models::users::OAuth2UserTrait; use loco_rs::{auth::jwt, hash, prelude::*}; use passwords::PasswordGenerator; use serde::{Deserialize, Serialize}; use serde_json::Map; use uuid::Uuid; use crate::models::_entities::o_auth2_sessions; pub use crate::models::_entities::users::{self, ActiveModel, Entity, Model}; pub const MAGIC_LINK_LENGTH: i8 = 32; pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5; /// Minimum gap between verification-email resends for one account, in seconds. pub const VERIFICATION_RESEND_COOLDOWN_SECS: i64 = 60; #[derive(Debug, Deserialize, Serialize)] pub struct LoginParams { pub email: String, pub password: String, } #[derive(Debug, Deserialize, Serialize)] pub struct RegisterParams { pub email: String, pub password: String, pub name: String, /// "personal" or "company"; permanent for the account. Optional on the wire /// (older/JSON callers omit it) and normalized via [`normalize_account_type`]. #[serde(default)] pub account_type: Option, } /// Normalize an account type to one of the two permanent values, defaulting to /// "personal" for anything missing or unexpected. An account's type is chosen /// once at registration and never changes. #[must_use] pub fn normalize_account_type(value: Option<&str>) -> String { match value.map(str::trim) { Some("company") => "company".to_string(), _ => "personal".to_string(), } } #[derive(Debug, Validate, Deserialize)] pub struct Validator { #[validate(length(min = 2, message = "Name must be at least 2 characters long."))] pub name: String, #[validate(email(message = "invalid email"))] pub email: String, } impl Validatable for ActiveModel { fn validator(&self) -> Box { Box::new(Validator { name: self.name.as_ref().to_owned(), email: self.email.as_ref().to_owned(), }) } } #[async_trait::async_trait] impl ActiveModelBehavior for crate::models::_entities::users::ActiveModel { async fn before_save(self, _db: &C, insert: bool) -> Result where C: ConnectionTrait, { self.validate()?; if insert { let mut this = self; this.pid = ActiveValue::Set(Uuid::new_v4()); this.api_key = ActiveValue::Set(format!("lo-{}", Uuid::new_v4())); Ok(this) } else { Ok(self) } } } #[async_trait] impl Authenticable for Model { async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult { let user = users::Entity::find() .filter( model::query::condition() .eq(users::Column::ApiKey, api_key) .build(), ) .one(db) .await?; user.ok_or_else(|| ModelError::EntityNotFound) } async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult { Self::find_by_pid(db, claims_key).await } } impl Model { /// finds a user by the provided email /// /// # Errors /// /// When could not find user by the given token or DB query error pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult { let user = users::Entity::find() .filter( model::query::condition() .eq(users::Column::Email, email) .build(), ) .one(db) .await?; user.ok_or_else(|| ModelError::EntityNotFound) } /// finds a user by the provided verification token /// /// # Errors /// /// When could not find user by the given token or DB query error pub async fn find_by_verification_token( db: &DatabaseConnection, token: &str, ) -> ModelResult { let user = users::Entity::find() .filter( model::query::condition() .eq(users::Column::EmailVerificationToken, token) .build(), ) .one(db) .await?; user.ok_or_else(|| ModelError::EntityNotFound) } /// finds a user by the magic token and verify and token expiration /// /// # Errors /// /// When could not find user by the given token or DB query error ot token expired pub async fn find_by_magic_token(db: &DatabaseConnection, token: &str) -> ModelResult { let user = users::Entity::find() .filter( query::condition() .eq(users::Column::MagicLinkToken, token) .build(), ) .one(db) .await?; let user = user.ok_or_else(|| ModelError::EntityNotFound)?; if let Some(expired_at) = user.magic_link_expiration { if expired_at >= Local::now() { Ok(user) } else { tracing::debug!( user_pid = user.pid.to_string(), token_expiration = expired_at.to_string(), "magic token expired for the user." ); Err(ModelError::msg("magic token expired")) } } else { tracing::error!( user_pid = user.pid.to_string(), "magic link expiration time not exists" ); Err(ModelError::msg("expiration token not exists")) } } /// finds a user by the provided reset token /// /// # Errors /// /// When could not find user by the given token or DB query error pub async fn find_by_reset_token(db: &DatabaseConnection, token: &str) -> ModelResult { let user = users::Entity::find() .filter( model::query::condition() .eq(users::Column::ResetToken, token) .build(), ) .one(db) .await?; user.ok_or_else(|| ModelError::EntityNotFound) } /// finds a user by the provided pid /// /// # Errors /// /// When could not find user or DB query error pub async fn find_by_pid(db: &DatabaseConnection, pid: &str) -> ModelResult { let parse_uuid = Uuid::parse_str(pid).map_err(|e| ModelError::Any(e.into()))?; let user = users::Entity::find() .filter( model::query::condition() .eq(users::Column::Pid, parse_uuid) .build(), ) .one(db) .await?; user.ok_or_else(|| ModelError::EntityNotFound) } /// finds a user by the provided api key /// /// # Errors /// /// When could not find user by the given token or DB query error pub async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult { let user = users::Entity::find() .filter( model::query::condition() .eq(users::Column::ApiKey, api_key) .build(), ) .one(db) .await?; user.ok_or_else(|| ModelError::EntityNotFound) } /// Verifies whether the provided plain password matches the hashed password /// /// # Errors /// /// when could not verify password #[must_use] pub fn verify_password(&self, password: &str) -> bool { hash::verify_password(password, &self.password) } /// Whether this is a company account (vs a personal one). Fixed at /// registration. #[must_use] pub fn is_company(&self) -> bool { self.account_type == "company" } /// Seconds the user must still wait before another verification email may be /// sent — 0 means a resend is allowed now. Throttling resends off the last /// `email_verification_sent_at` keeps the endpoint from being an easy way to /// spam someone's inbox. #[must_use] pub fn verification_resend_wait_secs(&self) -> i64 { match self.email_verification_sent_at { Some(sent) => { let elapsed = (chrono::Utc::now() - sent.with_timezone(&chrono::Utc)).num_seconds(); (VERIFICATION_RESEND_COOLDOWN_SECS - elapsed).max(0) } None => 0, } } /// Asynchronously creates a user with a password and saves it to the /// database. /// /// # Errors /// /// When could not save the user into the DB pub async fn create_with_password( db: &DatabaseConnection, params: &RegisterParams, ) -> ModelResult { let txn = db.begin().await?; if users::Entity::find() .filter( model::query::condition() .eq(users::Column::Email, ¶ms.email) .build(), ) .one(&txn) .await? .is_some() { return Err(ModelError::EntityAlreadyExists {}); } let password_hash = hash::hash_password(¶ms.password).map_err(|e| ModelError::Any(e.into()))?; let user = users::ActiveModel { email: ActiveValue::set(params.email.to_string()), password: ActiveValue::set(password_hash), name: ActiveValue::set(params.name.to_string()), account_type: ActiveValue::set(normalize_account_type(params.account_type.as_deref())), ..Default::default() } .insert(&txn) .await?; txn.commit().await?; Ok(user) } /// Creates an account on behalf of a checkout guest. The user never picks a /// password here (a strong random one satisfies the NOT NULL column, as in /// the OAuth path); they receive a "set your password" link by email. Errors /// with [`ModelError::EntityAlreadyExists`] if the email is already taken. /// /// # Errors /// /// When the email already exists or the insert fails. pub async fn create_guest_account( db: &DatabaseConnection, email: &str, name: &str, account_type: &str, ) -> ModelResult { let password = PasswordGenerator::new() .length(16) .numbers(true) .lowercase_letters(true) .uppercase_letters(true) .symbols(true) .strict(true) .generate_one() .map_err(|e| ModelError::Any(e.into()))?; Self::create_with_password( db, &RegisterParams { email: email.to_string(), password, name: name.to_string(), account_type: Some(account_type.to_string()), }, ) .await } /// Creates a JWT /// /// # Errors /// /// when could not convert user claims to jwt token pub fn generate_jwt(&self, secret: &str, expiration: u64) -> ModelResult { jwt::JWT::new(secret) .generate_token(expiration, self.pid.to_string(), Map::new()) .map_err(ModelError::from) } } impl ActiveModel { /// Sets the email verification information for the user and /// updates it in the database. /// /// This method is used to record the timestamp when the email verification /// was sent and generate a unique verification token for the user. /// /// # Errors /// /// when has DB query error pub async fn set_email_verification_sent( mut self, db: &DatabaseConnection, ) -> ModelResult { self.email_verification_sent_at = ActiveValue::set(Some(Local::now().into())); self.email_verification_token = ActiveValue::Set(Some(Uuid::new_v4().to_string())); self.update(db).await.map_err(ModelError::from) } /// Sets the information for a reset password request, /// generates a unique reset password token, and updates it in the /// database. /// /// This method records the timestamp when the reset password token is sent /// and generates a unique token for the user. /// /// # Arguments /// /// # Errors /// /// when has DB query error pub async fn set_forgot_password_sent(mut self, db: &DatabaseConnection) -> ModelResult { self.reset_sent_at = ActiveValue::set(Some(Local::now().into())); self.reset_token = ActiveValue::Set(Some(Uuid::new_v4().to_string())); self.update(db).await.map_err(ModelError::from) } /// Records the verification time when a user verifies their /// email and updates it in the database. /// /// This method sets the timestamp when the user successfully verifies their /// email. /// /// # Errors /// /// when has DB query error pub async fn verified(mut self, db: &DatabaseConnection) -> ModelResult { self.email_verified_at = ActiveValue::set(Some(Local::now().into())); self.update(db).await.map_err(ModelError::from) } /// Resets the current user password with a new password and /// updates it in the database. /// /// This method hashes the provided password and sets it as the new password /// for the user. /// /// # Errors /// /// when has DB query error or could not hashed the given password pub async fn reset_password( mut self, db: &DatabaseConnection, password: &str, ) -> ModelResult { self.password = ActiveValue::set(hash::hash_password(password).map_err(|e| ModelError::Any(e.into()))?); self.reset_token = ActiveValue::Set(None); self.reset_sent_at = ActiveValue::Set(None); self.update(db).await.map_err(ModelError::from) } /// Creates a magic link token for passwordless authentication. /// /// Generates a random token with a specified length and sets an expiration time /// for the magic link. This method is used to initiate the magic link authentication flow. /// /// # Errors /// - Returns an error if database update fails pub async fn create_magic_link(mut self, db: &DatabaseConnection) -> ModelResult { let random_str = hash::random_string(MAGIC_LINK_LENGTH as usize); let expired = Local::now() + Duration::minutes(MAGIC_LINK_EXPIRATION_MIN.into()); self.magic_link_token = ActiveValue::set(Some(random_str)); self.magic_link_expiration = ActiveValue::set(Some(expired.into())); self.update(db).await.map_err(ModelError::from) } /// Verifies and invalidates the magic link after successful authentication. /// /// Clears the magic link token and expiration time after the user has /// successfully authenticated using the magic link. /// /// # Errors /// - Returns an error if database update fails pub async fn clear_magic_link(mut self, db: &DatabaseConnection) -> ModelResult { self.magic_link_token = ActiveValue::set(None); self.magic_link_expiration = ActiveValue::set(None); self.update(db).await.map_err(ModelError::from) } } /// Google OpenID Connect user profile (the fields our scopes request). /// #[derive(Serialize, Deserialize, Clone, Debug)] pub struct OAuth2UserProfile { pub email: String, pub name: String, pub sub: String, pub email_verified: bool, pub given_name: Option, pub family_name: Option, pub picture: Option, pub locale: Option, } #[async_trait] impl OAuth2UserTrait for Model { /// Resolve the user behind an active OAuth2 session id. async fn find_by_oauth2_session_id( db: &DatabaseConnection, session_id: &str, ) -> ModelResult { let session = o_auth2_sessions::Entity::find() .filter(o_auth2_sessions::Column::SessionId.eq(session_id)) .one(db) .await? .ok_or_else(|| ModelError::EntityNotFound)?; users::Entity::find_by_id(session.user_id) .one(db) .await? .ok_or_else(|| ModelError::EntityNotFound) } /// Find-or-create the local user for a verified OAuth2 profile. /// /// Per security advisory LOC-2025-04, OAuth2-created accounts get a strong /// RANDOM password (never the provider `sub`) — they sign in via the /// provider, and the random secret just satisfies the NOT NULL column. /// Google has already verified the email, so we mark it verified. async fn upsert_with_oauth( db: &DatabaseConnection, profile: &OAuth2UserProfile, ) -> ModelResult { let txn = db.begin().await?; let user = match users::Entity::find() .filter(users::Column::Email.eq(&profile.email)) .one(&txn) .await? { Some(user) => user, None => { let password = PasswordGenerator::new() .length(16) .numbers(true) .lowercase_letters(true) .uppercase_letters(true) .symbols(true) .exclude_similar_characters(true) .strict(true) .generate_one() .map_err(|e| ModelError::Any(e.into()))?; let password_hash = hash::hash_password(&password).map_err(|e| ModelError::Any(e.into()))?; users::ActiveModel { email: ActiveValue::set(profile.email.to_string()), name: ActiveValue::set(profile.name.to_string()), email_verified_at: ActiveValue::set(Some(Local::now().into())), password: ActiveValue::set(password_hash), ..Default::default() } .insert(&txn) .await .map_err(|e| { tracing::error!(error = %e, "failed to create OAuth2 user"); ModelError::Any(e.into()) })? } }; txn.commit().await?; Ok(user) } /// Required by the trait; mirrors the inherent [`Model::generate_jwt`] /// (inlined to avoid the inherent/trait name clash). fn generate_jwt(&self, secret: &str, expiration: &u64) -> ModelResult { jwt::JWT::new(secret) .generate_token(*expiration, self.pid.to_string(), Map::new()) .map_err(ModelError::from) } }