feat: accept forge access tokens (Basic/Bearer/X-API-Key) for /fetch + API
This commit is contained in:
parent
0fe375cfaa
commit
4854b09a00
72
app.py
72
app.py
|
|
@ -1,11 +1,13 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -22,6 +24,49 @@ LOG_FILE = os.path.join(DATA_DIR, "logs.jsonl")
|
||||||
PROXY = os.getenv("PROXY", "")
|
PROXY = os.getenv("PROXY", "")
|
||||||
ADMIN_USER = os.getenv("ADMIN_USER", "admin")
|
ADMIN_USER = os.getenv("ADMIN_USER", "admin")
|
||||||
ADMIN_PASS = os.getenv("ADMIN_PASS", "") # empty = no auth (LAN mode)
|
ADMIN_PASS = os.getenv("ADMIN_PASS", "") # empty = no auth (LAN mode)
|
||||||
|
FORGEJO_API_URL = os.getenv("FORGEJO_API_URL", "").rstrip("/") # validate tokens against forge
|
||||||
|
_forge_cache: dict = {} # token -> (ts, ok)
|
||||||
|
|
||||||
|
|
||||||
|
def _forge_check(user: str, token: str) -> bool:
|
||||||
|
key = f"{user}:{token}"
|
||||||
|
now = time.time()
|
||||||
|
hit = _forge_cache.get(key)
|
||||||
|
if hit and now - hit[0] < 60:
|
||||||
|
return hit[1]
|
||||||
|
ok = False
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(FORGEJO_API_URL + "/api/v1/user")
|
||||||
|
cred = base64.b64encode(f"{user}:{token}".encode()).decode()
|
||||||
|
req.add_header("Authorization", "Basic " + cred)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
ok = resp.status == 200
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
_forge_cache[key] = (now, ok)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def _forge_whoami(token: str) -> bool:
|
||||||
|
"""Validate a bare token (Bearer / X-API-Key): token used as basic-auth identity."""
|
||||||
|
key = f"token:{token}"
|
||||||
|
now = time.time()
|
||||||
|
hit = _forge_cache.get(key)
|
||||||
|
if hit and now - hit[0] < 60:
|
||||||
|
return hit[1]
|
||||||
|
ok = False
|
||||||
|
for password in ("x-oauth-basic", ""):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(FORGEJO_API_URL + "/api/v1/user")
|
||||||
|
cred = base64.b64encode(f"{token}:{password}".encode()).decode()
|
||||||
|
req.add_header("Authorization", "Basic " + cred)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
ok = resp.status == 200
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
_forge_cache[key] = (now, ok)
|
||||||
|
return ok
|
||||||
CAPTCHA_WAIT_MS = int(os.getenv("CAPTCHA_WAIT_MS", "4000"))
|
CAPTCHA_WAIT_MS = int(os.getenv("CAPTCHA_WAIT_MS", "4000"))
|
||||||
SOLVE_TIMEOUT_S = int(os.getenv("SOLVE_TIMEOUT_S", "900"))
|
SOLVE_TIMEOUT_S = int(os.getenv("SOLVE_TIMEOUT_S", "900"))
|
||||||
|
|
||||||
|
|
@ -41,7 +86,30 @@ _semaphore = asyncio.Semaphore(MAX_CONCURRENT)
|
||||||
security = HTTPBasic(auto_error=False)
|
security = HTTPBasic(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def require_auth(creds: HTTPBasicCredentials | None = Depends(security)):
|
def require_auth(
|
||||||
|
creds: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
x_api_key: str | None = Header(default=None),
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
):
|
||||||
|
if creds and secrets.compare_digest(creds.username, ADMIN_USER) and secrets.compare_digest(creds.password, ADMIN_PASS):
|
||||||
|
return creds # local admin (WebUI / ops) always allowed
|
||||||
|
if FORGEJO_API_URL:
|
||||||
|
identity = None
|
||||||
|
if creds:
|
||||||
|
identity = (creds.username, creds.password)
|
||||||
|
elif x_api_key:
|
||||||
|
identity = ("token", x_api_key)
|
||||||
|
elif authorization and authorization.lower().startswith("bearer "):
|
||||||
|
identity = ("token", authorization.split(" ", 1)[1].strip())
|
||||||
|
if not identity:
|
||||||
|
raise HTTPException(401, "Missing forge credentials (Basic user:token, Bearer, or X-API-Key)")
|
||||||
|
if identity[0] == "token":
|
||||||
|
ok = _forge_whoami(identity[1])
|
||||||
|
else:
|
||||||
|
ok = _forge_check(identity[0], identity[1])
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(401, "Invalid forge token or insufficient scope")
|
||||||
|
return creds
|
||||||
if not ADMIN_PASS:
|
if not ADMIN_PASS:
|
||||||
return None # open mode
|
return None # open mode
|
||||||
if not creds or not secrets.compare_digest(creds.username, ADMIN_USER) or not secrets.compare_digest(creds.password, ADMIN_PASS):
|
if not creds or not secrets.compare_digest(creds.username, ADMIN_USER) or not secrets.compare_digest(creds.password, ADMIN_PASS):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue