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 totp_rs::{Algorithm, Secret, TOTP}; 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; // TODO(security): `users.totp_secret` is stored as a PLAINTEXT base32 string. // Unlike `password` (a one-way hash) the TOTP secret must be kept in reversible // form — the server needs the original value to recompute codes — so it is // effectively password-equivalent: anyone who can read this column can mint // valid 2FA codes for that user. It is deliberately left in plaintext for now // and treated like the app's other server-side secrets (e.g. the SMTP password, // kept out of the DB entirely). When secrets get a proper at-rest story, encrypt // this column with a key held OUTSIDE the database (env / `pass`), decrypting // only in memory. The single read/write site is `build_totp` + // `begin_totp_enrollment` below; `totp_backup_codes` are already hashed and need // no change. Grep `TODO(security)` to find this. /// TOTP (Google Authenticator) parameters. These are the values Google /// Authenticator assumes; it ignores anything else encoded in the otpauth URL, /// so they must stay SHA1 / 6 digits / 30s or codes won't match. const TOTP_ISSUER: &str = "Kompress"; const TOTP_DIGITS: usize = 6; /// Accept codes ±1 time-step (~30s) to tolerate client/server clock drift. const TOTP_SKEW: u8 = 1; const TOTP_STEP: u64 = 30; /// Number of one-time recovery codes generated when 2FA is enabled. pub const TOTP_BACKUP_CODE_COUNT: usize = 8; /// Build a [`TOTP`] from a stored base32 secret and the account label (email). /// Returns `None` if the secret can't be decoded. fn build_totp(secret_base32: &str, account: &str) -> Option { let bytes = Secret::Encoded(secret_base32.to_string()).to_bytes().ok()?; TOTP::new( Algorithm::SHA1, TOTP_DIGITS, TOTP_SKEW, TOTP_STEP, bytes, Some(TOTP_ISSUER.to_string()), account.to_string(), ) .ok() } #[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" } /// Whether two-factor auth is active for this account. This is the single /// source of truth used by the login flow: a secret may be present during a /// half-finished enrollment, but 2FA only gates login once it is *confirmed* /// (which is what sets `totp_enabled_at`). #[must_use] pub fn totp_enabled(&self) -> bool { self.totp_enabled_at.is_some() } /// Build the [`TOTP`] for this user from its stored secret, if any. fn totp(&self) -> Option { let secret = self.totp_secret.as_deref()?; build_totp(secret, &self.email) } /// A `data:image/png;base64,...` QR for the *pending* secret plus the secret /// itself (shown as a manual-entry fallback). Used on the enrollment page. /// Returns `None` if no secret is staged or QR rendering fails. #[must_use] pub fn totp_provisioning(&self) -> Option<(String, String)> { let totp = self.totp()?; let qr = totp.get_qr_base64().ok()?; Some(( format!("data:image/png;base64,{qr}"), self.totp_secret.clone()?, )) } /// Verify a 6-digit authenticator code against the stored secret. Returns /// false if no secret is staged or the code is wrong. Works both during /// enrollment confirmation and at login. #[must_use] pub fn verify_totp_code(&self, code: &str) -> bool { let code = code.trim().replace(' ', ""); self.totp() .and_then(|t| t.check_current(&code).ok()) .unwrap_or(false) } /// Whether `code` matches one of the still-unused backup codes. #[must_use] pub fn matches_backup_code(&self, code: &str) -> bool { let code = code.trim().replace([' ', '-'], ""); self.backup_code_hashes() .iter() .any(|h| hash::verify_password(&code, h)) } /// The stored hashed backup codes (empty if none). fn backup_code_hashes(&self) -> Vec { self.totp_backup_codes .as_deref() .and_then(|s| serde_json::from_str::>(s).ok()) .unwrap_or_default() } /// How many unused backup codes remain. #[must_use] pub fn backup_codes_remaining(&self) -> usize { self.backup_code_hashes().len() } /// 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) } /// Stage a fresh TOTP secret for enrollment. This does **not** turn 2FA on — /// `totp_enabled_at` stays null until the user proves they scanned it by /// confirming a code (see [`Self::enable_totp`]). Any previously staged /// secret/backup codes are discarded so re-enrolling always starts clean. pub async fn begin_totp_enrollment(mut self, db: &DatabaseConnection) -> ModelResult { let secret = match Secret::generate_secret().to_encoded() { Secret::Encoded(s) => s, // generate_secret() always yields raw bytes that encode cleanly. Secret::Raw(_) => unreachable!("to_encoded() returns Encoded"), }; self.totp_secret = ActiveValue::set(Some(secret)); self.totp_enabled_at = ActiveValue::set(None); self.totp_backup_codes = ActiveValue::set(None); self.update(db).await.map_err(ModelError::from) } /// Confirm enrollment and switch 2FA on. The caller must have already /// verified a code against the staged secret. Generates and stores hashed /// one-time backup codes and returns the plaintext codes to display **once**. pub async fn enable_totp( mut self, db: &DatabaseConnection, ) -> ModelResult<(Model, Vec)> { let (plain, hashes) = generate_backup_codes()?; let encoded = serde_json::to_string(&hashes).map_err(|e| ModelError::Any(e.into()))?; self.totp_enabled_at = ActiveValue::set(Some(Local::now().into())); self.totp_backup_codes = ActiveValue::set(Some(encoded)); let model = self.update(db).await.map_err(ModelError::from)?; Ok((model, plain)) } /// Turn 2FA off and wipe all TOTP state. Callers gate this behind a fresh /// confirmation (password or a current code). pub async fn disable_totp(mut self, db: &DatabaseConnection) -> ModelResult { self.totp_secret = ActiveValue::set(None); self.totp_enabled_at = ActiveValue::set(None); self.totp_backup_codes = ActiveValue::set(None); self.update(db).await.map_err(ModelError::from) } /// Remove a used backup code from the stored set so it can't be reused. /// `code` is matched against the remaining hashes; a no-op if it doesn't /// match (the caller decides whether a match was required). pub async fn consume_backup_code( mut self, db: &DatabaseConnection, code: &str, ) -> ModelResult { let code = code.trim().replace([' ', '-'], ""); let current: Vec = match self.totp_backup_codes.as_ref() { Some(s) => serde_json::from_str(s.as_str()).unwrap_or_default(), None => Vec::new(), }; let remaining: Vec = current .into_iter() .filter(|h| !hash::verify_password(&code, h)) .collect(); let encoded = serde_json::to_string(&remaining).map_err(|e| ModelError::Any(e.into()))?; self.totp_backup_codes = ActiveValue::set(Some(encoded)); self.update(db).await.map_err(ModelError::from) } /// Replace the backup codes with a fresh set (e.g. after the user used some). /// Only meaningful while 2FA is enabled; returns the new plaintext codes. pub async fn regenerate_backup_codes( mut self, db: &DatabaseConnection, ) -> ModelResult<(Model, Vec)> { let (plain, hashes) = generate_backup_codes()?; let encoded = serde_json::to_string(&hashes).map_err(|e| ModelError::Any(e.into()))?; self.totp_backup_codes = ActiveValue::set(Some(encoded)); let model = self.update(db).await.map_err(ModelError::from)?; Ok((model, plain)) } } /// Generate `TOTP_BACKUP_CODE_COUNT` recovery codes, returning /// `(plaintext, hashes)`. Only the hashes are persisted; the plaintext is shown /// to the user once and never stored. fn generate_backup_codes() -> ModelResult<(Vec, Vec)> { let mut plain = Vec::with_capacity(TOTP_BACKUP_CODE_COUNT); let mut hashes = Vec::with_capacity(TOTP_BACKUP_CODE_COUNT); for _ in 0..TOTP_BACKUP_CODE_COUNT { let code = hash::random_string(10).to_lowercase(); let hashed = hash::hash_password(&code).map_err(|e| ModelError::Any(e.into()))?; plain.push(code); hashes.push(hashed); } Ok((plain, hashes)) } /// 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) } }