diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 26fb5dd54..d47d100bf 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -55,6 +55,7 @@ def require_admin_by_default(): "/auth", "/auth/first_time_login", "/login", + "/auth/verify", # Authenticated user endpoints (allow_any_authenticated) "/logout", "/profile", @@ -751,6 +752,30 @@ def login(request: Request, body: AppPostLoginBody): return JSONResponse(content={"message": "Login failed"}, status_code=401) +@router.post("/auth/verify", dependencies=[Depends(allow_public())]) +@limiter.limit(limit_value=rateLimiter.get_limit) +def verify(request: Request, body: AppPostLoginBody): + """Verify credentials without creating a session. + + This endpoint is used for password change verification and other + credential validation scenarios that don't require session creation. + """ + user = body.user + password = body.password + + try: + db_user: User = User.get_by_id(user) + except DoesNotExist: + return JSONResponse(content={"message": "Verification failed"}, status_code=401) + + password_hash = db_user.password_hash + if verify_password(password, password_hash): + return JSONResponse( + content={"message": "Verification successful"}, status_code=200 + ) + return JSONResponse(content={"message": "Verification failed"}, status_code=401) + + @router.get("/users", dependencies=[Depends(require_role(["admin"]))]) def get_users(): exports = ( @@ -863,6 +888,8 @@ async def update_password( } ).where(User.username == username).execute() + response = JSONResponse(content={"success": True}) + # If user changed their own password, issue a new JWT to keep them logged in if current_username == username: JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name @@ -873,13 +900,12 @@ async def update_password( encoded_jwt = create_encoded_jwt( username, current_role, expiration, request.app.jwt_token ) - response = JSONResponse(content={"success": True}) + # Set new JWT cookie on response set_jwt_cookie( response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE ) - return response - return JSONResponse(content={"success": True}) + return response @router.put( diff --git a/web/src/components/menu/AccountSettings.tsx b/web/src/components/menu/AccountSettings.tsx index cb1d5fefa..74701031d 100644 --- a/web/src/components/menu/AccountSettings.tsx +++ b/web/src/components/menu/AccountSettings.tsx @@ -30,6 +30,7 @@ import axios from "axios"; import { toast } from "sonner"; import SetPasswordDialog from "../overlay/SetPasswordDialog"; import { useTranslation } from "react-i18next"; +import { verifyPassword } from "@/utils/authUtil"; type AccountSettingsProps = { className?: string; @@ -51,15 +52,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) { const verifyOldPassword = async (oldPassword: string): Promise => { if (!profile?.username || profile.username === "anonymous") return false; - try { - const response = await axios.post("login", { - user: profile.username, - password: oldPassword, - }); - return response.status === 200; - } catch (error) { - return false; - } + return verifyPassword(profile.username, oldPassword); }; const handlePasswordSave = async (password: string, oldPassword?: string) => { diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index ca7fa9bab..93376650f 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -66,6 +66,7 @@ import { supportedLanguageKeys } from "@/lib/const"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { MdCategory } from "react-icons/md"; +import { verifyPassword } from "@/utils/authUtil"; type GeneralSettingsProps = { className?: string; @@ -120,15 +121,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { const verifyOldPassword = async (oldPassword: string): Promise => { if (!profile?.username || profile.username === "anonymous") return false; - try { - const response = await axios.post("login", { - user: profile.username, - password: oldPassword, - }); - return response.status === 200; - } catch (error) { - return false; - } + return verifyPassword(profile.username, oldPassword); }; const handlePasswordSave = async (password: string, oldPassword?: string) => { diff --git a/web/src/utils/authUtil.ts b/web/src/utils/authUtil.ts new file mode 100644 index 000000000..06f4ab95e --- /dev/null +++ b/web/src/utils/authUtil.ts @@ -0,0 +1,24 @@ +import axios from "axios"; + +/** + * Verifies a user's password without creating a session. + * This is used for password change verification. + * + * @param username - The username to verify + * @param password - The password to verify + * @returns true if credentials are valid, false otherwise + */ +export async function verifyPassword( + username: string, + password: string, +): Promise { + try { + const response = await axios.post("auth/verify", { + user: username, + password, + }); + return response.status === 200; + } catch (error) { + return false; + } +} diff --git a/web/src/views/settings/AuthenticationView.tsx b/web/src/views/settings/AuthenticationView.tsx index 29aa138ca..e69090a6f 100644 --- a/web/src/views/settings/AuthenticationView.tsx +++ b/web/src/views/settings/AuthenticationView.tsx @@ -37,6 +37,7 @@ import { useTranslation } from "react-i18next"; import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog"; import { Separator } from "@/components/ui/separator"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; +import { verifyPassword } from "@/utils/authUtil"; type AuthenticationViewProps = { section?: "users" | "roles"; @@ -73,15 +74,7 @@ export default function AuthenticationView({ const onVerifyOldPassword = useCallback( async (oldPassword: string): Promise => { if (!selectedUser) return false; - try { - const response = await axios.post("login", { - user: selectedUser, - password: oldPassword, - }); - return response.status === 200; - } catch (error) { - return false; - } + return verifyPassword(selectedUser, oldPassword); }, [selectedUser], );