Security Hardening: Firebase Auth + Quota V2 Enforcement
Browse files- firebase_manager.py +28 -1
- main.py +41 -2
firebase_manager.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import logging
|
| 2 |
import os
|
| 3 |
import firebase_admin
|
| 4 |
-
from firebase_admin import credentials, storage
|
| 5 |
|
| 6 |
# Initialize logging
|
| 7 |
logger = logging.getLogger("BIT-LOG")
|
|
@@ -14,6 +14,7 @@ class FirebaseManager:
|
|
| 14 |
|
| 15 |
_instance = None
|
| 16 |
_bucket = None
|
|
|
|
| 17 |
|
| 18 |
def __new__(cls):
|
| 19 |
if cls._instance is None:
|
|
@@ -56,9 +57,35 @@ class FirebaseManager:
|
|
| 56 |
logger.info(f"SUCCESS: [FIREBASE] Initialized successfully for {'PROD' if IS_PRODUCTION else 'DEV'}.")
|
| 57 |
|
| 58 |
self._bucket = storage.bucket()
|
|
|
|
| 59 |
except Exception as e:
|
| 60 |
logger.error(f"ERROR: [FIREBASE] Initialization failed: {e}")
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def upload_file(self, local_path: str, destination_blob_name: str) -> str:
|
| 63 |
"""
|
| 64 |
V273.1: Uploads local file and returns a Signed URL (CORS proof).
|
|
|
|
| 1 |
import logging
|
| 2 |
import os
|
| 3 |
import firebase_admin
|
| 4 |
+
from firebase_admin import credentials, storage, firestore, auth
|
| 5 |
|
| 6 |
# Initialize logging
|
| 7 |
logger = logging.getLogger("BIT-LOG")
|
|
|
|
| 14 |
|
| 15 |
_instance = None
|
| 16 |
_bucket = None
|
| 17 |
+
_db = None
|
| 18 |
|
| 19 |
def __new__(cls):
|
| 20 |
if cls._instance is None:
|
|
|
|
| 57 |
logger.info(f"SUCCESS: [FIREBASE] Initialized successfully for {'PROD' if IS_PRODUCTION else 'DEV'}.")
|
| 58 |
|
| 59 |
self._bucket = storage.bucket()
|
| 60 |
+
self._db = firestore.client()
|
| 61 |
except Exception as e:
|
| 62 |
logger.error(f"ERROR: [FIREBASE] Initialization failed: {e}")
|
| 63 |
|
| 64 |
+
def get_db(self):
|
| 65 |
+
"""V2: Returns the initialized Firestore client."""
|
| 66 |
+
if not self._db:
|
| 67 |
+
logger.error("❌ [FIREBASE] DB not initialized.")
|
| 68 |
+
return None
|
| 69 |
+
return self._db
|
| 70 |
+
|
| 71 |
+
def verify_token(self, id_token: str):
|
| 72 |
+
"""
|
| 73 |
+
V2: Verifies a Firebase Auth ID token.
|
| 74 |
+
Returns the decoded token dictionary (including 'uid') if valid, else None.
|
| 75 |
+
"""
|
| 76 |
+
try:
|
| 77 |
+
decoded_token = auth.verify_id_token(id_token)
|
| 78 |
+
return decoded_token
|
| 79 |
+
except auth.ExpiredIdTokenError:
|
| 80 |
+
logger.warning("⚠️ [FIREBASE] Token expired.")
|
| 81 |
+
return None
|
| 82 |
+
except auth.InvalidIdTokenError:
|
| 83 |
+
logger.error("❌ [FIREBASE] Invalid token.")
|
| 84 |
+
return None
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error(f"❌ [FIREBASE] Error verifying token: {e}")
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
def upload_file(self, local_path: str, destination_blob_name: str) -> str:
|
| 90 |
"""
|
| 91 |
V273.1: Uploads local file and returns a Signed URL (CORS proof).
|
main.py
CHANGED
|
@@ -21,6 +21,7 @@ class AskQuestionRequest(BaseModel):
|
|
| 21 |
context_data: dict | Any
|
| 22 |
question: str
|
| 23 |
student_name: str = "תלמיד"
|
|
|
|
| 24 |
|
| 25 |
# --- HEALTH CHECK : Top-level Dependency Verification ---
|
| 26 |
# We do this before standard imports to ensure a clear error message
|
|
@@ -340,12 +341,50 @@ async def solve_stream_v2(
|
|
| 340 |
@app.post("/explain_step")
|
| 341 |
async def explain_step(request: Request):
|
| 342 |
data = await request.json()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
res = await orchestrator.explain_specific_step(data.get("context"), data.get("step_text"), data.get("student_name"))
|
| 344 |
return JSONResponse(content=res)
|
| 345 |
|
| 346 |
@app.post("/ask_question")
|
| 347 |
-
async def ask_question(request: AskQuestionRequest):
|
| 348 |
-
data =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
res = await orchestrator.ask_question(data.get("context_data"), data.get("question"), data.get("student_name"))
|
| 350 |
return JSONResponse(content=res)
|
| 351 |
|
|
|
|
| 21 |
context_data: dict | Any
|
| 22 |
question: str
|
| 23 |
student_name: str = "תלמיד"
|
| 24 |
+
id_token: Optional[str] = None
|
| 25 |
|
| 26 |
# --- HEALTH CHECK : Top-level Dependency Verification ---
|
| 27 |
# We do this before standard imports to ensure a clear error message
|
|
|
|
| 341 |
@app.post("/explain_step")
|
| 342 |
async def explain_step(request: Request):
|
| 343 |
data = await request.json()
|
| 344 |
+
|
| 345 |
+
# Auth Hardening (V3.1)
|
| 346 |
+
auth_header = request.headers.get('Authorization')
|
| 347 |
+
token = (data.get("id_token") or (auth_header.split('Bearer ')[1] if auth_header and auth_header.startswith('Bearer ') else None))
|
| 348 |
+
|
| 349 |
+
if not token:
|
| 350 |
+
return JSONResponse(status_code=401, content={"error": "Unauthorized: Missing Token"})
|
| 351 |
+
|
| 352 |
+
decoded_token = firebase_manager.verify_token(token)
|
| 353 |
+
if not decoded_token:
|
| 354 |
+
return JSONResponse(status_code=401, content={"error": "Unauthorized: Invalid Token"})
|
| 355 |
+
|
| 356 |
+
uid = decoded_token.get('uid')
|
| 357 |
+
|
| 358 |
+
# Quota check (Gate only, no increment for simple explanations yet)
|
| 359 |
+
is_allowed, msg, _, _ = quota_manager_v2.check_limit(uid)
|
| 360 |
+
if not is_allowed:
|
| 361 |
+
return JSONResponse(status_code=403, content={"error": "QUOTA_EXCEEDED", "message": msg})
|
| 362 |
+
|
| 363 |
res = await orchestrator.explain_specific_step(data.get("context"), data.get("step_text"), data.get("student_name"))
|
| 364 |
return JSONResponse(content=res)
|
| 365 |
|
| 366 |
@app.post("/ask_question")
|
| 367 |
+
async def ask_question(request: Request, ask_req: AskQuestionRequest):
|
| 368 |
+
data = ask_req.dict()
|
| 369 |
+
|
| 370 |
+
# Auth Hardening (V3.1)
|
| 371 |
+
auth_header = request.headers.get('Authorization')
|
| 372 |
+
token = (data.get("id_token") or (auth_header.split('Bearer ')[1] if auth_header and auth_header.startswith('Bearer ') else None))
|
| 373 |
+
|
| 374 |
+
if not token:
|
| 375 |
+
return JSONResponse(status_code=401, content={"error": "Unauthorized: Missing Token"})
|
| 376 |
+
|
| 377 |
+
decoded_token = firebase_manager.verify_token(token)
|
| 378 |
+
if not decoded_token:
|
| 379 |
+
return JSONResponse(status_code=401, content={"error": "Unauthorized: Invalid Token"})
|
| 380 |
+
|
| 381 |
+
uid = decoded_token.get('uid')
|
| 382 |
+
|
| 383 |
+
# Quota check
|
| 384 |
+
is_allowed, msg, _, _ = quota_manager_v2.check_limit(uid)
|
| 385 |
+
if not is_allowed:
|
| 386 |
+
return JSONResponse(status_code=403, content={"error": "QUOTA_EXCEEDED", "message": msg})
|
| 387 |
+
|
| 388 |
res = await orchestrator.ask_question(data.get("context_data"), data.get("question"), data.get("student_name"))
|
| 389 |
return JSONResponse(content=res)
|
| 390 |
|