feat: Implement OAuth2 authentication with Google and GitHub

- Added OAuth2 endpoints for Google and GitHub authentication.
- Created OAuth service to handle provider interactions and user info retrieval.
- Implemented user OAuth repository for managing user OAuth links in the database.
- Updated auth service to support linking existing users and creating new users via OAuth.
- Added CORS middleware to allow frontend access.
- Created tests for OAuth endpoints and service functionality.
- Introduced environment configuration for OAuth client IDs and secrets.
- Added logging for OAuth operations and error handling.
This commit is contained in:
JSC
2025-07-26 14:38:13 +02:00
parent 52ebc59293
commit 51423779a8
14 changed files with 1119 additions and 37 deletions

View File

@@ -10,6 +10,7 @@ from app.core.config import settings
from app.core.logging import get_logger
from app.models.user import User
from app.repositories.user import UserRepository
from app.repositories.user_oauth import UserOauthRepository
from app.schemas.auth import (
AuthResponse,
TokenResponse,
@@ -17,6 +18,7 @@ from app.schemas.auth import (
UserRegisterRequest,
UserResponse,
)
from app.services.oauth import OAuthUserInfo
from app.utils.auth import JWTUtils, PasswordUtils
logger = get_logger(__name__)
@@ -29,6 +31,7 @@ class AuthService:
"""Initialize the auth service."""
self.session = session
self.user_repo = UserRepository(session)
self.oauth_repo = UserOauthRepository(session)
async def register(self, request: UserRegisterRequest) -> AuthResponse:
"""Register a new user."""
@@ -203,7 +206,7 @@ class AuthService:
# Check if refresh token is expired
if user.refresh_token_expires_at and datetime.now(
UTC
UTC,
) > user.refresh_token_expires_at.replace(tzinfo=UTC):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -272,3 +275,127 @@ class AuthService:
created_at=user.created_at,
updated_at=user.updated_at,
)
async def oauth_login(self, oauth_user_info: OAuthUserInfo) -> AuthResponse:
"""Handle OAuth login - link or create user."""
logger.info(
"OAuth login attempt for %s with provider %s",
oauth_user_info.email,
oauth_user_info.provider,
)
# Check if user already has OAuth link for this provider
existing_oauth = await self.oauth_repo.get_by_provider_user_id(
oauth_user_info.provider,
oauth_user_info.provider_user_id,
)
if existing_oauth:
# User exists with this OAuth provider, get the user
user = await self.user_repo.get_by_id(existing_oauth.user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Linked user not found",
)
# Refresh user to avoid greenlet issues
await self.session.refresh(user)
# Update OAuth record with latest info
oauth_update_data = {
"email": oauth_user_info.email,
"name": oauth_user_info.name,
"picture": oauth_user_info.picture,
}
await self.oauth_repo.update(existing_oauth, oauth_update_data)
logger.info(
"OAuth login successful for existing user: %s",
oauth_user_info.email,
)
else:
# Check if user exists by email
user = await self.user_repo.get_by_email(oauth_user_info.email)
if user:
# Refresh user to avoid greenlet issues
await self.session.refresh(user)
# Store user picture value to avoid greenlet issues later
current_user_picture = user.picture
# Link existing user to OAuth provider
oauth_data = {
"user_id": user.id,
"provider": oauth_user_info.provider,
"provider_user_id": oauth_user_info.provider_user_id,
"email": oauth_user_info.email,
"name": oauth_user_info.name,
"picture": oauth_user_info.picture,
}
await self.oauth_repo.create(oauth_data)
# Update user profile with OAuth info if needed
user_update_data = {}
if not current_user_picture and oauth_user_info.picture:
user_update_data["picture"] = oauth_user_info.picture
if user_update_data:
await self.user_repo.update(user, user_update_data)
# Refresh user after update to avoid greenlet issues
await self.session.refresh(user)
logger.info(
"Linked existing user %s to OAuth provider %s",
oauth_user_info.email,
oauth_user_info.provider,
)
else:
# Create new user
user_data = {
"email": oauth_user_info.email,
"name": oauth_user_info.name,
"picture": oauth_user_info.picture,
"is_active": True,
# No password for OAuth users
"password_hash": None,
}
user = await self.user_repo.create(user_data)
# Create OAuth link
oauth_data = {
"user_id": user.id,
"provider": oauth_user_info.provider,
"provider_user_id": oauth_user_info.provider_user_id,
"email": oauth_user_info.email,
"name": oauth_user_info.name,
"picture": oauth_user_info.picture,
}
await self.oauth_repo.create(oauth_data)
logger.info(
"Created new user %s from OAuth provider %s",
oauth_user_info.email,
oauth_user_info.provider,
)
# Refresh user to avoid greenlet issues and check if user is active
await self.session.refresh(user)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is deactivated",
)
# Generate access token
token = self._create_access_token(user)
# Create response
user_response = await self.create_user_response(user)
logger.info(
"OAuth login completed successfully for user: %s",
oauth_user_info.email,
)
return AuthResponse(user=user_response, token=token)