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:
- 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?

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_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
# 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)

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,
"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
}
}

View File

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

View File

@@ -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 () => {

View File

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

View File

@@ -12,6 +12,7 @@
<h1>Configuration</h1>
<div class="top-controls">
<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>
@@ -95,6 +96,24 @@
<button class="config-save-btn" data-key="CHAIN_TIMER_THRESHOLD">Save</button>
</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>

View File

@@ -14,6 +14,7 @@
<div class="top-controls">
<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="reset-groups-btn" class="reset-btn">Reset Groups</button>
</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