Add comprehensive Google OAuth integration for easy login/signup

Features:
- Complete Google OAuth 2.0 integration with ID token and authorization code flows
- Enhanced User model with Google OAuth fields (google_id, is_google_user, email_verified, profile_picture)
- Google OAuth service for token verification and user info extraction
- Multiple authentication endpoints:
  - GET /auth/google/oauth-url (get OAuth URL for frontend)
  - POST /auth/google/login-with-token (direct ID token login)
  - POST /auth/google/login-with-code (authorization code exchange)
- Smart user handling: creates new users or links existing accounts
- Issues own JWT tokens after Google authentication
- Database migration 004 for Google OAuth fields
- Enhanced login logic to handle Google vs password users
- Comprehensive README with Google OAuth setup instructions
- Frontend integration examples for both OAuth flows

Google OAuth automatically:
- Creates user accounts on first login
- Links existing email accounts to Google
- Extracts profile information (name, picture, locale)
- Verifies email addresses
- Issues secure JWT tokens for API access
This commit is contained in:
Automated Action 2025-06-25 05:49:54 +00:00
parent 3e90deba20
commit 99937b3fd7
6 changed files with 511 additions and 9 deletions

View File

@ -38,6 +38,11 @@ Create a `.env` file in the root directory with the following variables:
# Authentication # Authentication
SECRET_KEY=your-secret-key-change-this-in-production SECRET_KEY=your-secret-key-change-this-in-production
# Google OAuth Configuration
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback
# AWS S3 Configuration # AWS S3 Configuration
AWS_ACCESS_KEY_ID=your-aws-access-key AWS_ACCESS_KEY_ID=your-aws-access-key
AWS_SECRET_ACCESS_KEY=your-aws-secret-key AWS_SECRET_ACCESS_KEY=your-aws-secret-key
@ -76,8 +81,11 @@ The API will be available at:
## API Endpoints ## API Endpoints
### Authentication ### Authentication
- `POST /auth/register` - User registration - `POST /auth/register` - User registration with email/password
- `POST /auth/login` - User login - `POST /auth/login` - User login with email/password
- `GET /auth/google/oauth-url` - Get Google OAuth URL for frontend
- `POST /auth/google/login-with-token` - Login/signup with Google ID token
- `POST /auth/google/login-with-code` - Login/signup with Google authorization code
### Profile Management ### Profile Management
- `GET /profile/` - Get user profile - `GET /profile/` - Get user profile
@ -104,9 +112,59 @@ The API will be available at:
### Results ### Results
- `GET /process/results/{video_id}` - Get complete processing results - `GET /process/results/{video_id}` - Get complete processing results
## Google OAuth Setup
### 1. Create Google OAuth Application
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing one
3. Enable the Google+ API
4. Go to "Credentials" → "Create Credentials" → "OAuth 2.0 Client IDs"
5. Choose "Web application"
6. Add authorized redirect URIs:
- `http://localhost:3000/auth/google/callback` (for development)
- Your production callback URL
### 2. Configure Environment Variables
Add these to your `.env` file:
```env
GOOGLE_CLIENT_ID=your-google-oauth-client-id
GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback
```
### 3. Frontend Integration
**Option 1: Direct Token Method**
```javascript
// Use Google's JavaScript library to get ID token
const response = await fetch('/auth/google/login-with-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id_token: googleIdToken })
});
```
**Option 2: Authorization Code Method**
```javascript
// Redirect user to Google OAuth URL, then exchange code
const oauthUrl = await fetch('/auth/google/oauth-url').then(r => r.json());
// Redirect to oauthUrl.oauth_url
// On callback, exchange code:
const response = await fetch('/auth/google/login-with-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: authorizationCode,
redirect_uri: 'http://localhost:3000/auth/google/callback'
})
});
```
## Workflow ## Workflow
1. **Register/Login** to get JWT token 1. **Register/Login** (Email/Password or Google OAuth) to get JWT token
2. **Upload Video** with source and target languages 2. **Upload Video** with source and target languages
3. **Transcribe** the audio from the video 3. **Transcribe** the audio from the video
4. **Translate** the transcribed text 4. **Translate** the transcribed text
@ -119,6 +177,9 @@ The API will be available at:
| Variable | Description | Required | | Variable | Description | Required |
|----------|-------------|----------| |----------|-------------|----------|
| `SECRET_KEY` | JWT secret key for authentication | Yes | | `SECRET_KEY` | JWT secret key for authentication | Yes |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | No* |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | No* |
| `GOOGLE_REDIRECT_URI` | Google OAuth redirect URI | No* |
| `AWS_ACCESS_KEY_ID` | AWS access key for S3 | Yes | | `AWS_ACCESS_KEY_ID` | AWS access key for S3 | Yes |
| `AWS_SECRET_ACCESS_KEY` | AWS secret key for S3 | Yes | | `AWS_SECRET_ACCESS_KEY` | AWS secret key for S3 | Yes |
| `AWS_REGION` | AWS region (default: us-east-1) | No | | `AWS_REGION` | AWS region (default: us-east-1) | No |
@ -126,6 +187,8 @@ The API will be available at:
| `OPENAI_API_KEY` | OpenAI API key for Whisper and GPT-4 | Yes | | `OPENAI_API_KEY` | OpenAI API key for Whisper and GPT-4 | Yes |
| `ELEVENLABS_API_KEY` | ElevenLabs API key for voice cloning | Yes | | `ELEVENLABS_API_KEY` | ElevenLabs API key for voice cloning | Yes |
*Required only if Google OAuth is enabled
## File Storage Structure ## File Storage Structure
Files are stored in S3 with the following structure: Files are stored in S3 with the following structure:

