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:
Erik 2026-04-10 19:45:08 +02:00
parent fac5063878
commit b09169ade2
9 changed files with 878 additions and 60 deletions

261
main.py
View file

@ -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(),
}