Authenticatoin Implementation

This commit is contained in:
2026-01-27 09:48:58 -05:00
parent 6e3f8b46a5
commit 4ae3a9eb17
17 changed files with 535 additions and 9 deletions

View File

@@ -9,8 +9,6 @@ Features:
ToDo: 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 - 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? - Hit Leaderboard?

Binary file not shown.

View File

@@ -39,3 +39,7 @@ ASSIGNMENT_REMINDER = _config.get("ASSIGNMENT_REMINDER", 45) # Seconds before s
# Chain Timer Settings # Chain Timer Settings
CHAIN_TIMER_THRESHOLD = _config.get("CHAIN_TIMER_THRESHOLD", 5) # Minutes - start assigning hits when chain timer is at or below this 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

View File

@@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles
from services.bot_assignment import BotAssignmentManager from services.bot_assignment import BotAssignmentManager
# Import routers # 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 from routers import bot as bot_router
@@ -24,6 +24,7 @@ app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
# Include all routers # Include all routers
app.include_router(auth.router)
app.include_router(pages.router) app.include_router(pages.router)
app.include_router(factions.router) app.include_router(factions.router)
app.include_router(assignments.router) app.include_router(assignments.router)

Binary file not shown.

164
routers/auth.py Normal file
View File

@@ -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}

View File

@@ -42,7 +42,9 @@ async def get_config():
"REASSIGN_DELAY": config_module.REASSIGN_DELAY, "REASSIGN_DELAY": config_module.REASSIGN_DELAY,
"ASSIGNMENT_TIMEOUT": config_module.ASSIGNMENT_TIMEOUT, "ASSIGNMENT_TIMEOUT": config_module.ASSIGNMENT_TIMEOUT,
"ASSIGNMENT_REMINDER": config_module.ASSIGNMENT_REMINDER, "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(): if path.exists():
@@ -57,7 +59,7 @@ async def get_config():
# Mask sensitive values # Mask sensitive values
masked_config = config_values.copy() 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: for key in sensitive:
if key in masked_config and masked_config[key]: if key in masked_config and masked_config[key]:
val = str(masked_config[key]) val = str(masked_config[key])
@@ -75,7 +77,8 @@ async def update_config(req: ConfigUpdateRequest):
valid_keys = { valid_keys = {
"TORN_API_KEY", "FFSCOUTER_KEY", "DISCORD_TOKEN", "ALLOWED_CHANNEL_ID", "TORN_API_KEY", "FFSCOUTER_KEY", "DISCORD_TOKEN", "ALLOWED_CHANNEL_ID",
"HIT_CHECK_INTERVAL", "REASSIGN_DELAY", "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 # Validate key is valid
@@ -98,7 +101,9 @@ async def update_config(req: ConfigUpdateRequest):
"REASSIGN_DELAY": config_module.REASSIGN_DELAY, "REASSIGN_DELAY": config_module.REASSIGN_DELAY,
"ASSIGNMENT_TIMEOUT": config_module.ASSIGNMENT_TIMEOUT, "ASSIGNMENT_TIMEOUT": config_module.ASSIGNMENT_TIMEOUT,
"ASSIGNMENT_REMINDER": config_module.ASSIGNMENT_REMINDER, "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
} }
} }

View File

@@ -1,18 +1,34 @@
"""Web page routes for dashboard and config page.""" """Web page routes for dashboard and config page."""
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from utils.auth import check_auth
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates") 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) @router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request): 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") print(">>> DASHBOARD ROUTE LOADED")
return templates.TemplateResponse("dashboard.html", {"request": request}) return templates.TemplateResponse("dashboard.html", {"request": request})
@router.get("/config", response_class=HTMLResponse) @router.get("/config", response_class=HTMLResponse)
async def config_page(request: Request): 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}) return templates.TemplateResponse("config.html", {"request": request})

View File

