Spaces:
Sleeping
Sleeping
File size: 15,359 Bytes
1bd7131 050d8f8 1bd7131 050d8f8 e39877e 34f76dc 1bd7131 bc8ed4e 1bd7131 bc8ed4e 5e3877e 1bd7131 bc8ed4e 1bd7131 75fb504 5e3877e 1bd7131 a42ab7e e39877e 5e3877e 050d8f8 1bd7131 050d8f8 c4fd9c8 050d8f8 1bd7131 050d8f8 1bd7131 050d8f8 34f76dc 050d8f8 34f76dc 050d8f8 34f76dc 050d8f8 05b100e 1bd7131 050d8f8 1bd7131 75fb504 05b100e 050d8f8 75fb504 050d8f8 1bd7131 05b100e 1bd7131 5e3877e 34f76dc 1bd7131 5e3877e 1bd7131 050d8f8 1bd7131 050d8f8 1bd7131 34f76dc 8d69ae4 34f76dc 8d69ae4 34f76dc 8d69ae4 34f76dc 1bd7131 40628e2 1bd7131 34f76dc 8d69ae4 34f76dc 8d69ae4 34f76dc 1bd7131 5e3877e 34f76dc 5e3877e 34f76dc 1bd7131 5e3877e 050d8f8 1bd7131 75fb504 19e4a8c 75fb504 c4fd9c8 be85b16 1bd7131 75fb504 178694a 75fb504 178694a 75fb504 178694a 75fb504 178694a 75fb504 178694a 75fb504 050d8f8 1bd7131 050d8f8 1bd7131 050d8f8 1bd7131 19e4a8c 050d8f8 75fb504 1bd7131 050d8f8 1bd7131 19e4a8c 75fb504 19e4a8c 75fb504 19e4a8c 75fb504 19e4a8c 75fb504 19e4a8c 75fb504 c4fd9c8 75fb504 178694a 75fb504 178694a 75fb504 178694a 75fb504 1bd7131 050d8f8 1bd7131 050d8f8 1bd7131 050d8f8 1bd7131 050d8f8 1bd7131 19e4a8c 1bd7131 050d8f8 1bd7131 19e4a8c 1bd7131 5e3877e 34f76dc 5e3877e 1bd7131 5e3877e 1bd7131 be85b16 1bd7131 75fb504 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 |
"""
Authentication Router - Google OAuth
Endpoints for Google Sign-In authentication flow.
No more secret keys - users authenticate with their Google account.
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request, BackgroundTasks
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime
import uuid
import logging
from core.database import get_db
from core.models import User, AuditLog, ClientUser
from core.schemas import (
CheckRegistrationRequest,
GoogleAuthRequest,
AuthResponse,
UserInfoResponse,
TokenRefreshRequest,
TokenRefreshResponse
)
from services.auth_service.google_provider import (
GoogleAuthService,
GoogleUserInfo,
InvalidTokenError as GoogleInvalidTokenError,
ConfigurationError as GoogleConfigError,
get_google_auth_service,
)
from services.auth_service.jwt_provider import (
JWTService,
create_access_token,
create_refresh_token,
get_jwt_service,
InvalidTokenError as JWTInvalidTokenError,
)
from core.dependencies import check_rate_limit, get_current_user
from services.drive_service import DriveService
from services.audit_service import AuditService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
drive_service = DriveService()
@router.post("/check-registration")
async def check_registration(
request: CheckRegistrationRequest,
req: Request,
db: AsyncSession = Depends(get_db)
):
"""
Check if a temporary user_id has completed registration.
Useful for frontend to check if user needs to sign in.
"""
# Rate Limit: 10 requests per minute per IP
ip = req.client.host
if not await check_rate_limit(db, ip, "/auth/check-registration", 10, 1):
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many requests")
# Check if this client_user_id has been linked to a server user
query = select(ClientUser).where(ClientUser.client_user_id == request.user_id)
result = await db.execute(query)
client_user = result.scalar_one_or_none()
return {"is_registered": client_user is not None}
def detect_client_type(request: Request) -> str:
"""
Detect client type from User-Agent header.
Browsers get 'web', native apps get 'mobile'.
"""
user_agent = request.headers.get("user-agent", "").lower()
# Browser indicators
browser_keywords = ["mozilla", "chrome", "firefox", "safari", "edge", "opera"]
if any(keyword in user_agent for keyword in browser_keywords):
return "web"
return "mobile"
@router.post("/google", response_model=AuthResponse)
async def google_auth(
request: GoogleAuthRequest,
req: Request,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""
Authenticate with Google ID token.
Supports two client types:
- "web": Sets refresh_token in HttpOnly cookie (secure)
- "mobile": Returns refresh_token in JSON body
Client type is auto-detected from User-Agent if not provided.
"""
response = JSONResponse(content={}) # Placeholder, will be populated later
ip = req.client.host
# Auto-detect client type if not explicitly provided
client_type = request.client_type if request.client_type else detect_client_type(req)
# Rate Limit: 10 attempts per minute per IP
if not await check_rate_limit(db, ip, "/auth/google", 10, 1):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many authentication attempts"
)
# Verify Google token
try:
google_service = get_google_auth_service()
google_info = google_service.verify_token(request.id_token)
except GoogleConfigError as e:
logger.error(f"Google Auth not configured: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Google authentication is not configured"
)
except GoogleInvalidTokenError as e:
logger.warning(f"Invalid Google token from {ip}: {e}")
# Log failed attempt
await AuditService.log_event(
db=db,
log_type="server",
action="google_auth",
status="failed",
error_message=str(e),
request=req
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Google token. Please try signing in again."
)
# Check for existing user by email (preserves credits for migrated users)
query = select(User).where(User.email == google_info.email)
result = await db.execute(query)
user = result.scalar_one_or_none()
is_new_user = False
if user:
# Existing user - update Google info
if not user.google_id:
user.google_id = google_info.google_id
logger.info(f"Linked Google account to existing user: {user.email}")
user.name = google_info.name
user.profile_picture = google_info.picture
user.last_used_at = datetime.utcnow()
# Link client_user_id if provided
if request.temp_user_id:
# Check if this client mapping exists
client_query = select(ClientUser).where(
ClientUser.user_id == user.id, # Integer FK comparison
ClientUser.client_user_id == request.temp_user_id
)
client_result = await db.execute(client_query)
existing_client = client_result.scalar_one_or_none()
if not existing_client:
# Create new client user mapping
client_user = ClientUser(
user_id=user.id, # Integer FK to users.id
client_user_id=request.temp_user_id,
ip_address=ip, # Standardized IP column
last_seen_at=datetime.utcnow()
)
db.add(client_user)
else:
# Update last seen
existing_client.last_seen_at = datetime.utcnow()
else:
# New user - create account
is_new_user = True
user = User(
user_id="usr_" + str(uuid.uuid4()),
email=google_info.email,
google_id=google_info.google_id,
name=google_info.name,
profile_picture=google_info.picture,
credits=0
)
db.add(user)
logger.info(f"New user created via Google: {google_info.email}")
# Create client user mapping if temp_user_id provided
if request.temp_user_id:
client_user = ClientUser(
user_id=user.id, # Integer FK to users.id (will be set after flush)
client_user_id=request.temp_user_id,
ip_address=ip, # Standardized IP column
last_seen_at=datetime.utcnow()
)
db.add(client_user)
# Log successful auth
await AuditService.log_event(
db=db,
log_type="server",
user_id=user.id,
client_user_id=request.temp_user_id,
action="google_auth",
status="success",
request=req
)
await db.commit()
# Create our JWT access token and refresh token
access_token = create_access_token(user.user_id, user.email, user.token_version)
refresh_token = create_refresh_token(user.user_id, user.email, user.token_version)
# Sync DB to Drive (Async)
from services.backup_service import get_backup_service
backup_service = get_backup_service()
background_tasks.add_task(backup_service.backup_async)
# Prepare response data
response_data = {
"success": True,
"access_token": access_token,
"user_id": user.user_id,
"email": user.email,
"name": user.name,
"credits": user.credits,
"is_new_user": is_new_user
}
# Handle token delivery based on client type
if client_type == "web":
# Web: Set HttpOnly cookie for refresh token
response = JSONResponse(content=response_data)
# Cookie settings for production
import os
is_production = os.getenv("ENVIRONMENT", "production") == "production"
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=is_production, # True in production (HTTPS), False locally (HTTP)
samesite="none" if is_production else "lax", # 'none' for cross-origin in production
max_age=7 * 24 * 60 * 60, # 7 days
domain=None # Let browser set domain automatically
)
logger.info(f"Set refresh_token cookie for web client (production={is_production})")
else:
# Mobile: Return refresh token in body
response_data["refresh_token"] = refresh_token
response = JSONResponse(content=response_data)
logger.info(f"Returned refresh_token in body for mobile client")
return response
@router.get("/me", response_model=UserInfoResponse)
async def get_current_user_info(
user: User = Depends(get_current_user)
):
"""
Get current authenticated user info.
Requires Authorization: Bearer <token> header.
"""
return UserInfoResponse(
user_id=user.user_id,
email=user.email,
name=user.name,
credits=user.credits,
profile_picture=user.profile_picture
)
@router.post("/refresh", response_model=TokenRefreshResponse)
async def refresh_token(
request: TokenRefreshRequest,
req: Request,
db: AsyncSession = Depends(get_db)
):
"""
Refresh an access token.
Use this when the current token is about to expire
(or has recently expired) to get a new one without
requiring the user to sign in again.
Validates that the token_version is still valid before refreshing.
"""
ip = req.client.host
# Rate Limit: 20 refreshes per minute per IP (increased for proactive refresh on page load)
if not await check_rate_limit(db, ip, "/auth/refresh", 20, 1):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many refresh attempts"
)
try:
jwt_service = get_jwt_service()
# Get token from body or cookie
token_to_refresh = request.token
using_cookie = False
if not token_to_refresh:
token_to_refresh = req.cookies.get("refresh_token")
using_cookie = True
if not token_to_refresh:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token missing"
)
# Decode the token (without verifying expiry) to get user info
import jwt as pyjwt
payload = pyjwt.decode(
token_to_refresh,
jwt_service.secret_key,
algorithms=[jwt_service.algorithm],
options={"verify_exp": False}
)
user_id = payload.get("sub")
token_version = payload.get("tv", 1)
token_type = payload.get("type", "access")
if not user_id:
raise JWTInvalidTokenError("Token missing required claims")
# Verify it's a refresh token
if token_type != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type. Expected refresh token."
)
# Check if user exists and token version is still valid
query = select(User).where(User.user_id == user_id, User.is_active == True)
result = await db.execute(query)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive"
)
# Validate token version
if token_version < user.token_version:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has been invalidated. Please sign in again."
)
# Create new access token
new_access_token = create_access_token(user.user_id, user.email, user.token_version)
# ROTATION: Issue new refresh token
new_refresh_token = create_refresh_token(user.user_id, user.email, user.token_version)
response_data = {
"success": True,
"access_token": new_access_token
}
if using_cookie:
# If came from cookie, rotate cookie
response = JSONResponse(content=response_data)
# Cookie settings for production
import os
is_production = os.getenv("ENVIRONMENT", "production") == "production"
response.set_cookie(
key="refresh_token",
value=new_refresh_token,
httponly=True,
secure=is_production, # True in production (HTTPS), False locally (HTTP)
samesite="none" if is_production else "lax", # 'none' for cross-origin in production
max_age=7 * 24 * 60 * 60,
domain=None # Let browser set domain automatically
)
logger.info(f"Rotated refresh_token cookie (production={is_production})")
return response
else:
# If came from body, return in body
response_data["refresh_token"] = new_refresh_token
return TokenRefreshResponse(**response_data)
except JWTInvalidTokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Cannot refresh token: {str(e)}"
)
@router.post("/logout")
async def logout(
req: Request,
background_tasks: BackgroundTasks,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Logout current user.
Increments the user's token_version which invalidates ALL existing
tokens for this user. This provides instant logout across all devices.
"""
ip = req.client.host
# Increment token version to invalidate all existing tokens
user.token_version += 1
logger.info(f"User {user.user_id} logged out. Token version incremented to {user.token_version}")
# Log logout
await AuditService.log_event(
db=db,
log_type="server",
user_id=user.id,
action="logout",
status="success",
request=req
)
await db.commit()
# Sync DB to Drive (Async)
from services.backup_service import get_backup_service
backup_service = get_backup_service()
background_tasks.add_task(backup_service.backup_async)
response = JSONResponse(content={"success": True, "message": "Logged out successfully. All sessions invalidated."})
response.delete_cookie(key="refresh_token")
return response
|