use argon2::password_hash::SaltString; use argon2::{Argon2, password_hash::PasswordHasher}; use rand::Rng; use serde::{Deserialize, Serialize}; use std::time::Duration; use crate::error::AppError; use crate::models::users::User; use crate::pb::email::{EmailAddress, SendEmailRequest}; use crate::service::AuthService; use crate::session::Session; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct RegisterParams { pub username: String, pub email: String, pub password: String, pub captcha: String, pub email_code: String, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct RegisterEmailCodeParams { pub email: String, pub captcha: String, } #[derive(Serialize, Clone, Debug, utoipa::ToSchema)] pub struct RegisterEmailCodeResponse { pub expires_in_secs: u64, } impl AuthService { const REGISTER_EMAIL_CODE_PREFIX: &'static str = "auth:register_email:"; const REGISTER_EMAIL_CODE_TTL_SECS: u64 = 600; const REGISTER_EMAIL_CODE_COOLDOWN_SECS: u64 = 60; pub async fn auth_register_email_code( &self, params: RegisterEmailCodeParams, context: &Session, ) -> Result { self.auth_check_captcha(context, params.captcha).await?; let email = params.email.trim().to_lowercase(); if email.is_empty() { return Err(AppError::BadRequest("email is required".into())); } if self.auth_verified_email_exists(&email).await? { return Err(AppError::EmailExists); } let cooldown_key = format!("{}cooldown:{}", Self::REGISTER_EMAIL_CODE_PREFIX, email); if self.ctx.cache.exists(&cooldown_key) { return Err(AppError::BadRequest( "verification code was sent recently; please try again later".into(), )); } let code = Self::generate_register_email_code(); let cache_key = Self::register_email_code_key(&email); self.ctx .cache .set( &cache_key, &code, Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_TTL_SECS)), ) .map_err(|e| AppError::InternalServerError(e.to_string()))?; self.ctx .cache .set( &cooldown_key, &true, Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_COOLDOWN_SECS)), ) .map_err(|e| AppError::InternalServerError(e.to_string()))?; let mut mail = self .ctx .registry .get_email_client() .ok_or(AppError::Config("mail service not available".into()))?; mail.send_email(tonic::Request::new(SendEmailRequest { to: vec![EmailAddress { email: email.clone(), name: String::new(), }], subject: "Register Email Verification".into(), text_body: format!( "Your registration verification code is: {}\n\nThis code expires in 10 minutes.", code ), ..Default::default() })) .await .map_err(|e| AppError::InternalServerError(e.to_string()))?; Ok(RegisterEmailCodeResponse { expires_in_secs: Self::REGISTER_EMAIL_CODE_TTL_SECS, }) } fn auth_check_register_email_code(&self, email: &str, code: &str) -> Result<(), AppError> { let cache_key = Self::register_email_code_key(email); let stored = self .ctx .cache .get::(&cache_key) .ok_or(AppError::InvalidEmailCode)?; if !crate::service::util::constant_time_eq(stored.trim(), code.trim()) { return Err(AppError::InvalidEmailCode); } let _ = self.ctx.cache.delete(&cache_key); Ok(()) } fn register_email_code_key(email: &str) -> String { format!( "{}{}", Self::REGISTER_EMAIL_CODE_PREFIX, email.trim().to_lowercase() ) } fn generate_register_email_code() -> String { let mut rng = rand::thread_rng(); format!("{:06}", rng.gen_range(0..1_000_000)) } async fn auth_username_exists(&self, username: &str) -> Result { sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM \"user\" WHERE lower(username) = lower($1) AND deleted_at IS NULL)", ) .bind(username) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database) } async fn auth_verified_email_exists(&self, email: &str) -> Result { sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM user_mail WHERE lower(email) = lower($1) AND is_verified = true)", ) .bind(email) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database) } #[tracing::instrument(skip(self, params, context), fields(username = %params.username))] pub async fn auth_register( &self, params: RegisterParams, context: &Session, ) -> Result { self.auth_check_captcha(context, params.captcha).await?; let username = params.username.trim().to_string(); let email = params.email.trim().to_lowercase(); if username.is_empty() || email.is_empty() { return Err(AppError::BadRequest( "username and email are required".into(), )); } let password = self.auth_rsa_decode(context, params.password).await?; crate::service::util::validate_password_strength(&password)?; let username_exists = self.auth_username_exists(&username).await?; let email_exists = self.auth_verified_email_exists(&email).await?; if username_exists || email_exists { return Err(AppError::AccountAlreadyExists); } self.auth_check_register_email_code(&email, ¶ms.email_code)?; let user_id = uuid::Uuid::now_v7(); let now = chrono::Utc::now(); let salt = SaltString::generate(&mut rand::thread_rng()); let password_hash = Argon2::default() .hash_password(password.as_bytes(), &salt) .map_err(|e| AppError::PasswordHashError(e.to_string()))? .to_string(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; let user = sqlx::query_as::<_, User>( "INSERT INTO \"user\" \ (id, username, display_name, status, role, visibility, is_active, is_bot, \ last_login_at, created_at, updated_at) \ VALUES ($1, $2, $2, 'active', 'user', 'public', true, false, NULL, $3, $3) \ RETURNING id, username, display_name, avatar_url, bio, status, role, visibility, \ is_active, is_bot, last_login_at, created_at, updated_at, deleted_at", ) .bind(user_id) .bind(&username) .bind(now) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; sqlx::query( "INSERT INTO user_mail (id, user_id, email, is_primary, is_verified, created_at, updated_at) \ VALUES ($1, $2, $3, true, true, $4, $4)", ) .bind(uuid::Uuid::now_v7()) .bind(user_id) .bind(&email) .bind(now) .execute(&mut *txn) .await .map_err(AppError::Database)?; sqlx::query( "INSERT INTO user_password (user_id, password_hash, password_algo, password_salt, \ must_change_password, password_updated_at, created_at, updated_at) \ VALUES ($1, $2, 'argon2id', '', false, $3, $3, $3)", ) .bind(user_id) .bind(&password_hash) .bind(now) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; context.set_user(user_id); context.remove(Self::RSA_PRIVATE_KEY); context.remove(Self::RSA_PUBLIC_KEY); tracing::info!(user_uid = %user_id, username = %user.username, "User registered"); Ok(user) } }