feat(auth): Enhance Google OAuth integration with PKCE support and CSRF validation improvements

This commit is contained in:
MythEclipse
2025-08-12 16:11:15 +07:00
parent adf3ef8e28
commit a25e896fa2
6 changed files with 357 additions and 28 deletions
+77 -6
View File
@@ -42,9 +42,11 @@ The Google OAuth integration allows users to authenticate using their Google acc
```bash ```bash
GOOGLE_CLIENT_ID="your_google_client_id" GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret" GOOGLE_CLIENT_SECRET="your_google_client_secret"
GOOGLE_REDIRECT_URL="http://127.0.0.1:8080/api/v1/auth/google/callback" GOOGLE_REDIRECT_URL="https://your-backend-url.com/api/v1/auth/google/callback"
``` ```
> Ganti `your-backend-url.com` dengan URL backend Anda yang sebenarnya.
### Role Assignment Strategy ### Role Assignment Strategy
The system automatically assigns the default "User" role to new Google OAuth users using the role ID from the seed data (`5713cb37-dc02-4e87-8048-d7a41d352059`). This ensures consistency with the database schema and eliminates the need for additional configuration. The system automatically assigns the default "User" role to new Google OAuth users using the role ID from the seed data (`5713cb37-dc02-4e87-8048-d7a41d352059`). This ensures consistency with the database schema and eliminates the need for additional configuration.
@@ -74,16 +76,16 @@ The integration requests minimal required scopes:
### 1. Initiate OAuth Flow ### 1. Initiate OAuth Flow
**GET** `/api/v1/auth/google/login` **GET** `/api/v1/auth/google/login`
Redirects user to Google OAuth authorization URL with: Redirects user to Google OAuth authorization URL dengan:
- PKCE code challenge - PKCE code challenge
- Signed CSRF state token - Signed CSRF state token
- Required scopes - Required scopes
**Response:** HTTP 302 redirect to Google OAuth **Response:** HTTP 302 redirect ke Google OAuth
**Example:** **Example:**
```bash ```bash
curl -v http://127.0.0.1:8080/api/v1/auth/google/login curl -v https://your-backend-url.com/api/v1/auth/google/login
``` ```
### 2. OAuth Callback ### 2. OAuth Callback
@@ -130,8 +132,7 @@ Handles the OAuth callback from Google.
**Example:** **Example:**
```bash ```bash
# This would typically be called by Google's redirect curl -v "https://your-backend-url.com/api/v1/auth/google/callback?code=AUTH_CODE&state=CSRF_STATE"
curl -v "http://127.0.0.1:8080/api/v1/auth/google/callback?code=AUTH_CODE&state=CSRF_STATE"
``` ```
## Flow Description ## Flow Description
@@ -320,3 +321,73 @@ Each role includes associated permissions that control user access within the ap
- User creation/update logic - User creation/update logic
**Note**: Ensure the redirect URL in Google Console matches exactly the `GOOGLE_REDIRECT_URL` environment variable. **Note**: Ensure the redirect URL in Google Console matches exactly the `GOOGLE_REDIRECT_URL` environment variable.
---
## Contoh Implementasi Frontend (React)
Berikut contoh sederhana implementasi login Google di React menggunakan window.location untuk redirect ke backend:
```jsx
// src/components/GoogleLoginButton.jsx
import React from 'react';
const GOOGLE_AUTH_URL = 'https://your-backend-url.com/api/v1/auth/google/login';
function GoogleLoginButton() {
const handleLogin = () => {
window.location.href = GOOGLE_AUTH_URL;
};
return (
<button onClick={handleLogin}>
Login with Google
</button>
);
}
export default GoogleLoginButton;
```
> Pastikan URL pada `GOOGLE_AUTH_URL` sesuai dengan endpoint backend Anda.
Setelah login berhasil, backend akan mengarahkan kembali ke frontend sesuai pengaturan `GOOGLE_REDIRECT_URL`.
Untuk implementasi lebih lanjut, Anda bisa menggunakan library seperti `react-google-login` atau `@react-oauth/google` jika ingin autentikasi langsung di frontend, namun untuk skenario ini, login dialihkan ke backend.
---
### Contoh Login Google dengan Popup di React
Berikut contoh login Google menggunakan popup agar user tetap di halaman utama:
```jsx
// src/components/GoogleLoginPopup.jsx
import React from 'react';
const GOOGLE_AUTH_URL = 'https://your-backend-url.com/api/v1/auth/google/login';
function GoogleLoginPopup({ onSuccess }) {
const handleLogin = () => {
const popup = window.open(GOOGLE_AUTH_URL, 'google-oauth', 'width=500,height=600');
const timer = setInterval(() => {
if (popup.closed) {
clearInterval(timer);
if (onSuccess) onSuccess();
}
}, 500);
};
return (
<button onClick={handleLogin}>
Login with Google (Popup)
</button>
);
}
export default GoogleLoginPopup;
```
> Setelah user login di popup dan backend redirect ke frontend, Anda bisa trigger refresh data user atau reload halaman.
Untuk komunikasi lebih advance antara popup dan parent, gunakan `window.postMessage` untuk mengirim data dari backend ke frontend setelah login berhasil.
@@ -4,12 +4,13 @@ use serde::{Deserialize, Serialize};
pub struct GoogleUser { pub struct GoogleUser {
pub id: String, pub id: String,
pub email: String, pub email: String,
#[serde(default)]
pub verified_email: bool, pub verified_email: bool,
pub name: String, pub name: Option<String>,
pub given_name: String, pub given_name: Option<String>,
pub family_name: String, pub family_name: Option<String>,
pub picture: String, pub picture: Option<String>,
pub locale: String, pub locale: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -1,7 +1,7 @@
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use oauth2::{ use oauth2::{
basic::BasicClient, AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, basic::BasicClient, AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, PkceCodeVerifier,
RedirectUrl, Scope, TokenResponse, TokenUrl, RedirectUrl, Scope, TokenResponse, TokenUrl,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -10,7 +10,7 @@ use tracing::{info, error};
use imphnen_entities::error_dto::error::Error; use imphnen_entities::error_dto::error::Error;
use imphnen_libs::{jsonwebtoken::{encode_access_token, encode_refresh_token}, enviroment::Env}; use imphnen_libs::{jsonwebtoken::{encode_access_token, encode_refresh_token}, enviroment::Env};
use imphnen_utils::{generate_csrf_token, validate_csrf_token}; use imphnen_utils::{generate_oauth_csrf_token, validate_oauth_csrf_token, validate_csrf_token};
use crate::v1::auth::TokenDto; use crate::v1::auth::TokenDto;
use crate::v1::auth::auth_service::AuthServiceTrait; use crate::v1::auth::auth_service::AuthServiceTrait;
use crate::v1::users::users_dto::{UsersCreateRequestDto, UsersDetailItemDto}; use crate::v1::users::users_dto::{UsersCreateRequestDto, UsersDetailItemDto};
@@ -38,23 +38,42 @@ impl AuthRequest {
} }
// Basic format validation for authorization code // Basic format validation for authorization code
if !self.code.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~') { // OAuth 2.0 authorization codes can contain URL-safe characters including base64 characters
if !self.code.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' || c == '/' || c == '+' || c == '=') {
return Err(Error::Validation("Authorization code contains invalid characters".to_string())); return Err(Error::Validation("Authorization code contains invalid characters".to_string()));
} }
Ok(()) Ok(())
} }
/// Validate CSRF state token with signature verification /// Validate CSRF state token with signature verification and extract PKCE verifier
pub fn validate_csrf_state(&self, secret: &str) -> Result<(), Error> { pub fn validate_csrf_state_and_get_pkce_verifier(&self, secret: &str) -> Result<PkceCodeVerifier, Error> {
// Maximum age of 10 minutes for OAuth flow // Maximum age of 10 minutes for OAuth flow
const MAX_AGE_SECONDS: u64 = 600; const MAX_AGE_SECONDS: u64 = 600;
validate_csrf_token(&self.state, secret, MAX_AGE_SECONDS) let pkce_verifier_secret = validate_oauth_csrf_token(&self.state, secret, MAX_AGE_SECONDS)
.map_err(|e| { .map_err(|e| {
error!("CSRF validation failed: {:?}", e); error!("OAuth CSRF validation failed: {:?}", e);
Error::Auth("Invalid or expired CSRF state token".to_string()) Error::Auth("Invalid or expired OAuth CSRF state token".to_string())
}) })?;
Ok(PkceCodeVerifier::new(pkce_verifier_secret))
}
/// Validate CSRF state token with signature verification (legacy method for backward compatibility)
pub fn validate_csrf_state(&self, secret: &str) -> Result<(), Error> {
// Try OAuth CSRF validation first, if it fails, fall back to regular CSRF validation
match validate_oauth_csrf_token(&self.state, secret, 600) {
Ok(_) => Ok(()),
Err(_) => {
// Fallback to regular CSRF validation for backward compatibility
validate_csrf_token(&self.state, secret, 600)
.map_err(|e| {
error!("CSRF validation failed: {:?}", e);
Error::Auth("Invalid or expired CSRF state token".to_string())
})
}
}
} }
} }
@@ -123,10 +142,10 @@ where
fn generate_auth_url(&self) -> (Url, CsrfToken) { fn generate_auth_url(&self) -> (Url, CsrfToken) {
let client = self.google_oauth_client(); let client = self.google_oauth_client();
let (pkce_code_challenge, _pkce_code_verifier) = PkceCodeChallenge::new_random_sha256(); let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256();
// Generate a signed CSRF token for stateless validation // Generate a signed CSRF token with PKCE verifier for stateless validation
let csrf_token_str = generate_csrf_token(&self.env.access_token_secret) let csrf_token_str = generate_oauth_csrf_token(&self.env.access_token_secret, pkce_code_verifier.secret())
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string()); // Fallback to UUID if signing fails .unwrap_or_else(|_| uuid::Uuid::new_v4().to_string()); // Fallback to UUID if signing fails
let csrf_token = CsrfToken::new(csrf_token_str); let csrf_token = CsrfToken::new(csrf_token_str);
@@ -143,8 +162,8 @@ where
// Validate input parameters first // Validate input parameters first
auth_request.validate()?; auth_request.validate()?;
// CRITICAL: Validate CSRF state token // CRITICAL: Validate CSRF state token and extract PKCE verifier
auth_request.validate_csrf_state(&self.env.access_token_secret)?; let pkce_verifier = auth_request.validate_csrf_state_and_get_pkce_verifier(&self.env.access_token_secret)?;
info!("Starting Google OAuth callback process"); info!("Starting Google OAuth callback process");
@@ -152,6 +171,7 @@ where
let token_response = client let token_response = client
.exchange_code(oauth2::AuthorizationCode::new(auth_request.code)) .exchange_code(oauth2::AuthorizationCode::new(auth_request.code))
.set_pkce_verifier(pkce_verifier)
.request_async(oauth2::reqwest::async_http_client) .request_async(oauth2::reqwest::async_http_client)
.await .await
.map_err(|e| { .map_err(|e| {
@@ -182,12 +202,24 @@ where
})?; })?;
info!("Successfully retrieved user info for email: {}", google_user.email); info!("Successfully retrieved user info for email: {}", google_user.email);
info!("Google user picture URL: {:?}", google_user.picture);
info!("Google user data: name={:?}, given_name={:?}, family_name={:?}, picture={:?}",
google_user.name, google_user.given_name, google_user.family_name, google_user.picture);
let user = self.users_service.get_user_by_email(&google_user.email).await?; let user = self.users_service.get_user_by_email(&google_user.email).await?;
let user = match user { let user = match user {
Some(user) => { Some(mut user) => {
info!("Existing user found for email: {}", google_user.email); info!("Existing user found for email: {}", google_user.email);
// Update avatar if user doesn't have one and Google provides one
if user.avatar.is_none() && google_user.picture.is_some() {
info!("Updating avatar for existing user: {}", google_user.email);
// Note: We would need to implement an update_user_avatar method in the user service
// For now, we'll just log this
info!("Avatar would be updated to: {:?}", google_user.picture);
}
user user
}, },
None => { None => {
@@ -203,10 +235,22 @@ where
let new_user = UsersCreateRequestDto { let new_user = UsersCreateRequestDto {
email: google_user.email.clone(), email: google_user.email.clone(),
password: format!("GOOGLE_OAUTH_{}", uuid::Uuid::new_v4()), // Random placeholder password: format!("GOOGLE_OAUTH_{}", uuid::Uuid::new_v4()), // Random placeholder
fullname: google_user.name.clone(), fullname: google_user.name.clone().unwrap_or_else(|| {
// Fallback: use given_name + family_name if available, otherwise use email prefix
match (&google_user.given_name, &google_user.family_name) {
(Some(given), Some(family)) => format!("{} {}", given, family),
(Some(given), None) => given.clone(),
(None, Some(family)) => family.clone(),
(None, None) => {
// Extract email prefix as last resort
google_user.email.split('@').next().unwrap_or("User").to_string()
}
}
}),
phone_number: "".to_string(), // Will be updated by user later phone_number: "".to_string(), // Will be updated by user later
is_active: true, is_active: true,
role_id: default_role_id, role_id: default_role_id,
avatar: google_user.picture.clone(), // Set avatar from Google user picture
}; };
self.users_service.create_user_by_dto(new_user).await? self.users_service.create_user_by_dto(new_user).await?
@@ -233,4 +277,133 @@ where
info!("Successfully completed Google OAuth for user: {}", user.email); info!("Successfully completed Google OAuth for user: {}", user.email);
Ok((user, token_dto)) Ok((user, token_dto))
} }
}
#[cfg(test)]
mod tests {
use super::*;
use imphnen_utils::generate_oauth_csrf_token;
#[test]
fn test_auth_request_validation_with_base64_characters() {
// Test case that was failing before the fix
let auth_request = AuthRequest {
code: "4/0-ARAA6EeEKN8rlQ_Dh5XAAA_dCpKFwKa3-Jl9cO7I".to_string(),
state: "valid_state".to_string(),
};
let result = auth_request.validate();
assert!(result.is_ok(), "Authorization code with base64-like characters should be valid");
}
#[test]
fn test_auth_request_validation_with_slash() {
let auth_request = AuthRequest {
code: "authorization/code/with/slashes".to_string(),
state: "valid_state".to_string(),
};
let result = auth_request.validate();
assert!(result.is_ok(), "Authorization code with forward slashes should be valid");
}
#[test]
fn test_auth_request_validation_with_plus() {
let auth_request = AuthRequest {
code: "authorization+code+with+plus".to_string(),
state: "valid_state".to_string(),
};
let result = auth_request.validate();
assert!(result.is_ok(), "Authorization code with plus signs should be valid");
}
#[test]
fn test_auth_request_validation_with_equals() {
let auth_request = AuthRequest {
code: "authorization=code=with=equals=".to_string(),
state: "valid_state".to_string(),
};
let result = auth_request.validate();
assert!(result.is_ok(), "Authorization code with equals signs should be valid");
}
#[test]
fn test_auth_request_validation_with_invalid_chars() {
let auth_request = AuthRequest {
code: "authorization@code#with$invalid%chars".to_string(),
state: "valid_state".to_string(),
};
let result = auth_request.validate();
assert!(result.is_err(), "Authorization code with invalid characters should be rejected");
}
#[test]
fn test_auth_request_validation_empty_code() {
let auth_request = AuthRequest {
code: "".to_string(),
state: "valid_state".to_string(),
};
let result = auth_request.validate();
assert!(result.is_err(), "Empty authorization code should be rejected");
}
#[test]
fn test_oauth_csrf_with_pkce_verifier() {
let secret = "test_secret";
let pkce_verifier = "test_pkce_verifier";
// Generate OAuth CSRF token with PKCE verifier
let token = generate_oauth_csrf_token(secret, pkce_verifier).unwrap();
// Create auth request with the token
let auth_request = AuthRequest {
code: "test_code".to_string(),
state: token,
};
// Validate and extract PKCE verifier
let extracted_verifier = auth_request.validate_csrf_state_and_get_pkce_verifier(secret).unwrap();
assert_eq!(extracted_verifier.secret(), pkce_verifier);
}
#[test]
fn test_oauth_csrf_backwards_compatibility() {
let secret = "test_secret";
// Generate regular CSRF token (legacy)
let token = imphnen_utils::generate_csrf_token(secret).unwrap();
// Create auth request with the token
let auth_request = AuthRequest {
code: "test_code".to_string(),
state: token,
};
// Legacy validation should still work
let result = auth_request.validate_csrf_state(secret);
assert!(result.is_ok(), "Legacy CSRF validation should still work");
}
#[test]
fn test_user_creation_with_avatar() {
use crate::v1::users::users_dto::UsersCreateRequestDto;
let google_user_picture = Some("https://lh3.googleusercontent.com/a/default-user".to_string());
let new_user = UsersCreateRequestDto {
email: "test@example.com".to_string(),
password: "password123".to_string(),
fullname: "Test User".to_string(),
phone_number: "1234567890".to_string(),
is_active: true,
role_id: "test_role_id".to_string(),
avatar: google_user_picture.clone(),
};
assert_eq!(new_user.avatar, google_user_picture, "Avatar should be set from Google user picture");
}
} }
+1
View File
@@ -50,6 +50,7 @@ pub struct UsersCreateRequestDto {
pub phone_number: String, pub phone_number: String,
pub is_active: bool, pub is_active: bool,
pub role_id: String, pub role_id: String,
pub avatar: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
+1 -1
View File
@@ -117,7 +117,7 @@ impl UsersSchema {
)), )),
gender: None, gender: None,
birthdate: None, birthdate: None,
avatar: None, avatar: user.avatar,
is_deleted: false, is_deleted: false,
role: make_thing(&ResourceEnum::Roles.to_string(), &user.role_id), role: make_thing(&ResourceEnum::Roles.to_string(), &user.role_id),
created_at: get_iso_date(), created_at: get_iso_date(),
+83
View File
@@ -10,6 +10,13 @@ struct CsrfPayload {
pub random: String, pub random: String,
} }
#[derive(Debug, Serialize, Deserialize)]
struct OAuthCsrfPayload {
pub timestamp: u64,
pub random: String,
pub pkce_verifier: String,
}
/// Generate a signed CSRF token that can be validated without server-side storage /// Generate a signed CSRF token that can be validated without server-side storage
pub fn generate_csrf_token(secret: &str) -> Result<String, Error> { pub fn generate_csrf_token(secret: &str) -> Result<String, Error> {
let timestamp = SystemTime::now() let timestamp = SystemTime::now()
@@ -38,6 +45,35 @@ pub fn generate_csrf_token(secret: &str) -> Result<String, Error> {
Ok(format!("{}.{}", payload_b64, signature)) Ok(format!("{}.{}", payload_b64, signature))
} }
/// Generate a signed OAuth CSRF token with PKCE verifier
pub fn generate_oauth_csrf_token(secret: &str, pkce_verifier: &str) -> Result<String, Error> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get timestamp".to_string()))?
.as_secs();
let random = uuid::Uuid::new_v4().to_string();
let payload = OAuthCsrfPayload {
timestamp,
random,
pkce_verifier: pkce_verifier.to_string(),
};
let payload_json = serde_json::to_string(&payload)
.map_err(|_| Error::Auth("Failed to serialize OAuth CSRF payload".to_string()))?;
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
// Create signature
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
Ok(format!("{}.{}", payload_b64, signature))
}
/// Validate a CSRF token /// Validate a CSRF token
pub fn validate_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<(), Error> { pub fn validate_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<(), Error> {
let parts: Vec<&str> = token.split('.').collect(); let parts: Vec<&str> = token.split('.').collect();
@@ -85,6 +121,53 @@ pub fn validate_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> R
Ok(()) Ok(())
} }
/// Validate OAuth CSRF token and extract PKCE verifier
pub fn validate_oauth_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<String, Error> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 2 {
return Err(Error::Auth("Invalid OAuth CSRF token format".to_string()));
}
let payload_b64 = parts[0];
let provided_signature = parts[1];
// Verify signature
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
if provided_signature != expected_signature {
return Err(Error::Auth("Invalid OAuth CSRF token signature".to_string()));
}
// Decode and validate payload
let payload_json = URL_SAFE_NO_PAD.decode(payload_b64)
.map_err(|_| Error::Auth("Failed to decode OAuth CSRF token".to_string()))?;
let payload_str = String::from_utf8(payload_json)
.map_err(|_| Error::Auth("Invalid OAuth CSRF token encoding".to_string()))?;
let payload: OAuthCsrfPayload = serde_json::from_str(&payload_str)
.map_err(|_| Error::Auth("Failed to parse OAuth CSRF token".to_string()))?;
// Check timestamp
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))?
.as_secs();
if now > payload.timestamp + max_age_seconds {
return Err(Error::Auth("OAuth CSRF token has expired".to_string()));
}
if payload.timestamp > now + 60 { // Allow 1 minute clock skew
return Err(Error::Auth("OAuth CSRF token timestamp is in the future".to_string()));
}
Ok(payload.pkce_verifier)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;