a06673b
use time::OffsetDateTime;
use uuid::Uuid;
use db::conn::sea_orm::{
ActiveModelTrait, DatabaseConnection, EntityTrait, QueryFilter, TransactionTrait,
};
use db::user::user_email_verification_codes::EmailVerificationPurpose;
use db::user::user_emails::{ActiveModel as EmailActiveModel, Entity as EmailEntity};
use db::user::users::{
ActiveModel as UserActiveModel, Entity as UserEntity, ProfileVisibility,
};
use email::events::{EmailAddress, SendEmailRequest};
use crate::error::AuthError;
use crate::features::password_hash;
use crate::pow::PowService;
use super::types::{RegisterCmd, RegisterResp};
fn lower_email(email: &str) -> String {
email.trim().to_ascii_lowercase()
}
fn derive_username(email: &str, custom: Option<&str>) -> String {
if let Some(c) = custom {
return c.to_string();
}
let at = email.find('@').unwrap_or(email.len());
email[..at].to_string()
}
fn validate_email(email: &str) -> Result<(), AuthError> {
use validator::ValidateEmail;
if email.is_empty() || email.len() > 254 || !email.validate_email() {
return Err(AuthError::BadRequest("invalid email".into()));
}
Ok(())
}
fn validate_username(name: &str) -> Result<(), AuthError> {
let len = name.chars().count();
if !(1..=64).contains(&len) {
return Err(AuthError::BadRequest(
"username must be 1-64 characters".into(),
));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
{
return Err(AuthError::BadRequest(
"username may contain only letters, digits, '_', '-', '.'".into(),
));
}
Ok(())
}
async fn email_exists(db: &DatabaseConnection, email: &str) -> Result<bool, AuthError> {
let lower = lower_email(email);
let hit = EmailEntity::find()
.filter(EmailEntity::COLUMN.lower_email.eq(lower))
.one(db)
.await?;
Ok(hit.is_some())
}
async fn username_exists(db: &DatabaseConnection, username: &str) -> Result<bool, AuthError> {
let lower = username.to_ascii_lowercase();
let hit = UserEntity::find()
.filter(UserEntity::COLUMN.lower_name.eq(lower))
.one(db)
.await?;
Ok(hit.is_some())
}
async fn verify_pow(
pow: &PowService,
proof: &super::types::PowProof,
expected_cid: Option<&str>,
) -> Result<(), AuthError> {
let result = pow
.verify_solution_once(&proof.challenge, &proof.solution, expected_cid)
.await?;
if !result.verified {
return Err(AuthError::PowFailed("PoW solution not accepted".into()));
}
Ok(())
}
async fn send_verification_email(
svc: &email::EmailService,
email_addr: &str,
code: &str,
) -> Result<(), AuthError> {
let subject = "Verify your account".to_string();
let text = format!(
"Welcome!\n\nYour verification code is: {}\n\nThe code expires in 10 minutes.",
code
);
let req = SendEmailRequest {
to: vec![EmailAddress::new(email_addr)],
subject,
text: Some(text),
..Default::default()
};
svc.send(req).await?;
Ok(())
}
/// Register a new user. The account is created with `is_active = false` and a
/// verification code is emailed. The caller must complete verification before
/// the user can sign in with a password.
pub async fn register(
cmd: RegisterCmd,
svc: &crate::features::AuthService,
expected_cid: Option<&str>,
) -> Result<RegisterResp, AuthError> {
validate_email(&cmd.email)?;
let username = derive_username(&cmd.email, cmd.username.as_deref());
validate_username(&username)?;
password_hash::validate_password(
password_hash::ValidatePasswordRequest {
password: &cmd.password,
},
&svc.config.password_policy,
)?;
verify_pow(&svc.pow, &cmd.pow, expected_cid).await?;
let db = svc.db.writer();
// VULN-006: return identical messages for email and username collisions
// so an attacker cannot enumerate which addresses / handles are taken.
if email_exists(&db, &cmd.email).await? {
return Err(AuthError::Conflict("registration failed".into()));
}
if username_exists(&db, &username).await? {
return Err(AuthError::Conflict("registration failed".into()));
}
let now = OffsetDateTime::now_utc();
let uid = Uuid::new_v4();
let hash = password_hash::usecase::hash_password(password_hash::PasswordInput {
password: &cmd.password,
})?;
// Transactional insert: users + user_email.
let txn = db.begin().await?;
let user_am: UserActiveModel = UserActiveModel::builder()
.set_uid(uid)
.set_username(username.clone())
.set_lower_name(username.to_ascii_lowercase())
.set_display_name(username.clone())
.set_avatar(String::new())
.set_is_active(false)
.set_is_admin(false)
.set_is_restricted(false)
.set_prohibit_login(false)
.set_visibility(ProfileVisibility::Public)
.set_bot(false)
.set_system(false)
.set_last_login_at(None)
.set_last_login_ip_hash(None)
.set_version(1)
.set_passwd_hash(Some(hash.0))
.set_password_last_changed_at(Some(now))
.set_passwd_version(Some(1i64))
.set_totp_encrypted_secret(None)
.set_totp_enabled(false)
.set_totp_last_used_at(None)
.set_totp_last_counter(None)
.set_timezone(None)
.set_locale(None)
.set_language(None)
.set_created_at(now)
.set_updated_at(now)
.into();
user_am.insert(&txn).await?;
let email_am: EmailActiveModel = EmailActiveModel::builder()
.set_id(Uuid::new_v4())
.set_user_id(uid)
.set_email(cmd.email.clone())
.set_lower_email(lower_email(&cmd.email))
.set_is_activated(false)
.set_is_primary(true)
.set_created_at(now)
.set_updated_at(now)
.set_activated_at(None)
.set_deleted_at(None)
.into();
email_am.insert(&txn).await?;
txn.commit().await?;
// Issue verification code (Redis + DB row).
let artifacts = crate::features::email_code::usecase::issue_code(
crate::features::email_code::types::IssueCodeCmd {
email: cmd.email.clone(),
purpose: EmailVerificationPurpose::Signup,
user_id: uid,
},
&db,
&svc.kv,
&svc.config,
)
.await?;
// VULN-008: keep the account alive even if the email send fails.
// The user can re-issue the verification code through the resend
// endpoint; rolling the user back would let an attacker create and
// immediately abandon accounts to spam the email pipeline.
if let Err(e) = send_verification_email(&svc.email, &cmd.email, &artifacts.code).await {
tracing::warn!(?e, email = %cmd.email, "verification email send failed; account kept pending, caller may resend");
}
// Record accepted agreements (best-effort; ignore unknown keys).
for key in &cmd.accept_agreements {
if let Some(doc) = svc.config.agreements.iter().find(|d| &d.key == key) {
let _ = svc.record_agreement(uid, &doc.key, &doc.version).await;
}
}
Ok(RegisterResp {
uid,
pending: true,
temp_key: artifacts.temp_key,
purpose: EmailVerificationPurpose::Signup,
})
}
// Helper trait to iterate agreements (Terms + Privacy) uniformly.
impl crate::config::AgreementsConfig {
pub fn iter(&self) -> impl Iterator<Item = &crate::config::AgreementDoc> {
vec![&self.terms, &self.privacy].into_iter()
}
}