diff --git a/README.md b/README.md index c753add..91edfaa 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,6 @@ Features: ToDo: -- basic auth - - since control of the Discord bot would also be through there technically - Log output section on webpage, to see who is being assigned to who, when the hit is complete, if it is missed, how many hits a person has done - Hit Leaderboard? diff --git a/__pycache__/config.cpython-311.pyc b/__pycache__/config.cpython-311.pyc index 100ae0d..5aae51a 100644 Binary files a/__pycache__/config.cpython-311.pyc and b/__pycache__/config.cpython-311.pyc differ diff --git a/config.py b/config.py index d921284..ddd330f 100644 --- a/config.py +++ b/config.py @@ -39,3 +39,7 @@ ASSIGNMENT_REMINDER = _config.get("ASSIGNMENT_REMINDER", 45) # Seconds before s # Chain Timer Settings CHAIN_TIMER_THRESHOLD = _config.get("CHAIN_TIMER_THRESHOLD", 5) # Minutes - start assigning hits when chain timer is at or below this + +# Authentication +AUTH_PASSWORD = _config.get("AUTH_PASSWORD", "YOUR_AUTH_PASSWORD_HERE") # Universal password for all users +JWT_SECRET = _config.get("JWT_SECRET", "your-secret-key-change-this") # Secret key for JWT tokens diff --git a/main.py b/main.py index 9c88d7a..4b1d9f7 100644 --- a/main.py +++ b/main.py @@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles from services.bot_assignment import BotAssignmentManager # Import routers -from routers import pages, factions, assignments, discord_mappings, config +from routers import pages, factions, assignments, discord_mappings, config, auth from routers import bot as bot_router @@ -24,6 +24,7 @@ app = FastAPI() app.mount("/static", StaticFiles(directory="static"), name="static") # Include all routers +app.include_router(auth.router) app.include_router(pages.router) app.include_router(factions.router) app.include_router(assignments.router) diff --git a/routers/__pycache__/auth.cpython-311.pyc b/routers/__pycache__/auth.cpython-311.pyc new file mode 100644 index 0000000..c0cfca8 Binary files /dev/null and b/routers/__pycache__/auth.cpython-311.pyc differ diff --git a/routers/__pycache__/config.cpython-311.pyc b/routers/__pycache__/config.cpython-311.pyc index 97308eb..b41968a 100644 Binary files a/routers/__pycache__/config.cpython-311.pyc and b/routers/__pycache__/config.cpython-311.pyc differ diff --git a/routers/__pycache__/pages.cpython-311.pyc b/routers/__pycache__/pages.cpython-311.pyc index 9ee6ef8..6110159 100644 Binary files a/routers/__pycache__/pages.cpython-311.pyc and b/routers/__pycache__/pages.cpython-311.pyc differ diff --git a/routers/auth.py b/routers/auth.py new file mode 100644 index 0000000..cb82874 --- /dev/null +++ b/routers/auth.py @@ -0,0 +1,164 @@ +"""Authentication endpoints for login/logout.""" +import jwt +from datetime import datetime, timedelta +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from typing import Dict + +import config as config_module + +router = APIRouter(prefix="/auth", tags=["auth"]) + +# In-memory storage for failed login attempts +# Format: {ip_address: {"count": int, "locked_until": datetime}} +failed_attempts: Dict[str, Dict] = {} + +class LoginRequest(BaseModel): + name: str + password: str + + +def get_client_ip(request: Request) -> str: + """Get client IP address from request""" + # Check X-Forwarded-For header first (for proxy/load balancer) + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def is_locked_out(ip: str) -> bool: + """Check if IP is currently locked out""" + if ip not in failed_attempts: + return False + + attempt_data = failed_attempts[ip] + locked_until = attempt_data.get("locked_until") + + if not locked_until: + return False + + # Check if lockout has expired + if datetime.now() >= locked_until: + # Clear the lockout + del failed_attempts[ip] + return False + + return True + + +def record_failed_attempt(ip: str): + """Record a failed login attempt""" + now = datetime.now() + + if ip not in failed_attempts: + failed_attempts[ip] = {"count": 1, "locked_until": None} + else: + failed_attempts[ip]["count"] += 1 + + # Lock out after 5 failed attempts + if failed_attempts[ip]["count"] >= 5: + failed_attempts[ip]["locked_until"] = now + timedelta(minutes=5) + print(f"IP {ip} locked out until {failed_attempts[ip]['locked_until']}") + + +def clear_failed_attempts(ip: str): + """Clear failed attempts for an IP after successful login""" + if ip in failed_attempts: + del failed_attempts[ip] + + +def create_jwt_token(username: str) -> str: + """Create a JWT token for the user""" + expiration = datetime.utcnow() + timedelta(days=7) # Token valid for 7 days + payload = { + "username": username, + "exp": expiration + } + token = jwt.encode(payload, config_module.JWT_SECRET, algorithm="HS256") + return token + + +def verify_jwt_token(token: str) -> dict: + """Verify and decode a JWT token""" + try: + payload = jwt.decode(token, config_module.JWT_SECRET, algorithms=["HS256"]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token") + + +@router.post("/login") +async def login(request: Request, response: Response, req: LoginRequest): + """Login endpoint with rate limiting""" + client_ip = get_client_ip(request) + + # Check if IP is locked out + if is_locked_out(client_ip): + attempt_data = failed_attempts[client_ip] + locked_until = attempt_data["locked_until"] + remaining = (locked_until - datetime.now()).total_seconds() + raise HTTPException( + status_code=429, + detail=f"Too many failed attempts. Try again in {int(remaining)} seconds." + ) + + # Verify password (universal password for all users) + if req.password != config_module.AUTH_PASSWORD: + record_failed_attempt(client_ip) + attempts_left = 5 - failed_attempts[client_ip]["count"] + + if attempts_left <= 0: + raise HTTPException( + status_code=429, + detail="Too many failed attempts. Locked out for 5 minutes." + ) + + raise HTTPException( + status_code=401, + detail=f"Invalid password. {attempts_left} attempts remaining." + ) + + # Successful login + clear_failed_attempts(client_ip) + + # Create JWT token + token = create_jwt_token(req.name) + + # Set token as HTTP-only cookie + response.set_cookie( + key="auth_token", + value=token, + httponly=True, + max_age=7 * 24 * 60 * 60, # 7 days in seconds + samesite="lax" + ) + + print(f"User '{req.name}' logged in from IP {client_ip}") + + return {"status": "success", "username": req.name} + + +@router.post("/logout") +async def logout(response: Response): + """Logout endpoint""" + response.delete_cookie("auth_token") + return {"status": "success"} + + +@router.get("/status") +async def auth_status(request: Request): + """Check authentication status""" + token = request.cookies.get("auth_token") + + if not token: + return {"authenticated": False} + + try: + payload = verify_jwt_token(token) + return {"authenticated": True, "username": payload.get("username")} + except HTTPException: + return {"authenticated": False} diff --git a/routers/config.py b/routers/config.py index 579dc3a..91ac0d3 100644 --- a/routers/config.py +++ b/routers/config.py @@ -42,7 +42,9 @@ async def get_config(): "REASSIGN_DELAY": config_module.REASSIGN_DELAY, "ASSIGNMENT_TIMEOUT": config_module.ASSIGNMENT_TIMEOUT, "ASSIGNMENT_REMINDER": config_module.ASSIGNMENT_REMINDER, - "CHAIN_TIMER_THRESHOLD": config_module.CHAIN_TIMER_THRESHOLD + "CHAIN_TIMER_THRESHOLD": config_module.CHAIN_TIMER_THRESHOLD, + "AUTH_PASSWORD": config_module.AUTH_PASSWORD, + "JWT_SECRET": config_module.JWT_SECRET } if path.exists(): @@ -57,7 +59,7 @@ async def get_config(): # Mask sensitive values masked_config = config_values.copy() - sensitive = ["TORN_API_KEY", "FFSCOUTER_KEY", "DISCORD_TOKEN"] + sensitive = ["TORN_API_KEY", "FFSCOUTER_KEY", "DISCORD_TOKEN", "AUTH_PASSWORD", "JWT_SECRET"] for key in sensitive: if key in masked_config and masked_config[key]: val = str(masked_config[key]) @@ -75,7 +77,8 @@ async def update_config(req: ConfigUpdateRequest): valid_keys = { "TORN_API_KEY", "FFSCOUTER_KEY", "DISCORD_TOKEN", "ALLOWED_CHANNEL_ID", "HIT_CHECK_INTERVAL", "REASSIGN_DELAY", - "ASSIGNMENT_TIMEOUT", "ASSIGNMENT_REMINDER", "CHAIN_TIMER_THRESHOLD" + "ASSIGNMENT_TIMEOUT", "ASSIGNMENT_REMINDER", "CHAIN_TIMER_THRESHOLD", + "AUTH_PASSWORD", "JWT_SECRET" } # Validate key is valid @@ -98,7 +101,9 @@ async def update_config(req: ConfigUpdateRequest): "REASSIGN_DELAY": config_module.REASSIGN_DELAY, "ASSIGNMENT_TIMEOUT": config_module.ASSIGNMENT_TIMEOUT, "ASSIGNMENT_REMINDER": config_module.ASSIGNMENT_REMINDER, - "CHAIN_TIMER_THRESHOLD": config_module.CHAIN_TIMER_THRESHOLD + "CHAIN_TIMER_THRESHOLD": config_module.CHAIN_TIMER_THRESHOLD, + "AUTH_PASSWORD": config_module.AUTH_PASSWORD, + "JWT_SECRET": config_module.JWT_SECRET } } diff --git a/routers/pages.py b/routers/pages.py index c815286..15be19c 100644 --- a/routers/pages.py +++ b/routers/pages.py @@ -1,18 +1,34 @@ """Web page routes for dashboard and config page.""" from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates +from utils.auth import check_auth router = APIRouter() templates = Jinja2Templates(directory="templates") +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + """Login page""" + # If already authenticated, redirect to dashboard + if check_auth(request): + return RedirectResponse(url="/", status_code=302) + return templates.TemplateResponse("login.html", {"request": request}) + + @router.get("/", response_class=HTMLResponse) async def dashboard(request: Request): + """Dashboard page - requires authentication""" + if not check_auth(request): + return RedirectResponse(url="/login", status_code=302) print(">>> DASHBOARD ROUTE LOADED") return templates.TemplateResponse("dashboard.html", {"request": request}) @router.get("/config", response_class=HTMLResponse) async def config_page(request: Request): + """Config page - requires authentication""" + if not check_auth(request): + return RedirectResponse(url="/login", status_code=302) return templates.TemplateResponse("config.html", {"request": request}) diff --git a/static/config.js b/static/config.js index 2adf3ec..0aa99eb 100644 --- a/static/config.js +++ b/static/config.js @@ -8,7 +8,9 @@ const CONFIG_FIELDS = { "REASSIGN_DELAY": "reassign-delay", "ASSIGNMENT_TIMEOUT": "assignment-timeout", "ASSIGNMENT_REMINDER": "assignment-reminder", - "CHAIN_TIMER_THRESHOLD": "chain-timer-threshold" + "CHAIN_TIMER_THRESHOLD": "chain-timer-threshold", + "AUTH_PASSWORD": "auth-password", + "JWT_SECRET": "jwt-secret" }; let sensitiveFields = []; @@ -43,8 +45,11 @@ async function loadConfig() { } async function saveConfigValue(key) { + console.log("saveConfigValue called with key:", key); const inputId = CONFIG_FIELDS[key]; + console.log("Input ID:", inputId); const input = document.getElementById(inputId); + console.log("Input element:", input); if (!input) { console.error("Input not found:", inputId); @@ -52,13 +57,17 @@ async function saveConfigValue(key) { } let value = input.value.trim(); + console.log("Value to save:", value); // Don't save if sensitive field is empty (means user didn't change it) if (sensitiveFields.includes(key) && value === "") { + console.log("No changes to save - field is empty"); alert("No changes to save"); return; } + console.log("Proceeding with save..."); + // Convert to number if needed if (input.type === "number") { value = parseInt(value); @@ -69,11 +78,13 @@ async function saveConfigValue(key) { } try { + console.log("Sending API request to /api/config with:", { key, value }); const res = await fetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value }) }); + console.log("API response status:", res.status); if (!res.ok) { console.error("Save failed:", res.status); @@ -94,12 +105,41 @@ async function saveConfigValue(key) { } function wireUp() { + console.log("wireUp called"); // Attach save handlers to all save buttons const saveButtons = document.querySelectorAll(".config-save-btn"); + console.log("Found save buttons:", saveButtons.length); saveButtons.forEach(btn => { const key = btn.dataset.key; + console.log("Attaching handler for key:", key); btn.addEventListener("click", () => saveConfigValue(key)); }); + + // Attach logout handler + const logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) logoutBtn.addEventListener("click", handleLogout); +} + +async function handleLogout() { + console.log("handleLogout called"); + try { + console.log("Sending logout request to /auth/logout"); + const response = await fetch("/auth/logout", { + method: "POST" + }); + console.log("Logout response status:", response.status); + + if (response.ok) { + console.log("Logout successful, redirecting to /login"); + window.location.href = "/login"; + } else { + console.error("Logout failed with status:", response.status); + window.location.href = "/login"; + } + } catch (error) { + console.error("Error during logout:", error); + window.location.href = "/login"; + } } document.addEventListener("DOMContentLoaded", async () => { diff --git a/static/dashboard.js b/static/dashboard.js index bf08a01..77c1a3b 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -730,10 +730,41 @@ function wireUp() { const resetBtn = document.getElementById("reset-groups-btn"); if (resetBtn) resetBtn.addEventListener("click", resetGroups); + const logoutBtn = document.getElementById("logout-btn"); + if (logoutBtn) logoutBtn.addEventListener("click", handleLogout); + setupDropZones(); console.log(">>> wireUp completed"); } +// --------------------------- +// Logout handler +// --------------------------- +async function handleLogout() { + console.log("handleLogout called"); + try { + console.log("Sending logout request to /auth/logout"); + const response = await fetch("/auth/logout", { + method: "POST" + }); + console.log("Logout response status:", response.status); + + if (response.ok) { + console.log("Logout successful, redirecting to /login"); + // Redirect to login page + window.location.href = "/login"; + } else { + console.error("Logout failed with status:", response.status); + // Still redirect to login page + window.location.href = "/login"; + } + } catch (error) { + console.error("Error during logout:", error); + // Still redirect to login page + window.location.href = "/login"; + } +} + // --------------------------- // Initial load // --------------------------- diff --git a/templates/config.html b/templates/config.html index 0066313..c14ebd4 100644 --- a/templates/config.html +++ b/templates/config.html @@ -12,6 +12,7 @@
Shared password that all users use to log in
+ + +Secret key for session tokens (change from default for security)
+ + +Please log in to continue
+ + + + + ++ Contact Faction Management if you need the shared password. +
+