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
|
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: |
|
||||||
|
|||||||
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