Automated Action 99937b3fd7 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
2025-06-25 05:49:54 +00:00

308 lines
10 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
import logging
from app.db.session import get_db
from app.models.user import User
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__)
router = APIRouter()
@router.get("/test")
async def test_auth():
logger.info("Auth test endpoint called")
return {
"message": "Auth router is working",
"status": "success"
}
class UserCreate(BaseModel):
email: EmailStr
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class Token(BaseModel):
access_token: str
token_type: str
class UserResponse(BaseModel):
id: int
email: str
created_at: str
class Config:
orm_mode = True
@router.post("/register", response_model=UserResponse)
async def register(user: UserCreate, db: Session = Depends(get_db)):
try:
logger.info(f"Registration attempt for email: {user.email}")
# Check if user already exists
db_user = db.query(User).filter(User.email == user.email).first()
if db_user:
logger.warning(f"Registration failed - email already exists: {user.email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Hash password and create user
hashed_password = get_password_hash(user.password)
db_user = User(
email=user.email,
password_hash=hashed_password
)
db.add(db_user)
db.commit()
db.refresh(db_user)
logger.info(f"User registered successfully: {user.email}")
return UserResponse(
id=db_user.id,
email=db_user.email,
created_at=str(db_user.created_at)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Registration error for {user.email}: {str(e)}")
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error during registration"
)
@router.post("/login", response_model=Token)
async def login(user: UserLogin, db: Session = Depends(get_db)):
db_user = db.query(User).filter(User.email == user.email).first()
# 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(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(data={"sub": db_user.email})
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"
)