View File

@ -0,0 +1,81 @@
"""Add Google OAuth fields to users table
Revision ID: 004
Revises: 003
Create Date: 2024-01-01 00:00:03.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
# revision identifiers, used by Alembic.
revision: str = '004'
down_revision: Union[str, None] = '003'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name, column_name):
"""Check if a column exists in a table"""
bind = op.get_bind()
inspector = Inspector.from_engine(bind)
columns = [c['name'] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# List of Google OAuth columns to add
google_oauth_columns = [
('google_id', sa.Column('google_id', sa.String(), nullable=True)),
('is_google_user', sa.Column('is_google_user', sa.Boolean(), nullable=True)),
('email_verified', sa.Column('email_verified', sa.Boolean(), nullable=True)),
('profile_picture', sa.Column('profile_picture', sa.String(), nullable=True))
]
# Add Google OAuth columns only if they don't exist
for column_name, column_def in google_oauth_columns:
if not column_exists('users', column_name):
op.add_column('users', column_def)
# Create index for google_id if column exists
if column_exists('users', 'google_id'):
try:
op.create_index(op.f('ix_users_google_id'), 'users', ['google_id'], unique=True)
except Exception:
# Index might already exist, ignore
pass
# Set default values for existing users
if column_exists('users', 'is_google_user'):
op.execute("UPDATE users SET is_google_user = 0 WHERE is_google_user IS NULL")
if column_exists('users', 'email_verified'):
op.execute("UPDATE users SET email_verified = 0 WHERE email_verified IS NULL")
# Make password_hash nullable for Google OAuth users
# Note: SQLite doesn't support modifying column constraints directly
# So we'll handle this in the application logic
def downgrade() -> None:
# List of columns to remove (in reverse order)
google_oauth_columns = [
'profile_picture',
'email_verified',
'is_google_user',
'google_id'
]
# Drop index first if it exists
try:
op.drop_index(op.f('ix_users_google_id'), table_name='users')
except Exception:
# Index might not exist, ignore
pass
# Remove columns only if they exist
for column_name in google_oauth_columns:
if column_exists('users', column_name):
op.drop_column('users', column_name)

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy import Column, Integer, String, DateTime, Boolean
from datetime import datetime from datetime import datetime
from app.db.base import Base from app.db.base import Base
@ -8,13 +8,20 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False) email = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False) password_hash = Column(String, nullable=True) # Made nullable for Google OAuth users
first_name = Column(String, nullable=True) first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True) last_name = Column(String, nullable=True)
phone = Column(String, nullable=True) phone = Column(String, nullable=True)
bio = Column(String, nullable=True) bio = Column(String, nullable=True)
preferred_language = Column(String, default="en") preferred_language = Column(String, default="en")
timezone = Column(String, default="UTC") timezone = Column(String, default="UTC")
# Google OAuth fields
google_id = Column(String, unique=True, nullable=True, index=True)
is_google_user = Column(Boolean, default=False)
email_verified = Column(Boolean, default=False)
profile_picture = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), nullable=True) created_at = Column(DateTime(timezone=True), nullable=True)
updated_at = Column(DateTime(timezone=True), nullable=True) updated_at = Column(DateTime(timezone=True), nullable=True)
@ -24,3 +31,22 @@ class User(Base):
self.created_at = datetime.utcnow() self.created_at = datetime.utcnow()
if not self.updated_at: if not self.updated_at:
self.updated_at = datetime.utcnow() self.updated_at = datetime.utcnow()
@property
def full_name(self):
"""Get user's full name"""
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
elif self.first_name:
return self.first_name
elif self.last_name:
return self.last_name
return self.email.split('@')[0] # Fallback to email username
def is_password_user(self):
"""Check if user uses password authentication"""
return self.password_hash is not None and not self.is_google_user
def can_login_with_password(self):
"""Check if user can login with password"""
return self.password_hash is not None

