serve: add JWT consumer shim for bincio-auth integration
When --jwt-secret / BINCIO_AUTH_JWT_SECRET is set, auth is validated locally by decoding the bincio-auth-issued JWT — no DB session lookup. Falls back to existing DB-based session lookup when the flag is absent, so standalone deployments keep working without any config change. Changes: - deps.py: add jwt_secret global, _decode_jwt helper, wire into _current_user and _require_auth - cli.py: add --jwt-secret option; log active auth mode on startup - pyproject.toml: add PyJWT>=2.8 to serve and dev extras
This commit is contained in:
+16
-9
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
@@ -23,11 +22,12 @@ console = Console()
|
||||
@click.option("--webroot", default=None, type=click.Path(), help="Nginx webroot (e.g. /var/www/bincio). When set, uploads trigger a full Astro build + rsync so new activity pages are immediately accessible without a git push.")
|
||||
@click.option("--dem-url", default=None, envvar="DEM_URL", help="Base URL of an Open-Elevation-compatible API (default: https://api.open-elevation.com).")
|
||||
@click.option("--sync-secret", default=None, envvar="BINCIO_SYNC_SECRET", help="Shared secret for POST /api/internal/rebuild (used by the sync-strava systemd timer).")
|
||||
def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||
strava_client_id: Optional[str], strava_client_secret: Optional[str],
|
||||
max_users: Optional[int], public_url: Optional[str],
|
||||
webroot: Optional[str], dem_url: Optional[str],
|
||||
sync_secret: Optional[str]) -> None:
|
||||
@click.option("--jwt-secret", default=None, envvar="BINCIO_AUTH_JWT_SECRET", help="Shared JWT secret from bincio-auth. When set, validates JWTs locally instead of DB session lookup.")
|
||||
def serve(data_dir: str, site_dir: str | None, host: str, port: int,
|
||||
strava_client_id: str | None, strava_client_secret: str | None,
|
||||
max_users: int | None, public_url: str | None,
|
||||
webroot: str | None, dem_url: str | None,
|
||||
sync_secret: str | None, jwt_secret: str | None) -> None:
|
||||
"""Start the bincio multi-user application server.
|
||||
|
||||
Handles auth, user management, and write operations.
|
||||
@@ -36,9 +36,10 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||
Requires a data directory initialised with `bincio init`.
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
import bincio.serve.server as srv
|
||||
from bincio.serve import deps
|
||||
from bincio.serve.db import open_db, set_setting, get_setting
|
||||
from bincio.serve.db import get_setting, open_db, set_setting
|
||||
|
||||
dd = Path(data_dir).expanduser().resolve()
|
||||
if not (dd / "instance.db").exists():
|
||||
@@ -66,12 +67,14 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||
deps.dem_url = dem_url
|
||||
if sync_secret:
|
||||
deps.sync_secret = sync_secret
|
||||
if jwt_secret:
|
||||
deps.jwt_secret = jwt_secret
|
||||
|
||||
db = open_db(dd)
|
||||
current_limit = get_setting(db, "max_users")
|
||||
db.close()
|
||||
|
||||
console.print(f"[bold]bincio serve[/bold]")
|
||||
console.print("[bold]bincio serve[/bold]")
|
||||
console.print(f" Data: [cyan]{dd}[/cyan]")
|
||||
if deps.site_dir:
|
||||
console.print(f" Site: [cyan]{deps.site_dir}[/cyan]")
|
||||
@@ -81,8 +84,12 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||
if current_limit and int(current_limit) > 0:
|
||||
console.print(f" Users: [yellow]max {current_limit}[/yellow]")
|
||||
else:
|
||||
console.print(f" Users: [dim]unlimited[/dim]")
|
||||
console.print(" Users: [dim]unlimited[/dim]")
|
||||
console.print(f" DEM: [cyan]{deps.dem_url}[/cyan]")
|
||||
if deps.jwt_secret:
|
||||
console.print(" Auth: [green]JWT (bincio-auth)[/green]")
|
||||
else:
|
||||
console.print(" Auth: [dim]local DB sessions[/dim]")
|
||||
console.print()
|
||||
|
||||
log_config = uvicorn.config.LOGGING_CONFIG.copy()
|
||||
|
||||
+30
-11
@@ -12,20 +12,16 @@ import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import jwt as _jwt
|
||||
from fastapi import Cookie, HTTPException, Request, Response
|
||||
|
||||
from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID
|
||||
from bincio.serve.db import (
|
||||
User,
|
||||
authenticate,
|
||||
create_session,
|
||||
delete_session,
|
||||
get_session,
|
||||
get_user,
|
||||
open_db,
|
||||
)
|
||||
from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID
|
||||
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES # noqa: F401
|
||||
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES # noqa: F401
|
||||
from bincio.shared.images import unique_image_name as _unique_image_name # noqa: F401
|
||||
@@ -40,6 +36,7 @@ strava_client_secret: str = ""
|
||||
public_url: str = ""
|
||||
dem_url: str = "https://api.open-elevation.com"
|
||||
sync_secret: str = ""
|
||||
jwt_secret: str = "" # when set, validates JWTs from bincio-auth instead of DB session lookup
|
||||
_db = None
|
||||
_strava_sync_running = False
|
||||
_strava_sync_lock = threading.Lock()
|
||||
@@ -118,20 +115,42 @@ def _check_rate_limit(
|
||||
|
||||
# ── Auth dependency functions ─────────────────────────────────────────────────
|
||||
|
||||
def _current_user(bincio_session: Optional[str] = Cookie(default=None)) -> Optional[User]:
|
||||
def _decode_jwt(token: str) -> User | None:
|
||||
"""Decode a bincio-auth JWT and return a User. Returns None on any failure."""
|
||||
try:
|
||||
payload = _jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||
except _jwt.PyJWTError:
|
||||
return None
|
||||
handle = payload.get("sub")
|
||||
if not handle:
|
||||
return None
|
||||
return User(
|
||||
handle=handle,
|
||||
display_name=payload.get("display_name", ""),
|
||||
is_admin=bool(payload.get("is_admin", False)),
|
||||
wiki_access=bool(payload.get("wiki_access", True)),
|
||||
activity_access=bool(payload.get("activity_access", False)),
|
||||
suspended=False,
|
||||
created_at=0,
|
||||
)
|
||||
|
||||
|
||||
def _current_user(bincio_session: str | None = Cookie(default=None)) -> User | None:
|
||||
if not bincio_session:
|
||||
return None
|
||||
if jwt_secret:
|
||||
return _decode_jwt(bincio_session)
|
||||
return get_session(_get_db(), bincio_session)
|
||||
|
||||
|
||||
def _require_user(bincio_session: Optional[str] = Cookie(default=None)) -> User:
|
||||
def _require_user(bincio_session: str | None = Cookie(default=None)) -> User:
|
||||
user = _current_user(bincio_session)
|
||||
if not user:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def _require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User:
|
||||
def _require_admin(bincio_session: str | None = Cookie(default=None)) -> User:
|
||||
user = _require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Admin required")
|
||||
@@ -140,7 +159,7 @@ def _require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User
|
||||
|
||||
def _require_auth(
|
||||
request: Request,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
bincio_session: str | None = Cookie(default=None),
|
||||
) -> User:
|
||||
"""Accept session cookie (web) OR Authorization: Bearer token (mobile)."""
|
||||
token = bincio_session
|
||||
@@ -150,7 +169,7 @@ def _require_auth(
|
||||
token = auth[7:]
|
||||
if not token:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
user = get_session(_get_db(), token)
|
||||
user = _decode_jwt(token) if jwt_secret else get_session(_get_db(), token)
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired session")
|
||||
return user
|
||||
|
||||
Reference in New Issue
Block a user