mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
#1108 — Long-lived camera-stream tokens for HA / Frigate / kiosks. Camera-only V1, hard 365-day cap (no infinite tokens), pbkdf2 hashed at rest, plaintext shown to user exactly once on creation. New "Camera API Tokens" panel under Settings → API Keys with self-service create/revoke, styled confirm modal, admin "All users" view for leak triage. Auth path: /camera/stream tries the existing 60-min ephemeral table first, falls through to the long-lived path. Indexed lookup_prefix keeps verify O(1) per token. Permission audit: gated the existing API-keys-CRUD + Webhook docs + API Browser content behind api_keys:read so non-admins with camera:view land on the API Keys tab and see only the Camera Tokens panel they actually have permission to use. Grid layout collapses to single column for non-admins. Tests: 29 new backend (15 service + 14 integration covering create/list/ revoke ownership rules, the auth fall-through, scope enforcement, prefix collisions) + 6 new frontend tests for the section UI including the new modal flow. All 77 backend tests + 21 frontend camera tests pass. Ruff clean (lint + format). Docs: README updated with fan-out + long-lived-token bullets. Wiki gets a new "Long-Lived Camera Tokens" section under features/camera.md (HA YAML example, security model, permission requirements, revoke flow). Website features.html gets the bullet under Camera Streaming. Also includes #1089 follow-up tweaks already merged in this branch: _stream_start_times.setdefault for accurate stream_uptime, subscribe() RuntimeError retry to close the grace-vs-subscribe race, atomic unsubscribe count via the iter_subscriber on_unsubscribe callback.
This commit is contained in:
@@ -1277,3 +1277,199 @@ async def get_ldap_status(db: AsyncSession = Depends(get_db)):
|
||||
"ldap_enabled": settings.get("ldap_enabled", "false").lower() == "true",
|
||||
"ldap_configured": bool(settings.get("ldap_server_url")),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Long-lived camera-stream tokens (#1108)
|
||||
# =============================================================================
|
||||
# Camera-only V1. Issue scope: a token a user can paste into Home Assistant /
|
||||
# Frigate / a kiosk and have it keep working for days/weeks rather than
|
||||
# refreshing the 60-minute ephemeral token. Permission gate: CAMERA_VIEW
|
||||
# (same blast radius as the existing 60-min token-mint endpoint).
|
||||
|
||||
|
||||
def _long_lived_token_to_response(record, *, plaintext: str | None = None) -> dict:
|
||||
"""Serialise a LongLivedToken row for the SPA. Plaintext is included
|
||||
only at create time (and then never again), per the issue's "shown once"
|
||||
contract.
|
||||
"""
|
||||
return {
|
||||
"id": record.id,
|
||||
"user_id": record.user_id,
|
||||
"name": record.name,
|
||||
"scope": record.scope,
|
||||
"lookup_prefix": record.lookup_prefix,
|
||||
"created_at": record.created_at.isoformat() if record.created_at else None,
|
||||
"expires_at": record.expires_at.isoformat() if record.expires_at else None,
|
||||
"last_used_at": record.last_used_at.isoformat() if record.last_used_at else None,
|
||||
# Plaintext is the ONLY field the user ever sees in full — copied once
|
||||
# to a clipboard / kiosk config and then forgotten.
|
||||
"token": plaintext,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tokens", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
async def create_long_lived_camera_token(
|
||||
payload: dict,
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Mint a long-lived camera-stream token (#1108).
|
||||
|
||||
Body: ``{"name": str, "expires_in_days": int, "scope": "camera_stream"}``.
|
||||
|
||||
The plaintext token is returned **exactly once** in the response. The DB
|
||||
only ever stores a pbkdf2 hash, so a leaked DB dump cannot replay the
|
||||
token. Hard cap of 365 days; the issue's ``expire_in: 0`` (never) is
|
||||
explicitly rejected.
|
||||
"""
|
||||
from backend.app.services.long_lived_tokens import (
|
||||
ALLOWED_SCOPES,
|
||||
MAX_TOKEN_LIFETIME_DAYS,
|
||||
create_token,
|
||||
)
|
||||
|
||||
# Auth-disabled path: tokens are user-owned, but if auth is off there is
|
||||
# no user to own them. Refuse rather than silently picking a random user.
|
||||
if current_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Long-lived tokens require authentication to be enabled",
|
||||
)
|
||||
|
||||
name = payload.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
expires_in_days = payload.get("expires_in_days")
|
||||
if not isinstance(expires_in_days, int) or expires_in_days <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"expires_in_days must be a positive integer (max {MAX_TOKEN_LIFETIME_DAYS}; #1108: no infinite tokens)"
|
||||
),
|
||||
)
|
||||
scope = payload.get("scope", "camera_stream")
|
||||
if scope not in ALLOWED_SCOPES:
|
||||
raise HTTPException(status_code=400, detail=f"unsupported scope: {scope!r}")
|
||||
|
||||
try:
|
||||
created = await create_token(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
name=name,
|
||||
expires_in_days=expires_in_days,
|
||||
scope=scope,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
_logger.info(
|
||||
"Long-lived camera token created: user=%s name=%r scope=%s expires=%s",
|
||||
current_user.username,
|
||||
name,
|
||||
scope,
|
||||
created.record.expires_at.isoformat(),
|
||||
)
|
||||
return _long_lived_token_to_response(created.record, plaintext=created.plaintext)
|
||||
|
||||
|
||||
@router.get("/tokens", response_model=list[dict])
|
||||
async def list_long_lived_tokens(
|
||||
user_id: int | None = None,
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List long-lived tokens.
|
||||
|
||||
Default: caller's own tokens.
|
||||
Admins can pass ``?user_id=N`` to see another user's tokens, or omit it
|
||||
to see everything (handy for leak triage).
|
||||
"""
|
||||
from backend.app.services.long_lived_tokens import list_user_tokens
|
||||
|
||||
# Auth-disabled installs don't have a notion of "my tokens" — refuse so
|
||||
# we don't leak a global list to whoever can hit the API.
|
||||
if current_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Long-lived tokens require authentication to be enabled",
|
||||
)
|
||||
|
||||
# Reload with groups so is_admin reflects group membership reliably.
|
||||
user_with_groups = (
|
||||
await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
|
||||
).scalar_one()
|
||||
|
||||
if user_id is None or user_id == current_user.id:
|
||||
records = await list_user_tokens(db, current_user.id)
|
||||
elif user_with_groups.is_admin:
|
||||
records = await list_user_tokens(db, user_id)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can list other users' tokens",
|
||||
)
|
||||
return [_long_lived_token_to_response(r) for r in records]
|
||||
|
||||
|
||||
@router.get("/tokens/all", response_model=list[dict])
|
||||
async def list_all_long_lived_tokens(
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Admin-only: every active long-lived token in the system, newest first.
|
||||
Used by the leak-triage view in admin settings.
|
||||
"""
|
||||
from backend.app.services.long_lived_tokens import list_all_tokens
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Auth required")
|
||||
user_with_groups = (
|
||||
await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
|
||||
).scalar_one()
|
||||
if not user_with_groups.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin only",
|
||||
)
|
||||
records = await list_all_tokens(db)
|
||||
return [_long_lived_token_to_response(r) for r in records]
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def revoke_long_lived_token(
|
||||
token_id: int,
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Revoke a long-lived token. Owners can revoke their own; admins any."""
|
||||
from backend.app.models.long_lived_token import LongLivedToken
|
||||
from backend.app.services.long_lived_tokens import revoke_token
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Auth required")
|
||||
|
||||
record = (await db.execute(select(LongLivedToken).where(LongLivedToken.id == token_id))).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
|
||||
if record.user_id != current_user.id:
|
||||
# Reload for is_admin so admins can revoke any user's token (leak response).
|
||||
user_with_groups = (
|
||||
await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
|
||||
).scalar_one()
|
||||
if not user_with_groups.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You can only revoke your own tokens",
|
||||
)
|
||||
|
||||
revoked = await revoke_token(db, token_id)
|
||||
if not revoked:
|
||||
# Already revoked is treated as 404 for idempotency from the UI side.
|
||||
raise HTTPException(status_code=404, detail="Token not found or already revoked")
|
||||
_logger.info(
|
||||
"Long-lived camera token revoked: id=%d by user=%s",
|
||||
token_id,
|
||||
current_user.username,
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
Reference in New Issue
Block a user