mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +02:00
Minimal OAuth 2.0 authorization-code flow with PKCE (S256): admins register an app with one exact callback URL (Settings > API Keys > Connected Apps); /connect/authorize asks for consent once and returns a single-use, 60 s code bound to app, callback and challenge; POST /api/v1/connect/token swaps it, with the client secret, for the user's identity and permissions. Codes and secrets stored hashed, exchanges rate-limited per client and IP, no redirect before the callback is validated, API keys cannot authorize, refused while auth is disabled. i18n for all 15 locales. ----- fix(db): upgrading from 0.2.4.0 or older no longer crashes at startup The #2974 failure-reason conversion ran before the #1378 migration that adds print_log_entries.failure_reason, so older databases stopped with "no such column: failure_reason". It now skips a table without the column, only runs where a legacy label exists, and on SQLite rebuilds archive_fts first, since archives created before that index existed trip "database disk image is malformed" when updated.
406 lines
16 KiB
Python
406 lines
16 KiB
Python
"""Connected apps: sign users in to external applications with Bambuddy.
|
|
|
|
A minimal OAuth 2.0 authorization-code flow with PKCE (S256 only):
|
|
|
|
1. The app sends the user's browser to the SPA page ``/connect/authorize``.
|
|
The page, running with the user's normal Bambuddy session, calls
|
|
``GET /connect/authorize/info`` to show who is asking and, after consent,
|
|
``POST /connect/authorize`` for a code. It then sends the browser back to
|
|
the app's registered callback URL with that code.
|
|
2. The app's server calls ``POST /connect/token`` with the code, its client
|
|
secret and the PKCE verifier, and receives the user's identity and
|
|
permissions.
|
|
|
|
What the app never gets is the user's Bambuddy login token. What a code is
|
|
worth is deliberately small: single use, 60 seconds, bound to one app, its
|
|
exact callback URL and the PKCE challenge, and only redeemable with the app's
|
|
secret.
|
|
|
|
Sign-in through an app only exists while Bambuddy authentication is enabled.
|
|
With it disabled there are no users to sign in, and both authorize and token
|
|
answer ``auth_disabled`` so the app can fall back to its own login rather
|
|
than let everyone in.
|
|
"""
|
|
|
|
import base64
|
|
import functools
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
import secrets
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from fastapi.security import HTTPAuthorizationCredentials
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.core.auth import (
|
|
RequirePermissionIfAuthEnabled,
|
|
get_current_user,
|
|
get_password_hash,
|
|
get_user_by_username,
|
|
is_auth_enabled,
|
|
security,
|
|
verify_password,
|
|
)
|
|
from backend.app.core.database import get_db
|
|
from backend.app.core.permissions import Permission
|
|
from backend.app.models.auth_ephemeral import AuthEphemeralToken, EventType, TokenType
|
|
from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
|
|
from backend.app.models.user import User
|
|
from backend.app.schemas.connected_app import (
|
|
ConnectAuthorizeInfo,
|
|
ConnectAuthorizeRequest,
|
|
ConnectAuthorizeResponse,
|
|
ConnectedAppCreate,
|
|
ConnectedAppResponse,
|
|
ConnectedAppSecretResponse,
|
|
ConnectedAppUpdate,
|
|
ConnectedUser,
|
|
ConnectTokenRequest,
|
|
ConnectTokenResponse,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/connect", tags=["connected-apps"])
|
|
|
|
CODE_TTL = timedelta(seconds=60)
|
|
# Failed token exchanges tolerated per client and per IP in the rate-limit
|
|
# window (mfa.LOCKOUT_WINDOW). A working app fails almost never; this only has
|
|
# to stop someone guessing secrets or codes.
|
|
MAX_FAILED_TOKEN_EXCHANGES = 20
|
|
|
|
AUTH_DISABLED = "auth_disabled"
|
|
|
|
|
|
@functools.cache
|
|
def _dummy_secret_hash() -> str:
|
|
"""Verified against when the client_id is unknown, so an unknown client
|
|
costs the same bcrypt round as a wrong secret and the two can't be told
|
|
apart by timing. Built on first use to keep bcrypt out of import time."""
|
|
return get_password_hash(secrets.token_urlsafe(32))
|
|
|
|
|
|
def _hash_code(code: str) -> str:
|
|
return hashlib.sha256(code.encode()).hexdigest()
|
|
|
|
|
|
def _s256(verifier: str) -> str:
|
|
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
|
|
|
|
def _new_client_credentials() -> tuple[str, str]:
|
|
return f"bba_{secrets.token_hex(12)}", f"bbs_{secrets.token_urlsafe(32)}"
|
|
|
|
|
|
def _with_secret(app: ConnectedApp, client_secret: str) -> ConnectedAppSecretResponse:
|
|
return ConnectedAppSecretResponse(
|
|
**ConnectedAppResponse.model_validate(app).model_dump(), client_secret=client_secret
|
|
)
|
|
|
|
|
|
async def _get_app_or_404(db: AsyncSession, app_id: int) -> ConnectedApp:
|
|
app = await db.get(ConnectedApp, app_id)
|
|
if app is None:
|
|
raise HTTPException(404, "Connected app not found")
|
|
return app
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Admin: register and manage apps
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/apps", response_model=list[ConnectedAppResponse])
|
|
async def list_connected_apps(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
|
):
|
|
result = await db.execute(select(ConnectedApp).order_by(ConnectedApp.created_at.desc()))
|
|
return list(result.scalars().all())
|
|
|
|
|
|
@router.post("/apps", response_model=ConnectedAppSecretResponse)
|
|
async def create_connected_app(
|
|
data: ConnectedAppCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
|
):
|
|
"""Register an app. The client secret is in this response and nowhere else."""
|
|
if not await is_auth_enabled(db):
|
|
# Nobody could sign in through it, and an admin who set one up would
|
|
# reasonably assume the app is now protected by Bambuddy's login.
|
|
raise HTTPException(400, "Connected apps require authentication to be enabled")
|
|
client_id, client_secret = _new_client_credentials()
|
|
app = ConnectedApp(
|
|
name=data.name.strip(),
|
|
client_id=client_id,
|
|
client_secret_hash=get_password_hash(client_secret),
|
|
redirect_uri=data.redirect_uri,
|
|
enabled=True,
|
|
created_by_id=current_user.id if current_user else None,
|
|
)
|
|
db.add(app)
|
|
await db.commit()
|
|
await db.refresh(app)
|
|
logger.info("Connected app %s '%s' registered", app.id, app.name)
|
|
return _with_secret(app, client_secret)
|
|
|
|
|
|
@router.patch("/apps/{app_id}", response_model=ConnectedAppResponse)
|
|
async def update_connected_app(
|
|
app_id: int,
|
|
data: ConnectedAppUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
|
):
|
|
app = await _get_app_or_404(db, app_id)
|
|
if data.name is not None:
|
|
app.name = data.name.strip()
|
|
if data.redirect_uri is not None:
|
|
app.redirect_uri = data.redirect_uri
|
|
if data.enabled is not None:
|
|
app.enabled = data.enabled
|
|
await db.commit()
|
|
await db.refresh(app)
|
|
return app
|
|
|
|
|
|
@router.post("/apps/{app_id}/rotate-secret", response_model=ConnectedAppSecretResponse)
|
|
async def rotate_connected_app_secret(
|
|
app_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
|
):
|
|
"""Replace the client secret. The old one stops working immediately."""
|
|
app = await _get_app_or_404(db, app_id)
|
|
_, client_secret = _new_client_credentials()
|
|
app.client_secret_hash = get_password_hash(client_secret)
|
|
await db.commit()
|
|
await db.refresh(app)
|
|
logger.info("Connected app %s secret rotated", app.id)
|
|
return _with_secret(app, client_secret)
|
|
|
|
|
|
@router.delete("/apps/{app_id}")
|
|
async def delete_connected_app(
|
|
app_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
|
):
|
|
app = await _get_app_or_404(db, app_id)
|
|
await db.execute(delete(ConnectedAppGrant).where(ConnectedAppGrant.app_id == app.id))
|
|
# Outstanding codes would fail anyway (their app is gone); drop them now.
|
|
await db.execute(
|
|
delete(AuthEphemeralToken).where(
|
|
AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
|
|
AuthEphemeralToken.provider_id == app.id,
|
|
)
|
|
)
|
|
await db.delete(app)
|
|
await db.commit()
|
|
logger.info("Connected app %s deleted", app_id)
|
|
return {"message": "Connected app deleted"}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Authorize: called by the SPA with the signed-in user's session
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def require_signed_in_user(
|
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
"""The user behind a Bambuddy login token. API keys are refused.
|
|
|
|
An API key would let a script sign its owner in to an app without the
|
|
owner ever seeing a consent screen; only a person's own session may do
|
|
that. ``get_current_user`` accepts JWTs only, so a ``bb_`` key fails there.
|
|
"""
|
|
if not await is_auth_enabled(db):
|
|
raise HTTPException(409, AUTH_DISABLED)
|
|
return await get_current_user(credentials)
|
|
|
|
|
|
async def _app_for_authorize(db: AsyncSession, client_id: str, redirect_uri: str) -> ConnectedApp:
|
|
result = await db.execute(select(ConnectedApp).where(ConnectedApp.client_id == client_id))
|
|
app = result.scalar_one_or_none()
|
|
# One message for every case: the page must not redirect anywhere unless
|
|
# all of these hold, and there is nothing useful to tell the user apart.
|
|
if app is None or not app.enabled or not hmac.compare_digest(app.redirect_uri, redirect_uri):
|
|
raise HTTPException(400, "Unknown app, app disabled, or callback URL does not match its registration")
|
|
return app
|
|
|
|
|
|
@router.get("/authorize/info", response_model=ConnectAuthorizeInfo)
|
|
async def authorize_info(
|
|
client_id: str = Query(..., max_length=64),
|
|
redirect_uri: str = Query(..., max_length=500),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_signed_in_user),
|
|
):
|
|
"""What the consent screen shows. Validates the request before the page acts on it."""
|
|
app = await _app_for_authorize(db, client_id, redirect_uri)
|
|
granted = await db.execute(
|
|
select(ConnectedAppGrant.id).where(ConnectedAppGrant.app_id == app.id, ConnectedAppGrant.user_id == user.id)
|
|
)
|
|
return ConnectAuthorizeInfo(
|
|
app_name=app.name, username=user.username, already_granted=granted.scalar_one_or_none() is not None
|
|
)
|
|
|
|
|
|
@router.post("/authorize", response_model=ConnectAuthorizeResponse)
|
|
async def authorize(
|
|
data: ConnectAuthorizeRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_signed_in_user),
|
|
):
|
|
"""Issue a code for the signed-in user and record their consent."""
|
|
app = await _app_for_authorize(db, data.client_id, data.redirect_uri)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
# Keep the table small: codes live for a minute, so anything expired is junk.
|
|
await db.execute(
|
|
delete(AuthEphemeralToken).where(
|
|
AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
|
|
AuthEphemeralToken.expires_at < now,
|
|
)
|
|
)
|
|
|
|
granted = await db.execute(
|
|
select(ConnectedAppGrant.id).where(ConnectedAppGrant.app_id == app.id, ConnectedAppGrant.user_id == user.id)
|
|
)
|
|
if granted.scalar_one_or_none() is None:
|
|
db.add(ConnectedAppGrant(app_id=app.id, user_id=user.id))
|
|
|
|
code = secrets.token_urlsafe(32)
|
|
db.add(
|
|
AuthEphemeralToken.new_connect_code(
|
|
code_hash=_hash_code(code),
|
|
username=user.username,
|
|
app_id=app.id,
|
|
code_challenge=data.code_challenge,
|
|
expires_at=now + CODE_TTL,
|
|
)
|
|
)
|
|
try:
|
|
await db.commit()
|
|
except IntegrityError:
|
|
# Two tabs consenting at once both inserted the grant; the other one won.
|
|
await db.rollback()
|
|
db.add(
|
|
AuthEphemeralToken.new_connect_code(
|
|
code_hash=_hash_code(code),
|
|
username=user.username,
|
|
app_id=app.id,
|
|
code_challenge=data.code_challenge,
|
|
expires_at=now + CODE_TTL,
|
|
)
|
|
)
|
|
await db.commit()
|
|
|
|
logger.info("Connected app %s: code issued for user %s", app.id, user.id)
|
|
return ConnectAuthorizeResponse(code=code, redirect_uri=app.redirect_uri)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Token: called by the app's server, authenticated by its client secret
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _token_error(status_code: int, error: str) -> HTTPException:
|
|
return HTTPException(status_code, {"error": error})
|
|
|
|
|
|
@router.post("/token", response_model=ConnectTokenResponse)
|
|
async def exchange_code(
|
|
request: Request,
|
|
data: ConnectTokenRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Swap a code for the user's identity and permissions.
|
|
|
|
Every failure after the rate-limit check counts against both the client and
|
|
the caller's IP. The response says only ``invalid_client`` (who is asking
|
|
is wrong) or ``invalid_grant`` (the code is), never which check failed.
|
|
"""
|
|
from backend.app.api.routes.auth import _get_client_ip
|
|
from backend.app.api.routes.mfa import check_rate_limit, clear_failed_attempts, record_failed_attempt
|
|
|
|
client_ip = _get_client_ip(request)
|
|
await check_rate_limit(
|
|
db, data.client_id, event_type=EventType.CONNECT_TOKEN_CLIENT, max_attempts=MAX_FAILED_TOKEN_EXCHANGES
|
|
)
|
|
await check_rate_limit(
|
|
db, client_ip, event_type=EventType.CONNECT_TOKEN_IP, max_attempts=MAX_FAILED_TOKEN_EXCHANGES
|
|
)
|
|
|
|
async def fail(status_code: int, error: str, reason: str) -> HTTPException:
|
|
await record_failed_attempt(db, data.client_id, event_type=EventType.CONNECT_TOKEN_CLIENT)
|
|
await record_failed_attempt(db, client_ip, event_type=EventType.CONNECT_TOKEN_IP)
|
|
logger.warning("Connected app token exchange refused for client %s: %s", data.client_id, reason)
|
|
return _token_error(status_code, error)
|
|
|
|
if not await is_auth_enabled(db):
|
|
raise _token_error(400, AUTH_DISABLED)
|
|
|
|
result = await db.execute(select(ConnectedApp).where(ConnectedApp.client_id == data.client_id))
|
|
app = result.scalar_one_or_none()
|
|
secret_ok = verify_password(data.client_secret, app.client_secret_hash if app else _dummy_secret_hash())
|
|
if app is None or not secret_ok:
|
|
raise await fail(401, "invalid_client", "unknown client or wrong secret")
|
|
if not app.enabled:
|
|
raise await fail(401, "invalid_client", "app disabled")
|
|
|
|
# Consume first, then check: DELETE ... RETURNING makes the code single-use
|
|
# even under concurrent exchanges, and a code that fails a later check is
|
|
# spent all the same.
|
|
now = datetime.now(timezone.utc)
|
|
consumed = await db.execute(
|
|
delete(AuthEphemeralToken)
|
|
.where(
|
|
AuthEphemeralToken.token == _hash_code(data.code),
|
|
AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
|
|
AuthEphemeralToken.provider_id == app.id,
|
|
AuthEphemeralToken.expires_at > now,
|
|
)
|
|
.returning(AuthEphemeralToken.username, AuthEphemeralToken.nonce)
|
|
)
|
|
row = consumed.one_or_none()
|
|
await db.commit()
|
|
if row is None:
|
|
raise await fail(400, "invalid_grant", "unknown, expired, reused or foreign code")
|
|
username, code_challenge = row
|
|
|
|
if not hmac.compare_digest(app.redirect_uri, data.redirect_uri):
|
|
raise await fail(400, "invalid_grant", "callback URL mismatch")
|
|
if not code_challenge or not hmac.compare_digest(_s256(data.code_verifier), code_challenge):
|
|
raise await fail(400, "invalid_grant", "PKCE verifier mismatch")
|
|
|
|
user = await get_user_by_username(db, username)
|
|
if user is None or not user.is_active:
|
|
raise await fail(400, "invalid_grant", "user gone or disabled")
|
|
|
|
app.last_used_at = now.replace(tzinfo=None)
|
|
await clear_failed_attempts(db, data.client_id, event_type=EventType.CONNECT_TOKEN_CLIENT)
|
|
await db.commit()
|
|
|
|
logger.info("Connected app %s: user %s signed in", app.id, user.id)
|
|
return ConnectTokenResponse(
|
|
user=ConnectedUser(
|
|
id=user.id,
|
|
username=user.username,
|
|
email=user.email,
|
|
is_admin=user.is_admin,
|
|
groups=sorted(g.name for g in user.groups),
|
|
permissions=sorted(user.get_permissions()),
|
|
),
|
|
issued_at=now,
|
|
)
|