Spaces:
Running
Running
| import uuid | |
| from loguru import logger | |
| import cloudinary | |
| import cloudinary.uploader | |
| from datetime import timezone, datetime | |
| from fastapi import APIRouter, HTTPException, UploadFile, File, Response, Request | |
| from fastapi import Depends | |
| from typing import Optional | |
| from src.config import settings | |
| from src.database import get_auth_supabase, get_supabase | |
| from src.store import create_user, get_user_by_email, delete_user_data, update_user_profile, get_user_by_id | |
| from src.dependencies import get_current_user_id, get_current_user, _verify_token_cached | |
| from src.auth.jwt_utils import ( | |
| create_access_token, | |
| create_refresh_token, | |
| hash_token, | |
| ) | |
| from src.auth.refresh_token_store import ( | |
| save_refresh_token, | |
| get_refresh_token, | |
| revoke_refresh_token, | |
| is_token_valid, | |
| ) | |
| from .schemas import ProfileUpdateRequest, EmailRateLimitRequest | |
| from .constants import ( | |
| ALLOWED_MIME_TYPES, | |
| MAX_FILE_SIZE_BYTES, | |
| AVATAR_BUCKET, | |
| PLACEHOLDER_DOMAINS, | |
| EMAIL_RATE_LIMITS, | |
| REFRESH_COOKIE_NAME, | |
| REFRESH_COOKIE_PATH, | |
| REFRESH_COOKIE_MAX_AGE, | |
| ) | |
| from .rate_limiter import ( | |
| enforce_email_rate_limit, | |
| get_email_rate_limit_status, | |
| check_and_record_email_rate_limit, | |
| ) | |
| router = APIRouter(prefix="/api/auth", tags=["Auth"]) | |
| # ── Cookie helper ───────────────────────────────────────────────────────────── | |
| # Constants are defined in src/auth/constants.py | |
| def _set_refresh_cookie(response: Response, raw_token: str) -> None: | |
| """Attach the refresh token as an HttpOnly cookie on *response*.""" | |
| is_prod = settings.environment != "development" | |
| response.set_cookie( | |
| key=REFRESH_COOKIE_NAME, | |
| value=raw_token, | |
| httponly=True, | |
| secure=is_prod, | |
| samesite="none" if is_prod else "lax", | |
| max_age=REFRESH_COOKIE_MAX_AGE, | |
| path=REFRESH_COOKIE_PATH, | |
| ) | |
| def _clear_refresh_cookie(response: Response) -> None: | |
| """Remove the refresh token cookie.""" | |
| is_prod = settings.environment != "development" | |
| response.delete_cookie( | |
| key=REFRESH_COOKIE_NAME, | |
| path=REFRESH_COOKIE_PATH, | |
| samesite="none" if is_prod else "lax", | |
| secure=is_prod, | |
| ) | |
| async def upload_avatar( | |
| file: UploadFile = File(...), | |
| user_id: str = Depends(get_current_user_id), | |
| ): | |
| """Upload a profile avatar image to Cloudinary and return its public URL.""" | |
| # 1. Validate MIME type | |
| if file.content_type not in ALLOWED_MIME_TYPES: | |
| raise HTTPException( | |
| 400, | |
| f"Unsupported file type '{file.content_type}'. " | |
| f"Allowed types: {', '.join(sorted(ALLOWED_MIME_TYPES))}", | |
| ) | |
| # 2. Read file bytes and validate size | |
| content = await file.read() | |
| if len(content) > MAX_FILE_SIZE_BYTES: | |
| raise HTTPException( | |
| 400, | |
| f"File is too large ({len(content) // 1024} KB). Maximum allowed size is 6 MB.", | |
| ) | |
| # 3. Configure Cloudinary | |
| if not settings.cloudinary_cloud_name or not settings.cloudinary_api_key or not settings.cloudinary_api_secret: | |
| raise HTTPException(503, "Cloudinary service is not configured") | |
| cloudinary.config( | |
| cloud_name=settings.cloudinary_cloud_name, | |
| api_key=settings.cloudinary_api_key, | |
| api_secret=settings.cloudinary_api_secret, | |
| secure=True, | |
| ) | |
| # 4. Upload to Cloudinary with smart face-cropping & auto webp conversion | |
| try: | |
| response = cloudinary.uploader.upload( | |
| content, | |
| folder="avatars", | |
| public_id=f"avatar_{user_id}", | |
| overwrite=True, | |
| transformation=[ | |
| {"width": 300, "height": 300, "crop": "fill", "gravity": "face"}, | |
| {"fetch_format": "auto", "quality": "auto"} | |
| ] | |
| ) | |
| public_url = response.get("secure_url") or response.get("url") | |
| return {"status": "success", "avatar_url": public_url} | |
| except Exception as e: | |
| raise HTTPException(500, f"Failed to upload avatar to Cloudinary: {e}") | |
| async def delete_account(user_id: str = Depends(get_current_user_id)): | |
| # 1. Delete all DB data (materials, quizzes, profile, etc.) | |
| delete_user_data(user_id) | |
| # 2. Delete the Supabase Auth user so they can't sign in again | |
| admin_client = get_supabase() | |
| if admin_client: | |
| try: | |
| admin_client.auth.admin.delete_user(user_id) | |
| except Exception as e: | |
| raise HTTPException(500, f"Account data deleted but failed to remove auth user: {e}") | |
| return {"status": "success", "message": "Account deleted successfully"} | |
| async def get_profile( | |
| user_id: str = Depends(get_current_user_id), | |
| current_user=Depends(get_current_user) | |
| ): | |
| user = get_user_by_id(user_id) | |
| # Fast-path: profile row exists and has a real email — return immediately | |
| email = user.get("email", "") if user else "" | |
| is_placeholder = ( | |
| not user | |
| or not email | |
| or any(domain in email for domain in PLACEHOLDER_DOMAINS) | |
| ) | |
| if is_placeholder: | |
| # First-ever login: pull real data from Supabase Auth admin API and persist it | |
| from src.database import get_supabase | |
| supabase = get_supabase() | |
| if supabase: | |
| try: | |
| res = supabase.auth.admin.get_user_by_id(user_id) | |
| if res.user and res.user.email and "@" in res.user.email: | |
| real_email = res.user.email | |
| real_name = res.user.user_metadata.get("name") | |
| from src.store import _table_supabase, _map_profile | |
| data = {"id": user_id, "email": real_email} | |
| if real_name: | |
| data["display_name"] = real_name | |
| try: | |
| # Use upsert so we NEVER overwrite daily_requests / last_request_date | |
| # on subsequent sign-ins. Only id/email/display_name are safe to set. | |
| client = supabase # already resolved above | |
| res_upd = ( | |
| client.table("profiles") | |
| .upsert(data, on_conflict="id", ignore_duplicates=False) | |
| .execute() | |
| ) | |
| if res_upd.data: | |
| user = _map_profile(res_upd.data[0]) | |
| except Exception: | |
| # Upsert failed — fall back to a plain update (never resets usage) | |
| try: | |
| # Strip the id from the update payload to avoid PK conflicts | |
| update_data = {k: v for k, v in data.items() if k != "id"} | |
| res_upd = ( | |
| _table_supabase("profiles") | |
| .update(update_data) | |
| .eq("id", user_id) | |
| .execute() | |
| ) | |
| if res_upd.data: | |
| user = _map_profile(res_upd.data[0]) | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass | |
| if not user: | |
| # Last resort: synthetic profile from the JWT claims so the UI doesn't break. | |
| # We still try to fetch real usage from the DB to avoid resetting the counter. | |
| user_obj = current_user | |
| uid = getattr(user_obj, "id", None) or (user_obj.get("id") if isinstance(user_obj, dict) else None) | |
| if uid: | |
| from src.store import _map_profile, get_usage | |
| meta = getattr(user_obj, "user_metadata", {}) or {} | |
| # Fetch real usage so the fallback profile doesn't reset the counter to 0 | |
| real_usage = get_usage(uid) | |
| user = _map_profile({ | |
| "id": uid, | |
| "display_name": meta.get("full_name") or meta.get("name") or "User", | |
| "email": getattr(user_obj, "email", "") or "", | |
| "avatar_url": "", | |
| "daily_requests": real_usage.get("used", 0), | |
| "last_request_date": ( | |
| __import__('datetime').datetime.now( | |
| __import__('datetime').timezone(__import__('datetime').timedelta(hours=3)) | |
| ).date().isoformat() | |
| ), | |
| "_is_fallback": True, | |
| }) | |
| if not user: | |
| raise HTTPException(404, "Profile not found") | |
| return {"status": "success", "user": user} | |
| async def update_profile(body: ProfileUpdateRequest, user_id: str = Depends(get_current_user_id)): | |
| # Reject raw base64 image data — images must be uploaded via /upload-avatar first | |
| if body.avatar_url and body.avatar_url.startswith("data:"): | |
| raise HTTPException( | |
| 400, | |
| "Storing raw image data is not allowed. " | |
| "Upload the image via POST /api/auth/upload-avatar and use the returned URL instead." | |
| ) | |
| # Handle password update if password is provided | |
| if body.password is not None: | |
| # Verify current password if provided | |
| if body.current_password: | |
| user_data = get_user_by_id(user_id) | |
| user_email = user_data.get("email") if user_data else None | |
| if user_email and not any(domain in user_email for domain in PLACEHOLDER_DOMAINS): | |
| auth_client = get_auth_supabase() | |
| if auth_client: | |
| try: | |
| res = auth_client.auth.sign_in_with_password({ | |
| "email": user_email, | |
| "password": body.current_password | |
| }) | |
| if hasattr(res, "error") and res.error: | |
| raise HTTPException(400, "Current password is incorrect.") | |
| except HTTPException: | |
| raise | |
| except Exception: | |
| raise HTTPException(400, "Current password is incorrect.") | |
| # Update password in Supabase Auth | |
| admin_client = get_supabase() | |
| if admin_client: | |
| try: | |
| admin_client.auth.admin.update_user_by_id(user_id, {"password": body.password}) | |
| except Exception as e: | |
| raise HTTPException(500, f"Failed to update password: {e}") | |
| try: | |
| updated_user = update_user_profile( | |
| user_id, | |
| name=body.name, | |
| avatar_url=body.avatar_url, | |
| theme=body.theme | |
| ) | |
| return {"status": "success", "user": updated_user} | |
| except ValueError as e: | |
| raise HTTPException(404, str(e)) | |
| except Exception as e: | |
| raise HTTPException(500, f"Failed to update profile: {e}") | |
| async def check_email_limit(body: EmailRateLimitRequest): | |
| """ | |
| Enforce in-memory rate limit for email sending actions (3 emails per hour). | |
| Action must be one of: 'email_verification', 'forgot_password', or 'change_password_confirmation'. | |
| Raises HTTP 429 if limit is reached. | |
| """ | |
| remaining = enforce_email_rate_limit(body.action, body.email) | |
| return { | |
| "status": "allowed", | |
| "action": body.action, | |
| "email": body.email, | |
| "remaining_attempts": remaining, | |
| } | |
| async def email_limit_status(action: str, email: str): | |
| """ | |
| Get the status of an email rate limit window without recording a new attempt. | |
| """ | |
| status_info = get_email_rate_limit_status(action, email) | |
| return {"status": "success", "action": action, "email": email, **status_info} | |
| # ── Token Exchange & Refresh ─────────────────────────────────────────────────── | |
| async def exchange_session(request: Request, response: Response): | |
| """ | |
| Token exchange endpoint. | |
| Accepts a Supabase JWT in the Authorization header, validates it once | |
| (locally via the Supabase JWT secret — stateless, no network call), then issues: | |
| • A short-lived (15 min) JWT in the response body | |
| • A long-lived (30 day) opaque refresh token as an HttpOnly cookie | |
| The frontend should call this right after any Supabase sign-in event | |
| (onAuthStateChange fires with a session). | |
| IMPORTANT: We decode the Supabase JWT locally instead of calling | |
| client.auth.get_user() to avoid a 403 race condition — the Supabase JS | |
| SDK rotates the session internally immediately after sign-in, so a | |
| stateful get_user() call often fails before our backend can validate it. | |
| """ | |
| import jwt as pyjwt | |
| # 1. Extract the Supabase token from the request. | |
| headers = {k.lower(): v for k, v in request.headers.items()} | |
| auth = headers.get("authorization", "") | |
| x_auth = headers.get("x-auth-token", "") | |
| raw_supabase_token = x_auth or (auth[len("Bearer "):].strip() if auth.startswith("Bearer ") else None) | |
| if not raw_supabase_token: | |
| content_type = headers.get("content-type", "") | |
| if content_type.startswith("text/plain"): | |
| body = await request.body() | |
| raw_supabase_token = body.decode("utf-8").strip() if body else None | |
| if not raw_supabase_token: | |
| raise HTTPException(401, "Authorization header with Supabase token required") | |
| # Skip HF space tokens — they're not user auth tokens | |
| if raw_supabase_token.startswith("hf_"): | |
| raise HTTPException(401, "HuggingFace space token is not a valid user auth token") | |
| # 2. Decode the Supabase JWT locally (stateless — no round-trip to Supabase) | |
| supabase_jwt_secret = settings.supabase_jwt_secret | |
| user_id = None | |
| email = "" | |
| user_metadata: dict = {} | |
| if supabase_jwt_secret: | |
| try: | |
| payload = pyjwt.decode( | |
| raw_supabase_token, | |
| supabase_jwt_secret, | |
| algorithms=["HS256", "HS384", "HS512"], | |
| options={"verify_aud": False}, # Supabase uses 'authenticated' as aud | |
| ) | |
| user_id = payload.get("sub") | |
| email = payload.get("email", "") | |
| user_metadata = payload.get("user_metadata", {}) | |
| except pyjwt.ExpiredSignatureError: | |
| raise HTTPException(401, "Supabase token has expired. Please sign in again.") | |
| except Exception: | |
| pass # Fall through to Supabase API validation below | |
| # Fallback: validate via Supabase API (slower, but works if unverified decoding failed) | |
| if not user_id: | |
| client = get_auth_supabase() or get_supabase() | |
| if not client: | |
| raise HTTPException(503, "Auth service unavailable") | |
| try: | |
| sb_user = _verify_token_cached(client, raw_supabase_token) | |
| user_id = str(sb_user.id) | |
| email = getattr(sb_user, "email", "") or "" | |
| user_metadata = getattr(sb_user, "user_metadata", {}) or {} | |
| except Exception: | |
| raise HTTPException(401, "Invalid or expired Supabase token") | |
| if not user_id: | |
| raise HTTPException(401, "Could not extract user identity from token") | |
| # 3. Ensure a profile row exists (first login race-safe) | |
| profile = get_user_by_id(user_id) | |
| if not profile: | |
| name = ( | |
| user_metadata.get("full_name") | |
| or user_metadata.get("name") | |
| or email.split("@")[0] | |
| or "User" | |
| ) | |
| profile = create_user(name=name, email=email, password="", user_id=user_id) | |
| # 4. Issue our tokens | |
| access_token = create_access_token(user_id, email) | |
| raw_refresh = create_refresh_token() | |
| refresh_hash = hash_token(raw_refresh) | |
| save_refresh_token(user_id, refresh_hash, email=email) | |
| _set_refresh_cookie(response, raw_refresh) | |
| return { | |
| "access_token": access_token, | |
| "token_type": "bearer", | |
| "user": profile, | |
| } | |
| async def refresh_session(request: Request, response: Response): | |
| """ | |
| Silently re-issue a new access token using the HttpOnly refresh token cookie. | |
| Rotates the refresh token on every use (old token is revoked, new one is issued) | |
| so a stolen token can only be used once before it's invalidated. | |
| """ | |
| raw = request.cookies.get(REFRESH_COOKIE_NAME) | |
| if not raw: | |
| logger.warning("Refresh session failed: No refresh token cookie found in request. Cookies present: %s", list(request.cookies.keys())) | |
| raise HTTPException(401, "No refresh token cookie found") | |
| token_hash = hash_token(raw) | |
| row = get_refresh_token(token_hash) | |
| if not row: | |
| logger.warning("Refresh session failed: Token hash (%s...) not found in refresh_tokens store", token_hash[:8]) | |
| _clear_refresh_cookie(response) | |
| raise HTTPException(401, "Refresh token is invalid, expired, or revoked") | |
| if not is_token_valid(row): | |
| logger.warning("Refresh session failed: Token for user %s is expired or revoked (expires_at=%s, revoked=%s)", row.get("user_id"), row.get("expires_at"), row.get("revoked")) | |
| _clear_refresh_cookie(response) | |
| raise HTTPException(401, "Refresh token is invalid, expired, or revoked") | |
| user_id = str(row["user_id"]) | |
| email = row.get("email") | |
| # Fall back to DB lookup only if email wasn't cached in Redis | |
| if not email: | |
| profile = get_user_by_id(user_id) | |
| email = (profile or {}).get("email", "") | |
| # Rotate: revoke old, issue new refresh token in Redis | |
| revoke_refresh_token(token_hash) | |
| new_raw_refresh = create_refresh_token() | |
| new_hash = hash_token(new_raw_refresh) | |
| save_refresh_token(user_id, new_hash, email=email) | |
| access_token = create_access_token(user_id, email) | |
| _set_refresh_cookie(response, new_raw_refresh) | |
| return { | |
| "access_token": access_token, | |
| "token_type": "bearer", | |
| } | |
| async def logout(request: Request, response: Response): | |
| """ | |
| Revoke the refresh token in the database and clear the cookie. | |
| This is the only true logout — do not rely on JWT expiry alone. | |
| The short-lived access token will expire naturally within 15 minutes. | |
| """ | |
| raw = request.cookies.get(REFRESH_COOKIE_NAME) | |
| if raw: | |
| token_hash = hash_token(raw) | |
| revoke_refresh_token(token_hash) | |
| _clear_refresh_cookie(response) | |
| return {"status": "ok", "message": "Logged out successfully"} | |
| async def get_me( | |
| user_id: str = Depends(get_current_user_id), | |
| current_user=Depends(get_current_user), | |
| ): | |
| """ | |
| Return the authenticated user's profile. | |
| Protected route — requires a valid JWT in Authorization: Bearer header. | |
| This is a thin wrapper over the existing get_profile logic so both | |
| /api/auth/profile and /api/auth/me return the same shape. | |
| """ | |
| user = get_user_by_id(user_id) | |
| if not user: | |
| # Fallback: build minimal profile from JWT payload | |
| uid = ( | |
| getattr(current_user, "id", None) | |
| or (current_user.get("id") if isinstance(current_user, dict) else None) | |
| ) | |
| if uid: | |
| from src.store import _map_profile, get_usage | |
| email = ( | |
| getattr(current_user, "email", "") or | |
| (current_user.get("email") if isinstance(current_user, dict) else "") or "" | |
| ) | |
| real_usage = get_usage(uid) | |
| user = _map_profile({ | |
| "id": uid, | |
| "display_name": email.split("@")[0] or "User", | |
| "email": email, | |
| "avatar_url": "", | |
| "daily_requests": real_usage.get("used", 0), | |
| "last_request_date": ( | |
| datetime.now(timezone.utc).date().isoformat() | |
| ), | |
| }) | |
| if not user: | |
| raise HTTPException(404, "Profile not found") | |
| return {"status": "success", "user": user} | |