KSvend Claude Opus 4.6 (1M context) commited on
Commit ·
a7da499
1
Parent(s): 43dd6eb
feat: add get_current_user auth dependency and GET /api/jobs list endpoint
Browse files- app/api/auth.py +31 -3
- app/api/jobs.py +17 -1
- tests/test_api_auth.py +57 -0
app/api/auth.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
| 2 |
import hashlib
|
| 3 |
import hmac
|
| 4 |
import time
|
| 5 |
-
from fastapi import APIRouter, HTTPException
|
| 6 |
from pydantic import BaseModel
|
| 7 |
|
| 8 |
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
@@ -27,7 +27,35 @@ async def verify_token(req: VerifyRequest):
|
|
| 27 |
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
| 28 |
return {"email": req.email, "verified": True}
|
| 29 |
|
| 30 |
-
def
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
payload = f"{email}:{hour}:{SECRET}"
|
| 33 |
return hashlib.sha256(payload.encode()).hexdigest()[:32]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import hashlib
|
| 3 |
import hmac
|
| 4 |
import time
|
| 5 |
+
from fastapi import APIRouter, Header, HTTPException
|
| 6 |
from pydantic import BaseModel
|
| 7 |
|
| 8 |
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
| 27 |
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
| 28 |
return {"email": req.email, "verified": True}
|
| 29 |
|
| 30 |
+
async def get_current_user(authorization: str = Header(default=None)) -> str:
|
| 31 |
+
"""FastAPI dependency: extract and verify email from Authorization header.
|
| 32 |
+
|
| 33 |
+
Expected format: Bearer <email>:<token>
|
| 34 |
+
Returns the verified email address.
|
| 35 |
+
"""
|
| 36 |
+
if not authorization:
|
| 37 |
+
raise HTTPException(status_code=401, detail="Authorization header missing")
|
| 38 |
+
parts = authorization.split(" ", 1)
|
| 39 |
+
if len(parts) != 2 or parts[0] != "Bearer":
|
| 40 |
+
raise HTTPException(status_code=401, detail="Invalid authorization format")
|
| 41 |
+
payload = parts[1]
|
| 42 |
+
if ":" not in payload:
|
| 43 |
+
raise HTTPException(status_code=401, detail="Invalid token format")
|
| 44 |
+
email, token = payload.split(":", 1)
|
| 45 |
+
# Verify against current and previous hour (handle clock edge)
|
| 46 |
+
for offset in (0, -1):
|
| 47 |
+
expected = _generate_token_for_hour(email, offset)
|
| 48 |
+
if hmac.compare_digest(token, expected):
|
| 49 |
+
return email
|
| 50 |
+
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _generate_token_for_hour(email: str, hour_offset: int = 0) -> str:
|
| 54 |
+
"""Generate token for a specific hour offset (0 = current, -1 = previous)."""
|
| 55 |
+
hour = int(time.time() // 3600) + hour_offset
|
| 56 |
payload = f"{email}:{hour}:{SECRET}"
|
| 57 |
return hashlib.sha256(payload.encode()).hexdigest()[:32]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _generate_token(email: str) -> str:
|
| 61 |
+
return _generate_token_for_hour(email, 0)
|
app/api/jobs.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
-
from fastapi import APIRouter, HTTPException
|
|
|
|
| 2 |
from app.database import Database
|
| 3 |
from app.models import JobRequest
|
| 4 |
|
|
@@ -11,6 +12,21 @@ def init_router(db: Database) -> None:
|
|
| 11 |
_db = db
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
@router.post("", status_code=201)
|
| 15 |
async def submit_job(request: JobRequest):
|
| 16 |
job_id = await _db.create_job(request)
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from app.api.auth import get_current_user
|
| 3 |
from app.database import Database
|
| 4 |
from app.models import JobRequest
|
| 5 |
|
|
|
|
| 12 |
_db = db
|
| 13 |
|
| 14 |
|
| 15 |
+
@router.get("")
|
| 16 |
+
async def list_jobs(email: str = Depends(get_current_user)):
|
| 17 |
+
jobs = await _db.get_jobs_by_email(email)
|
| 18 |
+
return [
|
| 19 |
+
{
|
| 20 |
+
"id": j.id,
|
| 21 |
+
"status": j.status.value,
|
| 22 |
+
"aoi_name": j.request.aoi.name,
|
| 23 |
+
"created_at": j.created_at.isoformat(),
|
| 24 |
+
"indicator_count": len(j.request.indicator_ids),
|
| 25 |
+
}
|
| 26 |
+
for j in jobs
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
@router.post("", status_code=201)
|
| 31 |
async def submit_job(request: JobRequest):
|
| 32 |
job_id = await _db.create_job(request)
|
tests/test_api_auth.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for auth middleware — get_current_user dependency."""
|
| 2 |
+
import time
|
| 3 |
+
import hashlib
|
| 4 |
+
import pytest
|
| 5 |
+
from httpx import AsyncClient, ASGITransport
|
| 6 |
+
from app.main import create_app
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _make_token(email: str) -> str:
|
| 10 |
+
"""Mirror the backend token generation for test fixtures."""
|
| 11 |
+
secret = "aperture-mvp-secret-change-in-production"
|
| 12 |
+
hour = int(time.time() // 3600)
|
| 13 |
+
payload = f"{email}:{hour}:{secret}"
|
| 14 |
+
return hashlib.sha256(payload.encode()).hexdigest()[:32]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@pytest.fixture
|
| 18 |
+
async def client(tmp_path):
|
| 19 |
+
app = create_app(db_path=str(tmp_path / "test.db"), run_worker=False)
|
| 20 |
+
transport = ASGITransport(app=app)
|
| 21 |
+
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
| 22 |
+
yield c
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@pytest.mark.asyncio
|
| 26 |
+
async def test_auth_header_valid(client):
|
| 27 |
+
email = "user@example.com"
|
| 28 |
+
token = _make_token(email)
|
| 29 |
+
resp = await client.get(
|
| 30 |
+
"/api/jobs",
|
| 31 |
+
headers={"Authorization": f"Bearer {email}:{token}"},
|
| 32 |
+
)
|
| 33 |
+
assert resp.status_code == 200
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@pytest.mark.asyncio
|
| 37 |
+
async def test_auth_header_missing(client):
|
| 38 |
+
resp = await client.get("/api/jobs")
|
| 39 |
+
assert resp.status_code == 401
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@pytest.mark.asyncio
|
| 43 |
+
async def test_auth_header_bad_token(client):
|
| 44 |
+
resp = await client.get(
|
| 45 |
+
"/api/jobs",
|
| 46 |
+
headers={"Authorization": "Bearer user@example.com:badtoken"},
|
| 47 |
+
)
|
| 48 |
+
assert resp.status_code == 401
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@pytest.mark.asyncio
|
| 52 |
+
async def test_auth_header_malformed(client):
|
| 53 |
+
resp = await client.get(
|
| 54 |
+
"/api/jobs",
|
| 55 |
+
headers={"Authorization": "Bearer garbage"},
|
| 56 |
+
)
|
| 57 |
+
assert resp.status_code == 401
|