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

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