Add scoped tokens, L2-side revocation, and security docs
Security improvements: - Tokens now include optional l1_server claim for scoping - /auth/verify checks token scope matches requesting L1 - L2 maintains revoked_tokens table - even if L1 ignores revoke, token fails - Logout revokes token in L2 db before notifying L1s - /renderers/attach creates scoped tokens (not embedded in HTML) - Add get_token_claims() to auth.py Database: - Add revoked_tokens table with token_hash, username, expires_at - Add db functions: revoke_token, is_token_revoked, cleanup_expired_revocations Documentation: - Document security features in README Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
79
server.py
79
server.py
@@ -38,7 +38,7 @@ import db
|
||||
from auth import (
|
||||
UserCreate, UserLogin, Token, User,
|
||||
create_user, authenticate_user, create_access_token,
|
||||
verify_token, get_current_user
|
||||
verify_token, get_token_claims, get_current_user
|
||||
)
|
||||
|
||||
# Configuration
|
||||
@@ -532,12 +532,18 @@ async def ui_register_submit(request: Request):
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Handle logout - clear cookie, revoke token on attached L1s, and redirect to home."""
|
||||
"""Handle logout - clear cookie, revoke token on L2 and attached L1s, and redirect to home."""
|
||||
token = request.cookies.get("auth_token")
|
||||
username = verify_token(token) if token else None
|
||||
claims = get_token_claims(token) if token else None
|
||||
username = claims.get("sub") if claims else None
|
||||
|
||||
# Revoke token on all attached L1 renderers
|
||||
if username and token:
|
||||
if username and token and claims:
|
||||
# Revoke token in L2 database (so even if L1 ignores revoke, token won't verify)
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
expires_at = datetime.fromtimestamp(claims.get("exp", 0), tz=timezone.utc)
|
||||
await db.revoke_token(token_hash, username, expires_at)
|
||||
|
||||
# Revoke token on all attached L1 renderers
|
||||
attached = await db.get_user_renderers(username)
|
||||
for l1_url in attached:
|
||||
try:
|
||||
@@ -1342,16 +1348,35 @@ async def verify_auth(
|
||||
if not credentials:
|
||||
raise HTTPException(401, "No token provided")
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
# Check L1 is authorized
|
||||
l1_normalized = request.l1_server.rstrip("/")
|
||||
authorized = any(l1_normalized == s.rstrip("/") for s in L1_SERVERS)
|
||||
if not authorized:
|
||||
raise HTTPException(403, f"L1 server not authorized: {request.l1_server}")
|
||||
|
||||
username = verify_token(credentials.credentials)
|
||||
# Check if token is revoked (L2-side revocation)
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
if await db.is_token_revoked(token_hash):
|
||||
raise HTTPException(401, "Token has been revoked")
|
||||
|
||||
# Verify token and get claims
|
||||
claims = get_token_claims(token)
|
||||
if not claims:
|
||||
raise HTTPException(401, "Invalid token")
|
||||
|
||||
username = claims.get("sub")
|
||||
if not username:
|
||||
raise HTTPException(401, "Invalid token")
|
||||
|
||||
# Check token scope - if token is scoped to an L1, it must match
|
||||
token_l1_server = claims.get("l1_server")
|
||||
if token_l1_server:
|
||||
token_l1_normalized = token_l1_server.rstrip("/")
|
||||
if token_l1_normalized != l1_normalized:
|
||||
raise HTTPException(403, f"Token is scoped to {token_l1_server}, not {request.l1_server}")
|
||||
|
||||
# Record the attachment (L1 successfully verified user's token)
|
||||
await db.attach_renderer(username, l1_normalized)
|
||||
|
||||
@@ -2879,7 +2904,7 @@ async def renderers_page(request: Request):
|
||||
|
||||
# Get user's attached renderers
|
||||
attached = await db.get_user_renderers(username)
|
||||
token = request.cookies.get("auth_token", "")
|
||||
from urllib.parse import quote
|
||||
|
||||
# Build renderer list
|
||||
rows = []
|
||||
@@ -2901,11 +2926,10 @@ async def renderers_page(request: Request):
|
||||
'''
|
||||
else:
|
||||
status = '<span class="px-2 py-1 bg-gray-600 text-gray-300 text-xs font-medium rounded-full">Not attached</span>'
|
||||
# Attach redirects to L1 with token
|
||||
attach_url = f"{l1_url}/auth?auth_token={token}"
|
||||
# Attach via endpoint that creates scoped token (not raw token in URL)
|
||||
attach_url = f"/renderers/attach?l1_url={quote(l1_url, safe='')}"
|
||||
action = f'''
|
||||
<a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors"
|
||||
onclick="setTimeout(() => location.reload(), 2000)">
|
||||
<a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors">
|
||||
Attach
|
||||
</a>
|
||||
'''
|
||||
@@ -2934,6 +2958,30 @@ async def renderers_page(request: Request):
|
||||
return HTMLResponse(base_html("Renderers", content, username))
|
||||
|
||||
|
||||
@app.get("/renderers/attach")
|
||||
async def attach_renderer_redirect(request: Request, l1_url: str):
|
||||
"""Create a scoped token and redirect to L1 for attachment."""
|
||||
username = get_user_from_cookie(request)
|
||||
if not username:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
# Verify L1 is in our allowed list
|
||||
l1_normalized = l1_url.rstrip("/")
|
||||
if not any(l1_normalized == s.rstrip("/") for s in L1_SERVERS):
|
||||
raise HTTPException(403, f"L1 server not authorized: {l1_url}")
|
||||
|
||||
# Create a scoped token that only works for this specific L1
|
||||
scoped_token = create_access_token(
|
||||
username,
|
||||
l2_server=f"https://{DOMAIN}",
|
||||
l1_server=l1_normalized
|
||||
)
|
||||
|
||||
# Redirect to L1 with scoped token
|
||||
redirect_url = f"{l1_normalized}/auth?auth_token={scoped_token.access_token}"
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
|
||||
@app.post("/renderers/detach", response_class=HTMLResponse)
|
||||
async def detach_renderer(request: Request):
|
||||
"""Detach from an L1 renderer."""
|
||||
@@ -2946,10 +2994,10 @@ async def detach_renderer(request: Request):
|
||||
|
||||
await db.detach_renderer(username, l1_url)
|
||||
|
||||
# Return updated row
|
||||
# Return updated row with link to attach endpoint (not raw token)
|
||||
display_name = l1_url.replace("https://", "").replace("http://", "")
|
||||
token = request.cookies.get("auth_token", "")
|
||||
attach_url = f"{l1_url}/auth?auth_token={token}"
|
||||
from urllib.parse import quote
|
||||
attach_url = f"/renderers/attach?l1_url={quote(l1_url, safe='')}"
|
||||
row_id = l1_url.replace("://", "-").replace("/", "-").replace(".", "-")
|
||||
|
||||
return HTMLResponse(f'''
|
||||
@@ -2960,8 +3008,7 @@ async def detach_renderer(request: Request):
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="px-2 py-1 bg-gray-600 text-gray-300 text-xs font-medium rounded-full">Not attached</span>
|
||||
<a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors"
|
||||
onclick="setTimeout(() => location.reload(), 2000)">
|
||||
<a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors">
|
||||
Attach
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user