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:
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
|
||||
Reference in New Issue
Block a user