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 { 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 { 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 { 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 { vec![&self.terms, &self.privacy].into_iter() } }