View File

@ -1,10 +1,13 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
import logging import logging
from app.db.session import get_db from app.db.session import get_db
from app.models.user import User from app.models.user import User
from app.utils.auth import get_password_hash, verify_password, create_access_token from app.utils.auth import get_password_hash, verify_password, create_access_token
from app.services.google_oauth_service import GoogleOAuthService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -89,7 +92,25 @@ async def register(user: UserCreate, db: Session = Depends(get_db)):
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
async def login(user: UserLogin, db: Session = Depends(get_db)): async def login(user: UserLogin, db: Session = Depends(get_db)):
db_user = db.query(User).filter(User.email == user.email).first() db_user = db.query(User).filter(User.email == user.email).first()
if not db_user or not verify_password(user.password, db_user.password_hash):
# Check if user exists
if not db_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if user can login with password
if not db_user.can_login_with_password():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="This account uses Google sign-in. Please use Google to login.",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify password
if not verify_password(user.password, db_user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password", detail="Incorrect email or password",
@ -98,3 +119,190 @@ async def login(user: UserLogin, db: Session = Depends(get_db)):
access_token = create_access_token(data={"sub": db_user.email}) access_token = create_access_token(data={"sub": db_user.email})
return {"access_token": access_token, "token_type": "bearer"} return {"access_token": access_token, "token_type": "bearer"}
# Google OAuth Models
class GoogleTokenLogin(BaseModel):
id_token: str
class GoogleCodeLogin(BaseModel):
code: str
redirect_uri: str
class GoogleOAuthURL(BaseModel):
oauth_url: str
class GoogleUserResponse(BaseModel):
id: int
email: str
first_name: Optional[str]
last_name: Optional[str]
full_name: str
is_google_user: bool
email_verified: bool
profile_picture: Optional[str]
created_at: str
is_new_user: bool
class Config:
orm_mode = True
# Google OAuth Endpoints
@router.get("/google/oauth-url", response_model=GoogleOAuthURL)
async def get_google_oauth_url():
"""Get Google OAuth URL for frontend redirect"""
oauth_url = GoogleOAuthService.get_google_oauth_url()
if not oauth_url:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Google OAuth not configured"
)
return {"oauth_url": oauth_url}
@router.post("/google/login-with-token", response_model=Token)
async def google_login_with_token(
google_data: GoogleTokenLogin,
db: Session = Depends(get_db)
):
"""Login/Register with Google ID token"""
try:
logger.info("Google OAuth login attempt with ID token")
# Verify Google token
user_info = await GoogleOAuthService.verify_google_token(google_data.id_token)
if not user_info:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Google token"
)
# Check if user exists by email
db_user = db.query(User).filter(User.email == user_info['email']).first()
if db_user:
# Existing user - update Google info if not already a Google user
if not db_user.is_google_user:
db_user.google_id = user_info['google_id']
db_user.is_google_user = True
db_user.email_verified = user_info['email_verified']
if user_info.get('picture'):
db_user.profile_picture = user_info['picture']
if user_info.get('first_name') and not db_user.first_name:
db_user.first_name = user_info['first_name']
if user_info.get('last_name') and not db_user.last_name:
db_user.last_name = user_info['last_name']
db_user.updated_at = datetime.utcnow()
db.commit()
db.refresh(db_user)
logger.info(f"Updated existing user with Google OAuth: {db_user.email}")
else:
# New user - create account
db_user = User(
email=user_info['email'],
google_id=user_info['google_id'],
is_google_user=True,
email_verified=user_info['email_verified'],
first_name=user_info.get('first_name'),
last_name=user_info.get('last_name'),
profile_picture=user_info.get('picture'),
preferred_language=user_info.get('locale', 'en')
)
db.add(db_user)
db.commit()
db.refresh(db_user)
logger.info(f"Created new Google user: {db_user.email}")
# Create JWT token
access_token = create_access_token(data={"sub": db_user.email})
return {"access_token": access_token, "token_type": "bearer"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Google OAuth error: {str(e)}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error during Google authentication"
)
@router.post("/google/login-with-code", response_model=Token)
async def google_login_with_code(
google_data: GoogleCodeLogin,
db: Session = Depends(get_db)
):
"""Login/Register with Google authorization code"""
try:
logger.info("Google OAuth login attempt with authorization code")
# Exchange code for ID token
id_token = await GoogleOAuthService.exchange_code_for_token(
google_data.code,
google_data.redirect_uri
)
if not id_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Failed to exchange authorization code"
)
# Verify the ID token and process user
user_info = await GoogleOAuthService.verify_google_token(id_token)
if not user_info:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Google token"
)
# Same logic as token login
db_user = db.query(User).filter(User.email == user_info['email']).first()
if db_user:
if not db_user.is_google_user:
db_user.google_id = user_info['google_id']
db_user.is_google_user = True
db_user.email_verified = user_info['email_verified']
if user_info.get('picture'):
db_user.profile_picture = user_info['picture']
if user_info.get('first_name') and not db_user.first_name:
db_user.first_name = user_info['first_name']
if user_info.get('last_name') and not db_user.last_name:
db_user.last_name = user_info['last_name']
db_user.updated_at = datetime.utcnow()
db.commit()
db.refresh(db_user)
else:
db_user = User(
email=user_info['email'],
google_id=user_info['google_id'],
is_google_user=True,
email_verified=user_info['email_verified'],
first_name=user_info.get('first_name'),
last_name=user_info.get('last_name'),
profile_picture=user_info.get('picture'),
preferred_language=user_info.get('locale', 'en')
)
db.add(db_user)
db.commit()
db.refresh(db_user)
access_token = create_access_token(data={"sub": db_user.email})
return {"access_token": access_token, "token_type": "bearer"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Google OAuth code exchange error: {str(e)}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error during Google authentication"
)

