mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-20 05:32:49 +03:00
* take the jwt role from the user and revoke stale sessions /auth trusted the role claim in the token, and only checked password_changed_at when refreshing a cookie. Demoting or deleting a user, changing a password, or resetting the admin password left existing cookies and bearer tokens working with their old role. The API now keeps each user's role and password change time in memory, loads it at startup, and updates it from the user endpoints. /auth rejects unknown users and tokens issued before the last password change, and authorizes with the stored role without touching the database. Resetting the admin password now sets password_changed_at. * set the secure cookie flag when the request arrived over tls The session cookie only got the Secure flag from auth.cookie_secure, which defaults to false even though TLS is on by default, so browsers would send it over plain HTTP. The cookie is now Secure whenever nginx reports an https scheme, and nginx passes that scheme to the /auth subrequest so refreshed cookies match. cookie_secure still forces the flag for a reverse proxy that terminates TLS in front of plain-HTTP Frigate. * regenerate config schema mock data * revoke tokens issued in the same second as a password change /auth compared the token's whole-second iat against the truncated change time, so a token issued earlier in the same second as a password change stayed valid. The change time is now rounded up, and the cookie reissued to a user who changes their own password carries that rounded-up iat so it still passes. * note that a proxy on the tls port must serve https to browsers * reject users whose role was removed from the config A user whose role was removed from the config fell back to viewer, which has no camera restrictions, so a restricted role removed from yaml gained access to every camera. /auth now rejects that user's tokens, and login refuses them until an admin assigns a valid role. * floor password change time to match iat The password change time was rounded up while the token iat was rounded down, so a login in the same second as a password change got a token that /auth rejected as revoked. Both are floored now, which leaves a sub-second window where a token issued just before the change stays valid. * set password_changed_at on user creation A recreated username had no password change cutoff, so an unexpired token from the deleted account passed /auth with the new account's role. Creating a user now sets the cutoff to the creation time.
205 lines
7.0 KiB
Python
205 lines
7.0 KiB
Python
import asyncio
|
|
import logging
|
|
import re
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import Depends, FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
from joserfc.jwk import OctKey
|
|
from playhouse.sqliteq import SqliteQueueDatabase
|
|
from slowapi import _rate_limit_exceeded_handler
|
|
from slowapi.errors import RateLimitExceeded
|
|
from slowapi.middleware import SlowAPIMiddleware
|
|
from starlette_context import middleware, plugins
|
|
from starlette_context.plugins import Plugin
|
|
|
|
from frigate.api import app as main_app
|
|
from frigate.api import (
|
|
auth,
|
|
camera,
|
|
chat,
|
|
classification,
|
|
debug_replay,
|
|
event,
|
|
export,
|
|
hardware,
|
|
media,
|
|
motion_search,
|
|
notices,
|
|
notification,
|
|
preview,
|
|
record,
|
|
review,
|
|
)
|
|
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
|
|
from frigate.comms.dispatcher import Dispatcher
|
|
from frigate.comms.event_metadata_updater import (
|
|
EventMetadataPublisher,
|
|
)
|
|
from frigate.config import FrigateConfig
|
|
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
|
from frigate.config.holder import ConfigHolder
|
|
from frigate.config.profile_manager import ProfileManager
|
|
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
|
|
from frigate.embeddings import EmbeddingsContext
|
|
from frigate.genai import GenAIClientManager
|
|
from frigate.notices.registry import NoticeRegistry
|
|
from frigate.ptz.onvif import OnvifController
|
|
from frigate.stats.emitter import StatsEmitter
|
|
from frigate.storage import StorageMaintainer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def check_csrf(request: Request) -> bool:
|
|
if request.method in ["GET", "HEAD", "OPTIONS", "TRACE"]:
|
|
return True
|
|
if "origin" in request.headers and "x-csrf-token" not in request.headers:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
# Used to retrieve the remote-user header: https://starlette-context.readthedocs.io/en/latest/plugins.html#easy-mode
|
|
class RemoteUserPlugin(Plugin):
|
|
key = "Remote-User"
|
|
|
|
|
|
def create_fastapi_app(
|
|
frigate_config: FrigateConfig,
|
|
database: SqliteQueueDatabase,
|
|
embeddings: EmbeddingsContext | None,
|
|
detected_frames_processor,
|
|
storage_maintainer: StorageMaintainer,
|
|
onvif: OnvifController,
|
|
stats_emitter: StatsEmitter,
|
|
event_metadata_updater: EventMetadataPublisher,
|
|
config_publisher: CameraConfigUpdatePublisher,
|
|
replay_manager: DebugReplayManager,
|
|
dispatcher: Dispatcher | None = None,
|
|
profile_manager: ProfileManager | None = None,
|
|
enforce_default_admin: bool = True,
|
|
config_holder: ConfigHolder | None = None,
|
|
notice_registry: NoticeRegistry | None = None,
|
|
):
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
logger.info("FastAPI started")
|
|
asyncio.create_task(
|
|
debug_replay_auto_stop_watchdog(
|
|
replay_manager, frigate_config, config_publisher
|
|
)
|
|
)
|
|
yield
|
|
|
|
logger.info("Starting FastAPI app")
|
|
app = FastAPI(
|
|
debug=False,
|
|
lifespan=lifespan,
|
|
swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"},
|
|
dependencies=[Depends(require_admin_by_default())]
|
|
if enforce_default_admin
|
|
else [],
|
|
)
|
|
|
|
# update the request_address with the x-forwarded-for header from nginx
|
|
# https://starlette-context.readthedocs.io/en/latest/plugins.html#forwarded-for
|
|
app.add_middleware(
|
|
middleware.ContextMiddleware,
|
|
plugins=(plugins.ForwardedForPlugin(),),
|
|
)
|
|
|
|
# Middleware to connect to DB before and close connection after request
|
|
# https://github.com/fastapi/full-stack-fastapi-template/issues/224#issuecomment-737423886
|
|
# https://fastapi.tiangolo.com/tutorial/middleware/#before-and-after-the-response
|
|
@app.middleware("http")
|
|
async def frigate_middleware(request: Request, call_next):
|
|
# Before request
|
|
if not check_csrf(request):
|
|
return JSONResponse(
|
|
content={"success": False, "message": "Missing CSRF header"},
|
|
status_code=401,
|
|
)
|
|
|
|
if database.is_closed():
|
|
database.connect()
|
|
|
|
response = await call_next(request)
|
|
|
|
# After request https://stackoverflow.com/a/75487519
|
|
if not database.is_closed():
|
|
database.close()
|
|
return response
|
|
|
|
# Rate limiter (used for login endpoint)
|
|
if frigate_config.auth.failed_login_rate_limit is None:
|
|
limiter.enabled = False
|
|
else:
|
|
auth.rateLimiter.set_limit(frigate_config.auth.failed_login_rate_limit)
|
|
|
|
app.state.limiter = limiter
|
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
app.add_middleware(SlowAPIMiddleware)
|
|
|
|
# Routes
|
|
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
|
|
app.include_router(auth.router)
|
|
app.include_router(camera.router)
|
|
app.include_router(chat.router)
|
|
app.include_router(classification.router)
|
|
app.include_router(review.router)
|
|
app.include_router(main_app.router)
|
|
app.include_router(preview.router)
|
|
app.include_router(notification.router)
|
|
app.include_router(export.router)
|
|
app.include_router(hardware.router)
|
|
app.include_router(notices.router)
|
|
app.include_router(event.router)
|
|
app.include_router(media.router)
|
|
app.include_router(motion_search.router)
|
|
app.include_router(record.router)
|
|
app.include_router(debug_replay.router)
|
|
# App Properties
|
|
app.frigate_config = frigate_config
|
|
# snapshot the port nginx bound at startup, the live config can be swapped
|
|
app.auth_internal_port = frigate_config.networking.listen.internal_port
|
|
app.genai_manager = GenAIClientManager(frigate_config)
|
|
app.embeddings = embeddings
|
|
app.detected_frames_processor = detected_frames_processor
|
|
app.storage_maintainer = storage_maintainer
|
|
app.camera_error_image = None
|
|
app.onvif = onvif
|
|
app.stats_emitter = stats_emitter
|
|
app.notice_registry = notice_registry
|
|
app.event_metadata_updater = event_metadata_updater
|
|
app.config_publisher = config_publisher
|
|
app.replay_manager = replay_manager
|
|
app.dispatcher = dispatcher
|
|
app.profile_manager = profile_manager
|
|
app.config_holder = config_holder
|
|
|
|
if frigate_config.auth.enabled:
|
|
secret = get_jwt_secret()
|
|
key_bytes = None
|
|
if isinstance(secret, str):
|
|
# If the secret looks like hex (e.g., generated by secrets.token_hex), use raw bytes
|
|
if len(secret) % 2 == 0 and re.fullmatch(r"[0-9a-fA-F]+", secret or ""):
|
|
try:
|
|
key_bytes = bytes.fromhex(secret)
|
|
except ValueError:
|
|
key_bytes = secret.encode("utf-8")
|
|
else:
|
|
key_bytes = secret.encode("utf-8")
|
|
elif isinstance(secret, (bytes, bytearray)):
|
|
key_bytes = bytes(secret)
|
|
else:
|
|
key_bytes = str(secret).encode("utf-8")
|
|
|
|
app.jwt_token = OctKey.import_key(key_bytes)
|
|
app.user_sessions = auth.load_user_sessions()
|
|
else:
|
|
app.jwt_token = None
|
|
app.user_sessions = {}
|
|
|
|
return app
|