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