View File

@ -0,0 +1,121 @@
import os
import logging
from typing import Optional, Dict, Any
from google.auth.transport import requests as google_requests
from google.oauth2 import id_token
from google.auth.exceptions import GoogleAuthError
logger = logging.getLogger(__name__)
# Google OAuth configuration
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET:
logger.warning("Google OAuth credentials not configured")
class GoogleOAuthService:
@staticmethod
async def verify_google_token(token: str) -> Optional[Dict[str, Any]]:
"""
Verify Google ID token and return user information
"""
if not GOOGLE_CLIENT_ID:
logger.error("Google Client ID not configured")
return None
try:
# Verify the token
id_info = id_token.verify_oauth2_token(
token,
google_requests.Request(),
GOOGLE_CLIENT_ID
)
# Verify that the token is issued by Google
if id_info['iss'] not in ['accounts.google.com', 'https://accounts.google.com']:
logger.error("Invalid token issuer")
return None
# Extract user information
user_info = {
'google_id': id_info.get('sub'),
'email': id_info.get('email'),
'email_verified': id_info.get('email_verified', False),
'first_name': id_info.get('given_name'),
'last_name': id_info.get('family_name'),
'full_name': id_info.get('name'),
'picture': id_info.get('picture'),
'locale': id_info.get('locale', 'en')
}
logger.info(f"Successfully verified Google token for user: {user_info['email']}")
return user_info
except ValueError as e:
logger.error(f"Invalid Google token: {e}")
return None
except GoogleAuthError as e:
logger.error(f"Google auth error: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error verifying Google token: {e}")
return None
@staticmethod
def get_google_oauth_url() -> Optional[str]:
"""
Generate Google OAuth URL for frontend to redirect users
"""
if not GOOGLE_CLIENT_ID:
return None
redirect_uri = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:3000/auth/google/callback")
oauth_url = (
f"https://accounts.google.com/o/oauth2/auth?"
f"client_id={GOOGLE_CLIENT_ID}&"
f"redirect_uri={redirect_uri}&"
f"scope=openid email profile&"
f"response_type=code&"
f"access_type=offline&"
f"prompt=consent"
)
return oauth_url
@staticmethod
async def exchange_code_for_token(code: str, redirect_uri: str) -> Optional[str]:
"""
Exchange authorization code for ID token
"""
if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET:
logger.error("Google OAuth credentials not configured")
return None
try:
import requests
token_url = "https://oauth2.googleapis.com/token"
data = {
'client_id': GOOGLE_CLIENT_ID,
'client_secret': GOOGLE_CLIENT_SECRET,
'code': code,
'grant_type': 'authorization_code',
'redirect_uri': redirect_uri
}
response = requests.post(token_url, data=data)
if response.status_code == 200:
token_data = response.json()
return token_data.get('id_token')
else:
logger.error(f"Failed to exchange code for token: {response.text}")
return None
except Exception as e:
logger.error(f"Error exchanging code for token: {e}")
return None

View File

@ -13,3 +13,6 @@ requests==2.31.0
ffmpeg-python==0.2.0 ffmpeg-python==0.2.0
python-dotenv==1.0.0 python-dotenv==1.0.0
email-validator==2.1.0 email-validator==2.1.0
google-auth==2.25.2
google-auth-oauthlib==1.1.0
google-auth-httplib2==0.2.0