feat: BFF (Python FastAPI) — proxy to Tracker with JWT auth
Some checks failed
Deploy Web Client / deploy (push) Has been cancelled
Some checks failed
Deploy Web Client / deploy (push) Has been cancelled
- BFF on port 8200: auth + proxy to tracker - All /api/* routes go through BFF - WebSocket proxy with JWT auth - Tracker no longer exposed to internet - Logging on all requests - Removed Next.js API routes for auth (BFF handles it)
This commit is contained in:
parent
e8a7852986
commit
2f1778c821
@ -13,15 +13,20 @@ jobs:
|
||||
cd /root/projects/team-board/web-client
|
||||
git pull origin main
|
||||
|
||||
- name: Install and build
|
||||
- name: Install and build frontend
|
||||
run: |
|
||||
cd /root/projects/team-board/web-client
|
||||
npm install --production=false
|
||||
npm run build
|
||||
|
||||
- name: Restart service
|
||||
- name: Install BFF dependencies
|
||||
run: |
|
||||
/opt/team-board-bff-venv/bin/pip install -r /root/projects/team-board/web-client/bff/requirements.txt -q
|
||||
|
||||
- name: Restart services
|
||||
run: |
|
||||
systemctl restart team-board-web
|
||||
systemctl restart team-board-bff
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
|
||||
BIN
bff/__pycache__/app.cpython-312.pyc
Normal file
BIN
bff/__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
bff/__pycache__/auth.cpython-312.pyc
Normal file
BIN
bff/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
bff/__pycache__/config.cpython-312.pyc
Normal file
BIN
bff/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
bff/__pycache__/tracker_client.cpython-312.pyc
Normal file
BIN
bff/__pycache__/tracker_client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
bff/__pycache__/ws_proxy.cpython-312.pyc
Normal file
BIN
bff/__pycache__/ws_proxy.cpython-312.pyc
Normal file
Binary file not shown.
175
bff/app.py
Normal file
175
bff/app.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""BFF — Backend for Frontend. Proxies to Tracker with user auth."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI, Request, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth import create_token, get_current_user
|
||||
from config import AUTH_USER, AUTH_PASS, ENV
|
||||
from tracker_client import tracker_get, tracker_post, tracker_patch, tracker_delete, close_client
|
||||
from ws_proxy import router as ws_router
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if ENV == "dev" else logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("bff")
|
||||
|
||||
app = FastAPI(title="Team Board BFF", version="0.1.0")
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://team.uix.su", "http://localhost:3100"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# --- Request logging ---
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
start = time.time()
|
||||
logger.info("[REQ] %s %s", request.method, request.url.path)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
elapsed = (time.time() - start) * 1000
|
||||
logger.info("[RES] %s %s → %d (%.0fms)", request.method, request.url.path, response.status_code, elapsed)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error("[ERR] %s %s → %s", request.method, request.url.path, str(e))
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
# --- Auth ---
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def login(data: LoginRequest):
|
||||
if data.username == AUTH_USER and data.password == AUTH_PASS:
|
||||
token = create_token(data.username)
|
||||
return {"token": token, "user": {"name": data.username, "provider": "local"}}
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
async def me(user: dict = Depends(get_current_user)):
|
||||
return {"user": {"name": user["name"], "provider": user["provider"]}}
|
||||
|
||||
|
||||
# --- Proxy: Projects ---
|
||||
|
||||
@app.get("/api/v1/projects/")
|
||||
async def list_projects(user: dict = Depends(get_current_user)):
|
||||
return await tracker_get("/api/v1/projects/")
|
||||
|
||||
|
||||
@app.post("/api/v1/projects/")
|
||||
async def create_project(request: Request, user: dict = Depends(get_current_user)):
|
||||
body = await request.json()
|
||||
return await tracker_post("/api/v1/projects/", json=body)
|
||||
|
||||
|
||||
@app.get("/api/v1/projects/{project_id}")
|
||||
async def get_project(project_id: str, user: dict = Depends(get_current_user)):
|
||||
return await tracker_get(f"/api/v1/projects/{project_id}")
|
||||
|
||||
|
||||
@app.patch("/api/v1/projects/{project_id}")
|
||||
async def update_project(project_id: str, request: Request, user: dict = Depends(get_current_user)):
|
||||
body = await request.json()
|
||||
return await tracker_patch(f"/api/v1/projects/{project_id}", json=body)
|
||||
|
||||
|
||||
@app.delete("/api/v1/projects/{project_id}")
|
||||
async def delete_project(project_id: str, user: dict = Depends(get_current_user)):
|
||||
await tracker_delete(f"/api/v1/projects/{project_id}")
|
||||
return JSONResponse(status_code=204)
|
||||
|
||||
|
||||
# --- Proxy: Tasks ---
|
||||
|
||||
@app.get("/api/v1/tasks/")
|
||||
async def list_tasks(project_id: str = None, status: str = None, user: dict = Depends(get_current_user)):
|
||||
params = {}
|
||||
if project_id:
|
||||
params["project_id"] = project_id
|
||||
if status:
|
||||
params["status"] = status
|
||||
return await tracker_get("/api/v1/tasks/", params=params)
|
||||
|
||||
|
||||
@app.post("/api/v1/tasks/")
|
||||
async def create_task(request: Request, user: dict = Depends(get_current_user)):
|
||||
body = await request.json()
|
||||
return await tracker_post("/api/v1/tasks/", json=body)
|
||||
|
||||
|
||||
@app.get("/api/v1/tasks/{task_id}")
|
||||
async def get_task(task_id: str, user: dict = Depends(get_current_user)):
|
||||
return await tracker_get(f"/api/v1/tasks/{task_id}")
|
||||
|
||||
|
||||
@app.patch("/api/v1/tasks/{task_id}")
|
||||
async def update_task(task_id: str, request: Request, user: dict = Depends(get_current_user)):
|
||||
body = await request.json()
|
||||
return await tracker_patch(f"/api/v1/tasks/{task_id}", json=body)
|
||||
|
||||
|
||||
@app.delete("/api/v1/tasks/{task_id}")
|
||||
async def delete_task(task_id: str, user: dict = Depends(get_current_user)):
|
||||
await tracker_delete(f"/api/v1/tasks/{task_id}")
|
||||
return JSONResponse(status_code=204)
|
||||
|
||||
|
||||
# --- Proxy: Agents ---
|
||||
|
||||
@app.get("/api/v1/agents/")
|
||||
async def list_agents(user: dict = Depends(get_current_user)):
|
||||
return await tracker_get("/api/v1/agents/")
|
||||
|
||||
|
||||
@app.get("/api/v1/agents/adapters")
|
||||
async def list_adapters(user: dict = Depends(get_current_user)):
|
||||
return await tracker_get("/api/v1/agents/adapters")
|
||||
|
||||
|
||||
# --- Proxy: Labels ---
|
||||
|
||||
@app.get("/api/v1/labels/")
|
||||
async def list_labels(user: dict = Depends(get_current_user)):
|
||||
return await tracker_get("/api/v1/labels/")
|
||||
|
||||
|
||||
@app.post("/api/v1/labels/")
|
||||
async def create_label(request: Request, user: dict = Depends(get_current_user)):
|
||||
body = await request.json()
|
||||
return await tracker_post("/api/v1/labels/", json=body)
|
||||
|
||||
|
||||
# --- Health ---
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "bff", "version": "0.1.0"}
|
||||
|
||||
|
||||
# --- WebSocket ---
|
||||
|
||||
app.include_router(ws_router)
|
||||
|
||||
|
||||
# --- Shutdown ---
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
await close_client()
|
||||
39
bff/auth.py
Normal file
39
bff/auth.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""JWT auth for web users."""
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
|
||||
from config import JWT_SECRET, JWT_ALGORITHM
|
||||
|
||||
TOKEN_EXPIRY = 7 * 24 * 3600 # 7 days
|
||||
|
||||
|
||||
def create_token(username: str, provider: str = "local") -> str:
|
||||
payload = {
|
||||
"sub": username,
|
||||
"name": username,
|
||||
"provider": provider,
|
||||
"iat": int(time.time()),
|
||||
"exp": int(time.time()) + TOKEN_EXPIRY,
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> dict:
|
||||
auth = request.headers.get("authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
payload = verify_token(auth[7:])
|
||||
if not payload:
|
||||
raise HTTPException(401, "Invalid token")
|
||||
return payload
|
||||
19
bff/config.py
Normal file
19
bff/config.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""BFF configuration."""
|
||||
|
||||
import os
|
||||
|
||||
# Tracker (internal, not exposed to internet)
|
||||
TRACKER_URL = os.getenv("TRACKER_URL", "http://localhost:8100")
|
||||
TRACKER_WS_URL = os.getenv("TRACKER_WS_URL", "ws://localhost:8100/ws")
|
||||
TRACKER_TOKEN = os.getenv("TRACKER_TOKEN", "tb-tracker-dev-token")
|
||||
|
||||
# Auth
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "tb-jwt-Kx9mP4vQ7wZn2bR5")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
AUTH_USER = os.getenv("AUTH_USER", "admin")
|
||||
AUTH_PASS = os.getenv("AUTH_PASS", "teamboard")
|
||||
|
||||
# Server
|
||||
HOST = os.getenv("BFF_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("BFF_PORT", "8200"))
|
||||
ENV = os.getenv("BFF_ENV", "dev")
|
||||
5
bff/requirements.txt
Normal file
5
bff/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
httpx>=0.28
|
||||
websockets>=14.0
|
||||
pyjwt>=2.9
|
||||
62
bff/tracker_client.py
Normal file
62
bff/tracker_client.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""HTTP client for Tracker API."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from config import TRACKER_URL, TRACKER_TOKEN
|
||||
|
||||
logger = logging.getLogger("bff.tracker")
|
||||
|
||||
_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
|
||||
def get_client() -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None or _client.is_closed:
|
||||
_client = httpx.AsyncClient(
|
||||
base_url=TRACKER_URL,
|
||||
headers={"Authorization": f"Bearer {TRACKER_TOKEN}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
async def tracker_request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
client = get_client()
|
||||
logger.info("[TRACKER] %s %s", method, path)
|
||||
resp = await client.request(method, path, **kwargs)
|
||||
logger.info("[TRACKER] %s %s → %d", method, path, resp.status_code)
|
||||
return resp
|
||||
|
||||
|
||||
async def tracker_get(path: str, **kwargs) -> Any:
|
||||
resp = await tracker_request("GET", path, **kwargs)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def tracker_post(path: str, **kwargs) -> Any:
|
||||
resp = await tracker_request("POST", path, **kwargs)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def tracker_patch(path: str, **kwargs) -> Any:
|
||||
resp = await tracker_request("PATCH", path, **kwargs)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def tracker_delete(path: str, **kwargs) -> int:
|
||||
resp = await tracker_request("DELETE", path, **kwargs)
|
||||
resp.raise_for_status()
|
||||
return resp.status_code
|
||||
|
||||
|
||||
async def close_client():
|
||||
global _client
|
||||
if _client and not _client.is_closed:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
54
bff/ws_proxy.py
Normal file
54
bff/ws_proxy.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""WebSocket proxy: authenticated users ↔ Tracker."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
import websockets
|
||||
|
||||
from auth import verify_token
|
||||
from config import TRACKER_WS_URL, TRACKER_TOKEN
|
||||
|
||||
logger = logging.getLogger("bff.ws")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def ws_proxy(ws: WebSocket, token: str = ""):
|
||||
# Authenticate user
|
||||
if not token:
|
||||
await ws.close(code=4001, reason="No token")
|
||||
return
|
||||
user = verify_token(token)
|
||||
if not user:
|
||||
await ws.close(code=4001, reason="Invalid token")
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
logger.info("WS connected: %s", user.get("name"))
|
||||
|
||||
# Connect to tracker
|
||||
tracker_url = f"{TRACKER_WS_URL}?client_type=human&client_id={user['sub']}&token={TRACKER_TOKEN}"
|
||||
try:
|
||||
async with websockets.connect(tracker_url) as tracker_ws:
|
||||
async def forward_to_tracker():
|
||||
try:
|
||||
while True:
|
||||
data = await ws.receive_text()
|
||||
await tracker_ws.send(data)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
async def forward_to_client():
|
||||
try:
|
||||
async for msg in tracker_ws:
|
||||
await ws.send_text(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(forward_to_tracker(), forward_to_client())
|
||||
except Exception as e:
|
||||
logger.error("WS proxy error: %s", e)
|
||||
finally:
|
||||
logger.info("WS disconnected: %s", user.get("name"))
|
||||
@ -1,20 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createToken } from "@/lib/auth";
|
||||
|
||||
const AUTH_USER = process.env.AUTH_USER || "admin";
|
||||
const AUTH_PASS = process.env.AUTH_PASS || "teamboard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { username, password } = await req.json();
|
||||
|
||||
if (username === AUTH_USER && password === AUTH_PASS) {
|
||||
const token = await createToken({
|
||||
sub: username,
|
||||
name: username,
|
||||
provider: "local",
|
||||
});
|
||||
return NextResponse.json({ token, user: { name: username, provider: "local" } });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { verifyToken } from "@/lib/auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
const token = authHeader?.replace("Bearer ", "");
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "No token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await verifyToken(token);
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ user: { name: payload.name, provider: payload.provider } });
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "team-board-dev-secret-change-me");
|
||||
const TOKEN_EXPIRY = "7d";
|
||||
|
||||
export interface TokenPayload {
|
||||
sub: string; // username
|
||||
name: string; // display name
|
||||
provider: string; // "local" | "authentik"
|
||||
}
|
||||
|
||||
export async function createToken(payload: TokenPayload): Promise<string> {
|
||||
return new SignJWT({ ...payload })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(TOKEN_EXPIRY)
|
||||
.sign(JWT_SECRET);
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string): Promise<TokenPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
return payload as unknown as TokenPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user