feat: BFF (Python FastAPI) — proxy to Tracker with JWT auth
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:
Markov 2026-02-15 19:44:55 +01:00
parent e8a7852986
commit 2f1778c821
15 changed files with 361 additions and 67 deletions

View File

@ -13,15 +13,20 @@ jobs:
cd /root/projects/team-board/web-client cd /root/projects/team-board/web-client
git pull origin main git pull origin main
- name: Install and build - name: Install and build frontend
run: | run: |
cd /root/projects/team-board/web-client cd /root/projects/team-board/web-client
npm install --production=false npm install --production=false
npm run build 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: | run: |
systemctl restart team-board-web systemctl restart team-board-web
systemctl restart team-board-bff
- name: Health check - name: Health check
run: | run: |

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

175
bff/app.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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"))

View File

@ -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 });
}

View File

@ -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 } });
}

View File

@ -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;
}
}