Authenticatoin Implementation
This commit is contained in:
@@ -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.
@@ -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
|
||||||
|
|||||||
3
main.py
3
main.py
@@ -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)
|
||||||
|
|||||||
BIN
routers/__pycache__/auth.cpython-311.pyc
Normal file
BIN
routers/__pycache__/auth.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
164
routers/auth.py
Normal file
164
routers/auth.py
Normal 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}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
214
templates/login.html
Normal 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>
|
||||||
BIN
utils/__pycache__/auth.cpython-311.pyc
Normal file
BIN
utils/__pycache__/auth.cpython-311.pyc
Normal file
Binary file not shown.
33
utils/auth.py
Normal file
33
utils/auth.py
Normal 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
|
||||||
Reference in New Issue
Block a user