8fefadc
use models::users::user_email_verification_codes::{self, EmailVerificationPurpose};
use models::users::user_emails;
use sea_orm::sea_query::Expr;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::errors::UserError;
use crate::services::service::UserService;
use crate::share::crypto::{constant_time_eq, generate_numeric_code, sha256_b64url};
pub const EMAIL_MAX_LEN: usize = 254;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct EmailDto {
pub id: Uuid,
pub email: String,
pub is_activated: bool,
pub is_primary: bool,
pub created_at: OffsetDateTime,
pub activated_at: Option<OffsetDateTime>,
}
impl From<user_emails::Model> for EmailDto {
fn from(m: user_emails::Model) -> Self {
Self {
id: m.id,
email: m.email,
is_activated: m.is_activated,
is_primary: m.is_primary,
created_at: m.created_at,
activated_at: m.activated_at,
}
}
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct AddEmailCmd {
pub email: String,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct VerifyAddEmailCmd {
pub email: String,
pub code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AddEmailResp {
pub pending: bool,
pub expires_in: i64,
}
impl UserService {
pub async fn list_emails(&self, uid: Uuid) -> Result<Vec<EmailDto>, UserError> {
let rows = user_emails::Entity::find()
.filter(user_emails::Column::UserId.eq(uid))
.filter(user_emails::Column::DeletedAt.is_null())
.order_by_desc(user_emails::Column::CreatedAt)
.all(self.db().read())
.await?;
Ok(rows.into_iter().map(EmailDto::from).collect())
}
#[tracing::instrument(name = "user.add_email", skip_all, fields(uid = %uid))]
pub async fn add_email(&self, uid: Uuid, cmd: AddEmailCmd) -> Result<AddEmailResp, UserError> {
let config = &self.config().user;
let email = cmd.email.trim().to_lowercase();
if email.is_empty()
|| email.len() > EMAIL_MAX_LEN
|| !email.contains('@')
|| email.chars().any(|c| c.is_whitespace())
{
return Err(UserError::BadRequest(format!(
"invalid email (length 1..={EMAIL_MAX_LEN})"
)));
}
let rows = user_emails::Entity::find()
.filter(user_emails::Column::LowerEmail.eq(&email))
.all(self.db().read())
.await?;
if rows.iter().any(|row| row.user_id != uid) {
return Err(UserError::Conflict("email is already in use".to_string()));
}
if rows
.iter()
.any(|row| row.is_activated && row.deleted_at.is_none())
{
return Err(UserError::Conflict("already added".to_string()));
}
let rate_key = format!("user:email_code_rate:add_email:{email}");
let acquired = export::redis::set_nx_ex(
&self.state.redis,
&rate_key,
"1",
config.email_code_rate_limit_secs,
)
.await?;
if !acquired {
return Err(UserError::BadRequest(
"too many requests,try later".to_string(),
));
}
let now = OffsetDateTime::now_utc();
user_email_verification_codes::Entity::update_many()
.filter(user_email_verification_codes::Column::UserId.eq(uid))
.filter(
user_email_verification_codes::Column::Purpose
.eq(EmailVerificationPurpose::AddEmail),
)
.filter(user_email_verification_codes::Column::UsedAt.is_null())
.col_expr(
user_email_verification_codes::Column::UsedAt,
Expr::value(Some(now)),
)
.exec(self.db().write())
.await?;
let code = generate_numeric_code(config.email_verify_code_len);
let expires_at = now + time::Duration::seconds(config.email_verify_code_ttl_secs);
let row = user_email_verification_codes::ActiveModel {
id: Set(Uuid::now_v7()),
user_id: Set(uid),
email: Set(email.clone()),
purpose: Set(EmailVerificationPurpose::AddEmail),
token_hash: Set(sha256_b64url(code.as_bytes())),
created_at: Set(now),
expires_at: Set(expires_at),
used_at: Set(None),
};
row.insert(self.db().write()).await?;
// The user is signed in here, so the account preference is available.
let locale = self.recipient_locale(uid).await;
let text = i18n::catalog::add_email_text(locale, &code);
let html = format!("<p>{text}</p>");
if let Err(error) = self
.state
.email
.send_mail(
&email,
i18n::catalog::add_email_subject(locale),
&text,
&html,
)
.await
{
tracing::warn!(%error, "failed to send add email verification code");
}
Ok(AddEmailResp {
pending: true,
expires_in: config.email_verify_code_ttl_secs,
})
}
#[tracing::instrument(name = "user.verify_add_email", skip_all, fields(uid = %uid))]
pub async fn verify_add_email(
&self,
uid: Uuid,
cmd: VerifyAddEmailCmd,
) -> Result<EmailDto, UserError> {
let config = &self.config().user;
let lower = cmd.email.trim().to_lowercase();
let now = OffsetDateTime::now_utc();
let Some(row) = user_email_verification_codes::Entity::find()
.filter(user_email_verification_codes::Column::Email.eq(&lower))
.filter(user_email_verification_codes::Column::UserId.eq(uid))
.filter(
user_email_verification_codes::Column::Purpose
.eq(EmailVerificationPurpose::AddEmail),
)
.filter(user_email_verification_codes::Column::UsedAt.is_null())
.order_by_desc(user_email_verification_codes::Column::CreatedAt)
.one(self.db().read())
.await?
else {
return Err(UserError::BadRequest("no pending verification".to_string()));
};
if row.expires_at <= now {
return Err(UserError::BadRequest(
"verification code expired".to_string(),
));
}
let attempts_key = format!("user:email_verify_attempts:{lower}");
let attempts = export::redis::incr_expire(
&self.state.redis,
&attempts_key,
config.email_verify_code_ttl_secs as u64,
)
.await?;
if attempts > config.email_verify_max_attempts as i64 {
let mut used: user_email_verification_codes::ActiveModel = row.into();
used.used_at = Set(Some(now));
used.update(self.db().write()).await?;
return Err(UserError::BadRequest("too many attempts".to_string()));
}
let provided = sha256_b64url(cmd.code.trim().as_bytes());
if !constant_time_eq(provided.as_bytes(), row.token_hash.as_bytes()) {
return Err(UserError::BadRequest(
"invalid verification code".to_string(),
));
}
let txn = self.db().primary();
let updated = txn
.transaction::<_, EmailDto, UserError>(|tx| {
Box::pin(async move {
let conflicts = user_emails::Entity::find()
.filter(user_emails::Column::LowerEmail.eq(&lower))
.filter(user_emails::Column::UserId.ne(uid))
.one(tx)
.await?;
if conflicts.is_some() {
return Err(UserError::Conflict("email is already in use".to_string()));
}
let mut used: user_email_verification_codes::ActiveModel = row.into();
used.used_at = Set(Some(now));
used.update(tx).await?;
let mine = user_emails::Entity::find()
.filter(user_emails::Column::UserId.eq(uid))
.filter(user_emails::Column::LowerEmail.eq(&lower))
.one(tx)
.await?;
let model = match mine {
Some(existing) => {
let mut active: user_emails::ActiveModel = existing.into();
active.email = Set(lower.clone());
active.lower_email = Set(lower.clone());
active.is_activated = Set(true);
active.activated_at = Set(Some(now));
active.deleted_at = Set(None);
active.updated_at = Set(now);
active.update(tx).await?
}
None => {
let active = user_emails::ActiveModel {
id: Set(Uuid::now_v7()),
user_id: Set(uid),
email: Set(lower.clone()),
lower_email: Set(lower.clone()),
is_activated: Set(true),
is_primary: Set(false),
created_at: Set(now),
updated_at: Set(now),
activated_at: Set(Some(now)),
deleted_at: Set(None),
};
active.insert(tx).await?
}
};
Ok(EmailDto::from(model))
})
})
.await?;
Ok(updated)
}
#[tracing::instrument(
name = "user.set_primary_email",
skip_all,
fields(uid = %uid, email_id = %email_id)
)]
pub async fn set_primary_email(
&self,
uid: Uuid,
email_id: Uuid,
) -> Result<EmailDto, UserError> {
let txn = self.db().primary();
let updated = txn
.transaction::<_, EmailDto, UserError>(|tx| {
Box::pin(async move {
let target = user_emails::Entity::find_by_id(email_id)
.one(tx)
.await?
.ok_or(UserError::NotFound)?;
if target.user_id != uid {
return Err(UserError::Forbidden);
}
if !target.is_activated {
return Err(UserError::BadRequest("email not yet activated".into()));
}
if target.is_primary {
return Err(UserError::BadRequest("email already primary".into()));
}
let now = OffsetDateTime::now_utc();
user_emails::Entity::update_many()
.filter(user_emails::Column::UserId.eq(uid))
.filter(user_emails::Column::IsPrimary.eq(true))
.col_expr(user_emails::Column::IsPrimary, Expr::value(false))
.col_expr(user_emails::Column::UpdatedAt, Expr::value(now))
.exec(tx)
.await?;
let mut am: user_emails::ActiveModel = target.into();
am.is_primary = Set(true);
am.updated_at = Set(now);
let saved = am.update(tx).await?;
Ok(EmailDto::from(saved))
})
})
.await?;
Ok(updated)
}
#[tracing::instrument(
name = "user.delete_email",
skip_all,
fields(uid = %uid, email_id = %email_id)
)]
pub async fn delete_email(&self, uid: Uuid, email_id: Uuid) -> Result<(), UserError> {
let row = user_emails::Entity::find_by_id(email_id)
.one(self.db().read())
.await?
.ok_or(UserError::NotFound)?;
if row.user_id != uid {
return Err(UserError::Forbidden);
}
if row.is_primary && row.is_activated {
return Err(UserError::BadRequest(
"cannot delete primary activated email".into(),
));
}
let now = OffsetDateTime::now_utc();
let mut am: user_emails::ActiveModel = row.into();
am.deleted_at = Set(Some(now));
am.updated_at = Set(now);
am.update(self.db().write()).await?;
Ok(())
}
}