Add public map with JWT admin login and protect admin APIs.
Keep map reads open; gate admin UI/nav and object mutations behind env-based admin credentials, and default parser batch limit to 10. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""Admin auth: single env-based user + JWT bearer tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from fastapi import HTTPException
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
DEFAULT_TTL_SECONDS = 60 * 60 * 24 # 24h
|
||||
|
||||
|
||||
def _admin_user() -> str:
|
||||
return os.getenv("ADMIN_USER", "admin").strip() or "admin"
|
||||
|
||||
|
||||
def _admin_password() -> str:
|
||||
return os.getenv("ADMIN_PASSWORD", "").strip()
|
||||
|
||||
|
||||
def _jwt_secret() -> str:
|
||||
secret = os.getenv("ADMIN_JWT_SECRET", "").strip()
|
||||
if not secret:
|
||||
# Dev fallback: derive from password so local stacks boot without extra secret.
|
||||
password = _admin_password()
|
||||
if not password:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="ADMIN_PASSWORD is not configured",
|
||||
)
|
||||
return f"mapmil-dev:{password}"
|
||||
return secret
|
||||
|
||||
|
||||
def admin_credentials_configured() -> bool:
|
||||
return bool(_admin_password())
|
||||
|
||||
|
||||
def verify_credentials(username: str, password: str) -> bool:
|
||||
expected_user = _admin_user()
|
||||
expected_password = _admin_password()
|
||||
if not expected_password:
|
||||
return False
|
||||
user_ok = hmac.compare_digest(username.strip(), expected_user)
|
||||
pass_ok = hmac.compare_digest(password, expected_password)
|
||||
return user_ok and pass_ok
|
||||
|
||||
|
||||
def create_access_token(*, username: str, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> str:
|
||||
now = int(time.time())
|
||||
payload: dict[str, Any] = {
|
||||
"sub": username,
|
||||
"role": "admin",
|
||||
"iat": now,
|
||||
"exp": now + ttl_seconds,
|
||||
}
|
||||
return jwt.encode(payload, _jwt_secret(), algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
try:
|
||||
payload = jwt.decode(token, _jwt_secret(), algorithms=[ALGORITHM])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise HTTPException(status_code=401, detail="Token expired") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise HTTPException(status_code=401, detail="Invalid token") from exc
|
||||
|
||||
if payload.get("role") != "admin" or not payload.get("sub"):
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return payload
|
||||
@@ -2,6 +2,8 @@ import os
|
||||
|
||||
from fastapi import Header, HTTPException
|
||||
|
||||
from .auth import decode_access_token
|
||||
|
||||
|
||||
def verify_internal_token(
|
||||
x_internal_token: str | None = Header(default=None, alias="X-Internal-Token"),
|
||||
@@ -9,3 +11,16 @@ def verify_internal_token(
|
||||
expected = os.getenv("INTERNAL_TOKEN", "dev-internal-token")
|
||||
if not x_internal_token or x_internal_token != expected:
|
||||
raise HTTPException(status_code=401, detail="Invalid internal token")
|
||||
|
||||
|
||||
def verify_admin(
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> str:
|
||||
"""Require Authorization: Bearer <admin JWT>. Returns username (sub)."""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
||||
payload = decode_access_token(token)
|
||||
return str(payload["sub"])
|
||||
|
||||
@@ -13,7 +13,7 @@ for _candidate in (_HERE.parent, *_HERE.parents):
|
||||
break
|
||||
|
||||
from .database import Base, engine, get_db
|
||||
from .routers import admin, internal, map, objects, parse_channels, parser_profiles, v1
|
||||
from .routers import admin, auth, internal, map, objects, parse_channels, parser_profiles, v1
|
||||
from .seed import seed_objects, seed_test_consumer
|
||||
from .services.migrations import migrate_schema
|
||||
from .services.scheduler import start_scheduler
|
||||
@@ -49,6 +49,7 @@ app.add_middleware(
|
||||
app.include_router(objects.router)
|
||||
app.include_router(map.router)
|
||||
app.include_router(internal.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(parser_profiles.router)
|
||||
app.include_router(parse_channels.router)
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import verify_admin
|
||||
from ..models import Consumer, Event, MapObject, ParseChannel, ParseJob, ParserProfile
|
||||
from ..schemas import (
|
||||
AnalyticsSummary,
|
||||
@@ -37,7 +38,7 @@ from ..services.filtering import (
|
||||
)
|
||||
from ..services.jobs import enqueue_parse_job, flatten_pair_config
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(verify_admin)])
|
||||
|
||||
|
||||
def _validated_source_config(source_type: str, source_config: dict) -> dict:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import (
|
||||
admin_credentials_configured,
|
||||
create_access_token,
|
||||
verify_credentials,
|
||||
)
|
||||
from ..deps import verify_admin
|
||||
|
||||
router = APIRouter(prefix="/admin/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1)
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
username: str
|
||||
role: str = "admin"
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(payload: LoginRequest):
|
||||
if not admin_credentials_configured():
|
||||
raise HTTPException(status_code=503, detail="Admin auth is not configured")
|
||||
if not verify_credentials(payload.username, payload.password):
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
token = create_access_token(username=payload.username.strip())
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
def me(username: str = Depends(verify_admin)):
|
||||
return MeResponse(username=username)
|
||||
@@ -3,6 +3,7 @@ from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import verify_admin
|
||||
from ..models import MapObject, ObjectMedia
|
||||
from ..schemas import MapObjectCreate, MapObjectRead, MapObjectUpdate, ObjectMediaRead
|
||||
from ..storage import (
|
||||
@@ -48,7 +49,11 @@ def get_object(object_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/api/objects", response_model=MapObjectRead, status_code=201)
|
||||
def create_object(payload: MapObjectCreate, db: Session = Depends(get_db)):
|
||||
def create_object(
|
||||
payload: MapObjectCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(verify_admin),
|
||||
):
|
||||
data = payload.model_dump()
|
||||
created_at = data.pop("created_at", None)
|
||||
obj = MapObject(**data)
|
||||
@@ -65,6 +70,7 @@ def update_object(
|
||||
object_id: int,
|
||||
payload: MapObjectUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(verify_admin),
|
||||
):
|
||||
obj = db.query(MapObject).filter(MapObject.id == object_id).first()
|
||||
if not obj:
|
||||
@@ -79,7 +85,11 @@ def update_object(
|
||||
|
||||
|
||||
@router.delete("/api/objects/{object_id}", status_code=204)
|
||||
def delete_object(object_id: int, db: Session = Depends(get_db)):
|
||||
def delete_object(
|
||||
object_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(verify_admin),
|
||||
):
|
||||
obj = db.query(MapObject).filter(MapObject.id == object_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Объект не найден")
|
||||
@@ -111,6 +121,7 @@ async def upload_object_media(
|
||||
object_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(verify_admin),
|
||||
):
|
||||
obj = db.query(MapObject).filter(MapObject.id == object_id).first()
|
||||
if not obj:
|
||||
@@ -159,7 +170,11 @@ def get_media_file(media_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/api/media/{media_id}", status_code=204)
|
||||
def delete_media(media_id: int, db: Session = Depends(get_db)):
|
||||
def delete_media(
|
||||
media_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: str = Depends(verify_admin),
|
||||
):
|
||||
media = db.query(ObjectMedia).filter(ObjectMedia.id == media_id).first()
|
||||
if not media:
|
||||
raise HTTPException(status_code=404, detail="Медиафайл не найден")
|
||||
|
||||
@@ -6,10 +6,15 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import verify_admin
|
||||
from ..models import ParseChannel, ParseJob
|
||||
from ..schemas import ParseChannelCreate, ParseChannelRead, ParseChannelUpdate
|
||||
|
||||
router = APIRouter(prefix="/admin/parse-channels", tags=["parse-channels"])
|
||||
router = APIRouter(
|
||||
prefix="/admin/parse-channels",
|
||||
tags=["parse-channels"],
|
||||
dependencies=[Depends(verify_admin)],
|
||||
)
|
||||
|
||||
ALLOWED_SOURCE_TYPES = {"telegram"}
|
||||
|
||||
|
||||
@@ -11,11 +11,16 @@ from sqlalchemy.orm import Session
|
||||
from contracts.heuristic_profile import HeuristicProfile
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import verify_admin
|
||||
from ..models import ParseJob, ParserProfile
|
||||
from ..schemas import ParserProfileCreate, ParserProfileRead, ParserProfileUpdate
|
||||
from ..services import parser_builder as builder
|
||||
|
||||
router = APIRouter(prefix="/admin/parser-profiles", tags=["parser-profiles"])
|
||||
router = APIRouter(
|
||||
prefix="/admin/parser-profiles",
|
||||
tags=["parser-profiles"],
|
||||
dependencies=[Depends(verify_admin)],
|
||||
)
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user