Add web frontend with JWT auth, RBAC, SSE dashboard, and config editor
- FastAPI + htmx + Jinja2 web frontend, started with --web flag - JWT HS256 auth (WEB_SECRET_KEY) with httpOnly cookies; access (15 min) + refresh (7 day) tokens; refresh rotation + JTI revocation in data/web.db - RBAC: superadmin > admin > reader enforced per route - Live SSE dashboard fed by tui/events broadcast queue - Config editor: keyword groups and channel list saved to data/runtime_config.json and hot-reloaded in-process (scorer.reload_from_config, signal_channel_changed) - config.py migrated to load groups/channels from runtime_config.json; falls back to hardcoded defaults when file absent - tui/events.py: subscribe/unsubscribe broadcast, set_bot_context/signal_channel_changed - utils/scorer.py: import config as _config (fixes local binding); reload_from_config() - utils/database.py: count_by_severity, recent_for_domains, count_by_severity_for_domains - 53 new tests (events bus, JWT lifecycle, web DB CRUD, RBAC enforcement, config round-trip); total 141 passing
This commit is contained in:
0
web/routes/__init__.py
Normal file
0
web/routes/__init__.py
Normal file
106
web/routes/auth.py
Normal file
106
web/routes/auth.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
web/routes/auth.py — Login, logout, token refresh.
|
||||
|
||||
POST /login — form submit; sets access_token + refresh_token cookies
|
||||
POST /logout — revokes refresh token, clears cookies
|
||||
POST /refresh — exchanges refresh_token cookie for a new access_token
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from web import auth as auth_lib
|
||||
from web import db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _set_auth_cookies(response: Response, access_token: str, refresh_token: str) -> None:
|
||||
response.set_cookie(
|
||||
"access_token", access_token,
|
||||
httponly=True, samesite="strict", max_age=auth_lib.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
)
|
||||
response.set_cookie(
|
||||
"refresh_token", refresh_token,
|
||||
httponly=True, samesite="strict", max_age=auth_lib.REFRESH_TOKEN_EXPIRE_DAYS * 86400,
|
||||
)
|
||||
|
||||
|
||||
def _clear_auth_cookies(response: Response) -> None:
|
||||
response.delete_cookie("access_token")
|
||||
response.delete_cookie("refresh_token")
|
||||
|
||||
|
||||
def _templates(request: Request) -> Jinja2Templates:
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login_page(request: Request):
|
||||
return _templates(request).TemplateResponse(request, "login.html")
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
user = db.get_user_by_username(username)
|
||||
if user is None or not auth_lib.verify_password(password, user["password_hash"]):
|
||||
return _templates(request).TemplateResponse(
|
||||
request, "login.html",
|
||||
{"error": "Invalid username or password"},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
access_token = auth_lib.create_access_token(user["id"], user["role"])
|
||||
refresh_token, jti, expires_at = auth_lib.create_refresh_token(user["id"])
|
||||
db.store_refresh_token(jti, user["id"], expires_at)
|
||||
|
||||
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_303_SEE_OTHER)
|
||||
_set_auth_cookies(response, access_token, refresh_token)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
refresh_token = request.cookies.get("refresh_token")
|
||||
if refresh_token:
|
||||
payload = auth_lib.decode_refresh_token(refresh_token)
|
||||
if payload and payload.get("jti"):
|
||||
db.revoke_refresh_token(payload["jti"])
|
||||
|
||||
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
_clear_auth_cookies(response)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh(request: Request):
|
||||
refresh_token = request.cookies.get("refresh_token")
|
||||
if not refresh_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No refresh token")
|
||||
|
||||
payload = auth_lib.decode_refresh_token(refresh_token)
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
|
||||
|
||||
jti = payload.get("jti")
|
||||
if not jti or not db.is_refresh_token_valid(jti):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token revoked or expired")
|
||||
|
||||
user = db.get_user_by_id(payload["sub"])
|
||||
if user is None or not user["is_active"]:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
|
||||
# Rotate: revoke old, issue new
|
||||
db.revoke_refresh_token(jti)
|
||||
new_access = auth_lib.create_access_token(user["id"], user["role"])
|
||||
new_refresh, new_jti, expires_at = auth_lib.create_refresh_token(user["id"])
|
||||
db.store_refresh_token(new_jti, user["id"], expires_at)
|
||||
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
_set_auth_cookies(response, new_access, new_refresh)
|
||||
return response
|
||||
66
web/routes/config_routes.py
Normal file
66
web/routes/config_routes.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
web/routes/config_routes.py — Keyword groups and channel list management.
|
||||
|
||||
GET /config/keywords → render groups editor
|
||||
PUT /config/keywords → validate + save groups, reload scorer
|
||||
GET /config/channels → render channel list
|
||||
PUT /config/channels → save channels, signal bot to re-watch
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
import config
|
||||
import utils.scorer as scorer
|
||||
from tui import events as bus
|
||||
from web.dependencies import require_role
|
||||
from web.models import ChannelsPayload, KeywordGroupsPayload
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _templates(request: Request):
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
@router.get("/config/keywords")
|
||||
async def keywords_page(request: Request, user=Depends(require_role("admin"))):
|
||||
return _templates(request).TemplateResponse(
|
||||
request, "config.html",
|
||||
{
|
||||
"user": dict(user),
|
||||
"groups": config.KEYWORD_GROUPS,
|
||||
"channels": config.WATCHED_CHANNELS,
|
||||
"active_tab": "keywords",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/config/keywords")
|
||||
async def update_keywords(payload: KeywordGroupsPayload, _user=Depends(require_role("admin"))):
|
||||
groups = [g.model_dump() for g in payload.groups]
|
||||
config.save_runtime_config(groups, config.WATCHED_CHANNELS)
|
||||
scorer.reload_from_config()
|
||||
return {"status": "ok", "groups": len(groups)}
|
||||
|
||||
|
||||
@router.get("/config/channels")
|
||||
async def channels_page(request: Request, user=Depends(require_role("admin"))):
|
||||
return _templates(request).TemplateResponse(
|
||||
request, "config.html",
|
||||
{
|
||||
"user": dict(user),
|
||||
"groups": config.KEYWORD_GROUPS,
|
||||
"channels": config.WATCHED_CHANNELS,
|
||||
"active_tab": "channels",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/config/channels")
|
||||
async def update_channels(payload: ChannelsPayload, _user=Depends(require_role("admin"))):
|
||||
config.save_runtime_config(config.KEYWORD_GROUPS, payload.channels)
|
||||
bus.signal_channel_changed()
|
||||
return {"status": "ok", "channels": len(payload.channels)}
|
||||
108
web/routes/dashboard.py
Normal file
108
web/routes/dashboard.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
web/routes/dashboard.py — Dashboard views and SSE live stream.
|
||||
|
||||
GET / → redirect to /dashboard
|
||||
GET /dashboard → overview: all groups, stats, live hit feed
|
||||
GET /dashboard/groups/{group_id} → per-group hits + filtered SSE
|
||||
GET /api/stream → SSE event stream (one hit per event)
|
||||
GET /api/stats → JSON severity counts (for hx-trigger refresh)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
|
||||
import config
|
||||
from tui import events as bus
|
||||
from utils import database as hitdb
|
||||
from web.dependencies import require_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _templates(request: Request):
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
def _hit_to_dict(hit) -> dict:
|
||||
reasons_raw = hit["reasons"] or ""
|
||||
reasons = [r.strip() for r in reasons_raw.split("|") if r.strip()]
|
||||
return {
|
||||
"severity": hit["severity"],
|
||||
"score": hit["score"],
|
||||
"raw": hit["raw"],
|
||||
"source": hit["source"],
|
||||
"filename": hit["filename"],
|
||||
"reasons": reasons,
|
||||
"timestamp": hit["timestamp"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def root():
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def dashboard(request: Request, user=Depends(require_role("reader"))):
|
||||
hits = [_hit_to_dict(h) for h in hitdb.recent(limit=50)]
|
||||
counts = hitdb.count_by_severity()
|
||||
groups = config.KEYWORD_GROUPS
|
||||
return _templates(request).TemplateResponse(
|
||||
request, "dashboard.html",
|
||||
{"user": dict(user), "hits": hits, "counts": counts, "groups": groups},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard/groups/{group_id}")
|
||||
async def group_detail(request: Request, group_id: str, user=Depends(require_role("reader"))):
|
||||
groups = config.KEYWORD_GROUPS
|
||||
group = next((g for g in groups if g["id"] == group_id), None)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
|
||||
patterns = [p["regex"] for p in group.get("patterns", [])]
|
||||
hits = [_hit_to_dict(h) for h in hitdb.recent_for_domains(patterns, limit=100)]
|
||||
counts = hitdb.count_by_severity_for_domains(patterns)
|
||||
|
||||
return _templates(request).TemplateResponse(
|
||||
request, "group_detail.html",
|
||||
{"user": dict(user), "group": group, "hits": hits, "counts": counts},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/stream")
|
||||
async def event_stream(request: Request, _user=Depends(require_role("reader"))):
|
||||
"""Server-Sent Events: one data frame per EvHit."""
|
||||
q = bus.subscribe()
|
||||
|
||||
async def generate():
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
ev = q.get_nowait()
|
||||
if isinstance(ev, bus.EvHit):
|
||||
payload = json.dumps({
|
||||
"severity": ev.severity,
|
||||
"raw": ev.raw,
|
||||
"source": ev.source,
|
||||
"filename": ev.filename,
|
||||
"reasons": ev.reasons,
|
||||
})
|
||||
yield f"data: {payload}\n\n"
|
||||
except queue.Empty:
|
||||
await asyncio.sleep(0.1)
|
||||
finally:
|
||||
bus.unsubscribe(q)
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/api/stats")
|
||||
async def stats(_user=Depends(require_role("reader"))):
|
||||
return hitdb.count_by_severity()
|
||||
82
web/routes/users.py
Normal file
82
web/routes/users.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
web/routes/users.py — User CRUD (superadmin only).
|
||||
|
||||
GET /users → list all users
|
||||
POST /users → create a new user
|
||||
PATCH /users/{id} → update role / password / active flag
|
||||
DELETE /users/{id} → deactivate (cannot delete self or other superadmins)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from web import db
|
||||
from web.dependencies import require_role
|
||||
from web.models import CreateUserRequest, UpdateUserRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _templates(request: Request):
|
||||
return request.app.state.templates
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(request: Request, user=Depends(require_role("superadmin"))):
|
||||
users = db.list_users()
|
||||
return _templates(request).TemplateResponse(
|
||||
request, "users.html",
|
||||
{"user": dict(user), "users": [dict(u) for u in users]},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(
|
||||
payload: CreateUserRequest,
|
||||
_user=Depends(require_role("superadmin")),
|
||||
):
|
||||
try:
|
||||
user_id = db.create_user(payload.username, payload.password, payload.role)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
|
||||
return {"id": user_id}
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}")
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
payload: UpdateUserRequest,
|
||||
acting_user=Depends(require_role("superadmin")),
|
||||
):
|
||||
target = db.get_user_by_id(user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Cannot demote another superadmin via role patch
|
||||
if target["role"] == "superadmin" and payload.role and payload.role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Cannot demote another superadmin")
|
||||
|
||||
updates: dict = {}
|
||||
if payload.password is not None:
|
||||
updates["password"] = payload.password
|
||||
if payload.role is not None:
|
||||
updates["role"] = payload.role
|
||||
if payload.is_active is not None:
|
||||
updates["is_active"] = 1 if payload.is_active else 0
|
||||
|
||||
db.update_user(user_id, **updates)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def deactivate_user(
|
||||
user_id: str,
|
||||
acting_user=Depends(require_role("superadmin")),
|
||||
):
|
||||
if user_id == acting_user["id"]:
|
||||
raise HTTPException(status_code=403, detail="Cannot deactivate yourself")
|
||||
target = db.get_user_by_id(user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
db.deactivate_user(user_id)
|
||||
return {"status": "ok"}
|
||||
Reference in New Issue
Block a user