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 @just --choose
start-auth: start-auth:
./main_auth.py & ./main_auth.py
stop-auth: stop-auth:
pkill -f main_auth.py || true pkill -f main_auth.py || true

View file

@ -6,6 +6,7 @@
# "uvicorn[standard]", # "uvicorn[standard]",
# "python-jose[cryptography]", # "python-jose[cryptography]",
# "python-multipart", # "python-multipart",
# "rich",
# ] # ]
# /// # ///
from fastapi import FastAPI, Request, Response, HTTPException, Depends from fastapi import FastAPI, Request, Response, HTTPException, Depends
@ -14,12 +15,32 @@ from fastapi.staticfiles import StaticFiles
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
import secrets import secrets
from jose import JWTError, jwt from jose import JWTError, jwt
from datetime import datetime, timedelta import datetime
import os import os
import logging
from rich.logging import RichHandler
from rich.console import Console
# Configure rich console for consistent styling
console = Console()
app = FastAPI() app = FastAPI()
security = HTTPBasic() 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 # JWT Configuration
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-super-secure-secret-key-change-this-in-production") SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-super-secure-secret-key-change-this-in-production")
ALGORITHM = "HS256" ALGORITHM = "HS256"
@ -30,12 +51,12 @@ USERS = {
"reader": {"password": "reader", "role": "reader"}, "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() to_encode = data.copy()
if expires_delta: if expires_delta:
expire = datetime.utcnow() + expires_delta expire = datetime.datetime.now(datetime.UTC) + expires_delta
else: else:
expire = datetime.utcnow() + timedelta(minutes=15) expire = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=15)
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt return encoded_jwt
@ -64,17 +85,21 @@ def get_current_role(user: str):
return USERS[user]["role"] return USERS[user]["role"]
@app.post("/login") @app.post("/login")
async def login(credentials: HTTPBasicCredentials = Depends(security)): async def login(request: Request, credentials: HTTPBasicCredentials = Depends(security)):
user = credentials.username user = credentials.username
pwd = credentials.password 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): if user in USERS and secrets.compare_digest(USERS[user]['password'], pwd):
# Create JWT token # 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( access_token = create_access_token(
data={"sub": user, "role": USERS[user]["role"]}, data={"sub": user, "role": USERS[user]["role"]},
expires_delta=access_token_expires expires_delta=access_token_expires
) )
logger.info(f"🔑 LOGIN SUCCESS | {user}({USERS[user]['role']}) | {client_ip}")
resp = Response("OK", status_code=200) resp = Response("OK", status_code=200)
resp.set_cookie( resp.set_cookie(
"access_token", "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["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache" resp.headers["Pragma"] = "no-cache"
return resp return resp
logger.info(f"🔑 LOGIN FAILED | {user} | {client_ip}")
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
@app.get("/logout") @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 = RedirectResponse("/")
resp.delete_cookie("access_token", path="/") resp.delete_cookie("access_token", path="/")
# Ensure logout response isn't cached # Ensure logout response isn't cached
@ -104,20 +141,29 @@ def logout():
@app.get("/authz") @app.get("/authz")
def authz(request: Request): def authz(request: Request):
token = request.cookies.get("access_token") 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: if not token:
logger.info(f"🚫 AUTH DENIED | {method:4} {path} | No token | {client_ip}")
return Response("Not authenticated", status_code=401) return Response("Not authenticated", status_code=401)
username = verify_jwt_token(token) username = verify_jwt_token(token)
if not username or username not in USERS: 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) return Response("Invalid token", status_code=401)
user_role = USERS[username]['role'] user_role = USERS[username]['role']
# Only admin may access /admin # Only admin may access /admin
if path and path.startswith("/admin") and user_role != '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) return Response("Forbidden", status_code=403)
# Everything else: allowed # Everything else: allowed
logger.info(f"✅ AUTH ALLOW | {method:4} {path} | {username}({user_role}) | {client_ip}")
return Response("OK", status_code=200) return Response("OK", status_code=200)
@app.get("/me") @app.get("/me")
@ -139,5 +185,16 @@ def get_current_user_info(request: Request):
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
app.mount("/static", StaticFiles(directory="static"), name="static") 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; root /usr/share/nginx/html;
index index.html; index index.html;
# Custom error pages # Custom error pages (preserve original status codes)
error_page 403 /403/; error_page 403 /403/;
error_page 404 /404/; error_page 404 /404/;
location / { location / {
auth_request /authz; auth_request /authz;
error_page 401 = @login; # If not authed, redirect to login page 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 # Disable all caching for demo purposes
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
@ -32,6 +32,9 @@ http {
internal; internal;
proxy_pass http://127.0.0.1:5115/authz; proxy_pass http://127.0.0.1:5115/authz;
proxy_set_header X-Original-URI $request_uri; 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_pass_request_body off;
proxy_set_header Content-Length ""; proxy_set_header Content-Length "";
} }
@ -39,6 +42,15 @@ http {
add_header Content-Type text/html; add_header Content-Type text/html;
return 302 http://localhost:8000/login/; 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 { location /me {
auth_request /authz; auth_request /authz;
error_page 401 = @login; # If not authed, redirect to login page error_page 401 = @login; # If not authed, redirect to login page
@ -52,12 +64,7 @@ http {
proxy_pass http://localhost:5115/me; 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 # Custom error pages are public and shouldn't be cached
location ~ ^/(403|404)/$ { location ~ ^/(403|404)/$ {
internal;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache"; add_header Pragma "no-cache";
add_header Expires "Thu, 01 Jan 1970 00:00:00 GMT"; add_header Expires "Thu, 01 Jan 1970 00:00:00 GMT";