Files
kompress_eshop/src/models/users.rs
Priec 5b203ed248
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
TOTP google authenticator implemented properly well
2026-06-20 22:48:15 +02:00

732 lines
27 KiB
Rust

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<TOTP> {
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<String>,
}
/// 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<dyn Validate> {
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<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
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<Self> {
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> {
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<Self> {
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<Self> {
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<Self> {
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<Self> {
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<Self> {
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<Self> {
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<TOTP> {
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<String> {
self.totp_backup_codes
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(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<Self> {
let txn = db.begin().await?;
if users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::Email, &params.email)
.build(),
)
.one(&txn)
.await?
.is_some()
{
return Err(ModelError::EntityAlreadyExists {});
}
let password_hash =
hash::hash_password(&params.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<Self> {
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<String> {
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<Model> {
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<Model> {
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<Model> {
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<Model> {
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<Model> {
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<Model> {
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<Model> {
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<String>)> {
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<Model> {
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<Model> {
let code = code.trim().replace([' ', '-'], "");
let current: Vec<String> = 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<String> = 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<String>)> {
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<String>, Vec<String>)> {
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).
/// <https://developers.google.com/identity/openid-connect/openid-connect#obtainuserinfo>
#[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<String>,
pub family_name: Option<String>,
pub picture: Option<String>,
pub locale: Option<String>,
}
#[async_trait]
impl OAuth2UserTrait<OAuth2UserProfile> for Model {
/// Resolve the user behind an active OAuth2 session id.
async fn find_by_oauth2_session_id(
db: &DatabaseConnection,
session_id: &str,
) -> ModelResult<Self> {
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<Self> {
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<String> {
jwt::JWT::new(secret)
.generate_token(*expiration, self.pid.to_string(), Map::new())
.map_err(ModelError::from)
}
}