214 lines
6.7 KiB
Python
214 lines
6.7 KiB
Python
"""Authentication Router - Google OAuth"""
|
|
import os
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
from fastapi import APIRouter, HTTPException, Request, Response, Depends
|
|
from fastapi.responses import RedirectResponse
|
|
from pydantic import BaseModel
|
|
from authlib.integrations.starlette_client import OAuth
|
|
from jose import jwt, JWTError
|
|
import httpx
|
|
|
|
router = APIRouter()
|
|
|
|
# Configuration from environment
|
|
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
|
|
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
|
|
JWT_SECRET = os.getenv("JWT_SECRET", os.getenv("SECRET_KEY", "change-me-in-production"))
|
|
JWT_ALGORITHM = "HS256"
|
|
JWT_EXPIRY_HOURS = 24 * 7 # 1 week
|
|
|
|
# Frontend URL for redirects after auth
|
|
FRONTEND_URL = os.getenv("FRONTEND_URL", "https://cockpit.valuecurve.co")
|
|
# Backend URL for OAuth callback (defaults to FRONTEND_URL for production where they share domain)
|
|
BACKEND_URL = os.getenv("BACKEND_URL", FRONTEND_URL)
|
|
|
|
# Allowed emails (invite-only) - comma-separated in env var
|
|
ALLOWED_EMAILS_STR = os.getenv("ALLOWED_EMAILS", "")
|
|
ALLOWED_EMAILS = set(email.strip().lower() for email in ALLOWED_EMAILS_STR.split(",") if email.strip())
|
|
|
|
# OAuth setup
|
|
oauth = OAuth()
|
|
|
|
oauth.register(
|
|
name='google',
|
|
client_id=GOOGLE_CLIENT_ID,
|
|
client_secret=GOOGLE_CLIENT_SECRET,
|
|
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
|
|
client_kwargs={'scope': 'openid email profile'},
|
|
)
|
|
|
|
|
|
class UserInfo(BaseModel):
|
|
email: str
|
|
name: str
|
|
picture: Optional[str] = None
|
|
|
|
|
|
class TokenData(BaseModel):
|
|
email: str
|
|
name: str
|
|
picture: Optional[str] = None
|
|
exp: datetime
|
|
|
|
|
|
def create_token(user: UserInfo) -> str:
|
|
"""Create JWT token for user"""
|
|
expire = datetime.utcnow() + timedelta(hours=JWT_EXPIRY_HOURS)
|
|
payload = {
|
|
"email": user.email,
|
|
"name": user.name,
|
|
"picture": user.picture,
|
|
"exp": expire
|
|
}
|
|
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
|
|
|
|
|
def verify_token(token: str) -> Optional[TokenData]:
|
|
"""Verify JWT token and return user data"""
|
|
try:
|
|
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
|
return TokenData(**payload)
|
|
except JWTError:
|
|
return None
|
|
|
|
|
|
def get_token_from_cookie(request: Request) -> Optional[str]:
|
|
"""Extract token from cookie"""
|
|
return request.cookies.get("auth_token")
|
|
|
|
|
|
async def get_current_user(request: Request) -> TokenData:
|
|
"""Dependency to get current authenticated user"""
|
|
token = get_token_from_cookie(request)
|
|
if not token:
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
|
|
user = verify_token(token)
|
|
if not user:
|
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
|
|
return user
|
|
|
|
|
|
def is_email_allowed(email: str) -> bool:
|
|
"""Check if email is in allowed list (or if list is empty, allow all)"""
|
|
if not ALLOWED_EMAILS:
|
|
# If no allowed list configured, allow anyone with valid OAuth
|
|
return True
|
|
return email.lower() in ALLOWED_EMAILS
|
|
|
|
|
|
@router.get("/login/google")
|
|
async def login_google(request: Request):
|
|
"""Initiate Google OAuth login"""
|
|
if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET:
|
|
raise HTTPException(status_code=500, detail="Google OAuth not configured")
|
|
|
|
# Callback goes to backend URL (same as frontend in production, different locally)
|
|
redirect_uri = f"{BACKEND_URL}/auth/callback/google"
|
|
return await oauth.google.authorize_redirect(request, redirect_uri)
|
|
|
|
|
|
@router.get("/callback/google")
|
|
async def callback_google(request: Request):
|
|
"""Handle Google OAuth callback"""
|
|
try:
|
|
token = await oauth.google.authorize_access_token(request)
|
|
user_info = token.get('userinfo')
|
|
|
|
if not user_info:
|
|
# Fetch user info from Google
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(
|
|
'https://www.googleapis.com/oauth2/v3/userinfo',
|
|
headers={'Authorization': f'Bearer {token["access_token"]}'}
|
|
)
|
|
user_info = resp.json()
|
|
|
|
email = user_info.get('email', '').lower()
|
|
name = user_info.get('name', email.split('@')[0])
|
|
picture = user_info.get('picture')
|
|
|
|
# Check if email is allowed
|
|
if not is_email_allowed(email):
|
|
# Redirect to login with error
|
|
return RedirectResponse(
|
|
url=f"{FRONTEND_URL}/login?error=not_authorized",
|
|
status_code=302
|
|
)
|
|
|
|
# Create JWT token
|
|
user = UserInfo(email=email, name=name, picture=picture)
|
|
jwt_token = create_token(user)
|
|
|
|
# Set cookie and redirect to app
|
|
# Use secure=False for localhost (HTTP), secure=True for production (HTTPS)
|
|
is_secure = FRONTEND_URL.startswith("https://")
|
|
response = RedirectResponse(url=FRONTEND_URL, status_code=302)
|
|
response.set_cookie(
|
|
key="auth_token",
|
|
value=jwt_token,
|
|
httponly=True,
|
|
secure=is_secure,
|
|
samesite="lax",
|
|
max_age=JWT_EXPIRY_HOURS * 3600
|
|
)
|
|
return response
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
print(f"OAuth error: {e}")
|
|
traceback.print_exc()
|
|
return RedirectResponse(
|
|
url=f"{FRONTEND_URL}/login?error=oauth_failed",
|
|
status_code=302
|
|
)
|
|
|
|
|
|
@router.get("/me")
|
|
async def get_me(user: TokenData = Depends(get_current_user)):
|
|
"""Get current user info"""
|
|
return {
|
|
"email": user.email,
|
|
"name": user.name,
|
|
"picture": user.picture
|
|
}
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout():
|
|
"""Logout user by clearing cookie"""
|
|
response = Response(content='{"message": "Logged out"}', media_type="application/json")
|
|
response.delete_cookie(key="auth_token")
|
|
return response
|
|
|
|
|
|
@router.get("/status")
|
|
async def auth_status(request: Request):
|
|
"""Check authentication status (doesn't require auth)"""
|
|
token = get_token_from_cookie(request)
|
|
if not token:
|
|
return {"authenticated": False}
|
|
|
|
user = verify_token(token)
|
|
if not user:
|
|
return {"authenticated": False}
|
|
|
|
return {
|
|
"authenticated": True,
|
|
"user": {
|
|
"email": user.email,
|
|
"name": user.name,
|
|
"picture": user.picture
|
|
}
|
|
}
|
|
|
|
|
|
# Admin endpoint to manage allowed emails (protected)
|
|
@router.get("/allowed-emails")
|
|
async def get_allowed_emails(user: TokenData = Depends(get_current_user)):
|
|
"""Get list of allowed emails (admin only)"""
|
|
# For now, just return the list - could add admin check later
|
|
return {"allowed_emails": list(ALLOWED_EMAILS), "allow_all": len(ALLOWED_EMAILS) == 0}
|