Frigate HTTP API using FastAPI (#13871)

* POC: Added FastAPI with one endpoint (get /logs/service)

* POC: Revert error_log

* POC: Converted preview related endpoints to FastAPI

* POC: Converted two more endpoints to FastAPI

* POC: lint

* Convert all media endpoints to FastAPI. Added /media prefix (/media/camera && media/events && /media/preview)

* Convert all notifications API endpoints to FastAPI

* Convert first review API endpoints to FastAPI

* Convert remaining review API endpoints to FastAPI

* Convert export endpoints to FastAPI

* Fix path parameters

* Convert events endpoints to FastAPI

* Use body for multiple events endpoints

* Use body for multiple events endpoints (create and end event)

* Convert app endpoints to FastAPI

* Convert app endpoints to FastAPI

* Convert auth endpoints to FastAPI

* Removed flask app in favour of FastAPI app. Implemented FastAPI middleware to check CSRF, connect and disconnect from DB. Added middleware x-forwared-for headers

* Added starlette plugin to expose custom headers

* Use slowapi as the limiter

* Use query parameters for the frame latest endpoint

* Use query parameters for the media snapshot.jpg endpoint

* Use query parameters for the media MJPEG feed endpoint

* Revert initial nginx.conf change

* Added missing even_id for /events/search endpoint

* Removed left over comment

* Use FastAPI TestClient

* severity query parameter should be a string

* Use the same pattern for all tests

* Fix endpoint

* Revert media routers to old names. Order routes to make sure the dynamic ones from media.py are only used whenever there's no match on auth/etc

* Reverted paths for media on tsx files

* Deleted file

* Fix test_http to use TestClient

* Formatting

* Bind timeline to DB

* Fix http tests

* Replace filename with pathvalidate

* Fix latest.ext handling and disable uvicorn access logs

* Add cosntraints to api provided values

* Formatting

* Remove unused

* Remove unused

* Get rate limiter working

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
Rui Alves
2024-09-24 14:05:30 +01:00
committed by GitHub
parent dc54981784
commit cffc431bf0
24 changed files with 1654 additions and 1321 deletions

View File

@@ -12,25 +12,45 @@ import time
from datetime import datetime
from pathlib import Path
from flask import Blueprint, current_app, jsonify, make_response, redirect, request
from flask_limiter import Limiter
from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from joserfc import jwt
from peewee import DoesNotExist
from slowapi import Limiter
from frigate.api.defs.app_body import (
AppPostLoginBody,
AppPostUsersBody,
AppPutPasswordBody,
)
from frigate.api.defs.tags import Tags
from frigate.config import AuthConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User
logger = logging.getLogger(__name__)
AuthBp = Blueprint("auth", __name__)
router = APIRouter(tags=[Tags.auth])
def get_remote_addr():
class RateLimiter:
_limit = ""
def set_limit(self, limit: str):
self._limit = limit
def get_limit(self) -> str:
return self._limit
rateLimiter = RateLimiter()
def get_remote_addr(request: Request):
route = list(reversed(request.headers.get("x-forwarded-for").split(",")))
logger.debug(f"IP Route: {[r for r in route]}")
trusted_proxies = []
for proxy in current_app.frigate_config.auth.trusted_proxies:
for proxy in request.app.frigate_config.auth.trusted_proxies:
try:
network = ipaddress.ip_network(proxy)
except ValueError:
@@ -68,16 +88,6 @@ def get_remote_addr():
return request.remote_addr or "127.0.0.1"
limiter = Limiter(
get_remote_addr,
storage_uri="memory://",
)
def get_rate_limit():
return current_app.frigate_config.auth.failed_login_rate_limit
def get_jwt_secret() -> str:
jwt_secret = None
# check env var
@@ -132,7 +142,7 @@ def get_jwt_secret() -> str:
return jwt_secret
def hash_password(password, salt=None, iterations=600000):
def hash_password(password: str, salt=None, iterations=600000):
if salt is None:
salt = secrets.token_hex(16)
assert salt and isinstance(salt, str) and "$" not in salt
@@ -158,33 +168,36 @@ def create_encoded_jwt(user, expiration, secret):
return jwt.encode({"alg": "HS256"}, {"sub": user, "exp": expiration}, secret)
def set_jwt_cookie(response, cookie_name, encoded_jwt, expiration, secure):
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
# TODO: ideally this would set secure as well, but that requires TLS
response.set_cookie(
cookie_name, encoded_jwt, httponly=True, expires=expiration, secure=secure
key=cookie_name,
value=encoded_jwt,
httponly=True,
expires=expiration,
secure=secure,
)
# Endpoint for use with nginx auth_request
@AuthBp.route("/auth")
def auth():
auth_config: AuthConfig = current_app.frigate_config.auth
proxy_config: ProxyConfig = current_app.frigate_config.proxy
@router.get("/auth")
def auth(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth
proxy_config: ProxyConfig = request.app.frigate_config.proxy
success_response = make_response({}, 202)
success_response = Response("", status_code=202)
# dont require auth if the request is on the internal port
# this header is set by Frigate's nginx proxy, so it cant be spoofed
if request.headers.get("x-server-port", 0, type=int) == 5000:
if int(request.headers.get("x-server-port", default=0)) == 5000:
return success_response
fail_response = make_response({}, 401)
fail_response = Response("", status_code=401)
# ensure the proxy secret matches if configured
if (
proxy_config.auth_secret is not None
and request.headers.get("x-proxy-secret", "", type=str)
!= proxy_config.auth_secret
and request.headers.get("x-proxy-secret", "") != proxy_config.auth_secret
):
logger.debug("X-Proxy-Secret header does not match configured secret value")
return fail_response
@@ -196,7 +209,6 @@ def auth():
if proxy_config.header_map.user is not None:
upstream_user_header_value = request.headers.get(
proxy_config.header_map.user,
type=str,
default="anonymous",
)
success_response.headers["remote-user"] = upstream_user_header_value
@@ -207,10 +219,10 @@ def auth():
# now apply authentication
fail_response.headers["location"] = "/login"
JWT_COOKIE_NAME = current_app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = current_app.frigate_config.auth.cookie_secure
JWT_REFRESH = current_app.frigate_config.auth.refresh_time
JWT_SESSION_LENGTH = current_app.frigate_config.auth.session_length
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
JWT_REFRESH = request.app.frigate_config.auth.refresh_time
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
jwt_source = None
encoded_token = None
@@ -230,7 +242,7 @@ def auth():
return fail_response
try:
token = jwt.decode(encoded_token, current_app.jwt_token)
token = jwt.decode(encoded_token, request.app.jwt_token)
if "sub" not in token.claims:
logger.debug("user not set in jwt token")
return fail_response
@@ -266,7 +278,7 @@ def auth():
return fail_response
new_expiration = current_time + JWT_SESSION_LENGTH
new_encoded_jwt = create_encoded_jwt(
user, new_expiration, current_app.jwt_token
user, new_expiration, request.app.jwt_token
)
set_jwt_cookie(
success_response,
@@ -283,86 +295,84 @@ def auth():
return fail_response
@AuthBp.route("/profile")
def profile():
username = request.headers.get("remote-user", type=str)
return jsonify({"username": username})
@router.get("/profile")
def profile(request: Request):
username = request.headers.get("remote-user")
return JSONResponse(content={"username": username})
@AuthBp.route("/logout")
def logout():
auth_config: AuthConfig = current_app.frigate_config.auth
response = make_response(redirect("/login", code=303))
@router.get("/logout")
def logout(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(auth_config.cookie_name)
return response
@AuthBp.route("/login", methods=["POST"])
@limiter.limit(get_rate_limit, deduct_when=lambda response: response.status_code == 400)
def login():
JWT_COOKIE_NAME = current_app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = current_app.frigate_config.auth.cookie_secure
JWT_SESSION_LENGTH = current_app.frigate_config.auth.session_length
content = request.get_json()
user = content["user"]
password = content["password"]
limiter = Limiter(key_func=get_remote_addr)
@router.post("/login")
@limiter.limit(limit_value=rateLimiter.get_limit)
def login(request: Request, body: AppPostLoginBody):
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
user = body.user
password = body.password
try:
db_user: User = User.get_by_id(user)
except DoesNotExist:
return make_response({"message": "Login failed"}, 400)
return JSONResponse(content={"message": "Login failed"}, status_code=400)
password_hash = db_user.password_hash
if verify_password(password, password_hash):
expiration = int(time.time()) + JWT_SESSION_LENGTH
encoded_jwt = create_encoded_jwt(user, expiration, current_app.jwt_token)
response = make_response({}, 200)
encoded_jwt = create_encoded_jwt(user, expiration, request.app.jwt_token)
response = Response("", 200)
set_jwt_cookie(
response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE
)
return response
return make_response({"message": "Login failed"}, 400)
return JSONResponse(content={"message": "Login failed"}, status_code=400)
@AuthBp.route("/users")
@router.get("/users")
def get_users():
exports = User.select(User.username).order_by(User.username).dicts().iterator()
return jsonify([e for e in exports])
return JSONResponse([e for e in exports])
@AuthBp.route("/users", methods=["POST"])
def create_user():
HASH_ITERATIONS = current_app.frigate_config.auth.hash_iterations
@router.post("/users")
def create_user(request: Request, body: AppPostUsersBody):
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
request_data = request.get_json()
if not re.match("^[A-Za-z0-9._]+$", body.username):
JSONResponse(content={"message": "Invalid username"}, status_code=400)
if not re.match("^[A-Za-z0-9._]+$", request_data.get("username", "")):
make_response({"message": "Invalid username"}, 400)
password_hash = hash_password(request_data["password"], iterations=HASH_ITERATIONS)
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
User.insert(
{
User.username: request_data["username"],
User.username: body.username,
User.password_hash: password_hash,
}
).execute()
return jsonify({"username": request_data["username"]})
return JSONResponse(content={"username": body.username})
@AuthBp.route("/users/<username>", methods=["DELETE"])
@router.delete("/users/{username}")
def delete_user(username: str):
User.delete_by_id(username)
return jsonify({"success": True})
return JSONResponse(content={"success": True})
@AuthBp.route("/users/<username>/password", methods=["PUT"])
def update_password(username: str):
HASH_ITERATIONS = current_app.frigate_config.auth.hash_iterations
@router.put("/users/{username}/password")
def update_password(request: Request, username: str, body: AppPutPasswordBody):
HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations
request_data = request.get_json()
password_hash = hash_password(request_data["password"], iterations=HASH_ITERATIONS)
password_hash = hash_password(body.password, iterations=HASH_ITERATIONS)
User.set_by_id(
username,
@@ -370,4 +380,4 @@ def update_password(username: str):
User.password_hash: password_hash,
},
)
return jsonify({"success": True})
return JSONResponse(content={"success": True})