8fefadc
use models::users::user;
use models::users::user_email_change_tickets::EmailChangeTicketStatus;
use models::users::user_email_verification_codes::EmailVerificationPurpose;
use models::users::{user_email_change_tickets, user_email_verification_codes, user_emails};
use sea_orm::sea_query::Expr;
use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
use session_layer::Session;
use time::OffsetDateTime;
use uuid::Uuid;
use super::AuthService;
use crate::errors::AuthError;
use crate::share::crypto::{constant_time_eq, generate_numeric_code, sha256_b64url};
use crate::share::rate::{attempts_reset, attempts_track, rate_limit_once};
use crate::share::validate::validate_email;
fn purpose_str(purpose: EmailVerificationPurpose) -> &'static str {
match purpose {
EmailVerificationPurpose::Signup => "signup",
EmailVerificationPurpose::PasswordReset => "password_reset",
EmailVerificationPurpose::AddEmail => "add_email",
EmailVerificationPurpose::Reclaim => "reclaim",
}
}
pub(crate) struct IssueCodeArtifacts {
pub temp_id: Uuid,
pub expires_in_secs: u64,
pub code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct VerifyEmailCmd {
pub email: String,
pub purpose: EmailVerificationPurpose,
pub code: String,
pub temp_id: Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct VerifyEmailResp {
pub verified: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ResendCodeCmd {
pub email: String,
pub purpose: EmailVerificationPurpose,
pub pow_answer: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ResendCodeResp {
pub sent: bool,
pub temp_id: Uuid,
pub expires_in_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ReclaimCmd {
pub email: String,
pub password: String,
pub pow_answer: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ReclaimResp {
pub sent: bool,
pub temp_id: Uuid,
pub expires_in_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct EmailChangeTicketDto {
pub id: Uuid,
pub new_email: String,
pub status: String,
pub created_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RequestEmailChangeCmd {
pub new_email: String,
pub password: String,
}
impl AuthService {
pub(crate) async fn issue_code(
&self,
user_id: Uuid,
email: &str,
purpose: EmailVerificationPurpose,
) -> Result<IssueCodeArtifacts, AuthError> {
let config = &self.config().auth;
let lower_email = email.trim().to_lowercase();
let purpose_name = purpose_str(purpose);
let rate_key = format!("auth:code_rate:{purpose_name}:{lower_email}");
rate_limit_once(self.redis(), &rate_key, config.email_code_rate_limit_secs).await?;
let db = self.db();
let now = OffsetDateTime::now_utc();
let stale = user_email_verification_codes::Entity::find()
.filter(user_email_verification_codes::Column::Email.eq(&lower_email))
.filter(user_email_verification_codes::Column::Purpose.eq(purpose))
.filter(user_email_verification_codes::Column::UsedAt.is_null())
.all(db.write())
.await?;
for row in stale {
let mut active: user_email_verification_codes::ActiveModel = row.into();
active.used_at = Set(Some(now));
let _ = active.update(db.write()).await;
}
let code = generate_numeric_code(config.verify_code_len);
let temp_id = Uuid::now_v7();
let code_key = format!("auth:code:{purpose_name}:{temp_id}");
export::redis::set_string(self.redis(), &code_key, &code, config.verify_code_ttl_secs)
.await?;
let row = user_email_verification_codes::ActiveModel {
id: Set(Uuid::now_v7()),
user_id: Set(user_id),
email: Set(lower_email.clone()),
purpose: Set(purpose),
token_hash: Set(sha256_b64url(code.as_bytes())),
created_at: Set(now),
expires_at: Set(now + time::Duration::seconds(config.verify_code_ttl_secs as i64)),
used_at: Set(None),
};
row.insert(db.write()).await?;
Ok(IssueCodeArtifacts {
temp_id,
expires_in_secs: config.verify_code_ttl_secs,
code,
})
}
pub async fn verify_verification_code(
&self,
cmd: VerifyEmailCmd,
) -> Result<VerifyEmailResp, AuthError> {
let config = &self.config().auth;
let purpose_name = purpose_str(cmd.purpose);
let code_key = format!("auth:code:{purpose_name}:{}", cmd.temp_id);
let Some(stored) = export::redis::get_string(self.redis(), &code_key).await? else {
return Err(AuthError::VerificationCodeExpired);
};
let attempts_key = format!("auth:code_attempts:{purpose_name}:{}", cmd.temp_id);
if !attempts_track(
self.redis(),
&attempts_key,
config.max_verify_attempts,
config.verify_code_ttl_secs,
)
.await?
{
export::redis::delete(self.redis(), &code_key).await?;
return Err(AuthError::RateLimited);
}
let provided = cmd.code.trim().to_string();
if !constant_time_eq(stored.as_bytes(), provided.as_bytes()) {
return Err(AuthError::VerificationCodeInvalid);
}
export::redis::delete(self.redis(), &code_key).await?;
attempts_reset(self.redis(), &attempts_key).await?;
let db = self.db();
let lower_email = cmd.email.trim().to_lowercase();
let Some(row) = user_email_verification_codes::Entity::find()
.filter(user_email_verification_codes::Column::Email.eq(&lower_email))
.filter(user_email_verification_codes::Column::Purpose.eq(cmd.purpose))
.filter(
user_email_verification_codes::Column::TokenHash
.eq(sha256_b64url(provided.as_bytes())),
)
.filter(user_email_verification_codes::Column::UsedAt.is_null())
.one(db.write())
.await?
else {
return Err(AuthError::VerificationCodeInvalid);
};
if row.expires_at <= OffsetDateTime::now_utc() {
return Err(AuthError::VerificationCodeExpired);
}
let now = OffsetDateTime::now_utc();
match cmd.purpose {
EmailVerificationPurpose::Signup | EmailVerificationPurpose::Reclaim => {
let user_id = row.user_id;
let email = row.email.clone();
let is_reclaim = cmd.purpose == EmailVerificationPurpose::Reclaim;
let row = row.clone();
sea_orm::TransactionTrait::transaction(db.primary(), |txn| {
Box::pin(async move {
let mut active: user_email_verification_codes::ActiveModel = row.into();
active.used_at = Set(Some(now));
active.update(txn).await?;
let _ = user::Entity::update_many()
.col_expr(user::Column::IsActive, Expr::value(true))
.col_expr(user::Column::UpdatedAt, Expr::value(now))
.filter(user::Column::Uid.eq(user_id))
.exec(txn)
.await?;
let _ = user_emails::Entity::update_many()
.col_expr(user_emails::Column::IsActivated, Expr::value(true))
.col_expr(user_emails::Column::ActivatedAt, Expr::value(now))
.filter(user_emails::Column::UserId.eq(user_id))
.filter(user_emails::Column::LowerEmail.eq(&email))
.exec(txn)
.await?;
if is_reclaim {
let _ = user::Entity::update_many()
.col_expr(
user::Column::PasswdVersion,
Expr::cust("COALESCE(passwd_version, 0) + 1"),
)
.col_expr(user::Column::UpdatedAt, Expr::value(now))
.filter(user::Column::Uid.eq(user_id))
.exec(txn)
.await?;
}
Ok::<(), DbErr>(())
})
})
.await?;
}
EmailVerificationPurpose::PasswordReset => {
let mut active: user_email_verification_codes::ActiveModel = row.into();
active.used_at = Set(Some(now));
active.update(db.write()).await?;
}
EmailVerificationPurpose::AddEmail => {
return Err(AuthError::BadRequest(
"add_email verification is handled by the user service".to_string(),
));
}
}
Ok(VerifyEmailResp { verified: true })
}
pub async fn resend_code(
&self,
session: &Session,
cmd: ResendCodeCmd,
accept_language: Option<&str>,
) -> Result<ResendCodeResp, AuthError> {
self.verify_pow(session, &cmd.pow_answer)?;
let lower_email = cmd.email.trim().to_lowercase();
let Some(email_row) = user_emails::Entity::find()
.filter(user_emails::Column::LowerEmail.eq(&lower_email))
.filter(user_emails::Column::DeletedAt.is_null())
.one(self.db().read())
.await?
else {
return Ok(ResendCodeResp {
sent: true,
temp_id: Uuid::now_v7(),
expires_in_secs: self.config().auth.verify_code_ttl_secs,
});
};
let artifacts = self
.issue_code(email_row.user_id, &lower_email, cmd.purpose)
.await?;
let locale = self
.mail_locale(Some(email_row.user_id), accept_language)
.await;
let purpose = verify_purpose(cmd.purpose);
let subject = i18n::catalog::verify_subject(locale, purpose);
let minutes = self.config().auth.verify_code_ttl_secs / 60;
let link = build_verify_link(
&self.config().web.web_base_url,
&lower_email,
&artifacts.temp_id.to_string(),
&artifacts.code,
cmd.purpose,
);
let html = crate::templates::mail_verify_code(
&artifacts.code,
minutes,
subject,
link.as_deref(),
locale,
);
let body = i18n::catalog::verify_text(locale, &artifacts.code);
let sent = self
.send_email(email_row.email.as_str(), subject, &body, html.0.as_str())
.await
.map(|_| true)
.unwrap_or_else(|error| {
tracing::warn!(%error, "failed to send verification email");
false
});
Ok(ResendCodeResp {
sent,
temp_id: artifacts.temp_id,
expires_in_secs: artifacts.expires_in_secs,
})
}
pub async fn reclaim(
&self,
session: &Session,
cmd: ReclaimCmd,
accept_language: Option<&str>,
) -> Result<ReclaimResp, AuthError> {
self.verify_pow(session, &cmd.pow_answer)?;
let lower_email = cmd.email.trim().to_lowercase();
let placeholder = || ReclaimResp {
sent: true,
temp_id: Uuid::now_v7(),
expires_in_secs: self.config().auth.verify_code_ttl_secs,
};
let Some((email_row, Some(user))) = user_emails::Entity::find()
.filter(user_emails::Column::LowerEmail.eq(&lower_email))
.filter(user_emails::Column::DeletedAt.is_null())
.find_also_related(user::Entity)
.one(self.db().read())
.await?
else {
return Ok(placeholder());
};
if user.is_active {
return Ok(placeholder());
}
let stored = user
.passwd_hash
.as_deref()
.ok_or(AuthError::InvalidCredentials)?;
if !crate::share::crypto::verify_password(&cmd.password, stored) {
return Err(AuthError::InvalidCredentials);
}
let artifacts = self
.issue_code(user.uid, &lower_email, EmailVerificationPurpose::Reclaim)
.await?;
let minutes = self.config().auth.verify_code_ttl_secs / 60;
let link = build_verify_link(
&self.config().web.web_base_url,
&lower_email,
&artifacts.temp_id.to_string(),
&artifacts.code,
EmailVerificationPurpose::Reclaim,
);
let locale = self.mail_locale(Some(user.uid), accept_language).await;
let subject = i18n::catalog::verify_subject(locale, i18n::VerifyPurpose::Reclaim);
let html = crate::templates::mail_verify_code(
&artifacts.code,
minutes,
subject,
link.as_deref(),
locale,
);
let body = i18n::catalog::verify_text(locale, &artifacts.code);
let sent = self
.send_email(email_row.email.as_str(), subject, &body, html.0.as_str())
.await
.map(|_| true)
.unwrap_or_else(|error| {
tracing::warn!(%error, "failed to send reclaim email");
false
});
Ok(ReclaimResp {
sent,
temp_id: artifacts.temp_id,
expires_in_secs: artifacts.expires_in_secs,
})
}
#[tracing::instrument(
name = "auth.request_email_change",
skip_all,
fields(uid = %uid, new_email = %cmd.new_email)
)]
pub async fn request_email_change(
&self,
uid: Uuid,
cmd: RequestEmailChangeCmd,
) -> Result<EmailChangeTicketDto, AuthError> {
let config = &self.config().auth;
let new_email = validate_email(&cmd.new_email)?;
let db = self.db();
let now = OffsetDateTime::now_utc();
let Some(user) = user::Entity::find_by_id(uid).one(db.read()).await? else {
return Err(AuthError::NotFound);
};
let stored = user
.passwd_hash
.as_deref()
.ok_or_else(|| AuthError::BadRequest("account has no password".to_string()))?;
if !crate::share::crypto::verify_password(&cmd.password, stored) {
return Err(AuthError::InvalidCredentials);
}
let current = user_emails::Entity::find()
.filter(user_emails::Column::UserId.eq(uid))
.filter(user_emails::Column::IsPrimary.eq(true))
.one(db.read())
.await?;
let current_email = current
.as_ref()
.map(|row| row.lower_email.clone())
.unwrap_or_default();
if current_email == new_email {
return Err(AuthError::BadRequest(
"new email is the same as the current email".to_string(),
));
}
let taken = user_emails::Entity::find()
.filter(user_emails::Column::LowerEmail.eq(&new_email))
.filter(user_emails::Column::UserId.ne(uid))
.one(db.read())
.await?;
if taken.is_some() {
return Err(AuthError::Conflict("email is already in use".to_string()));
}
let rate_key = format!("auth:code_rate:email_change:{new_email}");
rate_limit_once(self.redis(), &rate_key, config.email_code_rate_limit_secs).await?;
let stale = user_email_change_tickets::Entity::find()
.filter(user_email_change_tickets::Column::UserId.eq(uid))
.filter(user_email_change_tickets::Column::Status.eq(EmailChangeTicketStatus::Pending))
.all(db.write())
.await?;
for ticket in stale {
let mut active: user_email_change_tickets::ActiveModel = ticket.into();
active.status = Set(EmailChangeTicketStatus::Cancelled);
let _ = active.update(db.write()).await;
}
let code = generate_numeric_code(config.verify_code_len);
let ticket_id = Uuid::now_v7();
let ticket = user_email_change_tickets::ActiveModel {
id: Set(ticket_id),
user_id: Set(uid),
token_hash: Set(sha256_b64url(code.as_bytes())),
new_email: Set(new_email.clone()),
original_email: Set(current.map(|row| row.email)),
status: Set(EmailChangeTicketStatus::Pending),
created_at: Set(now),
expires_at: Set(now + time::Duration::seconds(config.verify_code_ttl_secs as i64)),
used_at: Set(None),
};
ticket.insert(db.write()).await?;
let expires_at = now + time::Duration::seconds(config.verify_code_ttl_secs as i64);
let base_url = &self.config().web.web_base_url;
let locale = self.mail_locale(Some(uid), None).await;
let html = crate::templates::mail_email_change_confirm(
&new_email,
&ticket_id.to_string(),
&code,
base_url,
locale,
);
let body = i18n::catalog::email_change_text(locale, &code);
if let Err(error) = self
.send_email(
&new_email,
i18n::catalog::email_change_subject(locale),
&body,
html.0.as_str(),
)
.await
{
tracing::warn!(%error, "failed to send email change code");
}
Ok(EmailChangeTicketDto {
id: ticket_id,
new_email,
status: "pending".to_string(),
created_at: now,
expires_at,
})
}
#[tracing::instrument(
name = "auth.confirm_email_change",
skip_all,
fields(uid = %uid, ticket_id = %ticket_id)
)]
pub async fn confirm_email_change(
&self,
uid: Uuid,
ticket_id: Uuid,
code: &str,
) -> Result<(), AuthError> {
let config = &self.config().auth;
let attempts_key = format!("auth:email_change_attempts:{ticket_id}");
if !attempts_track(
self.redis(),
&attempts_key,
config.max_verify_attempts,
config.verify_code_ttl_secs,
)
.await?
{
return Err(AuthError::RateLimited);
}
let db = self.db();
let now = OffsetDateTime::now_utc();
let Some(ticket) = user_email_change_tickets::Entity::find()
.filter(user_email_change_tickets::Column::Id.eq(ticket_id))
.filter(user_email_change_tickets::Column::UserId.eq(uid))
.filter(user_email_change_tickets::Column::Status.eq(EmailChangeTicketStatus::Pending))
.one(db.write())
.await?
else {
return Err(AuthError::BadRequest(
"email change ticket not found".to_string(),
));
};
if ticket.expires_at <= now {
return Err(AuthError::BadRequest(
"email change ticket expired".to_string(),
));
}
let code_hash = sha256_b64url(code.trim().as_bytes());
if !constant_time_eq(code_hash.as_bytes(), ticket.token_hash.as_bytes()) {
return Err(AuthError::BadRequest(
"invalid email change code".to_string(),
));
}
attempts_reset(self.redis(), &attempts_key).await?;
let conflicting = user_emails::Entity::find()
.filter(user_emails::Column::LowerEmail.eq(ticket.new_email.to_lowercase()))
.filter(user_emails::Column::UserId.ne(uid))
.one(db.write())
.await?;
if conflicting.is_some() {
return Err(AuthError::Conflict("email is already in use".to_string()));
}
let new_email = ticket.new_email.clone();
let new_lower = new_email.to_lowercase();
sea_orm::TransactionTrait::transaction(db.primary(), |txn| {
Box::pin(async move {
let _ = user_emails::Entity::update_many()
.col_expr(user_emails::Column::IsPrimary, Expr::value(false))
.filter(user_emails::Column::UserId.eq(uid))
.filter(user_emails::Column::IsPrimary.eq(true))
.exec(txn)
.await?;
let existing = user_emails::Entity::find()
.filter(user_emails::Column::UserId.eq(uid))
.filter(user_emails::Column::LowerEmail.eq(&new_lower))
.one(txn)
.await?;
if let Some(row) = existing {
let mut active: user_emails::ActiveModel = row.into();
active.is_activated = Set(true);
active.is_primary = Set(true);
active.activated_at = Set(Some(now));
active.updated_at = Set(now);
active.update(txn).await?;
} else {
let row = user_emails::ActiveModel {
id: Set(Uuid::now_v7()),
user_id: Set(uid),
email: Set(new_email.clone()),
lower_email: Set(new_lower.clone()),
is_activated: Set(true),
is_primary: Set(true),
created_at: Set(now),
updated_at: Set(now),
activated_at: Set(Some(now)),
deleted_at: Set(None),
};
row.insert(txn).await?;
}
let mut active: user_email_change_tickets::ActiveModel = ticket.into();
active.status = Set(EmailChangeTicketStatus::Completed);
active.used_at = Set(Some(now));
active.update(txn).await?;
Ok::<(), DbErr>(())
})
})
.await?;
Ok(())
}
pub async fn cancel_email_change(&self, uid: Uuid, ticket_id: Uuid) -> Result<(), AuthError> {
let db = self.db();
let Some(ticket) = user_email_change_tickets::Entity::find()
.filter(user_email_change_tickets::Column::Id.eq(ticket_id))
.filter(user_email_change_tickets::Column::UserId.eq(uid))
.filter(user_email_change_tickets::Column::Status.eq(EmailChangeTicketStatus::Pending))
.one(db.write())
.await?
else {
return Err(AuthError::NotFound);
};
let mut active: user_email_change_tickets::ActiveModel = ticket.into();
active.status = Set(EmailChangeTicketStatus::Cancelled);
active.update(db.write()).await?;
Ok(())
}
}
/// Verification links must carry the one-time `code` and the `purpose` slug:
/// the web page confirms by submitting both, and defaults `purpose` to
/// `signup` when it is absent, which would reject non-signup flows.
pub(super) fn build_verify_link(
base_url: &str,
email: &str,
temp_id: &str,
code: &str,
purpose: EmailVerificationPurpose,
) -> Option<String> {
if base_url.is_empty() {
return None;
}
let base = base_url.trim_end_matches('/');
Some(format!(
"{base}/auth/verify-email?email={}&temp_id={}&code={}&purpose={}",
percent_encode(email),
percent_encode(temp_id),
percent_encode(code),
purpose_str(purpose),
))
}
fn percent_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for &b in input.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
}
out
}
/// Bridge the model enum to the catalogue's own purpose enum, keeping `i18n`
/// free of a dependency on `models`.
pub(crate) fn verify_purpose(purpose: EmailVerificationPurpose) -> i18n::VerifyPurpose {
match purpose {
EmailVerificationPurpose::Signup => i18n::VerifyPurpose::Signup,
EmailVerificationPurpose::PasswordReset => i18n::VerifyPurpose::PasswordReset,
EmailVerificationPurpose::AddEmail => i18n::VerifyPurpose::AddEmail,
EmailVerificationPurpose::Reclaim => i18n::VerifyPurpose::Reclaim,
}
}