Compare commits

...

3 Commits

Author SHA1 Message Date
Josh Hawkins
a8cccaaf1c change password requirements 2026-01-31 14:31:51 -06:00
Josh Hawkins
8b426743c2 add password requirements to new user dialog 2026-01-31 13:47:43 -06:00
Nicolas Mowen
ac6dda370d Don't allow # in face name 2026-01-31 07:21:20 -07:00
11 changed files with 238 additions and 134 deletions

View File

@ -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).

View File

@ -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,

View File

@ -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",

View File

@ -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"

View File

@ -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;

View File

@ -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<FrigateConfig>("config");
const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [showPasswordVisible, setShowPasswordVisible] =
useState<boolean>(false);
const [showConfirmPassword, setShowConfirmPassword] =
useState<boolean>(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")}
</FormLabel>
<FormControl>
<Input
placeholder={t("users.dialog.form.password.placeholder")}
type="password"
className="h-10"
{...field}
/>
<div className="relative">
<Input
placeholder={t(
"users.dialog.form.password.placeholder",
)}
type={showPasswordVisible ? "text" : "password"}
className="h-10 pr-10"
{...field}
/>
<Button
type="button"
variant="ghost"
size="sm"
tabIndex={-1}
aria-label={
showPasswordVisible
? t("users.dialog.form.password.hide", {
ns: "views/settings",
})
: t("users.dialog.form.password.show", {
ns: "views/settings",
})
}
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() =>
setShowPasswordVisible(!showPasswordVisible)
}
>
{showPasswordVisible ? (
<LuEyeOff className="size-4" />
) : (
<LuEye className="size-4" />
)}
</Button>
</div>
</FormControl>
{password && (
<div className="mt-2 space-y-2">
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
<div
className={`${getPasswordStrengthColor(
password,
)} transition-all duration-300`}
style={{ width: `${passwordStrength * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("users.dialog.form.password.strength.title")}
<span className="font-medium">
{getPasswordStrengthLabel(password, t)}
</span>
</p>
<div className="space-y-1 rounded-md bg-muted/50 p-2">
<p className="text-xs font-medium text-muted-foreground">
{t("users.dialog.form.password.requirements.title")}
</p>
<ul className="space-y-1">
<li className="flex items-center gap-2 text-xs">
{requirements.length ? (
<LuCheck className="size-3.5 text-green-500" />
) : (
<LuX className="size-3.5 text-red-500" />
)}
<span
className={
requirements.length
? "text-green-600"
: "text-red-600"
}
>
{t(
"users.dialog.form.password.requirements.length",
)}
</span>
</li>
</ul>
</div>
</div>
)}
<FormMessage />
</FormItem>
)}
@ -204,14 +308,41 @@ export default function CreateUserDialog({
{t("users.dialog.form.password.confirm.title")}
</FormLabel>
<FormControl>
<Input
placeholder={t(
"users.dialog.form.password.confirm.placeholder",
)}
type="password"
className="h-10"
{...field}
/>
<div className="relative">
<Input
placeholder={t(
"users.dialog.form.password.confirm.placeholder",
)}
type={showConfirmPassword ? "text" : "password"}
className="h-10 pr-10"
{...field}
/>
<Button
type="button"
variant="ghost"
size="sm"
tabIndex={-1}
aria-label={
showConfirmPassword
? t("users.dialog.form.password.hide", {
ns: "views/settings",
})
: t("users.dialog.form.password.show", {
ns: "views/settings",
})
}
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() =>
setShowConfirmPassword(!showConfirmPassword)
}
>
{showConfirmPassword ? (
<LuEyeOff className="size-4" />
) : (
<LuEye className="size-4" />
)}
</Button>
</div>
</FormControl>
{showMatchIndicator && (
<div className="mt-1 flex items-center gap-1.5 text-xs">

View File

@ -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 (
<Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]">
@ -367,14 +336,16 @@ export default function SetPasswordDialog({
<div className="mt-2 space-y-2">
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-secondary-foreground">
<div
className={`${getStrengthColor()} transition-all duration-300`}
style={{ width: `${(passwordStrength / 4) * 100}%` }}
className={`${getPasswordStrengthColor(
password,
)} transition-all duration-300`}
style={{ width: `${passwordStrength * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("users.dialog.form.password.strength.title")}
<span className="font-medium">
{getStrengthLabel()}
{getPasswordStrengthLabel(password, t)}
</span>
</p>
@ -401,60 +372,6 @@ export default function SetPasswordDialog({
)}
</span>
</li>
<li className="flex items-center gap-2 text-xs">
{requirements.uppercase ? (
<LuCheck className="size-3.5 text-green-500" />
) : (
<LuX className="size-3.5 text-red-500" />
)}
<span
className={
requirements.uppercase
? "text-green-600"
: "text-red-600"
}
>
{t(
"users.dialog.form.password.requirements.uppercase",
)}
</span>
</li>
<li className="flex items-center gap-2 text-xs">
{requirements.digit ? (
<LuCheck className="size-3.5 text-green-500" />
) : (
<LuX className="size-3.5 text-red-500" />
)}
<span
className={
requirements.digit
? "text-green-600"
: "text-red-600"
}
>
{t(
"users.dialog.form.password.requirements.digit",
)}
</span>
</li>
<li className="flex items-center gap-2 text-xs">
{requirements.special ? (
<LuCheck className="size-3.5 text-green-500" />
) : (
<LuX className="size-3.5 text-red-500" />
)}
<span
className={
requirements.special
? "text-green-600"
: "text-red-600"
}
>
{t(
"users.dialog.form.password.requirements.special",
)}
</span>
</li>
</ul>
</div>
</div>

View File

@ -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")}
>
<div className="flex justify-end py-2">
<Button variant="select" type="submit">

View File

@ -22,6 +22,8 @@ type TextEntryDialogProps = {
allowEmpty?: boolean;
regexPattern?: RegExp;
regexErrorMessage?: string;
forbiddenPattern?: RegExp;
forbiddenErrorMessage?: string;
};
export default function TextEntryDialog({
@ -34,6 +36,8 @@ export default function TextEntryDialog({
allowEmpty = false,
regexPattern,
regexErrorMessage,
forbiddenPattern,
forbiddenErrorMessage,
}: TextEntryDialogProps) {
const { t } = useTranslation("common");
@ -50,6 +54,8 @@ export default function TextEntryDialog({
onSave={onSave}
regexPattern={regexPattern}
regexErrorMessage={regexErrorMessage}
forbiddenPattern={forbiddenPattern}
forbiddenErrorMessage={forbiddenErrorMessage}
>
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
<Button type="button" onClick={() => setOpen(false)}>

View File

@ -560,6 +560,8 @@ function LibrarySelector({
defaultValue={renameFace || ""}
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
regexErrorMessage={t("description.invalidName")}
forbiddenPattern={/#/}
forbiddenErrorMessage={t("description.nameCannotContainHash")}
/>
<DropdownMenu modal={false}>

View File

@ -0,0 +1,34 @@
export const calculatePasswordStrength = (password: string): number => {
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;
};
export const getPasswordRequirements = (password: string) => ({
length: password?.length >= 12,
});
export const getPasswordStrengthLabel = (
password: string,
t: (key: string) => string,
): string => {
const strength = calculatePasswordStrength(password);
if (!password) return "";
if (strength < 1) return t("users.dialog.form.password.strength.weak");
return t("users.dialog.form.password.strength.veryStrong");
};
export const getPasswordStrengthColor = (password: string): string => {
const strength = calculatePasswordStrength(password);
if (!password) return "bg-gray-200";
if (strength === 0) return "bg-red-500";
return "bg-green-500";
};