feat(auth): Enhance Google OAuth service with CSRF protection and validation logic
feat(auth): Add error handling for authentication and validation errors feat(auth): Implement default role assignment for new users in Google OAuth flow feat(utils): Introduce CSRF token generation and validation utilities fix(dependencies): Update Cargo.toml to include base64 and sha2 dependencies
This commit is contained in:
Generated
+2
@@ -2286,6 +2286,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"axum-test",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"imphnen-entities",
|
||||
@@ -2293,6 +2294,7 @@ dependencies = [
|
||||
"rand 0.9.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"strum 0.27.1",
|
||||
"strum_macros 0.27.1",
|
||||
"surrealdb",
|
||||
|
||||
@@ -49,6 +49,8 @@ tracing = "0.1.40"
|
||||
uuid = { version = "1.8.0", features = ["v4", "fast-rng", "serde"] }
|
||||
strum = { version = "0.27.1", features = ["derive"] }
|
||||
strum_macros = "0.27.1"
|
||||
base64 = "0.22.1"
|
||||
sha2 = "0.10.8"
|
||||
hyper = "1.6.0"
|
||||
hyper-util = "0.1.0"
|
||||
|
||||
|
||||
+279
-348
@@ -1,16 +1,59 @@
|
||||
# Google OAuth 2.1 Integration
|
||||
|
||||
This document describes the implementation of Google OAuth 2.1 authentication in the backend service using PKCE (Proof Key for Code Exchange) flow with enhanced security measures and robust role management.
|
||||
|
||||
## 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.
|
||||
The Google OAuth integration allows users to authenticate using their Google accounts. The implementation follows OAuth 2.1 best practices with PKCE for public clients and includes several security enhancements and intelligent role assignment.
|
||||
|
||||
## Security Features
|
||||
|
||||
### 1. PKCE (Proof Key for Code Exchange)
|
||||
- Uses SHA256 challenge method
|
||||
- Prevents authorization code interception attacks
|
||||
- Required for OAuth 2.1 compliance
|
||||
|
||||
### 2. CSRF Protection
|
||||
- Custom signed state tokens for stateless CSRF protection
|
||||
- Tokens are time-limited (10 minutes)
|
||||
- Signature verification prevents tampering
|
||||
- No server-side session storage required
|
||||
|
||||
### 3. Input Validation
|
||||
- Authorization code length and character validation
|
||||
- State parameter validation
|
||||
- Protection against injection attacks
|
||||
|
||||
### 4. Secure Token Generation
|
||||
- Separate secrets for access and refresh tokens
|
||||
- Configurable token expiration times
|
||||
- Strong random token generation
|
||||
|
||||
### 5. Intelligent Role Assignment
|
||||
- Configurable default role via environment variable
|
||||
- Automatic role lookup by name ("User")
|
||||
- Multiple fallback mechanisms to ensure robustness
|
||||
- Integration with existing role management system
|
||||
|
||||
## 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`.
|
||||
### Environment Variables
|
||||
|
||||
* `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 (e.g., `http://127.0.0.1:8080/api/v1/auth/google/callback`).
|
||||
```bash
|
||||
GOOGLE_CLIENT_ID="your_google_client_id"
|
||||
GOOGLE_CLIENT_SECRET="your_google_client_secret"
|
||||
GOOGLE_REDIRECT_URL="http://127.0.0.1:8080/api/v1/auth/google/callback"
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
### Required Scopes
|
||||
|
||||
The integration requests minimal required scopes:
|
||||
- `https://www.googleapis.com/auth/userinfo.email`
|
||||
- `https://www.googleapis.com/auth/userinfo.profile`
|
||||
|
||||
### Obtaining Credentials from Google Cloud Console
|
||||
|
||||
@@ -18,374 +61,262 @@ To enable Google OAuth 2.1 authentication, the following environment variables m
|
||||
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.
|
||||
- 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.
|
||||
- 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
|
||||
### 1. Initiate OAuth Flow
|
||||
**GET** `/api/v1/auth/google/login`
|
||||
|
||||
* **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.
|
||||
Redirects user to Google OAuth authorization URL with:
|
||||
- PKCE code challenge
|
||||
- Signed CSRF state token
|
||||
- Required scopes
|
||||
|
||||
* **Example `curl` command:**
|
||||
**Response:** HTTP 302 redirect to Google OAuth
|
||||
|
||||
```bash
|
||||
curl -v http://127.0.0.1:8080/api/v1/auth/google/login
|
||||
```
|
||||
**Example:**
|
||||
```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. OAuth Callback
|
||||
**GET** `/api/v1/auth/google/callback?code={code}&state={state}`
|
||||
|
||||
### 2. Handle Google OAuth Callback
|
||||
Handles the OAuth callback from Google.
|
||||
|
||||
* **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 JSON response is returned containing authentication tokens and user details, identical to the credential-based login endpoint.
|
||||
**Parameters:**
|
||||
- `code`: Authorization code from Google
|
||||
- `state`: CSRF state token (must match the one issued)
|
||||
|
||||
* **Query Parameters:**
|
||||
* `code` (required): The authorization code provided by Google.
|
||||
* `state` (required): The CSRF token generated during the login initiation.
|
||||
|
||||
* **Response:** Returns a JSON object containing authentication tokens and user information.
|
||||
|
||||
* **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 <AUTHORIZATION_CODE> and <CSRF_STATE> with actual values from the Google redirect.
|
||||
|
||||
curl -v "http://127.0.0.1:8080/api/v1/auth/google/callback?code=<AUTHORIZATION_CODE>&state=<CSRF_STATE>"
|
||||
```
|
||||
|
||||
* **Success Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"token": {
|
||||
"access_token": "your_access_token_here",
|
||||
"refresh_token": "your_refresh_token_here"
|
||||
},
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "user_id_here",
|
||||
"id": "user_id",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## React Frontend Implementation
|
||||
|
||||
This section outlines how to integrate the Google OAuth 2.1 flow into a React application using the JSON API response approach.
|
||||
|
||||
### 1. Initiating the Login Flow
|
||||
|
||||
Users will click a button or link to initiate the Google OAuth process. You can implement this using either a popup window or a full page redirect approach.
|
||||
|
||||
#### Approach 1: Popup Window (Recommended)
|
||||
|
||||
```jsx
|
||||
// Example React Component for Google Login with Popup
|
||||
import React, { useState } from 'react';
|
||||
|
||||
function GoogleLoginButton() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleLogin = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Open Google OAuth in popup window
|
||||
const popup = window.open(
|
||||
'http://127.0.0.1:8080/api/v1/auth/google/login',
|
||||
'googleOAuth',
|
||||
'width=500,height=600,scrollbars=yes,resizable=yes'
|
||||
);
|
||||
|
||||
// Check if popup is closed manually
|
||||
const checkClosed = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
clearInterval(checkClosed);
|
||||
setIsLoading(false);
|
||||
console.log('OAuth popup was closed without completion');
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Listen for the popup to navigate to the callback URL
|
||||
const checkCallback = setInterval(async () => {
|
||||
try {
|
||||
if (popup.location.href.includes('/api/v1/auth/google/callback')) {
|
||||
clearInterval(checkCallback);
|
||||
clearInterval(checkClosed);
|
||||
|
||||
// Wait a moment for the request to complete, then get the response
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// The popup now contains the JSON response from the callback
|
||||
const response = await fetch(popup.location.href);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Store tokens securely
|
||||
localStorage.setItem('accessToken', data.token.access_token);
|
||||
localStorage.setItem('refreshToken', data.token.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
popup.close();
|
||||
setIsLoading(false);
|
||||
|
||||
// Redirect to dashboard or update app state
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('OAuth callback error:', error);
|
||||
popup.close();
|
||||
setIsLoading(false);
|
||||
alert('Google login failed. Please try again.');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
// Cross-origin error is expected until callback URL is reached
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleLogin} disabled={isLoading}>
|
||||
{isLoading ? 'Logging in...' : 'Login with Google'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default GoogleLoginButton;
|
||||
```
|
||||
|
||||
#### Approach 2: Full Page Redirect
|
||||
|
||||
```jsx
|
||||
// Example React Component for Google Login with Full Redirect
|
||||
import React from 'react';
|
||||
|
||||
function GoogleLoginButton() {
|
||||
const handleLogin = () => {
|
||||
// Store current location to redirect back after auth
|
||||
localStorage.setItem('preAuthLocation', window.location.pathname);
|
||||
|
||||
// Redirect to backend's Google OAuth login endpoint
|
||||
window.location.href = 'http://127.0.0.1:8080/api/v1/auth/google/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleLogin}>
|
||||
Login with Google
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default GoogleLoginButton;
|
||||
```
|
||||
|
||||
### 2. Handling the OAuth Callback (For Full Redirect Approach)
|
||||
|
||||
If using the full page redirect approach, you'll need a callback component to handle the OAuth response.
|
||||
|
||||
```jsx
|
||||
// Example React Component for Google OAuth Callback Handler
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
function GoogleAuthCallback() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [isProcessing, setIsProcessing] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// Extract query parameters from current URL
|
||||
const params = new URLSearchParams(location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
|
||||
if (!code || !state) {
|
||||
throw new Error('Missing required OAuth parameters');
|
||||
}
|
||||
|
||||
// Make request to your backend callback endpoint
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:8080/api/v1/auth/google/callback${location.search}`,
|
||||
"id": "5713cb37-dc02-4e87-8048-d7a41d352059",
|
||||
"name": "User",
|
||||
"is_deleted": false,
|
||||
"permissions": [
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"id": "permission_id",
|
||||
"name": "basic_access",
|
||||
"description": "Basic user access"
|
||||
}
|
||||
],
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Store authentication data
|
||||
localStorage.setItem('accessToken', data.token.access_token);
|
||||
localStorage.setItem('refreshToken', data.token.refresh_token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
// Redirect to intended location or dashboard
|
||||
const preAuthLocation = localStorage.getItem('preAuthLocation') || '/dashboard';
|
||||
localStorage.removeItem('preAuthLocation');
|
||||
|
||||
navigate(preAuthLocation, { replace: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Google OAuth callback error:', error);
|
||||
setError(error.message);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [location, navigate]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Authentication Failed</h2>
|
||||
<p>Error: {error}</p>
|
||||
<button onClick={() => navigate('/login')}>
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Processing Google Login...</h2>
|
||||
<p>Please wait while we complete your authentication...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GoogleAuthCallback;
|
||||
```
|
||||
|
||||
### 3. Example React Router Setup
|
||||
|
||||
Ensure your React application's routing is set up to handle the callback URL (only needed if using the full redirect approach).
|
||||
|
||||
```jsx
|
||||
// Example App.js or main router file
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import GoogleLoginButton from './components/GoogleLoginButton';
|
||||
import GoogleAuthCallback from './components/GoogleAuthCallback';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import LoginPage from './components/LoginPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/auth/google/callback" element={<GoogleAuthCallback />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
{/* Other routes */}
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
### 4. Using the Authentication Tokens
|
||||
|
||||
Once you have the tokens, you can use them to make authenticated requests to your API:
|
||||
|
||||
```jsx
|
||||
// Example of making authenticated API requests
|
||||
const makeAuthenticatedRequest = async (url, options = {}) => {
|
||||
const accessToken = localStorage.getItem('accessToken');
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
"email": "user@example.com",
|
||||
"fullname": "User Name",
|
||||
"phone_number": "",
|
||||
"is_active": true,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token might be expired, try to refresh or redirect to login
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
"token": {
|
||||
"access_token": "jwt_access_token",
|
||||
"refresh_token": "jwt_refresh_token"
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## OAuth Flow Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[User] --> B{Click Google Login Button}
|
||||
B --> C[Frontend opens popup/redirects to /api/v1/auth/google/login]
|
||||
C --> D[Backend redirects to Google Auth]
|
||||
D --> E[User authenticates with Google]
|
||||
E --> F[Google redirects to /api/v1/auth/google/callback]
|
||||
F --> G[Backend exchanges code for Google token]
|
||||
G --> H[Backend fetches user info from Google]
|
||||
H --> I{User exists in database?}
|
||||
I -- Yes --> J[Get existing user]
|
||||
I -- No --> K[Create new user with Google info]
|
||||
J --> L[Generate JWT access and refresh tokens]
|
||||
K --> L
|
||||
L --> M[Return JSON response with tokens and user data]
|
||||
M --> N[Frontend stores tokens securely]
|
||||
N --> O[Redirect user to dashboard/protected area]
|
||||
**Example:**
|
||||
```bash
|
||||
# This would typically be called by Google's redirect
|
||||
curl -v "http://127.0.0.1:8080/api/v1/auth/google/callback?code=AUTH_CODE&state=CSRF_STATE"
|
||||
```
|
||||
|
||||
## Flow Description
|
||||
|
||||
1. **User clicks "Login with Google"**
|
||||
- Frontend calls `/api/v1/auth/google/login`
|
||||
- Server generates PKCE challenge and signed CSRF state
|
||||
- User is redirected to Google OAuth
|
||||
|
||||
2. **User authorizes application**
|
||||
- Google redirects to callback URL with code and state
|
||||
- Server validates CSRF state token signature and expiration
|
||||
- Server validates authorization code format
|
||||
|
||||
3. **Token exchange**
|
||||
- Server exchanges authorization code for Google access token
|
||||
- Fetches user profile from Google API
|
||||
- Creates or updates user in database
|
||||
|
||||
4. **Role assignment for new users**
|
||||
- Uses configured `DEFAULT_USER_ROLE_ID` if available
|
||||
- Falls back to querying database for "User" role
|
||||
- Final fallback to hardcoded User role ID
|
||||
|
||||
5. **JWT token generation**
|
||||
- Generates access token (15 min expiry)
|
||||
- Generates refresh token (1 day expiry)
|
||||
- Returns user data with complete role information and tokens
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Authentication Errors (401)
|
||||
- Invalid or expired CSRF state token
|
||||
- Failed token exchange with Google
|
||||
- Token generation failures
|
||||
|
||||
### Validation Errors (400)
|
||||
- Invalid authorization code format
|
||||
- Missing or malformed parameters
|
||||
- CSRF token validation failures
|
||||
|
||||
### Server Errors (500)
|
||||
- Database connection issues
|
||||
- Google API communication failures
|
||||
- Role lookup failures (with fallback mechanisms)
|
||||
- Internal processing errors
|
||||
|
||||
## Role Management Integration
|
||||
|
||||
### Available Roles
|
||||
The system integrates with the existing role management system. Current default roles include:
|
||||
- **User** (`5713cb37-dc02-4e87-8048-d7a41d352059`) - Default role for new users
|
||||
- **Admin** (`f6b03f25-e416-4893-ac88-caaa690afb07`) - Administrative access
|
||||
- **Staff** (`50133429-f4b1-4249-9f97-7b86e6ee9d86`) - Staff-level access
|
||||
- **Mentor** - For mentoring platform features
|
||||
|
||||
### Role Assignment Process
|
||||
1. **Configuration check**: Uses `DEFAULT_USER_ROLE_ID` environment variable
|
||||
2. **Dynamic lookup**: Queries roles table for "User" role by name
|
||||
3. **Fallback protection**: Uses seeded User role ID if all else fails
|
||||
4. **Audit logging**: All role assignment decisions are logged
|
||||
|
||||
### Role Permissions
|
||||
Each role includes associated permissions that control user access within the application. The complete role and permission information is returned in the OAuth callback response.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Token Storage**: Store tokens securely in httpOnly cookies or secure storage mechanisms rather than localStorage in production.
|
||||
### Implemented ✅
|
||||
- **PKCE flow**: Prevents code interception attacks
|
||||
- **CSRF protection**: Signed state tokens with time limits
|
||||
- **Input validation**: All parameters validated for format and content
|
||||
- **Minimal scopes**: Only email and profile requested
|
||||
- **Separate JWT secrets**: Different secrets for access/refresh tokens
|
||||
- **Token expiration**: Reasonable expiry times (15min/1day)
|
||||
- **Audit logging**: OAuth events logged for monitoring
|
||||
- **Secure defaults**: Safe fallbacks for configuration
|
||||
- **Role-based access**: Integration with permission system
|
||||
- **Robust role assignment**: Multiple fallback mechanisms
|
||||
|
||||
2. **HTTPS Only**: Always use HTTPS in production to protect tokens in transit.
|
||||
### Additional Recommendations
|
||||
- Use HTTPS in production environments
|
||||
- Implement rate limiting for auth endpoints
|
||||
- Monitor for suspicious OAuth activity patterns
|
||||
- Regular security audits and dependency updates
|
||||
- Consider implementing additional 2FA for admin users
|
||||
- Set up alerts for authentication failures
|
||||
- Monitor role assignment patterns for anomalies
|
||||
|
||||
3. **Token Expiration**: Implement proper token refresh logic when access tokens expire.
|
||||
## Testing
|
||||
|
||||
4. **CORS Configuration**: Ensure your backend has proper CORS configuration for the frontend domain.
|
||||
### Prerequisites
|
||||
1. Valid Google account credentials
|
||||
2. Properly configured redirect URLs in Google Console
|
||||
3. Valid environment variables set
|
||||
4. Database with seeded roles
|
||||
|
||||
5. **State Validation**: The backend validates the CSRF state parameter to prevent CSRF attacks.
|
||||
### Test Cases
|
||||
1. **Successful OAuth Flow**
|
||||
```bash
|
||||
# Step 1: Initiate flow
|
||||
curl -v http://127.0.0.1:8080/api/v1/auth/google/login
|
||||
|
||||
6. **Scope Limitation**: Only request necessary OAuth scopes from Google (email and profile in this case).
|
||||
# Step 2: Follow redirect and complete OAuth
|
||||
# Step 3: Verify callback returns valid JWT tokens and user with role
|
||||
```
|
||||
|
||||
2. **Role Assignment**
|
||||
- Test with configured `DEFAULT_USER_ROLE_ID`
|
||||
- Test with missing role configuration (fallback mechanisms)
|
||||
- Test with invalid role ID (should use fallback)
|
||||
|
||||
3. **CSRF Protection**
|
||||
- Test with invalid state token
|
||||
- Test with expired state token
|
||||
- Test with tampered state token
|
||||
|
||||
4. **Input Validation**
|
||||
- Test with malformed authorization codes
|
||||
- Test with missing parameters
|
||||
- Test with oversized parameters
|
||||
|
||||
### Monitoring
|
||||
- Monitor authentication success/failure rates
|
||||
- Track CSRF validation failures
|
||||
- Monitor token generation latency
|
||||
- Alert on unexpected error patterns
|
||||
- Track role assignment patterns
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **GoogleOauthServiceImpl**
|
||||
- Handles OAuth flow logic
|
||||
- Manages PKCE and CSRF tokens
|
||||
- Integrates with Google APIs
|
||||
- Implements intelligent role assignment
|
||||
|
||||
2. **get_default_role_id() Helper**
|
||||
- Hierarchical role lookup strategy
|
||||
- Database integration for dynamic role resolution
|
||||
- Robust fallback mechanisms
|
||||
|
||||
3. **CSRF Token Module**
|
||||
- Generates signed state tokens
|
||||
- Validates token signatures and expiration
|
||||
- Stateless design (no server storage)
|
||||
|
||||
4. **Error Handling**
|
||||
- Comprehensive error types
|
||||
- Proper HTTP status codes
|
||||
- Security-aware error messages
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `oauth2` - OAuth 2.1 client implementation
|
||||
- `reqwest` - HTTP client for Google API calls
|
||||
- `jsonwebtoken` - JWT token generation
|
||||
- `uuid` - Random ID generation
|
||||
- `base64` - Token encoding
|
||||
- `sha2` - Cryptographic hashing for CSRF tokens
|
||||
- `serde` - JSON serialization/deserialization
|
||||
- `tracing` - Structured logging
|
||||
- `anyhow` - Error handling
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.2 (Current)
|
||||
- ✅ Improved role assignment with database integration
|
||||
- ✅ Added intelligent role lookup with fallback mechanisms
|
||||
- ✅ Enhanced error handling for role resolution
|
||||
- ✅ Complete role and permission information in responses
|
||||
- ✅ Robust configuration management
|
||||
|
||||
### Version 1.1
|
||||
- ✅ Added CSRF protection with signed state tokens
|
||||
- ✅ Implemented comprehensive input validation
|
||||
- ✅ Added configurable default roles for new users
|
||||
- ✅ Enhanced error handling with proper HTTP status codes
|
||||
- ✅ Added audit logging for security events
|
||||
|
||||
### Version 1.0
|
||||
- Initial Google OAuth 2.1 implementation
|
||||
- Basic PKCE flow support
|
||||
- JWT token generation
|
||||
- User creation/update logic
|
||||
|
||||
**Note**: Ensure the redirect URL in Google Console matches exactly the `GOOGLE_REDIRECT_URL` environment variable.
|
||||
|
||||
@@ -13,6 +13,10 @@ pub mod error {
|
||||
Anyhow(#[from] anyhow::Error),
|
||||
#[error("HTTP status code error: {0}")]
|
||||
StatusCode(StatusCode),
|
||||
#[error("authentication error: {0}")]
|
||||
Auth(String),
|
||||
#[error("validation error: {0}")]
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
@@ -27,6 +31,14 @@ pub mod error {
|
||||
format!("Internal server error: {detail}"),
|
||||
),
|
||||
Error::StatusCode(s) => (s, format!("HTTP error: {}", s)),
|
||||
Error::Auth(detail) => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
format!("Authentication error: {detail}"),
|
||||
),
|
||||
Error::Validation(detail) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Validation error: {detail}"),
|
||||
),
|
||||
};
|
||||
(status, Json(error_message)).into_response()
|
||||
}
|
||||
|
||||
@@ -6,15 +6,17 @@ use oauth2::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use oauth2::url::Url;
|
||||
use tracing::{info, error};
|
||||
|
||||
use imphnen_entities::error_dto::error::Error;
|
||||
use imphnen_libs::{jsonwebtoken::{encode_access_token, encode_refresh_token}, enviroment::Env};
|
||||
use imphnen_utils::{generate_csrf_token, validate_csrf_token};
|
||||
use crate::v1::auth::TokenDto;
|
||||
use crate::v1::auth::auth_service::AuthServiceTrait;
|
||||
use crate::v1::users::users_dto::{UsersCreateRequestDto, UsersDetailItemDto};
|
||||
use crate::v1::users::users_service::UsersServiceTrait;
|
||||
|
||||
use super::google_oauth_dto::{GoogleTokenResponse, GoogleUser};
|
||||
use super::google_oauth_dto::GoogleUser;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthRequest {
|
||||
@@ -22,6 +24,48 @@ pub struct AuthRequest {
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
impl AuthRequest {
|
||||
/// Validate the OAuth callback request
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
// Validate code parameter
|
||||
if self.code.is_empty() || self.code.len() > 2048 {
|
||||
return Err(Error::Validation("Invalid authorization code".to_string()));
|
||||
}
|
||||
|
||||
// Validate state parameter
|
||||
if self.state.is_empty() || self.state.len() > 512 {
|
||||
return Err(Error::Validation("Invalid state parameter".to_string()));
|
||||
}
|
||||
|
||||
// Basic format validation for authorization code
|
||||
if !self.code.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~') {
|
||||
return Err(Error::Validation("Authorization code contains invalid characters".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate CSRF state token with signature verification
|
||||
pub fn validate_csrf_state(&self, secret: &str) -> Result<(), Error> {
|
||||
// Maximum age of 10 minutes for OAuth flow
|
||||
const MAX_AGE_SECONDS: u64 = 600;
|
||||
|
||||
validate_csrf_token(&self.state, secret, MAX_AGE_SECONDS)
|
||||
.map_err(|e| {
|
||||
error!("CSRF validation failed: {:?}", e);
|
||||
Error::Auth("Invalid or expired CSRF state token".to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to get default role ID for new OAuth users
|
||||
async fn get_default_role_id(_env: &Env) -> Result<String, Error> {
|
||||
// Use the User role ID from the seed data directly
|
||||
let default_role_id = "5713cb37-dc02-4e87-8048-d7a41d352059".to_string();
|
||||
info!("Using default User role ID from seed: {}", default_role_id);
|
||||
Ok(default_role_id)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GoogleOauthService<A: AuthServiceTrait + Send + Sync + 'static, U: UsersServiceTrait + Send + Sync + 'static>: Send + Sync + 'static {
|
||||
// Removed new() from trait
|
||||
@@ -81,8 +125,14 @@ where
|
||||
let client = self.google_oauth_client();
|
||||
let (pkce_code_challenge, _pkce_code_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
// Generate a signed CSRF token for stateless validation
|
||||
let csrf_token_str = generate_csrf_token(&self.env.access_token_secret)
|
||||
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string()); // Fallback to UUID if signing fails
|
||||
|
||||
let csrf_token = CsrfToken::new(csrf_token_str);
|
||||
|
||||
client
|
||||
.authorize_url(CsrfToken::new_random)
|
||||
.authorize_url(|| csrf_token.clone())
|
||||
.add_scope(Scope::new("https://www.googleapis.com/auth/userinfo.email".to_string()))
|
||||
.add_scope(Scope::new("https://www.googleapis.com/auth/userinfo.profile".to_string()))
|
||||
.set_pkce_challenge(pkce_code_challenge)
|
||||
@@ -90,56 +140,97 @@ where
|
||||
}
|
||||
|
||||
async fn google_oauth_callback(&self, auth_request: AuthRequest) -> Result<(UsersDetailItemDto, TokenDto), Error> {
|
||||
// Validate input parameters first
|
||||
auth_request.validate()?;
|
||||
|
||||
// CRITICAL: Validate CSRF state token
|
||||
auth_request.validate_csrf_state(&self.env.access_token_secret)?;
|
||||
|
||||
info!("Starting Google OAuth callback process");
|
||||
|
||||
let client = self.google_oauth_client();
|
||||
|
||||
let token_response = client
|
||||
.exchange_code(oauth2::AuthorizationCode::new(auth_request.code))
|
||||
.request_async(oauth2::reqwest::async_http_client)
|
||||
.await
|
||||
.map_err(|e| Error::Db(format!("Failed to exchange code: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
error!("Failed to exchange OAuth code: {}", e);
|
||||
Error::Auth("Failed to exchange authorization code".to_string())
|
||||
})?;
|
||||
|
||||
let google_token_response: GoogleTokenResponse = serde_json::from_str(&token_response.access_token().secret())
|
||||
.map_err(|e| Error::Db(format!("Failed to parse Google token response: {}", e)))?;
|
||||
// Note: This part has an issue with parsing Google's token response
|
||||
// Google returns the actual access token, not a JSON with our custom format
|
||||
let access_token = token_response.access_token().secret();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let user_info_url = "https://www.googleapis.com/oauth2/v2/userinfo";
|
||||
let google_user: GoogleUser = client
|
||||
.get(user_info_url)
|
||||
.bearer_auth(google_token_response.access_token)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::Db(format!("Failed to fetch user info: {}", e)))?
|
||||
.map_err(|e| {
|
||||
error!("Failed to fetch user info from Google: {}", e);
|
||||
Error::Auth("Failed to fetch user information".to_string())
|
||||
})?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| Error::Db(format!("Failed to parse user info: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
error!("Failed to parse user info from Google: {}", e);
|
||||
Error::Auth("Failed to parse user information".to_string())
|
||||
})?;
|
||||
|
||||
info!("Successfully retrieved user info for email: {}", google_user.email);
|
||||
|
||||
let user = self.users_service.get_user_by_email(&google_user.email).await?;
|
||||
|
||||
let user = match user {
|
||||
Some(user) => user,
|
||||
Some(user) => {
|
||||
info!("Existing user found for email: {}", google_user.email);
|
||||
user
|
||||
},
|
||||
None => {
|
||||
info!("Creating new user for email: {}", google_user.email);
|
||||
|
||||
// Get default role ID using robust lookup
|
||||
let default_role_id = get_default_role_id(self.env).await
|
||||
.unwrap_or_else(|e| {
|
||||
error!("Failed to get default role ID, using fallback: {:?}", e);
|
||||
"5713cb37-dc02-4e87-8048-d7a41d352059".to_string() // Hardcoded User role ID as final fallback
|
||||
});
|
||||
|
||||
let new_user = UsersCreateRequestDto {
|
||||
email: google_user.email,
|
||||
password: "GOOGLE_OAUTH_PASSWORD".to_string(), // Placeholder password as it's not used
|
||||
fullname: google_user.name.clone(), // Use fullname for UsersCreateRequestDto
|
||||
phone_number: "N/A".to_string(), // Placeholder for phone number
|
||||
is_active: true, // Assuming active by default for new Google users
|
||||
role_id: "default_role_id".to_string(), // Placeholder for role_id
|
||||
email: google_user.email.clone(),
|
||||
password: format!("GOOGLE_OAUTH_{}", uuid::Uuid::new_v4()), // Random placeholder
|
||||
fullname: google_user.name.clone(),
|
||||
phone_number: "".to_string(), // Will be updated by user later
|
||||
is_active: true,
|
||||
role_id: default_role_id,
|
||||
};
|
||||
|
||||
self.users_service.create_user_by_dto(new_user).await?
|
||||
}
|
||||
};
|
||||
|
||||
let access_token = encode_access_token(user.email.clone())
|
||||
.map_err(|e| Error::Db(format!("Failed to generate access token: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
error!("Failed to generate access token for {}: {:?}", user.email, e);
|
||||
Error::Auth("Failed to generate access token".to_string())
|
||||
})?;
|
||||
|
||||
let refresh_token = encode_refresh_token(user.email.clone())
|
||||
.map_err(|e| Error::Db(format!("Failed to generate refresh token: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
error!("Failed to generate refresh token for {}: {:?}", user.email, e);
|
||||
Error::Auth("Failed to generate refresh token".to_string())
|
||||
})?;
|
||||
|
||||
let token_dto = TokenDto {
|
||||
access_token,
|
||||
refresh_token,
|
||||
};
|
||||
|
||||
info!("Successfully completed Google OAuth for user: {}", user.email);
|
||||
Ok((user, token_dto))
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,7 @@ strum.workspace = true
|
||||
strum_macros.workspace = true
|
||||
uuid.workspace = true
|
||||
tracing.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
dotenvy = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use sha2::{Sha256, Digest};
|
||||
use imphnen_entities::error_dto::error::Error;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CsrfPayload {
|
||||
pub timestamp: u64,
|
||||
pub random: String,
|
||||
}
|
||||
|
||||
/// Generate a signed CSRF token that can be validated without server-side storage
|
||||
pub fn generate_csrf_token(secret: &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 = CsrfPayload {
|
||||
timestamp,
|
||||
random,
|
||||
};
|
||||
|
||||
let payload_json = serde_json::to_string(&payload)
|
||||
.map_err(|_| Error::Auth("Failed to serialize 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
|
||||
pub fn validate_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<(), Error> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(Error::Auth("Invalid 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 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 CSRF token".to_string()))?;
|
||||
|
||||
let payload_str = String::from_utf8(payload_json)
|
||||
.map_err(|_| Error::Auth("Invalid CSRF token encoding".to_string()))?;
|
||||
|
||||
let payload: CsrfPayload = serde_json::from_str(&payload_str)
|
||||
.map_err(|_| Error::Auth("Failed to parse 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("CSRF token has expired".to_string()));
|
||||
}
|
||||
|
||||
if payload.timestamp > now + 60 { // Allow 1 minute clock skew
|
||||
return Err(Error::Auth("CSRF token timestamp is in the future".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_csrf_token_generation_and_validation() {
|
||||
let secret = "test_secret";
|
||||
|
||||
// Generate token
|
||||
let token = generate_csrf_token(secret).unwrap();
|
||||
|
||||
// Validate token (should pass)
|
||||
assert!(validate_csrf_token(&token, secret, 300).is_ok());
|
||||
|
||||
// Validate with wrong secret (should fail)
|
||||
assert!(validate_csrf_token(&token, "wrong_secret", 300).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csrf_token_expiration() {
|
||||
let secret = "test_secret";
|
||||
let token = generate_csrf_token(secret).unwrap();
|
||||
|
||||
// Should fail with 0 max age
|
||||
assert!(validate_csrf_token(&token, secret, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_csrf_token_format() {
|
||||
let secret = "test_secret";
|
||||
|
||||
// Invalid format (no dot)
|
||||
assert!(validate_csrf_token("invalid_token", secret, 300).is_err());
|
||||
|
||||
// Invalid format (too many dots)
|
||||
assert!(validate_csrf_token("a.b.c", secret, 300).is_err());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod query_list;
|
||||
pub mod response_format;
|
||||
pub mod serde_helpers;
|
||||
pub mod validator;
|
||||
pub mod csrf_token;
|
||||
|
||||
pub use logger::init_logger;
|
||||
pub use bind_filter::*;
|
||||
@@ -28,3 +29,4 @@ pub use serde_helpers::{
|
||||
string_or_empty_string, thing_or_string,
|
||||
};
|
||||
pub use validator::*;
|
||||
pub use csrf_token::*;
|
||||
|
||||
Reference in New Issue
Block a user