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

View file

@ -3,6 +3,7 @@
Defines table schemas via SQLAlchemy Core and provides an
initialization function to set up TimescaleDB hypertable.
"""
import os
import sqlalchemy
from datetime import datetime, timedelta, timezone
@ -10,9 +11,12 @@ from databases import Database
from sqlalchemy import MetaData, Table, Column, Integer, String, Float, DateTime, text
from sqlalchemy import Index, BigInteger, JSON, Boolean, UniqueConstraint
from sqlalchemy.sql import func
from passlib.hash import bcrypt
# Environment: Postgres/TimescaleDB connection URL
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth")
DATABASE_URL = os.getenv(
"DATABASE_URL", "postgresql://postgres:password@localhost:5432/dereth"
)
# Async database client with explicit connection pool configuration and query timeout
database = Database(DATABASE_URL, min_size=5, max_size=100, command_timeout=120)
# Metadata for SQLAlchemy Core
@ -48,9 +52,9 @@ telemetry_events = Table(
)
# Composite index to accelerate Grafana queries filtering by character_name then ordering by timestamp
Index(
'ix_telemetry_events_char_ts',
"ix_telemetry_events_char_ts",
telemetry_events.c.character_name,
telemetry_events.c.timestamp
telemetry_events.c.timestamp,
)
# Table for persistent total kills per character
@ -149,7 +153,12 @@ server_health_checks = Table(
Column("id", Integer, primary_key=True),
Column("server_name", String, nullable=False, index=True),
Column("server_address", String, nullable=False),
Column("timestamp", DateTime(timezone=True), nullable=False, default=sqlalchemy.func.now()),
Column(
"timestamp",
DateTime(timezone=True),
nullable=False,
default=sqlalchemy.func.now(),
),
Column("status", String(10), nullable=False), # 'up' or 'down'
Column("latency_ms", Float, nullable=True),
Column("player_count", Integer, nullable=True),
@ -171,16 +180,18 @@ server_status = Table(
# Index for efficient server health check queries
Index(
'ix_server_health_checks_name_ts',
"ix_server_health_checks_name_ts",
server_health_checks.c.server_name,
server_health_checks.c.timestamp.desc()
server_health_checks.c.timestamp.desc(),
)
character_stats = Table(
"character_stats",
metadata,
Column("character_name", String, primary_key=True, nullable=False),
Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column(
"timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()
),
Column("level", Integer, nullable=True),
Column("total_xp", BigInteger, nullable=True),
Column("unassigned_xp", BigInteger, nullable=True),
@ -190,6 +201,20 @@ character_stats = Table(
Column("stats_data", JSON, nullable=False),
)
# User accounts for app-level authentication
users = Table(
"users",
metadata,
Column("id", Integer, primary_key=True),
Column("username", String, nullable=False, unique=True),
Column("password_hash", String, nullable=False),
Column("is_admin", Boolean, nullable=False, default=False),
Column(
"created_at", DateTime(timezone=True), nullable=False, server_default=func.now()
),
)
async def init_db_async():
"""Initialize PostgreSQL/TimescaleDB schema and hypertable.
@ -212,10 +237,12 @@ async def init_db_async():
print(f"Warning: failed to create extension timescaledb: {e}")
# Convert to hypertable, migrating existing data and skipping default index creation
try:
conn.execute(text(
"SELECT create_hypertable('telemetry_events', 'timestamp', "
"if_not_exists => true, migrate_data => true, create_default_indexes => false)"
))
conn.execute(
text(
"SELECT create_hypertable('telemetry_events', 'timestamp', "
"if_not_exists => true, migrate_data => true, create_default_indexes => false)"
)
)
except Exception as e:
print(f"Warning: failed to create hypertable telemetry_events: {e}")
except Exception as e:
@ -223,44 +250,56 @@ async def init_db_async():
# Ensure composite index exists for efficient time-series queries by character
try:
with engine.connect() as conn:
conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_telemetry_events_char_ts "
"ON telemetry_events (character_name, timestamp)"
))
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_telemetry_events_char_ts "
"ON telemetry_events (character_name, timestamp)"
)
)
except Exception as e:
print(f"Warning: failed to create composite index ix_telemetry_events_char_ts: {e}")
print(
f"Warning: failed to create composite index ix_telemetry_events_char_ts: {e}"
)
# Add retention and compression policies on the hypertable
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Retain only recent data (default 7 days or override via DB_RETENTION_DAYS)
days = int(os.getenv('DB_RETENTION_DAYS', '7'))
conn.execute(text(
f"SELECT add_retention_policy('telemetry_events', INTERVAL '{days} days')"
))
days = int(os.getenv("DB_RETENTION_DAYS", "7"))
conn.execute(
text(
f"SELECT add_retention_policy('telemetry_events', INTERVAL '{days} days')"
)
)
# Compress chunks older than 1 day
conn.execute(text(
"SELECT add_compression_policy('telemetry_events', INTERVAL '1 day')"
))
conn.execute(
text(
"SELECT add_compression_policy('telemetry_events', INTERVAL '1 day')"
)
)
except Exception as e:
print(f"Warning: failed to set retention/compression policies: {e}")
# Create unique constraint on rounded portal coordinates
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
# Drop old portal_discoveries table if it exists
conn.execute(text("DROP TABLE IF EXISTS portal_discoveries CASCADE"))
# Create unique constraint on rounded coordinates for the new portals table
conn.execute(text(
"""CREATE UNIQUE INDEX IF NOT EXISTS unique_portal_coords
conn.execute(
text(
"""CREATE UNIQUE INDEX IF NOT EXISTS unique_portal_coords
ON portals (ROUND(ns::numeric, 2), ROUND(ew::numeric, 2))"""
))
)
)
# Create index on coordinates for efficient lookups
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_portals_coords ON portals (ns, ew)"
))
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_portals_coords ON portals (ns, ew)"
)
)
print("Portal table indexes and constraints created successfully")
except Exception as e:
print(f"Warning: failed to create portal table constraints: {e}")
@ -268,7 +307,8 @@ async def init_db_async():
# Ensure character_stats table exists with JSONB column type
try:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
conn.execute(text("""
conn.execute(
text("""
CREATE TABLE IF NOT EXISTS character_stats (
character_name VARCHAR(255) PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@ -280,25 +320,60 @@ async def init_db_async():
deaths INTEGER,
stats_data JSONB NOT NULL
)
"""))
""")
)
print("character_stats table created/verified successfully")
except Exception as e:
print(f"Warning: failed to create character_stats table: {e}")
async def cleanup_old_portals():
"""Clean up portals older than 1 hour."""
try:
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=1)
# Delete old portals
result = await database.execute(
"DELETE FROM portals WHERE discovered_at < :cutoff_time",
{"cutoff_time": cutoff_time}
{"cutoff_time": cutoff_time},
)
print(f"Cleaned up {result} portals older than 1 hour")
return result
except Exception as e:
print(f"Warning: failed to cleanup old portals: {e}")
return 0
return 0
async def seed_users():
"""Seed default users if the users table is empty."""
try:
count = await database.fetch_val("SELECT COUNT(*) FROM users")
if count > 0:
print(f"Users table already has {count} users, skipping seed")
return
default_users = [
{"username": "erik", "password": "erik123", "is_admin": True},
{"username": "alex", "password": "AlexGillar100Killar", "is_admin": False},
{
"username": "lundberg",
"password": "JohanGillar100Kvinnor",
"is_admin": False,
},
]
for u in default_users:
pw_hash = bcrypt.hash(u["password"])
await database.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (:username, :password_hash, :is_admin)",
{
"username": u["username"],
"password_hash": pw_hash,
"is_admin": u["is_admin"],
},
)
role = "admin" if u["is_admin"] else "user"
print(f"Seeded {role} user: {u['username']}")
except Exception as e:
print(f"Warning: failed to seed users: {e}")