service.rs
240 lignes · 8924 octets
use chrono::Utc; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use uuid::Uuid; use super::provider_trait::ProviderUserInfo; use crate::error::AppError; use crate::models::oauth_account; use crate::services::role_service::RoleService; use crate::services::user_service::UserService; /// High-level service for OAuth account linking and user provisioning. pub struct OAuthService; impl OAuthService { /// Find an existing OAuth account link for a provider + provider_user_id. pub async fn find_oauth_account( db: &DatabaseConnection, provider: &str, provider_user_id: &str, ) -> Result<Option<oauth_account::Model>, AppError> { let account = oauth_account::Entity::find() .filter(oauth_account::Column::Provider.eq(provider)) .filter(oauth_account::Column::ProviderUserId.eq(provider_user_id)) .one(db) .await?; Ok(account) } /// Find all OAuth accounts linked to a user. pub async fn find_user_oauth_accounts( db: &DatabaseConnection, user_id: Uuid, ) -> Result<Vec<oauth_account::Model>, AppError> { let accounts = oauth_account::Entity::find() .filter(oauth_account::Column::UserId.eq(user_id)) .all(db) .await?; Ok(accounts) } /// Link an OAuth account to an existing user. pub async fn link_account( db: &DatabaseConnection, user_id: Uuid, provider: &str, info: &ProviderUserInfo, ) -> Result<oauth_account::Model, AppError> { let now = Utc::now(); let account = oauth_account::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_id), provider: Set(provider.to_string()), provider_user_id: Set(info.provider_user_id.clone()), email: Set(info.email.clone()), display_name: Set(info.display_name.clone()), avatar_url: Set(info.avatar_url.clone()), created_at: Set(now), updated_at: Set(now), }; let model = account.insert(db).await?; tracing::info!("Linked {} account for user {}", provider, user_id); Ok(model) } /// Unlink an OAuth account from a user by provider name. /// Refuses if it's the user's only auth method (no password + last OAuth link). pub async fn unlink_account( db: &DatabaseConnection, user_id: Uuid, provider: &str, ) -> Result<(), AppError> { let linked_accounts = Self::find_user_oauth_accounts(db, user_id).await?; let account = linked_accounts .into_iter() .find(|a| a.provider == provider) .ok_or_else(|| AppError::NotFound("OAuth account not found".to_string()))?; Self::unlink_account_by_id(db, user_id, account.id).await } /// Unlink an OAuth account from a user by account ID. /// Refuses if it's the user's only auth method (no password + last OAuth link). pub async fn unlink_account_by_id( db: &DatabaseConnection, user_id: Uuid, account_id: Uuid, ) -> Result<(), AppError> { let user = UserService::get_user_by_id(db, user_id) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; let linked_accounts = Self::find_user_oauth_accounts(db, user_id).await?; let account = linked_accounts .iter() .find(|a| a.id == account_id) .ok_or_else(|| AppError::NotFound("OAuth account not found".to_string()))?; let has_password = !user.password_hash.is_empty(); if !has_password && linked_accounts.len() <= 1 { return Err(AppError::Validation( "Cannot unlink the last authentication method. Set a password first.".to_string(), )); } let provider_name = account.provider.clone(); oauth_account::Entity::delete_by_id(account_id) .exec(db) .await?; tracing::info!("Unlinked {} account for user {}", provider_name, user_id); Ok(()) } /// Find or create a user from OAuth provider info. /// - If an oauth_account link exists, return the linked user. /// - If a user with the same verified email exists, link the account and return the user. /// - Otherwise, create a new user, assign the "user" role, link the account, return user. /// /// SEC: Refuses login if provider did not verify the email. pub async fn find_or_create_user( db: &DatabaseConnection, provider: &str, info: &ProviderUserInfo, ) -> Result<(crate::models::user::Model, bool), AppError> { // 1. Check existing link if let Some(account) = Self::find_oauth_account(db, provider, &info.provider_user_id).await? { let user = UserService::get_user_by_id(db, account.user_id) .await? .ok_or_else(|| AppError::Internal("Linked user not found".to_string()))?; return Ok((user, false)); } // SEC: Require verified email from provider let email = info.email.as_ref().ok_or_else(|| { AppError::Validation("OAuth provider did not return an email address".to_string()) })?; if !info.email_verified { return Err(AppError::Validation( "Email address is not verified by the OAuth provider. Please verify your email first.".to_string(), )); } // 2. Check if user with same email already exists let is_new_user; let user = if let Some(existing) = UserService::get_user_by_email(db, email).await? { is_new_user = false; existing } else { // 3. Create new user is_new_user = true; let username = Self::generate_unique_username(db, info.display_name.as_deref().unwrap_or("user")) .await?; let user = UserService::create_user( db, username, email.clone(), String::new(), // No password for OAuth-only users false, ) .await?; // Mark email as verified (provider already verified it) use sea_orm::Set; let mut active: crate::models::user::ActiveModel = user.clone().into(); active.email_verified = Set(true); active.email_verified_at = Set(Some(Utc::now())); active.update(db).await?; // Assign default "user" role if let Some(role) = RoleService::get_role_by_name(db, "user").await? { UserService::assign_role_to_user(db, user.id, role.id).await?; } UserService::get_user_by_id(db, user.id) .await? .ok_or_else(|| AppError::Internal("User not found after creation".to_string()))? }; // 4. Link OAuth account to user Self::link_account(db, user.id, provider, info).await?; Ok((user, is_new_user)) } async fn generate_unique_username( db: &DatabaseConnection, base: &str, ) -> Result<String, AppError> { // Produce a candidate that passes Username::parse (strict): // lowercase, only [a-z0-9], at most 30 chars (leaves room for // "-NNN" / "-user" suffixes while staying inside the 39-char limit). let sanitized: String = base .to_ascii_lowercase() .chars() .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) .take(30) .collect(); let sanitized = if sanitized.len() < 3 { "user".to_string() } else { sanitized }; // Avoid reserved names by appending a disambiguating suffix. // Uses Username::is_reserved so the reserved-name list lives in one place. let base_candidate = if crate::types::Username::is_reserved(&sanitized) { format!("{}-user", sanitized) } else { sanitized }; if UserService::get_user_by_username(db, &base_candidate) .await? .is_none() { return Ok(base_candidate); } for i in 1..100 { let candidate = format!("{}-{}", base_candidate, i); if UserService::get_user_by_username(db, &candidate) .await? .is_none() { return Ok(candidate); } } // Last resort: 8-char hex suffix from a simple UUID (pure ASCII hex, // safe to slice by byte index). let uuid_simple = Uuid::new_v4().simple().to_string(); Ok(format!("{}-{}", base_candidate, &uuid_simple[..8])) } }
GitRust