From d2754de9ef3e7effb98c153fde0fc6c5a57f85b5 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 11 Aug 2025 22:54:10 +0700 Subject: [PATCH] feat(auth): Refactor Google OAuth service and controller to utilize environment variables and enhance response structure --- docs/google_oauth_integration.md | 111 +++++++++++++ .../v1/auth/google/google_oauth_controller.rs | 32 ++-- .../v1/auth/google/google_oauth_service.rs | 41 +++-- .../iam/auth/google/google_oauth_flow_test.rs | 148 ++++++++++++++---- 4 files changed, 268 insertions(+), 64 deletions(-) create mode 100644 docs/google_oauth_integration.md diff --git a/docs/google_oauth_integration.md b/docs/google_oauth_integration.md new file mode 100644 index 0000000..265a4fd --- /dev/null +++ b/docs/google_oauth_integration.md @@ -0,0 +1,111 @@ +# Google OAuth 2.1 Integration + +## Overview + +This document describes the integration of Google OAuth 2.1 for user authentication within the application. This feature allows users to sign in using their Google accounts, providing a seamless and secure authentication experience. The process involves initiating an OAuth flow with Google, handling the callback, exchanging authorization codes for tokens, and ultimately generating a JSON Web Token (JWT) for the authenticated user. + +## Configuration + +To enable Google OAuth 2.1 authentication, the following environment variables must be configured. These variables are loaded into the central `Env` struct in `imphnen-libs`. + +* `GOOGLE_CLIENT_ID`: Your Google OAuth 2.1 Client ID. +* `GOOGLE_CLIENT_SECRET`: Your Google OAuth 2.1 Client Secret. +* `GOOGLE_REDIRECT_URL`: The URL to which Google will redirect the user after successful authentication. This must match one of the authorized redirect URIs configured in your Google Cloud Console. + +### Obtaining Credentials from Google Cloud Console + +1. **Navigate to Google Cloud Console:** Go to the [Google Cloud Console](https://console.cloud.google.com/). +2. **Select/Create a Project:** Choose an existing project or create a new one. +3. **Enable Google People API:** In the navigation menu, go to `APIs & Services` > `Library` and search for "Google People API" and enable it. +4. **Create OAuth Consent Screen:** Go to `APIs & Services` > `OAuth consent screen`. + * Configure your consent screen, including application name, user support email, and developer contact information. +5. **Create Credentials:** Go to `APIs & Services` > `Credentials`. + * Click `Create Credentials` > `OAuth client ID`. + * Select "Web application" as the application type. + * Provide a name for your OAuth 2.0 client. + * Under `Authorized redirect URIs`, add the `GOOGLE_REDIRECT_URL` specified in your environment variables (e.g., `http://127.0.0.1:8080/api/v1/auth/google/callback`). + * Click "Create". Your Client ID and Client Secret will be displayed. Copy these values and set them as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in your environment. + +## API Endpoints + +### 1. Initiate Google OAuth Flow + +* **Endpoint:** `/api/v1/auth/google/login` +* **Method:** `GET` +* **Description:** This endpoint initiates the Google OAuth 2.1 authentication flow. When accessed, it generates a Google authorization URL and redirects the user's browser to Google's authentication page. The user will be prompted to grant permissions to your application. + +* **Example `curl` command:** + + ```bash + curl -v http://127.0.0.1:8080/api/v1/auth/google/login + ``` + + Upon successful execution, this command will return a `302 Found` status with a `Location` header containing the Google authorization URL. Your browser would typically follow this redirect. + +### 2. Handle Google OAuth Callback + +* **Endpoint:** `/api/v1/auth/google/callback` +* **Method:** `GET` +* **Description:** This endpoint handles the redirect from Google after the user has authenticated and granted permissions. Google sends an authorization `code` and a `state` parameter to this URL. The application then uses this `code` to exchange it for an access token and user information with Google. Upon successful validation and user creation/login, a full `LoginResponse` object is returned, identical to the credential-based login, which includes an access token, refresh token, and user details. + +* **Query Parameters:** + * `code` (required): The authorization code provided by Google. + * `state` (required): The CSRF token generated during the login initiation. + +* **Example `curl` command (conceptual, as `code` and `state` are dynamic):** + + ```bash + # This curl command is illustrative. The `code` and `state` values are obtained dynamically + # from Google's redirect after the user authorizes your application. + # Replace and with actual values from the Google redirect. + + curl -v "http://127.0.0.1:8080/api/v1/auth/google/callback?code=&state=" + ``` + + A successful response will typically return a JSON object containing the generated JWT: + + ```json + { + "token": { + "access_token": "your_access_token_here", + "refresh_token": "your_refresh_token_here" + }, + "user": { + "id": "user_id_here", + "role": { + "id": "role_id_here", + "name": "Role Name", + "permissions": [], + "created_at": "2023-01-01T12:00:00Z", + "updated_at": "2023-01-01T12:00:00Z" + }, + "fullname": "User Fullname", + "email": "user@example.com", + "avatar": "http://example.com/avatar.jpg", + "phone_number": "1234567890", + "is_active": true, + "gender": "Male", + "birthdate": "2000-01-01T00:00:00Z", + "created_at": "2023-01-01T12:00:00Z", + "updated_at": "2023-01-01T12:00:00Z" + } + } + ``` + +## OAuth Flow Diagram + +```mermaid +graph TD + A[User] --> B{Access /api/v1/auth/google/login}; + B --> C[Backend generates Auth URL]; + C --> D{Redirect to Google Auth Page}; + D --> E[User Authenticates with Google]; + E --> F{Google Redirects to /api/v1/auth/google/callback}; + F --> G[Backend Exchanges Code for Token]; + G --> H[Backend Fetches User Info]; + H --> I{User Exists?}; + I -- Yes --> J[Retrieve User]; + I -- No --> K[Create New User]; + J --> L[Generate JWT]; + K --> L; + L --> M[Return JWT to User]; \ No newline at end of file diff --git a/imphnen-iam/src/v1/auth/google/google_oauth_controller.rs b/imphnen-iam/src/v1/auth/google/google_oauth_controller.rs index 5c8809f..19389a2 100644 --- a/imphnen-iam/src/v1/auth/google/google_oauth_controller.rs +++ b/imphnen-iam/src/v1/auth/google/google_oauth_controller.rs @@ -7,35 +7,38 @@ use axum::{ use axum::http::StatusCode; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use std::sync::Arc; // Import Arc +use std::sync::Arc; +use imphnen_libs::enviroment::ENV; // Import ENV use crate::v1::auth::google::google_oauth_service::{AuthRequest, GoogleOauthService, GoogleOauthServiceImpl}; use crate::v1::auth::auth_service::AuthServiceTrait; use crate::v1::users::users_service::UsersServiceTrait; use imphnen_entities::error_dto::error::Error; +use crate::v1::auth::AuthLoginResponsetDto; #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct GoogleAuthUrlResponse { pub authorize_url: String, } -pub struct GoogleOauthController { // Generic over T +pub struct GoogleOauthController { google_oauth_service: T, } -// Concrete implementation for new() impl GoogleOauthController> { pub fn new() -> Self { - Self { - google_oauth_service: GoogleOauthServiceImpl::::new(), // Explicitly specify type parameters - } + let google_oauth_service = GoogleOauthServiceImpl::::with_services( + crate::v1::auth::auth_service::AuthService {}, + crate::v1::users::users_service::UsersService {}, + &ENV, // Pass a reference to the global ENV static + ); + Self::with_service(google_oauth_service) } } -// Generic implementation for with_service and get_routes impl GoogleOauthController where - T: GoogleOauthService + Clone + Send + Sync + 'static, // Explicitly constrain T + T: GoogleOauthService + Clone + Send + Sync + 'static, { pub fn with_service(google_oauth_service: T) -> Self { Self { @@ -43,7 +46,7 @@ where } } - pub fn get_routes(&self) -> Router { // Take self by reference + pub fn get_routes(&self) -> Router { Router::new() .route( "/google/login", @@ -62,7 +65,7 @@ where }, ), ) - .with_state(Arc::new(self.clone())) // Pass an Arc clone of self to with_state + .with_state(Arc::new(self.clone())) } pub async fn google_oauth_login(&self) -> Result { @@ -70,16 +73,15 @@ where Ok(Redirect::to(authorize_url.as_str())) } - pub async fn google_oauth_callback(&self, auth_request: AuthRequest) -> Result, Error> { - let token = self.google_oauth_service.google_oauth_callback(auth_request).await?; - Ok((StatusCode::OK, Json(serde_json::json!({"token": token})))) + pub async fn google_oauth_callback(&self, auth_request: AuthRequest) -> Result, Error> { + let response = self.google_oauth_service.google_oauth_callback(auth_request).await?; + Ok(Json(response)) } } -// Clone implementation impl Clone for GoogleOauthController where - T: GoogleOauthService + Clone, // Explicitly constrain T + T: GoogleOauthService + Clone, { fn clone(&self) -> Self { Self::with_service(self.google_oauth_service.clone()) diff --git a/imphnen-iam/src/v1/auth/google/google_oauth_service.rs b/imphnen-iam/src/v1/auth/google/google_oauth_service.rs index f1a3cd7..c23e083 100644 --- a/imphnen-iam/src/v1/auth/google/google_oauth_service.rs +++ b/imphnen-iam/src/v1/auth/google/google_oauth_service.rs @@ -8,7 +8,8 @@ use serde::{Deserialize, Serialize}; use oauth2::url::Url; use imphnen_entities::error_dto::error::Error; -use imphnen_libs::{jsonwebtoken::generate_jwt, enviroment::ENV}; +use imphnen_libs::{jsonwebtoken::{encode_access_token, encode_refresh_token}, enviroment::Env}; +use crate::v1::auth::{AuthLoginResponsetDto, TokenDto}; use crate::v1::auth::auth_service::AuthServiceTrait; use crate::v1::users::users_dto::{UsersCreateRequestDto, UsersDetailItemDto}; use crate::v1::users::users_service::UsersServiceTrait; @@ -24,25 +25,21 @@ pub struct AuthRequest { #[async_trait] pub trait GoogleOauthService: Send + Sync + 'static { // Removed new() from trait - fn with_services(auth_service: A, users_service: U) -> Self; + fn with_services(auth_service: A, users_service: U, env: &'static Env) -> Self; fn google_oauth_client(&self) -> BasicClient; fn generate_auth_url(&self) -> (Url, CsrfToken); - async fn google_oauth_callback(&self, auth_request: AuthRequest) -> Result; + async fn google_oauth_callback(&self, auth_request: AuthRequest) -> Result; } #[derive(Clone)] pub struct GoogleOauthServiceImpl { auth_service: A, users_service: U, + env: &'static Env, } impl GoogleOauthServiceImpl { - pub fn new() -> Self { - GoogleOauthServiceImpl { - auth_service: crate::v1::auth::auth_service::AuthService {}, - users_service: crate::v1::users::users_service::UsersService {}, - } - } + // Removed the `new()` method as it will be replaced by `with_services` } #[async_trait] @@ -51,16 +48,17 @@ where A: AuthServiceTrait + Send + Sync + 'static, U: UsersServiceTrait + Send + Sync + 'static, { - fn with_services(auth_service: A, users_service: U) -> Self { + fn with_services(auth_service: A, users_service: U, env: &'static Env) -> Self { Self { auth_service, users_service, + env, } } fn google_oauth_client(&self) -> BasicClient { - let google_client_id = ClientId::new(ENV.google_client_id.clone()); - let google_client_secret = ClientSecret::new(ENV.google_client_secret.clone()); + let google_client_id = ClientId::new(self.env.google_client_id.clone()); + let google_client_secret = ClientSecret::new(self.env.google_client_secret.clone()); let auth_url = AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string()) .expect("Invalid authorization endpoint URL"); let token_url = TokenUrl::new("https://oauth2.googleapis.com/token".to_string()) @@ -73,7 +71,7 @@ where Some(token_url), ) .set_redirect_uri( - RedirectUrl::new(ENV.google_redirect_url.clone()) + RedirectUrl::new(self.env.google_redirect_url.clone()) .expect("Invalid redirect URL"), ) } @@ -90,7 +88,7 @@ where .url() } - async fn google_oauth_callback(&self, auth_request: AuthRequest) -> Result { + async fn google_oauth_callback(&self, auth_request: AuthRequest) -> Result { let client = self.google_oauth_client(); let token_response = client @@ -131,7 +129,18 @@ where } }; - let token = generate_jwt(&user.id.to_string())?; - Ok(token) + let access_token = encode_access_token(user.email.clone()) + .map_err(|e| Error::Db(format!("Failed to generate access token: {}", e)))?; + let refresh_token = encode_refresh_token(user.email.clone()) + .map_err(|e| Error::Db(format!("Failed to generate refresh token: {}", e)))?; + + let response = AuthLoginResponsetDto { + user: user.clone(), // Cloned the user to satisfy potential ownership issues + token: TokenDto { + access_token, + refresh_token, + }, + }; + Ok(response) } } \ No newline at end of file diff --git a/tests/src/iam/auth/google/google_oauth_flow_test.rs b/tests/src/iam/auth/google/google_oauth_flow_test.rs index bb53718..758e14f 100644 --- a/tests/src/iam/auth/google/google_oauth_flow_test.rs +++ b/tests/src/iam/auth/google/google_oauth_flow_test.rs @@ -11,20 +11,22 @@ mod tests { use mockall::mock; use serde_json::json; + use imphnen_iam::v1::auth::{AuthLoginResponsetDto, TokenDto}; use imphnen_iam::v1::auth::google::google_oauth_controller::GoogleOauthController; use imphnen_iam::v1::auth::google::google_oauth_service::{AuthRequest, GoogleOauthService, GoogleOauthServiceImpl}; - use imphnen_iam::v1::users::users_dto::{UserDto, CreateUserDto}; + use imphnen_iam::v1::users::users_dto::{UsersDetailItemDto, UsersCreateRequestDto}; // Corrected: removed UserDto alias, used UsersCreateRequestDto use imphnen_entities::error_dto::ErrorResponse; use imphnen_libs::jsonwebtoken::generate_jwt; + use imphnen_libs::enviroment::{ENV, Env}; // Import ENV and Env mock! { pub GoogleOauthServiceMock {} impl GoogleOauthService for GoogleOauthServiceMock { fn new() -> Self; - fn with_services(auth_service: crate::v1::auth::auth_service::AuthService, users_service: crate::v1::users::users_service::UsersService) -> Self; + fn with_services(auth_service: crate::v1::auth::auth_service::AuthService, users_service: crate::v1::users::users_service::UsersService, env: &'static Env) -> Self; // Updated signature fn google_oauth_client(&self) -> oauth2::basic::BasicClient; fn generate_auth_url(&self) -> (url::Url, oauth2::CsrfToken); - async fn google_oauth_callback(&self, auth_request: AuthRequest) -> anyhow::Result; + async fn google_oauth_callback(&self, auth_request: AuthRequest) -> anyhow::Result; } } @@ -55,8 +57,8 @@ mod tests { async fn update_user_password(state: &crate::AppState, email: String, payload: crate::v1::users::UsersSetNewPasswordRequestDto) -> axum::response::Response; async fn get_user_by_mentor_id(state: &crate::AppState, mentor_id: String) -> axum::response::Response; async fn delete_user(state: &crate::AppState, id: String) -> axum::response::Response; - async fn get_user_by_email(&self, email: &str) -> anyhow::Result>; - async fn create_user_by_dto(&self, new_user: CreateUserDto) -> anyhow::Result; + async fn get_user_by_email(&self, email: &str) -> anyhow::Result>; // Updated return type + async fn create_user_by_dto(&self, new_user: UsersCreateRequestDto) -> anyhow::Result; // Updated return type } } @@ -70,7 +72,13 @@ mod tests { #[tokio::test] async fn google_login_redirects_to_google_auth_url() { - let app = setup_app_with_mocked_google_oauth_service(GoogleOauthServiceImpl::new()).await; + let app = setup_app_with_mocked_google_oauth_service( + GoogleOauthServiceImpl::with_services( + AuthServiceMock::new(), + UsersServiceMock::new(), + &ENV, // Pass ENV + ) + ).await; let request = Request::builder() .uri("/google/login") @@ -95,40 +103,74 @@ mod tests { } #[tokio::test] - async fn google_callback_new_user_creates_user_and_returns_jwt() { + async fn google_callback_new_user_creates_user_and_returns_login_response() { let mut mock_google_oauth_service = MockGoogleOauthServiceMock::new(); - let mut mock_users_service = MockUsersServiceMock::new(); + let mut mock_users_service = UsersServiceMock::new(); // Changed from MockUsersServiceMock to UsersServiceMock - let expected_jwt = generate_jwt("test_user_id").unwrap(); let user_email = "new.user@example.com".to_string(); + let expected_access_token = generate_jwt("test_user_id").unwrap(); + let expected_refresh_token = generate_jwt("test_user_id").unwrap(); + + let expected_response_dto = AuthLoginResponsetDto { + token: TokenDto { + access_token: expected_access_token.clone(), + refresh_token: expected_refresh_token.clone(), + }, + user: UsersDetailItemDto { + id: "test_user_id".to_string(), + email: user_email.clone(), + fullname: "Test User".to_string(), // Updated field + phone_number: "1234567890".to_string(), // Updated field + is_active: true, + gender: None, // Added field + birthdate: None, // Added field + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + role: imphnen_iam::v1::roles::roles_dto::RolesDetailItemDto { + id: "default_role_id".to_string(), + name: "User".to_string(), + permissions: vec![], + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }, + }, + }; mock_google_oauth_service.expect_google_oauth_callback() .with(eq(AuthRequest { code: "some_code".to_string(), state: "some_state".to_string() })) - .returning(move |_| Ok(expected_jwt.clone())); + .returning(move |_| Ok(expected_response_dto.clone())); mock_users_service.expect_get_user_by_email() .with(eq(user_email.clone())) .returning(|_| Ok(None)); // Simulate no existing user - + mock_users_service.expect_create_user_by_dto() .returning(|create_user_dto| { - Ok(UserDto { + Ok(UsersDetailItemDto { // Changed to UsersDetailItemDto id: "test_user_id".to_string(), email: create_user_dto.email, - username: create_user_dto.username, - first_name: create_user_dto.first_name, - last_name: create_user_dto.last_name, + fullname: create_user_dto.fullname, // Updated field + phone_number: create_user_dto.phone_number, // Updated field is_active: create_user_dto.is_active, - is_email_verified: create_user_dto.is_email_verified, + gender: None, // Added field + birthdate: None, // Added field created_at: chrono::Utc::now().to_rfc3339(), updated_at: chrono::Utc::now().to_rfc3339(), + role: imphnen_iam::v1::roles::roles_dto::RolesDetailItemDto { + id: "default_role_id".to_string(), + name: "User".to_string(), + permissions: vec![], + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }, }) }); let app = setup_app_with_mocked_google_oauth_service( GoogleOauthServiceImpl::with_services( - AuthServiceMock::new(), // Not directly used by google_oauth_callback logic, but required by trait + AuthServiceMock::new(), mock_users_service, + &ENV, // Pass ENV here ) ).await; @@ -141,35 +183,72 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); - let json_body: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(json_body["token"], expected_jwt); + let json_body: AuthLoginResponsetDto = serde_json::from_slice(&body).unwrap(); + assert_eq!(json_body.token.access_token, expected_access_token); + assert_eq!(json_body.token.refresh_token, expected_refresh_token); + assert_eq!(json_body.user.email, user_email); } #[tokio::test] - async fn google_callback_existing_user_returns_jwt() { + async fn google_callback_existing_user_returns_login_response() { let mut mock_google_oauth_service = MockGoogleOauthServiceMock::new(); - let mut mock_users_service = MockUsersServiceMock::new(); + let mut mock_users_service = UsersServiceMock::new(); // Changed from MockUsersServiceMock to UsersServiceMock - let expected_jwt = generate_jwt("existing_user_id").unwrap(); let user_email = "existing.user@example.com".to_string(); + let expected_access_token = generate_jwt("existing_user_id").unwrap(); + let expected_refresh_token = generate_jwt("existing_user_id").unwrap(); + + let existing_user_dto = UsersDetailItemDto { // Changed from UserDto to UsersDetailItemDto + id: "existing_user_id".to_string(), + email: user_email.clone(), + fullname: "Existing User".to_string(), // Updated field + phone_number: "0987654321".to_string(), // Updated field + is_active: true, + gender: None, // Added field + birthdate: None, // Added field + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + role: imphnen_iam::v1::roles::roles_dto::RolesDetailItemDto { + id: "default_role_id".to_string(), + name: "User".to_string(), + permissions: vec![], + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }, + }; + + let expected_response_dto = AuthLoginResponsetDto { + token: TokenDto { + access_token: expected_access_token.clone(), + refresh_token: expected_refresh_token.clone(), + }, + user: existing_user_dto.clone(), // Cloned + }; mock_google_oauth_service.expect_google_oauth_callback() .with(eq(AuthRequest { code: "some_code".to_string(), state: "some_state".to_string() })) - .returning(move |_| Ok(expected_jwt.clone())); + .returning(move |_| Ok(expected_response_dto.clone())); mock_users_service.expect_get_user_by_email() .with(eq(user_email.clone())) - .returning(|_| { - Ok(Some(UserDto { + .returning(move |_| { + Ok(Some(UsersDetailItemDto { // Changed to UsersDetailItemDto id: "existing_user_id".to_string(), email: user_email.clone(), - username: Some("existinguser".to_string()), - first_name: Some("Existing".to_string()), - last_name: Some("User".to_string()), - is_active: Some(true), - is_email_verified: Some(true), + fullname: "Existing User".to_string(), // Updated field + phone_number: "0987654321".to_string(), // Updated field + is_active: true, + gender: None, // Added field + birthdate: None, // Added field created_at: chrono::Utc::now().to_rfc3339(), updated_at: chrono::Utc::now().to_rfc3339(), + role: imphnen_iam::v1::roles::roles_dto::RolesDetailItemDto { + id: "default_role_id".to_string(), + name: "User".to_string(), + permissions: vec![], + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + }, })) }); // Simulate existing user @@ -178,8 +257,9 @@ mod tests { let app = setup_app_with_mocked_google_oauth_service( GoogleOauthServiceImpl::with_services( - AuthServiceMock::new(), // Not directly used by google_oauth_callback logic, but required by trait + AuthServiceMock::new(), mock_users_service, + &ENV, // Pass ENV here ) ).await; @@ -192,7 +272,9 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); - let json_body: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(json_body["token"], expected_jwt); + let json_body: AuthLoginResponsetDto = serde_json::from_slice(&body).unwrap(); + assert_eq!(json_body.token.access_token, expected_access_token); + assert_eq!(json_body.token.refresh_token, expected_refresh_token); + assert_eq!(json_body.user.email, user_email); } } \ No newline at end of file