This commit is contained in:
Waylon Walker 2025-11-21 13:09:03 -06:00
parent 1e11c8ca5e
commit 13b6d1b78a
9 changed files with 108 additions and 232 deletions

5
cookies.txt Normal file
View file

@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.haxx.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1763753445 access_token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc2Mzc1MzQ0NX0.-n86_vvqIdgpcOXAO7xk_f2Ka1ZQYtRNqbjo3iijz6k

View file

@ -7,7 +7,6 @@ stop-auth:
start-nginx: start-nginx:
docker run \ docker run \
--rm \ --rm \
-d \
--name nginx \ --name nginx \
--network host \ --network host \
-v "$PWD/site":/usr/share/nginx/html:ro \ -v "$PWD/site":/usr/share/nginx/html:ro \

View file

@ -4,6 +4,8 @@
# dependencies = [ # dependencies = [
# "fastapi", # "fastapi",
# "uvicorn[standard]", # "uvicorn[standard]",
# "python-jose[cryptography]",
# "python-multipart",
# ] # ]
# /// # ///
from fastapi import FastAPI, Request, Response, HTTPException, Depends from fastapi import FastAPI, Request, Response, HTTPException, Depends
@ -11,20 +13,51 @@ from fastapi.responses import RedirectResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles 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 datetime import datetime, timedelta
import os
app = FastAPI() app = FastAPI()
security = HTTPBasic() security = HTTPBasic()
# JWT Configuration
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-super-secure-secret-key-change-this-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
USERS = { USERS = {
"admin": {"password": "admin", "role": "admin"}, "admin": {"password": "admin", "role": "admin"},
"reader": {"password": "reader", "role": "reader"}, "reader": {"password": "reader", "role": "reader"},
} }
# Cookie format: session=username def create_access_token(data: dict, expires_delta: timedelta = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_jwt_token(token: str):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
return None
return username
except JWTError:
return None
def get_current_user(request: Request): def get_current_user(request: Request):
session = request.cookies.get("session") token = request.cookies.get("access_token")
if session and session in USERS: if not token:
return session return None
username = verify_jwt_token(token)
if username and username in USERS:
return username
return None return None
def get_current_role(user: str): def get_current_role(user: str):
@ -35,8 +68,23 @@ async def login(credentials: HTTPBasicCredentials = Depends(security)):
user = credentials.username user = credentials.username
pwd = credentials.password pwd = credentials.password
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
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user, "role": USERS[user]["role"]},
expires_delta=access_token_expires
)
resp = Response("OK", status_code=200) resp = Response("OK", status_code=200)
resp.set_cookie("session", user, httponly=True, samesite='lax', path="/") resp.set_cookie(
"access_token",
access_token,
httponly=True,
secure=False, # Set to True in production with HTTPS
samesite='lax',
path="/",
max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
# Ensure login response isn't cached # Ensure login response isn't cached
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"
@ -46,7 +94,7 @@ async def login(credentials: HTTPBasicCredentials = Depends(security)):
@app.get("/logout") @app.get("/logout")
def logout(): def logout():
resp = RedirectResponse("/") resp = RedirectResponse("/")
resp.delete_cookie("session") resp.delete_cookie("access_token", path="/")
# Ensure logout response isn't cached # Ensure logout response isn't cached
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"
@ -55,17 +103,40 @@ def logout():
@app.get("/authz") @app.get("/authz")
def authz(request: Request): def authz(request: Request):
session = request.cookies.get("session") token = request.cookies.get("access_token")
path = request.headers.get("X-Original-URI") path = request.headers.get("X-Original-URI")
if not session or session not in USERS:
if not token:
return Response("Not authenticated", status_code=401) return Response("Not authenticated", status_code=401)
user_role = USERS[session]['role']
username = verify_jwt_token(token)
if not username or username not in USERS:
return Response("Invalid token", status_code=401)
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':
return Response("Forbidden", status_code=403) return Response("Forbidden", status_code=403)
# Everything else: allowed # Everything else: allowed
return Response("OK", status_code=200) return Response("OK", status_code=200)
@app.get("/me")
def get_current_user_info(request: Request):
"""Debug endpoint to see current user info"""
token = request.cookies.get("access_token")
if not token:
return {"authenticated": False, "message": "No token"}
username = verify_jwt_token(token)
if not username or username not in USERS:
return {"authenticated": False, "message": "Invalid token"}
return {
"authenticated": True,
"username": username,
"role": USERS[username]["role"]
}
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")

View file

@ -39,6 +39,18 @@ 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 /me {
auth_request /authz;
error_page 401 = @login; # If not authed, redirect to login 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";
add_header Pragma "no-cache";
add_header Expires "Thu, 01 Jan 1970 00:00:00 GMT";
proxy_pass http://localhost:5115/me;
}
location @forbidden { location @forbidden {
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";
@ -73,8 +85,12 @@ http {
add_header Expires "Thu, 01 Jan 1970 00:00:00 GMT"; add_header Expires "Thu, 01 Jan 1970 00:00:00 GMT";
try_files $uri $uri/index.html =404; try_files $uri $uri/index.html =404;
} }
# AJAX login: POST to FastAPI # Handle /login - GET goes to page, POST goes to FastAPI
location /login { location = /login {
if ($request_method = GET) {
return 302 /login/;
}
# POST requests go to FastAPI
proxy_pass http://127.0.0.1:5115/login; proxy_pass http://127.0.0.1:5115/login;
proxy_set_header Content-Type $content_type; proxy_set_header Content-Type $content_type;
proxy_pass_request_body on; proxy_pass_request_body on;

5
reader_cookies.txt Normal file
View file

@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.haxx.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1763753499 access_token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWFkZXIiLCJyb2xlIjoicmVhZGVyIiwiZXhwIjoxNzYzNzUzNDk5fQ.VJipDyYYHl18pbb0XS8m5HBb-PLZ8VIz2eZT1ujgsG4

View file

@ -1,72 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>403 - Access Forbidden</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
background-color: #f8f9fa;
margin: 0;
padding: 50px;
}
.error-container {
max-width: 600px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.error-code {
font-size: 72px;
font-weight: bold;
color: #dc3545;
margin: 0;
}
.error-message {
font-size: 24px;
color: #6c757d;
margin: 20px 0;
}
.error-description {
font-size: 16px;
color: #495057;
margin: 20px 0;
}
.nav-links {
margin-top: 30px;
}
.nav-links a {
display: inline-block;
margin: 0 10px;
padding: 10px 20px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 5px;
transition: background-color 0.3s;
}
.nav-links a:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="error-container">
<h1 class="error-code">403</h1>
<h2 class="error-message">Access Forbidden</h2>
<p class="error-description">
You don't have permission to access this resource.
You may need to log in with appropriate credentials or your current user role doesn't have access to this page.
</p>
<div class="nav-links">
<a href="/">Go Home</a>
<a href="/login.html">Login</a>
<a href="/logout">Logout</a>
</div>
</div>
</body>
</html>

View file

@ -1,83 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Page Not Found</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
background-color: #f8f9fa;
margin: 0;
padding: 50px;
}
.error-container {
max-width: 600px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.error-code {
font-size: 72px;
font-weight: bold;
color: #ffc107;
margin: 0;
}
.error-message {
font-size: 24px;
color: #6c757d;
margin: 20px 0;
}
.error-description {
font-size: 16px;
color: #495057;
margin: 20px 0;
}
.nav-links {
margin-top: 30px;
}
.nav-links a {
display: inline-block;
margin: 0 10px;
padding: 10px 20px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 5px;
transition: background-color 0.3s;
}
.nav-links a:hover {
background-color: #0056b3;
}
.search-suggestion {
margin: 20px 0;
padding: 15px;
background-color: #e9ecef;
border-radius: 5px;
}
</style>
</head>
<body>
<div class="error-container">
<h1 class="error-code">404</h1>
<h2 class="error-message">Page Not Found</h2>
<p class="error-description">
The page you are looking for might have been removed, had its name changed,
or is temporarily unavailable.
</p>
<div class="search-suggestion">
<strong>Available pages:</strong><br>
<a href="/">Home</a> |
<a href="/admin.html">Admin Page</a> |
<a href="/login.html">Login</a>
</div>
<div class="nav-links">
<a href="/">Go Home</a>
<a href="/login.html">Login</a>
</div>
</div>
</body>
</html>

View file

@ -1,2 +0,0 @@
<h1>Admin page</h1>
<p>Only admins can see this page!</p>

View file

@ -1,63 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
<style>
body { font-family: sans-serif; margin: 2em; }
form { max-width: 300px; margin: 2em auto; padding: 1.5em; border: 1px solid #ccc; background: #fafafa; border-radius: 8px;}
label { display: block; margin-top: 1em; }
input[type="text"], input[type="password"] {
width: 100%; padding: 0.5em; box-sizing: border-box;
}
.error { color: red; margin-top: 1em;}
button { margin-top: 1.2em; width: 100%; padding: 0.7em; background: #007bff; color: #fff; border: none; border-radius: 4px;}
</style>
</head>
<body>
<h2>Login</h2>
<form id="login-form">
<label>
Username:
<input type="text" name="username" id="username" autocomplete="username" required autofocus />
</label>
<label>
Password:
<input type="password" name="password" id="password" autocomplete="current-password" required />
</label>
<button type="submit">Log In</button>
<div class="error" id="error" style="display:none;"></div>
</form>
<script>
document.getElementById('login-form').onsubmit = async function(e) {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value.trim();
const errorDiv = document.getElementById('error');
errorDiv.style.display = "none";
const headers = new Headers();
headers.set('Authorization', 'Basic ' + btoa(username + ":" + password));
try {
const resp = await fetch('/login', {
method: 'POST',
headers,
});
if (resp.ok) {
window.location.href = '/';
} else if (resp.status === 401) {
errorDiv.textContent = "Invalid username or password.";
errorDiv.style.display = "block";
} else {
errorDiv.textContent = "Unknown error (" + resp.status + ").";
errorDiv.style.display = "block";
}
} catch (err) {
errorDiv.textContent = "Network error.";
errorDiv.style.display = "block";
}
};
</script>
</body>
</html>