remove password verification endpoint

Just send old_password + new password in one request, let the backend handle verification in a single operation
This commit is contained in:
Josh Hawkins 2025-12-08 09:47:40 -06:00
parent 7bbf287698
commit f5e52179d4
6 changed files with 42 additions and 135 deletions

View File

@ -55,7 +55,6 @@ def require_admin_by_default():
"/auth", "/auth",
"/auth/first_time_login", "/auth/first_time_login",
"/login", "/login",
"/auth/verify",
"/logout", "/logout",
# Authenticated user endpoints (allow_any_authenticated) # Authenticated user endpoints (allow_any_authenticated)
"/profile", "/profile",
@ -753,30 +752,6 @@ def login(request: Request, body: AppPostLoginBody):
return JSONResponse(content={"message": "Login failed"}, status_code=401) 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"]))]) @router.get("/users", dependencies=[Depends(require_role(["admin"]))])
def get_users(): def get_users():
exports = ( exports = (

View File

@ -30,7 +30,6 @@ import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import SetPasswordDialog from "../overlay/SetPasswordDialog"; import SetPasswordDialog from "../overlay/SetPasswordDialog";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { verifyPassword } from "@/utils/authUtil";
type AccountSettingsProps = { type AccountSettingsProps = {
className?: string; className?: string;
@ -44,19 +43,16 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [passwordError, setPasswordError] = useState<string | null>(null); const [passwordError, setPasswordError] = useState<string | null>(null);
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const Container = isDesktop ? DropdownMenu : Drawer; const Container = isDesktop ? DropdownMenu : Drawer;
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
const Content = isDesktop ? DropdownMenuContent : DrawerContent; const Content = isDesktop ? DropdownMenuContent : DrawerContent;
const MenuItem = isDesktop ? DropdownMenuItem : DrawerClose; const MenuItem = isDesktop ? DropdownMenuItem : DrawerClose;
const verifyOldPassword = async (oldPassword: string): Promise<boolean> => {
if (!profile?.username || profile.username === "anonymous") return false;
return verifyPassword(profile.username, oldPassword);
};
const handlePasswordSave = async (password: string, oldPassword?: string) => { const handlePasswordSave = async (password: string, oldPassword?: string) => {
if (!profile?.username || profile.username === "anonymous") return; if (!profile?.username || profile.username === "anonymous") return;
setIsPasswordLoading(true);
axios axios
.put(`users/${profile.username}/password`, { .put(`users/${profile.username}/password`, {
password, password,
@ -66,6 +62,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
if (response.status === 200) { if (response.status === 200) {
setPasswordDialogOpen(false); setPasswordDialogOpen(false);
setPasswordError(null); setPasswordError(null);
setIsPasswordLoading(false);
toast.success(t("users.toast.success.updatePassword"), { toast.success(t("users.toast.success.updatePassword"), {
position: "top-center", position: "top-center",
}); });
@ -77,16 +74,9 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
error.response?.data?.detail || error.response?.data?.detail ||
"Unknown error"; "Unknown error";
setPasswordDialogOpen(false); // Keep dialog open and show error
setPasswordError(null); setPasswordError(errorMessage);
toast.error( setIsPasswordLoading(false);
t("users.toast.error.setPasswordFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
}); });
}; };
@ -172,9 +162,9 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
setPasswordDialogOpen(false); setPasswordDialogOpen(false);
setPasswordError(null); setPasswordError(null);
}} }}
onVerifyOldPassword={verifyOldPassword}
initialError={passwordError} initialError={passwordError}
username={profile?.username} username={profile?.username}
isLoading={isPasswordLoading}
/> />
</Container> </Container>
); );

View File

@ -66,7 +66,6 @@ import { supportedLanguageKeys } from "@/lib/const";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { MdCategory } from "react-icons/md"; import { MdCategory } from "react-icons/md";
import { verifyPassword } from "@/utils/authUtil";
type GeneralSettingsProps = { type GeneralSettingsProps = {
className?: string; className?: string;
@ -118,14 +117,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
const Portal = isDesktop ? DropdownMenuPortal : DialogPortal; const Portal = isDesktop ? DropdownMenuPortal : DialogPortal;
const [passwordError, setPasswordError] = useState<string | null>(null); const [passwordError, setPasswordError] = useState<string | null>(null);
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const verifyOldPassword = async (oldPassword: string): Promise<boolean> => {
if (!profile?.username || profile.username === "anonymous") return false;
return verifyPassword(profile.username, oldPassword);
};
const handlePasswordSave = async (password: string, oldPassword?: string) => { const handlePasswordSave = async (password: string, oldPassword?: string) => {
if (!profile?.username || profile.username === "anonymous") return; if (!profile?.username || profile.username === "anonymous") return;
setIsPasswordLoading(true);
axios axios
.put(`users/${profile.username}/password`, { .put(`users/${profile.username}/password`, {
password, password,
@ -135,6 +131,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
if (response.status === 200) { if (response.status === 200) {
setPasswordDialogOpen(false); setPasswordDialogOpen(false);
setPasswordError(null); setPasswordError(null);
setIsPasswordLoading(false);
toast.success( toast.success(
t("users.toast.success.updatePassword", { t("users.toast.success.updatePassword", {
ns: "views/settings", ns: "views/settings",
@ -151,17 +148,9 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
error.response?.data?.detail || error.response?.data?.detail ||
"Unknown error"; "Unknown error";
setPasswordDialogOpen(false); // Keep dialog open and show error
setPasswordError(null); setPasswordError(errorMessage);
toast.error( setIsPasswordLoading(false);
t("users.toast.error.setPasswordFailed", {
ns: "views/settings",
errorMessage,
}),
{
position: "top-center",
},
);
}); });
}; };
@ -573,9 +562,9 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
setPasswordDialogOpen(false); setPasswordDialogOpen(false);
setPasswordError(null); setPasswordError(null);
}} }}
onVerifyOldPassword={verifyOldPassword}
initialError={passwordError} initialError={passwordError}
username={profile?.username} username={profile?.username}
isLoading={isPasswordLoading}
/> />
</> </>
); );

View File

@ -22,18 +22,18 @@ type SetPasswordProps = {
show: boolean; show: boolean;
onSave: (password: string, oldPassword?: string) => void; onSave: (password: string, oldPassword?: string) => void;
onCancel: () => void; onCancel: () => void;
onVerifyOldPassword?: (oldPassword: string) => Promise<boolean>;
initialError?: string | null; initialError?: string | null;
username?: string; username?: string;
isLoading?: boolean;
}; };
export default function SetPasswordDialog({ export default function SetPasswordDialog({
show, show,
onSave, onSave,
onCancel, onCancel,
onVerifyOldPassword,
initialError, initialError,
username, username,
isLoading = false,
}: SetPasswordProps) { }: SetPasswordProps) {
const { t } = useTranslation(["views/settings", "common"]); const { t } = useTranslation(["views/settings", "common"]);
const { getLocaleDocUrl } = useDocDomain(); const { getLocaleDocUrl } = useDocDomain();
@ -49,8 +49,6 @@ export default function SetPasswordDialog({
const [confirmPassword, setConfirmPassword] = useState<string>(""); const [confirmPassword, setConfirmPassword] = useState<string>("");
const [passwordStrength, setPasswordStrength] = useState<number>(0); const [passwordStrength, setPasswordStrength] = useState<number>(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isValidatingOldPassword, setIsValidatingOldPassword] =
useState<boolean>(false);
// visibility toggles for password fields // visibility toggles for password fields
const [showOldPassword, setShowOldPassword] = useState<boolean>(false); const [showOldPassword, setShowOldPassword] = useState<boolean>(false);
@ -58,6 +56,7 @@ export default function SetPasswordDialog({
useState<boolean>(false); useState<boolean>(false);
const [showConfirmPassword, setShowConfirmPassword] = const [showConfirmPassword, setShowConfirmPassword] =
useState<boolean>(false); useState<boolean>(false);
const [hasInitialized, setHasInitialized] = useState<boolean>(false);
// Password strength requirements // Password strength requirements
@ -68,14 +67,23 @@ export default function SetPasswordDialog({
special: /[!@#$%^&*(),.?":{}|<>]/.test(password), special: /[!@#$%^&*(),.?":{}|<>]/.test(password),
}; };
// Reset state when dialog opens/closes
useEffect(() => { useEffect(() => {
if (show) { if (show) {
if (!hasInitialized) {
setOldPassword(""); setOldPassword("");
setPassword(""); setPassword("");
setConfirmPassword(""); setConfirmPassword("");
setError(initialError || null); setError(null);
setHasInitialized(true);
}
} else {
setHasInitialized(false);
}
}, [show, hasInitialized]);
useEffect(() => {
if (show && initialError) {
setError(initialError);
} }
}, [show, initialError]); }, [show, initialError]);
@ -133,24 +141,6 @@ export default function SetPasswordDialog({
return; return;
} }
// Verify old password if callback is provided and old password is provided
if (username && oldPassword && onVerifyOldPassword) {
setIsValidatingOldPassword(true);
try {
const isValid = await onVerifyOldPassword(oldPassword);
if (!isValid) {
setError(t("users.dialog.passwordSetting.incorrectCurrentPassword"));
setIsValidatingOldPassword(false);
return;
}
} catch (err) {
setError(t("users.dialog.passwordSetting.passwordVerificationFailed"));
setIsValidatingOldPassword(false);
return;
}
setIsValidatingOldPassword(false);
}
onSave(password, oldPassword || undefined); onSave(password, oldPassword || undefined);
}; };
@ -465,6 +455,7 @@ export default function SetPasswordDialog({
aria-label={t("button.cancel", { ns: "common" })} aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
type="button" type="button"
disabled={isLoading}
> >
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
@ -474,7 +465,7 @@ export default function SetPasswordDialog({
className="flex flex-1" className="flex flex-1"
onClick={handleSave} onClick={handleSave}
disabled={ disabled={
isValidatingOldPassword || isLoading ||
!password || !password ||
password !== confirmPassword || password !== confirmPassword ||
(username && !oldPassword) || (username && !oldPassword) ||
@ -484,7 +475,7 @@ export default function SetPasswordDialog({
!requirements.special !requirements.special
} }
> >
{isValidatingOldPassword ? ( {isLoading ? (
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<ActivityIndicator /> <ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span> <span>{t("button.saving", { ns: "common" })}</span>

View File

@ -1,24 +0,0 @@
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<boolean> {
try {
const response = await axios.post("auth/verify", {
user: username,
password,
});
return response.status === 200;
} catch (error) {
return false;
}
}

View File

@ -37,7 +37,6 @@ import { useTranslation } from "react-i18next";
import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog"; import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { verifyPassword } from "@/utils/authUtil";
type AuthenticationViewProps = { type AuthenticationViewProps = {
section?: "users" | "roles"; section?: "users" | "roles";
@ -59,6 +58,7 @@ export default function AuthenticationView({
const [showEditRole, setShowEditRole] = useState(false); const [showEditRole, setShowEditRole] = useState(false);
const [showDeleteRole, setShowDeleteRole] = useState(false); const [showDeleteRole, setShowDeleteRole] = useState(false);
const [passwordError, setPasswordError] = useState<string | null>(null); const [passwordError, setPasswordError] = useState<string | null>(null);
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const [selectedUser, setSelectedUser] = useState<string>(); const [selectedUser, setSelectedUser] = useState<string>();
const [selectedUserRole, setSelectedUserRole] = useState<string>(); const [selectedUserRole, setSelectedUserRole] = useState<string>();
@ -71,22 +71,16 @@ export default function AuthenticationView({
document.title = t("documentTitle.authentication"); document.title = t("documentTitle.authentication");
}, [t]); }, [t]);
const onVerifyOldPassword = useCallback(
async (oldPassword: string): Promise<boolean> => {
if (!selectedUser) return false;
return verifyPassword(selectedUser, oldPassword);
},
[selectedUser],
);
const onSavePassword = useCallback( const onSavePassword = useCallback(
(user: string, password: string, oldPassword?: string) => { (user: string, password: string, oldPassword?: string) => {
setIsPasswordLoading(true);
axios axios
.put(`users/${user}/password`, { password, old_password: oldPassword }) .put(`users/${user}/password`, { password, old_password: oldPassword })
.then((response) => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
setShowSetPassword(false); setShowSetPassword(false);
setPasswordError(null); setPasswordError(null);
setIsPasswordLoading(false);
toast.success(t("users.toast.success.updatePassword"), { toast.success(t("users.toast.success.updatePassword"), {
position: "top-center", position: "top-center",
}); });
@ -98,17 +92,9 @@ export default function AuthenticationView({
error.response?.data?.detail || error.response?.data?.detail ||
"Unknown error"; "Unknown error";
// Close dialog and show toast for any errors // Keep dialog open and show error
setShowSetPassword(false); setPasswordError(errorMessage);
setPasswordError(null); setIsPasswordLoading(false);
toast.error(
t("users.toast.error.setPasswordFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
}); });
}, },
[t], [t],
@ -582,11 +568,11 @@ export default function AuthenticationView({
setShowSetPassword(false); setShowSetPassword(false);
setPasswordError(null); setPasswordError(null);
}} }}
onVerifyOldPassword={onVerifyOldPassword}
initialError={passwordError} initialError={passwordError}
onSave={(password, oldPassword) => onSave={(password, oldPassword) =>
onSavePassword(selectedUser!, password, oldPassword) onSavePassword(selectedUser!, password, oldPassword)
} }
isLoading={isPasswordLoading}
/> />
<DeleteUserDialog <DeleteUserDialog
show={showDelete} show={showDelete}