feat: add app-level authentication with login, session cookies, and admin panel
Replace Nginx basic auth with proper user accounts: - Session cookies via itsdangerous (30-day expiry, httponly, secure) - Password hashing with bcrypt via passlib - Login page with AC-themed UI - Admin page for user management (CRUD) - AuthMiddleware exempts plugin WS and browser WS endpoints - Issues/comments author auto-populated from session - Sidebar shows logged-in username, admin link, and logout - Seed users: erik (admin), alex, lundberg - SECRET_KEY env var for cookie signing
This commit is contained in:
parent
fac5063878
commit
b09169ade2
9 changed files with 878 additions and 60 deletions
261
main.py
261
main.py
|
|
@ -28,13 +28,22 @@ from fastapi import (
|
|||
WebSocketDisconnect,
|
||||
Request,
|
||||
)
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from fastapi.responses import (
|
||||
JSONResponse,
|
||||
Response,
|
||||
StreamingResponse,
|
||||
HTMLResponse,
|
||||
RedirectResponse,
|
||||
)
|
||||
from fastapi.routing import APIRoute
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import httpx
|
||||
from passlib.hash import bcrypt
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
|
||||
# Async database support
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
|
@ -51,8 +60,10 @@ from db_async import (
|
|||
portals,
|
||||
server_health_checks,
|
||||
server_status,
|
||||
users,
|
||||
init_db_async,
|
||||
cleanup_old_portals,
|
||||
seed_users,
|
||||
)
|
||||
import asyncio
|
||||
|
||||
|
|
@ -987,11 +998,72 @@ dungeon_map_cache: Dict[str, dict] = {} # landblock hex string -> dungeon map d
|
|||
|
||||
# Shared secret used to authenticate plugin WebSocket connections (override for production)
|
||||
SHARED_SECRET = "your_shared_secret"
|
||||
# Secret key for signing session cookies (override via SECRET_KEY env var)
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production-please")
|
||||
SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days in seconds
|
||||
_serializer = URLSafeTimedSerializer(SECRET_KEY)
|
||||
|
||||
# LOG_FILE = "telemetry_log.jsonl"
|
||||
# ------------------------------------------------------------------
|
||||
ACTIVE_WINDOW = timedelta(
|
||||
seconds=30
|
||||
) # Time window defining “online” players (last 30 seconds)
|
||||
) # Time window defining "online" players (last 30 seconds)
|
||||
|
||||
|
||||
# ─── Session helpers ─────────────────────────────────────────────
|
||||
def create_session_cookie(username: str, is_admin: bool) -> str:
|
||||
"""Create a signed session token."""
|
||||
return _serializer.dumps({"u": username, "a": is_admin})
|
||||
|
||||
|
||||
def verify_session_cookie(token: str) -> dict | None:
|
||||
"""Verify and decode a session token. Returns None if invalid/expired."""
|
||||
try:
|
||||
data = _serializer.loads(token, max_age=SESSION_MAX_AGE)
|
||||
return {"username": data["u"], "is_admin": data["a"]}
|
||||
except (BadSignature, SignatureExpired, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
# Paths that don't require authentication
|
||||
_PUBLIC_PATHS = {"/login", "/logout"}
|
||||
_PUBLIC_PREFIXES = ("/ws/position",) # Plugin WS uses X-Plugin-Secret
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Redirect unauthenticated requests to /login."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
# Always allow public paths
|
||||
if path in _PUBLIC_PATHS or path.startswith(_PUBLIC_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
# Allow login page static assets
|
||||
if path == "/login.html" or path == "/login-style.css":
|
||||
return await call_next(request)
|
||||
|
||||
# WebSocket upgrade for /ws/live — allow (browser WS, read-only)
|
||||
if path.startswith("/ws/live"):
|
||||
return await call_next(request)
|
||||
|
||||
# Check session cookie
|
||||
token = request.cookies.get("session")
|
||||
if token:
|
||||
user = verify_session_cookie(token)
|
||||
if user:
|
||||
request.state.user = user
|
||||
return await call_next(request)
|
||||
|
||||
# Not authenticated — redirect browser, reject API
|
||||
if "text/html" in request.headers.get("accept", ""):
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
|
||||
|
||||
|
||||
app.add_middleware(AuthMiddleware)
|
||||
|
||||
|
||||
"""
|
||||
Data models for plugin events:
|
||||
|
|
@ -1159,6 +1231,8 @@ async def on_startup():
|
|||
logger.info(
|
||||
"Background cache refresh, server monitoring, and connection cleanup tasks started"
|
||||
)
|
||||
# Seed default users on first run
|
||||
await seed_users()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
|
|
@ -1212,6 +1286,175 @@ async def on_shutdown():
|
|||
await database.disconnect()
|
||||
|
||||
|
||||
# ─── Authentication endpoints ────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
async def login_page():
|
||||
"""Serve the login page."""
|
||||
login_html = Path("static/login.html")
|
||||
if login_html.exists():
|
||||
return HTMLResponse(login_html.read_text())
|
||||
return HTMLResponse("<h1>Login page not found</h1>", status_code=500)
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
async def login(request: Request):
|
||||
"""Authenticate user and set session cookie."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid request body")
|
||||
username = body.get("username", "").strip().lower()
|
||||
password = body.get("password", "")
|
||||
if not username or not password:
|
||||
raise HTTPException(status_code=400, detail="Username and password required")
|
||||
|
||||
row = await database.fetch_one(
|
||||
"SELECT id, username, password_hash, is_admin FROM users WHERE LOWER(username) = :username",
|
||||
{"username": username},
|
||||
)
|
||||
if not row or not bcrypt.verify(password, row["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
token = create_session_cookie(row["username"], row["is_admin"])
|
||||
response = JSONResponse(
|
||||
{"ok": True, "username": row["username"], "is_admin": row["is_admin"]}
|
||||
)
|
||||
response.set_cookie(
|
||||
"session",
|
||||
token,
|
||||
max_age=SESSION_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=True,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout():
|
||||
"""Clear session cookie and redirect to login."""
|
||||
response = RedirectResponse("/login", status_code=302)
|
||||
response.delete_cookie("session")
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/me")
|
||||
async def me(request: Request):
|
||||
"""Return current user info from session."""
|
||||
user = getattr(request.state, "user", None)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return {"username": user["username"], "is_admin": user["is_admin"]}
|
||||
|
||||
|
||||
# ─── Admin user management ───────────────────────────────────────
|
||||
|
||||
|
||||
def _require_admin(request: Request):
|
||||
"""Raise 403 if current user is not admin."""
|
||||
user = getattr(request.state, "user", None)
|
||||
if not user or not user.get("is_admin"):
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
|
||||
@app.get("/admin/users")
|
||||
async def admin_page(request: Request):
|
||||
"""Serve the admin user management page."""
|
||||
_require_admin(request)
|
||||
admin_html = Path("static/admin.html")
|
||||
if admin_html.exists():
|
||||
return HTMLResponse(admin_html.read_text())
|
||||
return HTMLResponse("<h1>Admin page not found</h1>", status_code=500)
|
||||
|
||||
|
||||
@app.get("/api-admin/users")
|
||||
async def list_users(request: Request):
|
||||
"""List all users (admin only)."""
|
||||
_require_admin(request)
|
||||
rows = await database.fetch_all(
|
||||
"SELECT id, username, is_admin, created_at FROM users ORDER BY id"
|
||||
)
|
||||
return {"users": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@app.post("/api-admin/users")
|
||||
async def create_user(request: Request):
|
||||
"""Create a new user (admin only)."""
|
||||
_require_admin(request)
|
||||
body = await request.json()
|
||||
username = body.get("username", "").strip()
|
||||
password = body.get("password", "")
|
||||
is_admin = bool(body.get("is_admin", False))
|
||||
if not username or not password:
|
||||
raise HTTPException(status_code=400, detail="Username and password required")
|
||||
if len(password) < 4:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Password must be at least 4 characters"
|
||||
)
|
||||
|
||||
existing = await database.fetch_one(
|
||||
"SELECT id FROM users WHERE LOWER(username) = :username",
|
||||
{"username": username.lower()},
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Username already exists")
|
||||
|
||||
pw_hash = bcrypt.hash(password)
|
||||
await database.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin) VALUES (:username, :password_hash, :is_admin)",
|
||||
{"username": username, "password_hash": pw_hash, "is_admin": is_admin},
|
||||
)
|
||||
return {"ok": True, "username": username}
|
||||
|
||||
|
||||
@app.delete("/api-admin/users/{user_id}")
|
||||
async def delete_user(user_id: int, request: Request):
|
||||
"""Delete a user (admin only). Cannot delete yourself."""
|
||||
_require_admin(request)
|
||||
current_user = request.state.user["username"]
|
||||
row = await database.fetch_one(
|
||||
"SELECT username FROM users WHERE id = :id", {"id": user_id}
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if row["username"].lower() == current_user.lower():
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
await database.execute("DELETE FROM users WHERE id = :id", {"id": user_id})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.patch("/api-admin/users/{user_id}")
|
||||
async def update_user(user_id: int, request: Request):
|
||||
"""Update user (admin only). Supports password reset and admin toggle."""
|
||||
_require_admin(request)
|
||||
body = await request.json()
|
||||
row = await database.fetch_one(
|
||||
"SELECT id, username FROM users WHERE id = :id", {"id": user_id}
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if "password" in body:
|
||||
password = body["password"]
|
||||
if len(password) < 4:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Password must be at least 4 characters"
|
||||
)
|
||||
pw_hash = bcrypt.hash(password)
|
||||
await database.execute(
|
||||
"UPDATE users SET password_hash = :pw WHERE id = :id",
|
||||
{"pw": pw_hash, "id": user_id},
|
||||
)
|
||||
if "is_admin" in body:
|
||||
await database.execute(
|
||||
"UPDATE users SET is_admin = :admin WHERE id = :id",
|
||||
{"admin": bool(body["is_admin"]), "id": user_id},
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ------------------------ GET -----------------------------------
|
||||
@app.get("/debug")
|
||||
def debug():
|
||||
|
|
@ -1358,15 +1601,16 @@ async def get_issues():
|
|||
|
||||
|
||||
@app.post("/issues")
|
||||
async def add_issue(issue: dict):
|
||||
"""Add a new issue."""
|
||||
async def add_issue(request: Request, issue: dict):
|
||||
"""Add a new issue. Author from session."""
|
||||
user = getattr(request.state, "user", {})
|
||||
issues = _load_issues()
|
||||
new_issue = {
|
||||
"id": uuid.uuid4().hex[:8],
|
||||
"title": issue.get("title", "").strip(),
|
||||
"description": issue.get("description", "").strip(),
|
||||
"category": issue.get("category", "other"),
|
||||
"author": issue.get("author", "Anonymous").strip(),
|
||||
"author": user.get("username", "Anonymous"),
|
||||
"created": datetime.utcnow().isoformat(),
|
||||
"resolved": False,
|
||||
"comments": [],
|
||||
|
|
@ -1405,8 +1649,9 @@ async def update_issue(issue_id: str, update: dict):
|
|||
|
||||
|
||||
@app.post("/issues/{issue_id}/comments")
|
||||
async def add_comment(issue_id: str, comment: dict):
|
||||
"""Add a comment to an issue."""
|
||||
async def add_comment(issue_id: str, request: Request, comment: dict):
|
||||
"""Add a comment to an issue. Author from session."""
|
||||
user = getattr(request.state, "user", {})
|
||||
issues = _load_issues()
|
||||
found = None
|
||||
for i in issues:
|
||||
|
|
@ -1420,7 +1665,7 @@ async def add_comment(issue_id: str, comment: dict):
|
|||
raise HTTPException(status_code=400, detail="Comment text is required")
|
||||
new_comment = {
|
||||
"id": uuid.uuid4().hex[:8],
|
||||
"author": comment.get("author", "Anonymous").strip(),
|
||||
"author": user.get("username", "Anonymous"),
|
||||
"text": text,
|
||||
"created": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue