diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index 17718c405..474998263 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -29,6 +29,10 @@ auth: reset_admin_password: true ``` +## Password guidance + +Constructing secure passwords and managing them properly is important. Frigate requires a minimum length of 12 characters. For guidance on password standards see [NIST SP 800-63B](https://pages.nist.gov/800-63-3/sp800-63b.html). To learn what makes a password truly secure, read this [article](https://medium.com/peerio/how-to-build-a-billion-dollar-password-3d92568d9277). + ## Login failure rate limiting In order to limit the risk of brute force attacks, rate limiting is available for login failures. This is implemented with SlowApi, and the string notation for valid values is available in [the documentation](https://limits.readthedocs.io/en/stable/quickstart.html#examples). diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index 5f70dd9a0..ac7942675 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -381,6 +381,7 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is ```yaml lpr: enabled: true + device: CPU debug_save_plates: true ``` diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 7ba845f45..bfb3b81a1 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -350,21 +350,15 @@ def validate_password_strength(password: str) -> tuple[bool, Optional[str]]: Validate password strength. Returns a tuple of (is_valid, error_message). + + Longer passwords are harder to crack than shorter complex ones. + https://pages.nist.gov/800-63-3/sp800-63b.html """ if not password: return False, "Password cannot be empty" - if len(password) < 8: - return False, "Password must be at least 8 characters long" - - if not any(c.isupper() for c in password): - return False, "Password must contain at least one uppercase letter" - - if not any(c.isdigit() for c in password): - return False, "Password must contain at least one digit" - - if not any(c in '!@#$%^&*(),.?":{}|<>' for c in password): - return False, "Password must contain at least one special character" + if len(password) < 12: + return False, "Password must be at least 12 characters long" return True, None @@ -800,7 +794,7 @@ def get_users(): "/users", dependencies=[Depends(require_role(["admin"]))], summary="Create new user", - description='Creates a new user with the specified username, password, and role. Requires admin role. Password must meet strength requirements: minimum 8 characters, at least one uppercase letter, at least one digit, and at least one special character (!@#$%^&*(),.?":{} |<>).', + description="Creates a new user with the specified username, password, and role. Requires admin role. Password must be at least 12 characters long.", ) def create_user( request: Request, @@ -817,6 +811,15 @@ def create_user( content={"message": f"Role must be one of: {', '.join(config_roles)}"}, status_code=400, ) + + # Validate password strength + is_valid, error_message = validate_password_strength(body.password) + if not is_valid: + return JSONResponse( + content={"message": error_message}, + status_code=400, + ) + role = body.role or "viewer" password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) User.insert( @@ -851,7 +854,7 @@ def delete_user(request: Request, username: str): "/users/{username}/password", dependencies=[Depends(allow_any_authenticated())], summary="Update user password", - description="Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity for non-admin users. Password must meet strength requirements: minimum 8 characters, at least one uppercase letter, at least one digit, and at least one special character (!@#$%^&*(),.?\":{} |<>). If user changes their own password, a new JWT cookie is automatically issued.", + description="Updates a user's password. Users can only change their own password unless they have admin role. Requires the current password to verify identity for non-admin users. Password must be at least 12 characters long. If user changes their own password, a new JWT cookie is automatically issued.", ) async def update_password( request: Request, diff --git a/frigate/config/camera/review.py b/frigate/config/camera/review.py index 67ba3b60c..6e55b6242 100644 --- a/frigate/config/camera/review.py +++ b/frigate/config/camera/review.py @@ -108,12 +108,13 @@ class GenAIReviewConfig(FrigateBaseModel): default="""### Normal Activity Indicators (Level 0) - Known/verified people in any zone at any time - People with pets in residential areas +- Routine residential vehicle access during daytime/evening (6 AM - 10 PM): entering, exiting, loading/unloading items — normal commute and travel patterns - Deliveries or services during daytime/evening (6 AM - 10 PM): carrying packages to doors/porches, placing items, leaving - Services/maintenance workers with visible tools, uniforms, or service vehicles during daytime - Activity confined to public areas only (sidewalks, streets) without entering property at any time ### Suspicious Activity Indicators (Level 1) -- **Testing or attempting to open doors/windows/handles on vehicles or buildings** — ALWAYS Level 1 regardless of time or duration +- **Checking or probing vehicle/building access**: trying handles without entering, peering through windows, examining multiple vehicles, or possessing break-in tools — Level 1 - **Unidentified person in private areas (driveways, near vehicles/buildings) during late night/early morning (11 PM - 5 AM)** — ALWAYS Level 1 regardless of activity or duration - Taking items that don't belong to them (packages, objects from porches/driveways) - Climbing or jumping fences/barriers to access property @@ -133,8 +134,8 @@ Evaluate in this order: 1. **If person is verified/known** → Level 0 regardless of time or activity 2. **If person is unidentified:** - Check time: If late night/early morning (11 PM - 5 AM) AND in private areas (driveways, near vehicles/buildings) → Level 1 - - Check actions: If testing doors/handles, taking items, climbing → Level 1 - - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service worker) → Level 0 + - Check actions: If probing access (trying handles without entering, checking multiple vehicles), taking items, climbing → Level 1 + - Otherwise, if daytime/evening (6 AM - 10 PM) with clear legitimate purpose (delivery, service, routine vehicle access) → Level 0 3. **Escalate to Level 2 if:** Weapons, break-in tools, forced entry in progress, violence, or active property damage visible (escalates from Level 0 or 1) The mere presence of an unidentified person in private areas during late night hours is inherently suspicious and warrants human review, regardless of what activity they appear to be doing or how brief the sequence is.""", diff --git a/frigate/data_processing/real_time/custom_classification.py b/frigate/data_processing/real_time/custom_classification.py index 19a40fd99..5a58cf122 100644 --- a/frigate/data_processing/real_time/custom_classification.py +++ b/frigate/data_processing/real_time/custom_classification.py @@ -97,7 +97,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi): self.interpreter.allocate_tensors() self.tensor_input_details = self.interpreter.get_input_details() self.tensor_output_details = self.interpreter.get_output_details() - self.labelmap = load_labels(labelmap_path, prefill=0) + self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False) self.classifications_per_second.start() def __update_metrics(self, duration: float) -> None: @@ -398,7 +398,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): self.interpreter.allocate_tensors() self.tensor_input_details = self.interpreter.get_input_details() self.tensor_output_details = self.interpreter.get_output_details() - self.labelmap = load_labels(labelmap_path, prefill=0) + self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False) def __update_metrics(self, duration: float) -> None: self.classifications_per_second.update() diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 1a0950cbb..bd707de15 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -633,7 +633,7 @@ class EmbeddingMaintainer(threading.Thread): camera, frame_name, _, _, motion_boxes, _ = data - if not camera or len(motion_boxes) == 0 or camera not in self.config.cameras: + if not camera or camera not in self.config.cameras: return camera_config = self.config.cameras[camera] @@ -660,8 +660,10 @@ class EmbeddingMaintainer(threading.Thread): return for processor in self.realtime_processors: - if dedicated_lpr_enabled and isinstance( - processor, LicensePlateRealTimeProcessor + if ( + dedicated_lpr_enabled + and len(motion_boxes) > 0 + and isinstance(processor, LicensePlateRealTimeProcessor) ): processor.process_frame(camera, yuv_frame, True) diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index 7f0192912..be1f6d1e7 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -99,8 +99,8 @@ When forming your description: ## Response Format Your response MUST be a flat JSON object with: -- `title` (string): A concise, direct title that describes the primary action or event in the sequence, not just what you literally see. Use spatial context when available to make titles more meaningful. When multiple objects/actions are present, prioritize whichever is most prominent or occurs first. Use names from "Objects in Scene" based on what you visually observe. If you see both a name and an unidentified object of the same type but visually observe only one person/object, use ONLY the name. Examples: "Joe walking dog", "Person taking out trash", "Vehicle arriving in driveway", "Joe accessing vehicle", "Person leaving porch for driveway". - `scene` (string): A narrative description of what happens across the sequence from start to finish, in chronological order. Start by describing how the sequence begins, then describe the progression of events. **Describe all significant movements and actions in the order they occur.** For example, if a vehicle arrives and then a person exits, describe both actions sequentially. **Only describe actions you can actually observe happening in the frames provided.** Do not infer or assume actions that aren't visible (e.g., if you see someone walking but never see them sit, don't say they sat down). Include setting, detected objects, and their observable actions. Avoid speculation or filling in assumed behaviors. Your description should align with and support the threat level you assign. +- `title` (string): A concise, grammatically complete title in the format "[Subject] [action verb] [context]" that matches your scene description. Use names from "Objects in Scene" when you visually observe them. - `shortSummary` (string): A brief 2-sentence summary of the scene, suitable for notifications. Should capture the key activity and context without full detail. This should be a condensed version of the scene description above. - `confidence` (float): 0-1 confidence in your analysis. Higher confidence when objects/actions are clearly visible and context is unambiguous. Lower confidence when the sequence is unclear, objects are partially obscured, or context is ambiguous. - `potential_threat_level` (integer): 0, 1, or 2 as defined in "Normal Activity Patterns for This Property" above. Your threat level must be consistent with your scene description and the guidance above. diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index b1a76214b..867d2533d 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -129,7 +129,9 @@ def get_ffmpeg_arg_list(arg: Any) -> list: return arg if isinstance(arg, list) else shlex.split(arg) -def load_labels(path: Optional[str], encoding="utf-8", prefill=91): +def load_labels( + path: Optional[str], encoding="utf-8", prefill=91, indexed: bool | None = None +): """Loads labels from file (with or without index numbers). Args: path: path to label file. @@ -146,11 +148,12 @@ def load_labels(path: Optional[str], encoding="utf-8", prefill=91): if not lines: return {} - if lines[0].split(" ", maxsplit=1)[0].isdigit(): + if indexed != False and lines[0].split(" ", maxsplit=1)[0].isdigit(): pairs = [line.split(" ", maxsplit=1) for line in lines] labels.update({int(index): label.strip() for index, label in pairs}) else: labels.update({index: line.strip() for index, line in enumerate(lines)}) + return labels diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json index 2dbb1a4fd..354049156 100644 --- a/web/public/locales/en/views/faceLibrary.json +++ b/web/public/locales/en/views/faceLibrary.json @@ -2,7 +2,8 @@ "description": { "addFace": "Add a new collection to the Face Library by uploading your first image.", "placeholder": "Enter a name for this collection", - "invalidName": "Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens." + "invalidName": "Invalid name. Names can only include letters, numbers, spaces, apostrophes, underscores, and hyphens.", + "nameCannotContainHash": "Name cannot contain #." }, "details": { "timestamp": "Timestamp", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 9f211a442..78d6a464d 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -728,10 +728,7 @@ }, "requirements": { "title": "Password requirements:", - "length": "At least 8 characters", - "uppercase": "At least one uppercase letter", - "digit": "At least one digit", - "special": "At least one special character (!@#$%^&*(),.?\":{}|<>)" + "length": "At least 12 characters" }, "match": "Passwords match", "notMatch": "Passwords don't match" diff --git a/web/src/components/input/TextEntry.tsx b/web/src/components/input/TextEntry.tsx index e266444c7..4abf942ac 100644 --- a/web/src/components/input/TextEntry.tsx +++ b/web/src/components/input/TextEntry.tsx @@ -20,6 +20,8 @@ type TextEntryProps = { children?: React.ReactNode; regexPattern?: RegExp; regexErrorMessage?: string; + forbiddenPattern?: RegExp; + forbiddenErrorMessage?: string; }; export default function TextEntry({ @@ -30,11 +32,16 @@ export default function TextEntry({ children, regexPattern, regexErrorMessage = "Input does not match the required format", + forbiddenPattern, + forbiddenErrorMessage = "Input contains invalid characters", }: TextEntryProps) { const formSchema = z.object({ text: z .string() .optional() + .refine((val) => !val || !forbiddenPattern?.test(val), { + message: forbiddenErrorMessage, + }) .refine( (val) => { if (!allowEmpty && !val) return false; diff --git a/web/src/components/overlay/CreateUserDialog.tsx b/web/src/components/overlay/CreateUserDialog.tsx index 6f2b3ecf3..1ce32b61b 100644 --- a/web/src/components/overlay/CreateUserDialog.tsx +++ b/web/src/components/overlay/CreateUserDialog.tsx @@ -32,11 +32,17 @@ import { SelectValue, } from "../ui/select"; import { Shield, User } from "lucide-react"; -import { LuCheck, LuX } from "react-icons/lu"; +import { LuCheck, LuX, LuEye, LuEyeOff } from "react-icons/lu"; import { useTranslation } from "react-i18next"; import { isDesktop, isMobile } from "react-device-detect"; import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; +import { + calculatePasswordStrength, + getPasswordRequirements, + getPasswordStrengthLabel, + getPasswordStrengthColor, +} from "@/utils/passwordUtil"; import { MobilePage, MobilePageContent, @@ -59,6 +65,10 @@ export default function CreateUserDialog({ const { data: config } = useSWR("config"); const { t } = useTranslation(["views/settings"]); const [isLoading, setIsLoading] = useState(false); + const [showPasswordVisible, setShowPasswordVisible] = + useState(false); + const [showConfirmPassword, setShowConfirmPassword] = + useState(false); const roles = useMemo(() => { const existingRoles = config ? Object.keys(config.auth?.roles || {}) : []; @@ -73,7 +83,9 @@ export default function CreateUserDialog({ .regex(/^[A-Za-z0-9._]+$/, { message: t("users.dialog.createUser.usernameOnlyInclude"), }), - password: z.string().min(1, t("users.dialog.form.passwordIsRequired")), + password: z + .string() + .min(12, t("users.dialog.form.password.requirements.length")), confirmPassword: z .string() .min(1, t("users.dialog.createUser.confirmPassword")), @@ -108,13 +120,27 @@ export default function CreateUserDialog({ const passwordsMatch = password === confirmPassword; const showMatchIndicator = password && confirmPassword; + // Password strength calculation + const passwordStrength = useMemo( + () => calculatePasswordStrength(password), + [password], + ); + + const requirements = useMemo( + () => getPasswordRequirements(password), + [password], + ); + useEffect(() => { if (!show) { form.reset({ user: "", password: "", + confirmPassword: "", role: "viewer", }); + setShowPasswordVisible(false); + setShowConfirmPassword(false); } }, [show, form]); @@ -122,8 +148,11 @@ export default function CreateUserDialog({ form.reset({ user: "", password: "", + confirmPassword: "", role: "viewer", }); + setShowPasswordVisible(false); + setShowConfirmPassword(false); onCancel(); }; @@ -184,13 +213,88 @@ export default function CreateUserDialog({ {t("users.dialog.form.password.title")} - +
+ + +
+ + {password && ( +
+
+
+
+

+ {t("users.dialog.form.password.strength.title")} + + {getPasswordStrengthLabel(password, t)} + +

+ +
+

+ {t("users.dialog.form.password.requirements.title")} +

+
    +
  • + {requirements.length ? ( + + ) : ( + + )} + + {t( + "users.dialog.form.password.requirements.length", + )} + +
  • +
+
+
+ )} + )} @@ -204,14 +308,41 @@ export default function CreateUserDialog({ {t("users.dialog.form.password.confirm.title")} - +
+ + +
{showMatchIndicator && (
diff --git a/web/src/components/overlay/SetPasswordDialog.tsx b/web/src/components/overlay/SetPasswordDialog.tsx index 7708201aa..084a841e4 100644 --- a/web/src/components/overlay/SetPasswordDialog.tsx +++ b/web/src/components/overlay/SetPasswordDialog.tsx @@ -28,6 +28,12 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { useIsAdmin } from "@/hooks/use-is-admin"; +import { + calculatePasswordStrength, + getPasswordRequirements, + getPasswordStrengthLabel, + getPasswordStrengthColor, +} from "@/utils/passwordUtil"; type SetPasswordProps = { show: boolean; @@ -70,13 +76,7 @@ export default function SetPasswordDialog({ const baseSchema = { password: z .string() - .min(8, t("users.dialog.form.password.requirements.length")) - .regex(/[A-Z]/, t("users.dialog.form.password.requirements.uppercase")) - .regex(/\d/, t("users.dialog.form.password.requirements.digit")) - .regex( - /[!@#$%^&*(),.?":{}|<>]/, - t("users.dialog.form.password.requirements.special"), - ), + .min(12, t("users.dialog.form.password.requirements.length")), confirmPassword: z.string(), }; @@ -125,25 +125,13 @@ export default function SetPasswordDialog({ const confirmPassword = form.watch("confirmPassword"); // Password strength calculation - const passwordStrength = useMemo(() => { - if (!password) return 0; - - let strength = 0; - if (password.length >= 8) strength += 1; - if (/\d/.test(password)) strength += 1; - if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1; - if (/[A-Z]/.test(password)) strength += 1; - - return strength; - }, [password]); + const passwordStrength = useMemo( + () => calculatePasswordStrength(password), + [password], + ); const requirements = useMemo( - () => ({ - length: password?.length >= 8, - uppercase: /[A-Z]/.test(password || ""), - digit: /\d/.test(password || ""), - special: /[!@#$%^&*(),.?":{}|<>]/.test(password || ""), - }), + () => getPasswordRequirements(password), [password], ); @@ -196,25 +184,6 @@ export default function SetPasswordDialog({ onSave(values.password, oldPassword); }; - const getStrengthLabel = () => { - if (!password) return ""; - if (passwordStrength <= 1) - return t("users.dialog.form.password.strength.weak"); - if (passwordStrength === 2) - return t("users.dialog.form.password.strength.medium"); - if (passwordStrength === 3) - return t("users.dialog.form.password.strength.strong"); - return t("users.dialog.form.password.strength.veryStrong"); - }; - - const getStrengthColor = () => { - if (!password) return "bg-gray-200"; - if (passwordStrength <= 1) return "bg-red-500"; - if (passwordStrength === 2) return "bg-yellow-500"; - if (passwordStrength === 3) return "bg-green-500"; - return "bg-green-600"; - }; - return ( @@ -367,14 +336,16 @@ export default function SetPasswordDialog({

{t("users.dialog.form.password.strength.title")} - {getStrengthLabel()} + {getPasswordStrengthLabel(password, t)}

@@ -401,60 +372,6 @@ export default function SetPasswordDialog({ )} -
  • - {requirements.uppercase ? ( - - ) : ( - - )} - - {t( - "users.dialog.form.password.requirements.uppercase", - )} - -
  • -
  • - {requirements.digit ? ( - - ) : ( - - )} - - {t( - "users.dialog.form.password.requirements.digit", - )} - -
  • -
  • - {requirements.special ? ( - - ) : ( - - )} - - {t( - "users.dialog.form.password.requirements.special", - )} - -
  • diff --git a/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx b/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx index 6436ef040..86eae6acb 100644 --- a/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx +++ b/web/src/components/overlay/detail/FaceCreateWizardDialog.tsx @@ -128,6 +128,8 @@ export default function CreateFaceWizardDialog({ }} regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u} regexErrorMessage={t("description.invalidName")} + forbiddenPattern={/#/} + forbiddenErrorMessage={t("description.nameCannotContainHash")} >