@@ -8,7 +8,9 @@ const CONFIG_FIELDS = {
"REASSIGN_DELAY": "reassign-delay", "REASSIGN_DELAY": "reassign-delay",
"ASSIGNMENT_TIMEOUT": "assignment-timeout", "ASSIGNMENT_TIMEOUT": "assignment-timeout",
"ASSIGNMENT_REMINDER": "assignment-reminder", "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 = []; let sensitiveFields = [];
@@ -43,8 +45,11 @@ async function loadConfig() {
} }
async function saveConfigValue(key) { async function saveConfigValue(key) {
console.log("saveConfigValue called with key:", key);
const inputId = CONFIG_FIELDS[key]; const inputId = CONFIG_FIELDS[key];
console.log("Input ID:", inputId);
const input = document.getElementById(inputId); const input = document.getElementById(inputId);
console.log("Input element:", input);
if (!input) { if (!input) {
console.error("Input not found:", inputId); console.error("Input not found:", inputId);
@@ -52,13 +57,17 @@ async function saveConfigValue(key) {
} }
let value = input.value.trim(); 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) // Don't save if sensitive field is empty (means user didn't change it)
if (sensitiveFields.includes(key) && value === "") { if (sensitiveFields.includes(key) && value === "") {
console.log("No changes to save - field is empty");
alert("No changes to save"); alert("No changes to save");
return; return;
} }
console.log("Proceeding with save...");
// Convert to number if needed // Convert to number if needed
if (input.type === "number") { if (input.type === "number") {
value = parseInt(value); value = parseInt(value);
@@ -69,11 +78,13 @@ async function saveConfigValue(key) {
} }
try { try {
console.log("Sending API request to /api/config with:", { key, value });
const res = await fetch("/api/config", { const res = await fetch("/api/config", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value }) body: JSON.stringify({ key, value })
}); });
console.log("API response status:", res.status);
if (!res.ok) { if (!res.ok) {
console.error("Save failed:", res.status); console.error("Save failed:", res.status);
@@ -94,12 +105,41 @@ async function saveConfigValue(key) {
} }
function wireUp() { function wireUp() {
console.log("wireUp called");
// Attach save handlers to all save buttons // Attach save handlers to all save buttons
const saveButtons = document.querySelectorAll(".config-save-btn"); const saveButtons = document.querySelectorAll(".config-save-btn");
console.log("Found save buttons:", saveButtons.length);
saveButtons.forEach(btn => { saveButtons.forEach(btn => {
const key = btn.dataset.key; const key = btn.dataset.key;
console.log("Attaching handler for key:", key);
btn.addEventListener("click", () => saveConfigValue(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 () => { document.addEventListener("DOMContentLoaded", async () => {

View File

@@ -730,10 +730,41 @@ function wireUp() {
const resetBtn = document.getElementById("reset-groups-btn"); const resetBtn = document.getElementById("reset-groups-btn");
if (resetBtn) resetBtn.addEventListener("click", resetGroups); if (resetBtn) resetBtn.addEventListener("click", resetGroups);
const logoutBtn = document.getElementById("logout-btn");
if (logoutBtn) logoutBtn.addEventListener("click", handleLogout);
setupDropZones(); setupDropZones();
console.log(">>> wireUp completed"); 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 // Initial load
// --------------------------- // ---------------------------

View File

@@ -12,6 +12,7 @@
<h1>Configuration</h1> <h1>Configuration</h1>
<div class="top-controls"> <div class="top-controls">
<a href="/" class="nav-link">Back to Dashboard</a> <a href="/" class="nav-link">Back to Dashboard</a>
<button id="logout-btn" class="nav-link" style="background:none;border:none;cursor:pointer;padding:0.6rem 1rem;">Logout</button>
</div> </div>
</div> </div>
@@ -95,6 +96,24 @@
<button class="config-save-btn" data-key="CHAIN_TIMER_THRESHOLD">Save</button> <button class="config-save-btn" data-key="CHAIN_TIMER_THRESHOLD">Save</button>
</div> </div>
</div> </div>
<!-- Authentication Settings Section -->
<div class="faction-card small config-section">
<h2>Authentication Settings</h2>
<div class="config-group">
<label for="auth-password">Universal Password</label>
<p class="config-description">Shared password that all users use to log in</p>
<input type="password" id="auth-password" class="config-input" />
<button class="config-save-btn" data-key="AUTH_PASSWORD">Save</button>
</div>
<div class="config-group">
<label for="jwt-secret">JWT Secret Key</label>
<p class="config-description">Secret key for session tokens (change from default for security)</p>
<input type="password" id="jwt-secret" class="config-input" />
<button class="config-save-btn" data-key="JWT_SECRET">Save</button>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -14,6 +14,7 @@
<div class="top-controls"> <div class="top-controls">
<a href="/config" class="nav-link">Settings</a> <a href="/config" class="nav-link">Settings</a>
<button id="logout-btn" class="nav-link" style="background:none;border:none;cursor:pointer;padding:0.6rem 1rem;">Logout</button>
<button id="bot-control-btn" class="bot-btn" data-running="false">Start Bot</button> <button id="bot-control-btn" class="bot-btn" data-running="false">Start Bot</button>
<button id="reset-groups-btn" class="reset-btn">Reset Groups</button> <button id="reset-groups-btn" class="reset-btn">Reset Groups</button>
</div> </div>

214
templates/login.html Normal file
View File

@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Login - War Dashboard</title>
<link rel="stylesheet" href="/static/styles.css" />
<style>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 2rem;
}
.login-card {
background: linear-gradient(135deg, #1a1a26 0%, #2d2d44 100%);
border: 1px solid rgba(102, 204, 255, 0.2);
border-radius: 12px;
padding: 3rem;
max-width: 450px;
width: 100%;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
.login-card h1 {
text-align: center;
color: #66ccff;
margin-bottom: 0.5rem;
font-size: 2rem;
}
.login-subtitle {
text-align: center;
color: #99a7bf;
margin-bottom: 2rem;
font-size: 0.95rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
color: #66ccff;
font-weight: bold;
font-size: 1rem;
}
.form-group input {
padding: 0.8rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
background-color: #1a1a26;
color: #f0f0f0;
font-size: 1rem;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #66ccff;
}
.login-btn {
padding: 1rem;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 6px;
font-size: 1.1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
margin-top: 0.5rem;
}
.login-btn:hover {
background-color: #45a049;
}
.login-btn:disabled {
background-color: #666;
cursor: not-allowed;
}
.error-message {
background-color: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.5);
border-radius: 6px;
padding: 1rem;
color: #f44336;
text-align: center;
display: none;
}
.error-message.show {
display: block;
}
.info-text {
text-align: center;
color: #99a7bf;
font-size: 0.85rem;
margin-top: 1rem;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<h1>War Dashboard</h1>
<p class="login-subtitle">Please log in to continue</p>
<div id="error-msg" class="error-message"></div>
<form class="login-form" id="login-form">
<div class="form-group">
<label for="name">Name</label>
<input
type="text"
id="name"
name="name"
placeholder="Enter your name"
required
autocomplete="username"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Enter password"
required
autocomplete="current-password"
/>
</div>
<button type="submit" class="login-btn" id="submit-btn">Log In</button>
</form>
<p class="info-text">
Contact Faction Management if you need the shared password.
</p>
</div>
</div>
<script>
const form = document.getElementById('login-form');
const errorMsg = document.getElementById('error-msg');
const submitBtn = document.getElementById('submit-btn');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('name').value.trim();
const password = document.getElementById('password').value;
if (!name || !password) {
showError('Please enter both name and password');
return;
}
// Disable submit button
submitBtn.disabled = true;
submitBtn.textContent = 'Logging in...';
errorMsg.classList.remove('show');
try {
const response = await fetch('/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name, password })
});
const data = await response.json();
if (response.ok) {
// Successful login - redirect to dashboard
window.location.href = '/';
} else {
// Show error message
showError(data.detail || 'Login failed');
submitBtn.disabled = false;
submitBtn.textContent = 'Log In';
}
} catch (error) {
showError('An error occurred. Please try again.');
submitBtn.disabled = false;
submitBtn.textContent = 'Log In';
}
});
function showError(message) {
errorMsg.textContent = message;
errorMsg.classList.add('show');
}
</script>
</body>
</html>

Binary file not shown.

33
utils/auth.py Normal file
View File

@@ -0,0 +1,33 @@
"""Authentication utilities and dependencies."""
import jwt
from fastapi import Request, HTTPException
import config as config_module
def get_current_user(request: Request) -> dict:
"""Dependency to check authentication and return user info"""
token = request.cookies.get("auth_token")
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
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")
def check_auth(request: Request) -> bool:
"""Check if user is authenticated (returns bool, doesn't raise exception)"""
token = request.cookies.get("auth_token")
if not token:
return False
try:
jwt.decode(token, config_module.JWT_SECRET, algorithms=["HS256"])
return True
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return False