diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 415de0336..91bdb732b 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -1,5 +1,6 @@ click == 8.1.* Flask == 3.0.* +Flask_Limiter == 3.6.* imutils == 0.5.* joserfc == 0.9.* markupsafe == 2.1.* diff --git a/frigate/api/app.py b/frigate/api/app.py index 2acf696d0..6ece92f2c 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -13,8 +13,9 @@ from flask import Blueprint, Flask, current_app, jsonify, make_response, request from markupsafe import escape from peewee import operator from playhouse.sqliteq import SqliteQueueDatabase +from werkzeug.middleware.proxy_fix import ProxyFix -from frigate.api.auth import AuthBp, get_jwt_secret +from frigate.api.auth import AuthBp, get_jwt_secret, limiter from frigate.api.event import EventBp from frigate.api.export import ExportBp from frigate.api.media import MediaBp @@ -86,6 +87,11 @@ def create_app( app.camera_error_image = None app.stats_emitter = stats_emitter app.jwt_token = get_jwt_secret() if frigate_config.auth.enabled else None + # initialize the rate limiter for the login endpoint + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1) + limiter.init_app(app) + if frigate_config.auth.failed_login_rate_limit is None: + limiter.enabled = False app.register_blueprint(bp) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 3115a85f5..c46b3a09a 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -11,6 +11,8 @@ from datetime import datetime from pathlib import Path from flask import Blueprint, current_app, make_response, request +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address from joserfc import jwt from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM @@ -19,6 +21,15 @@ logger = logging.getLogger(__name__) AuthBp = Blueprint("auth", __name__) +limiter = Limiter( + lambda: get_remote_address, + 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 @@ -179,6 +190,7 @@ def auth(): @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_SESSION_LENGTH = current_app.frigate_config.auth.session_length diff --git a/frigate/config.py b/frigate/config.py index a826b2595..f2349bee1 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -125,7 +125,6 @@ class UserConfig(FrigateBaseModel): class AuthConfig(FrigateBaseModel): enabled: bool = Field(default=False, title="Enable authentication") - # TODO: validation for cookie names cookie_name: str = Field( default="jwt.token", title="Name for jwt token cookie", pattern=r"^[a-z]_*$" ) @@ -137,6 +136,10 @@ class AuthConfig(FrigateBaseModel): title="Refresh the session if it is going to expire in this many seconds", ge=30, ) + failed_login_rate_limit: Optional[str] = Field( + default="1/second;5/minute;20/hour", + title="Rate limits for failed login attempts.", + ) users: Optional[List[UserConfig]] = Field(default=[], title="Users") diff --git a/web/src/components/AuthForm.tsx b/web/src/components/AuthForm.tsx index 113ebf508..cc1b6885b 100644 --- a/web/src/components/AuthForm.tsx +++ b/web/src/components/AuthForm.tsx @@ -6,7 +6,7 @@ import { cn } from "@/lib/utils"; import { Input } from "./ui/input"; import { Button } from "./ui/button"; import ActivityIndicator from "./indicators/activity-indicator"; -import axios from "axios"; +import axios, { AxiosError } from "axios"; import { Toaster } from "./ui/sonner"; import { toast } from "sonner"; import { @@ -56,9 +56,27 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { ); window.location.href = "/"; } catch (error) { - toast.error("Login failed", { - position: "top-center", - }); + if (axios.isAxiosError(error)) { + const err = error as AxiosError; + if (err.response?.status === 429) { + toast.error("Exceeded rate limit. Try again in 1 minute.", { + position: "top-center", + }); + } else if (err.response?.status === 400) { + toast.error("Login failed", { + position: "top-center", + }); + } else { + toast.error("Unknown error. Check logs.", { + position: "top-center", + }); + } + } else { + toast.error("Unknown error. Check console logs.", { + position: "top-center", + }); + } + setIsLoading(false); } };