Authenticatoin Implementation
This commit is contained in:
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,
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user