This commit is contained in:
Waylon Walker 2025-11-21 13:47:16 -06:00
parent bd77731487
commit 77d0c05a64
3 changed files with 83 additions and 18 deletions

View file

@ -2,7 +2,7 @@ default:
@just --choose
start-auth:
./main_auth.py &
./main_auth.py
stop-auth:
pkill -f main_auth.py || true

View file

@ -6,6 +6,7 @@
# "uvicorn[standard]",
# "python-jose[cryptography]",
# "python-multipart",
# "rich",
# ]
# ///
from fastapi import FastAPI, Request, Response, HTTPException, Depends
@ -14,12 +15,32 @@ from fastapi.staticfiles import StaticFiles
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import secrets
from jose import JWTError, jwt
from datetime import datetime, timedelta
import datetime
import os
import logging
from rich.logging import RichHandler
from rich.console import Console
# Configure rich console for consistent styling
console = Console()
app = FastAPI()
security = HTTPBasic()
# Configure structured logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(name)-12s | %(levelname)-8s | %(message)s",
datefmt="%H:%M:%S",
handlers=[RichHandler(console=console, show_path=False, rich_tracebacks=True)]
)
logger = logging.getLogger("nginx-auth")
# Configure uvicorn loggers to use our format
uvicorn_error_logger = logging.getLogger("uvicorn.error")
uvicorn_error_logger.handlers = [RichHandler(console=console, show_path=False)]
# JWT Configuration
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-super-secure-secret-key-change-this-in-production")
ALGORITHM = "HS256"
@ -30,12 +51,12 @@ USERS = {
"reader": {"password": "reader", "role": "reader"},
}
def create_access_token(data: dict, expires_delta: timedelta = None):
def create_access_token(data: dict, expires_delta: datetime.timedelta = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
expire = datetime.datetime.now(datetime.UTC) + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
expire = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
@ -64,17 +85,21 @@ def get_current_role(user: str):
return USERS[user]["role"]
@app.post("/login")
async def login(credentials: HTTPBasicCredentials = Depends(security)):
async def login(request: Request, credentials: HTTPBasicCredentials = Depends(security)):
user = credentials.username
pwd = credentials.password
client_ip = request.client.host if request.client else "unknown"
if user in USERS and secrets.compare_digest(USERS[user]['password'], pwd):
# Create JWT token
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token_expires = datetime.timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user, "role": USERS[user]["role"]},
expires_delta=access_token_expires
)
logger.info(f"🔑 LOGIN SUCCESS | {user}({USERS[user]['role']}) | {client_ip}")
resp = Response("OK", status_code=200)
resp.set_cookie(
"access_token",
@ -89,10 +114,22 @@ async def login(credentials: HTTPBasicCredentials = Depends(security)):
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
logger.info(f"🔑 LOGIN FAILED | {user} | {client_ip}")
raise HTTPException(status_code=401, detail="Invalid credentials")
@app.get("/logout")
def logout():
def logout(request: Request):
client_ip = request.client.host if request.client else "unknown"
# Try to get current user for logging
token = request.cookies.get("access_token")
username = "unknown"
if token:
username = verify_jwt_token(token) or "unknown"
logger.info(f"👋 LOGOUT | {username} | {client_ip}")
resp = RedirectResponse("/")
resp.delete_cookie("access_token", path="/")
# Ensure logout response isn't cached
@ -104,20 +141,29 @@ def logout():
@app.get("/authz")
def authz(request: Request):
token = request.cookies.get("access_token")
path = request.headers.get("X-Original-URI")
path = request.headers.get("X-Original-URI", "unknown")
method = request.headers.get("X-Original-Method", "GET")
host = request.headers.get("X-Original-Host", "localhost")
client_ip = request.client.host if request.client else "unknown"
if not token:
logger.info(f"🚫 AUTH DENIED | {method:4} {path} | No token | {client_ip}")
return Response("Not authenticated", status_code=401)
username = verify_jwt_token(token)
if not username or username not in USERS:
logger.info(f"🚫 AUTH DENIED | {method:4} {path} | Invalid token | {client_ip}")
return Response("Invalid token", status_code=401)
user_role = USERS[username]['role']
# Only admin may access /admin
if path and path.startswith("/admin") and user_role != 'admin':
logger.info(f"🚫 AUTH DENIED | {method:4} {path} | {username}({user_role}) | {client_ip}")
return Response("Forbidden", status_code=403)
# Everything else: allowed
logger.info(f"✅ AUTH ALLOW | {method:4} {path} | {username}({user_role}) | {client_ip}")
return Response("OK", status_code=200)
@app.get("/me")
@ -139,5 +185,16 @@ def get_current_user_info(request: Request):
if __name__ == "__main__":
import uvicorn
app.mount("/static", StaticFiles(directory="static"), name="static")
uvicorn.run(app, host="localhost", port=5115)
logger.info("🚀 Starting nginx-auth demo server...")
# Disable uvicorn access logs since we have our own structured auth logging
uvicorn.run(
app,
host="localhost",
port=5115,
access_log=False, # Disable to avoid conflicts
log_level="info"
)

View file

@ -12,14 +12,14 @@ http {
root /usr/share/nginx/html;
index index.html;
# Custom error pages
# Custom error pages (preserve original status codes)
error_page 403 /403/;
error_page 404 /404/;
location / {
auth_request /authz;
error_page 401 = @login; # If not authed, redirect to login page
error_page 403 = @forbidden; # If forbidden, show custom 403 page
error_page 403 @forbidden; # If forbidden, show custom 403 page
# Disable all caching for demo purposes
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
@ -32,6 +32,9 @@ http {
internal;
proxy_pass http://127.0.0.1:5115/authz;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-Host $host;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
@ -39,6 +42,15 @@ http {
add_header Content-Type text/html;
return 302 http://localhost:8000/login/;
}
location @forbidden {
internal;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "Thu, 01 Jan 1970 00:00:00 GMT";
# Serve the custom 403 page without changing status code
try_files /403/index.html =403;
}
location /me {
auth_request /authz;
error_page 401 = @login; # If not authed, redirect to login page
@ -52,12 +64,7 @@ http {
proxy_pass http://localhost:5115/me;
}
location @forbidden {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "Thu, 01 Jan 1970 00:00:00 GMT";
rewrite ^.*$ /403/ last;
}
@ -80,6 +87,7 @@ http {
# Custom error pages are public and shouldn't be cached
location ~ ^/(403|404)/$ {
internal;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "Thu, 01 Jan 1970 00:00:00 GMT";