Pass raw identity (username) to broker; portal resolves to email
This commit is contained in:
1 parent
5264c3e9f7
commit
105ff499f5
1 file changed
+34
-45
+34
-45
@@ -30,52 +30,43 @@ app = FastAPI()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity — Cloudron proxyAuth injects the authenticated user's email.
|
||||
# Identity — Cloudron proxyAuth injects the authenticated user's USERNAME.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_user_email(request: Request) -> str:
|
||||
"""Return the logged-in user's email from Cloudron's proxyAuth headers.
|
||||
def get_user_identity(request: Request) -> str:
|
||||
"""Return the logged-in user's identity (Cloudron username, or email if
|
||||
Cloudron happens to send one).
|
||||
|
||||
Cloudron's nginx auth-request module injects X-Forwarded-User. We also
|
||||
accept X-Remote-User and X-Auth-Request-Email as fallbacks, since the exact
|
||||
header set has varied across Cloudron versions.
|
||||
Cloudron's proxyAuth injects the USERNAME (e.g. "ntnsndr") via
|
||||
X-Remote-User, not the email. We pass it through unchanged to the broker,
|
||||
which resolves username → email (it holds the Cloudron admin token).
|
||||
"""
|
||||
for header in (
|
||||
"x-forwarded-user",
|
||||
"x-remote-user",
|
||||
"x-forwarded-user",
|
||||
"x-auth-request-user",
|
||||
"x-auth-request-email",
|
||||
"x-forwarded-email",
|
||||
):
|
||||
val = request.headers.get(header)
|
||||
if val:
|
||||
val = val.strip().lower()
|
||||
# Some proxies prefix "user:" or use a bare username; emails are the
|
||||
# canonical identity here.
|
||||
if "@" in val:
|
||||
return val
|
||||
logger.warning("Header %s had no email (@): %r", header, val)
|
||||
# Last resort: Cloudron also sets x-auth-request-email.
|
||||
logger.warning("No identity header found; user unknown")
|
||||
return val.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def is_trusted(request: Request) -> bool:
|
||||
"""Only trust identity headers from Cloudron's proxy (behind us)."""
|
||||
# Cloudron injects these headers itself; we trust them because the app is
|
||||
# only reachable through Cloudron's proxy. This is a defense-in-depth note;
|
||||
# the app is not directly exposed.
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Broker calls (to the member portal)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def broker_get(email: str, path: str) -> dict:
|
||||
headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email}
|
||||
def broker_headers(identity: str) -> dict:
|
||||
# Pass the raw identity (username or email); the portal resolves it to an
|
||||
# email via its own Cloudron user list.
|
||||
return {"X-Broker-Secret": BROKER_SECRET, "X-Member-User": identity}
|
||||
|
||||
|
||||
async def broker_get(identity: str, path: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
r = await client.get(f"{PORTAL_BASE}{path}", headers=headers)
|
||||
r = await client.get(f"{PORTAL_BASE}{path}", headers=broker_headers(identity))
|
||||
if r.status_code == 401:
|
||||
raise HTTPException(500, "Portal rejected broker credentials")
|
||||
if r.status_code == 403:
|
||||
@@ -85,10 +76,9 @@ async def broker_get(email: str, path: str) -> dict:
|
||||
return r.json()
|
||||
|
||||
|
||||
async def broker_post(email: str, path: str, payload: dict | None = None) -> dict:
|
||||
headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email}
|
||||
async def broker_post(identity: str, path: str, payload: dict | None = None) -> dict:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
r = await client.post(f"{PORTAL_BASE}{path}", headers=headers, json=payload or {})
|
||||
r = await client.post(f"{PORTAL_BASE}{path}", headers=broker_headers(identity), json=payload or {})
|
||||
if r.status_code in (401, 403):
|
||||
raise HTTPException(r.status_code, r.text)
|
||||
if r.status_code not in (200, 201):
|
||||
@@ -96,10 +86,9 @@ async def broker_post(email: str, path: str, payload: dict | None = None) -> dic
|
||||
return r.json()
|
||||
|
||||
|
||||
async def broker_delete(email: str, path: str) -> dict:
|
||||
headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email}
|
||||
async def broker_delete(identity: str, path: str) -> dict:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
r = await client.delete(f"{PORTAL_BASE}{path}", headers=headers)
|
||||
r = await client.delete(f"{PORTAL_BASE}{path}", headers=broker_headers(identity))
|
||||
if r.status_code in (401, 403):
|
||||
raise HTTPException(r.status_code, r.text)
|
||||
if r.status_code not in (200, 204):
|
||||
@@ -118,23 +107,23 @@ async def healthz():
|
||||
|
||||
@app.get("/")
|
||||
async def index(request: Request):
|
||||
email = get_user_email(request)
|
||||
if not email:
|
||||
identity = get_user_identity(request)
|
||||
if not identity:
|
||||
return HTMLResponse(
|
||||
"<h1>Not authenticated</h1><p>Please log in via the dashboard login.</p>",
|
||||
status_code=401,
|
||||
)
|
||||
return HTMLResponse(render_page(email))
|
||||
return HTMLResponse(render_page(identity))
|
||||
|
||||
|
||||
@app.get("/api/usage")
|
||||
async def api_usage(request: Request):
|
||||
email = get_user_email(request)
|
||||
if not email:
|
||||
identity = get_user_identity(request)
|
||||
if not identity:
|
||||
return JSONResponse({"error": "unauthenticated"}, status_code=401)
|
||||
try:
|
||||
data = await broker_get(email, "/broker/usage")
|
||||
keys = await broker_get(email, "/broker/keys")
|
||||
data = await broker_get(identity, "/broker/usage")
|
||||
keys = await broker_get(identity, "/broker/keys")
|
||||
data["keys"] = keys.get("keys", [])
|
||||
return data
|
||||
except HTTPException as e:
|
||||
@@ -143,26 +132,26 @@ async def api_usage(request: Request):
|
||||
|
||||
@app.post("/api/keys")
|
||||
async def api_create_key(request: Request):
|
||||
email = get_user_email(request)
|
||||
if not email:
|
||||
identity = get_user_identity(request)
|
||||
if not identity:
|
||||
return JSONResponse({"error": "unauthenticated"}, status_code=401)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name):
|
||||
return JSONResponse({"error": "Invalid key name"}, status_code=400)
|
||||
try:
|
||||
return await broker_post(email, "/broker/keys", {"name": name})
|
||||
return await broker_post(identity, "/broker/keys", {"name": name})
|
||||
except HTTPException as e:
|
||||
return JSONResponse({"error": e.detail}, status_code=e.status_code)
|
||||
|
||||
|
||||
@app.delete("/api/keys/{name}")
|
||||
async def api_revoke_key(name: str, request: Request):
|
||||
email = get_user_email(request)
|
||||
if not email:
|
||||
identity = get_user_identity(request)
|
||||
if not identity:
|
||||
return JSONResponse({"error": "unauthenticated"}, status_code=401)
|
||||
try:
|
||||
return await broker_delete(email, f"/broker/keys/{name}")
|
||||
return await broker_delete(identity, f"/broker/keys/{name}")
|
||||
except HTTPException as e:
|
||||
return JSONResponse({"error": e.detail}, status_code=e.status_code)
|
||||
|
||||
|
||||
Reference in new issue
Block a user