540 lines
18 KiB
Rust
540 lines
18 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 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<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"
|
|
}
|
|
|
|
/// 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, ¶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<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)
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|