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]))
    }
}