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:
|
def get_user_identity(request: Request) -> str:
|
||||||
"""Return the logged-in user's email from Cloudron's proxyAuth headers.
|
"""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
|
Cloudron's proxyAuth injects the USERNAME (e.g. "ntnsndr") via
|
||||||
accept X-Remote-User and X-Auth-Request-Email as fallbacks, since the exact
|
X-Remote-User, not the email. We pass it through unchanged to the broker,
|
||||||
header set has varied across Cloudron versions.
|
which resolves username → email (it holds the Cloudron admin token).
|
||||||
"""
|
"""
|
||||||
for header in (
|
for header in (
|
||||||
"x-forwarded-user",
|
|
||||||
"x-remote-user",
|
"x-remote-user",
|
||||||
|
"x-forwarded-user",
|
||||||
"x-auth-request-user",
|
"x-auth-request-user",
|
||||||
"x-auth-request-email",
|
"x-auth-request-email",
|
||||||
"x-forwarded-email",
|
"x-forwarded-email",
|
||||||
):
|
):
|
||||||
val = request.headers.get(header)
|
val = request.headers.get(header)
|
||||||
if val:
|
if val:
|
||||||
val = val.strip().lower()
|
return val.strip()
|
||||||
# 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 ""
|
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)
|
# Broker calls (to the member portal)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def broker_get(email: str, path: str) -> dict:
|
def broker_headers(identity: str) -> dict:
|
||||||
headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email}
|
# 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:
|
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:
|
if r.status_code == 401:
|
||||||
raise HTTPException(500, "Portal rejected broker credentials")
|
raise HTTPException(500, "Portal rejected broker credentials")
|
||||||
if r.status_code == 403:
|
if r.status_code == 403:
|
||||||
@@ -85,10 +76,9 @@ async def broker_get(email: str, path: str) -> dict:
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
async def broker_post(email: str, path: str, payload: dict | None = None) -> dict:
|
async def broker_post(identity: str, path: str, payload: dict | None = None) -> dict:
|
||||||
headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email}
|
|
||||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
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):
|
if r.status_code in (401, 403):
|
||||||
raise HTTPException(r.status_code, r.text)
|
raise HTTPException(r.status_code, r.text)
|
||||||
if r.status_code not in (200, 201):
|
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()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
async def broker_delete(email: str, path: str) -> dict:
|
async def broker_delete(identity: str, path: str) -> dict:
|
||||||
headers = {"X-Broker-Secret": BROKER_SECRET, "X-Member-Email": email}
|
|
||||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
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):
|
if r.status_code in (401, 403):
|
||||||
raise HTTPException(r.status_code, r.text)
|
raise HTTPException(r.status_code, r.text)
|
||||||
if r.status_code not in (200, 204):
|
if r.status_code not in (200, 204):
|
||||||
@@ -118,23 +107,23 @@ async def healthz():
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index(request: Request):
|
async def index(request: Request):
|
||||||
email = get_user_email(request)
|
identity = get_user_identity(request)
|
||||||
if not email:
|
if not identity:
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
"<h1>Not authenticated</h1><p>Please log in via the dashboard login.</p>",
|
"<h1>Not authenticated</h1><p>Please log in via the dashboard login.</p>",
|
||||||
status_code=401,
|
status_code=401,
|
||||||
)
|
)
|
||||||
return HTMLResponse(render_page(email))
|
return HTMLResponse(render_page(identity))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/usage")
|
@app.get("/api/usage")
|
||||||
async def api_usage(request: Request):
|
async def api_usage(request: Request):
|
||||||
email = get_user_email(request)
|
identity = get_user_identity(request)
|
||||||
if not email:
|
if not identity:
|
||||||
return JSONResponse({"error": "unauthenticated"}, status_code=401)
|
return JSONResponse({"error": "unauthenticated"}, status_code=401)
|
||||||
try:
|
try:
|
||||||
data = await broker_get(email, "/broker/usage")
|
data = await broker_get(identity, "/broker/usage")
|
||||||
keys = await broker_get(email, "/broker/keys")
|
keys = await broker_get(identity, "/broker/keys")
|
||||||
data["keys"] = keys.get("keys", [])
|
data["keys"] = keys.get("keys", [])
|
||||||
return data
|
return data
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
@@ -143,26 +132,26 @@ async def api_usage(request: Request):
|
|||||||
|
|
||||||
@app.post("/api/keys")
|
@app.post("/api/keys")
|
||||||
async def api_create_key(request: Request):
|
async def api_create_key(request: Request):
|
||||||
email = get_user_email(request)
|
identity = get_user_identity(request)
|
||||||
if not email:
|
if not identity:
|
||||||
return JSONResponse({"error": "unauthenticated"}, status_code=401)
|
return JSONResponse({"error": "unauthenticated"}, status_code=401)
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
name = (body.get("name") or "").strip()
|
name = (body.get("name") or "").strip()
|
||||||
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name):
|
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name):
|
||||||
return JSONResponse({"error": "Invalid key name"}, status_code=400)
|
return JSONResponse({"error": "Invalid key name"}, status_code=400)
|
||||||
try:
|
try:
|
||||||
return await broker_post(email, "/broker/keys", {"name": name})
|
return await broker_post(identity, "/broker/keys", {"name": name})
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
return JSONResponse({"error": e.detail}, status_code=e.status_code)
|
return JSONResponse({"error": e.detail}, status_code=e.status_code)
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/keys/{name}")
|
@app.delete("/api/keys/{name}")
|
||||||
async def api_revoke_key(name: str, request: Request):
|
async def api_revoke_key(name: str, request: Request):
|
||||||
email = get_user_email(request)
|
identity = get_user_identity(request)
|
||||||
if not email:
|
if not identity:
|
||||||
return JSONResponse({"error": "unauthenticated"}, status_code=401)
|
return JSONResponse({"error": "unauthenticated"}, status_code=401)
|
||||||
try:
|
try:
|
||||||
return await broker_delete(email, f"/broker/keys/{name}")
|
return await broker_delete(identity, f"/broker/keys/{name}")
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
return JSONResponse({"error": e.detail}, status_code=e.status_code)
|
return JSONResponse({"error": e.detail}, status_code=e.status_code)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user