Authentication improvements (#21194)

* jwt permissions

* add old password to body req

* add model and migration

need to track the datetime that passwords were changed for the jwt

* auth api backend changes

- use os.open to create jwt secret with restrictive permissions (0o600: read/write for owner only)
- add backend validation for password strength
- add iat claim to jwt so the server can determine when a token was issued and reject any jwts issued before a user's password_changed_at timestamp, ensuring old tokens are invalidated after a password change
- set logout route to public to avoid 401 when logging out
- issue new jwt for users who change their own password so they stay logged in

* improve set password dialog

- add field to verify old password
- add password strength requirements

* frontend tweaks for password dialog

* i18n

* use verify endpoint for existing password verification

avoid /login side effects (creating a new session)

* public logout

* only check if password has changed on jwt refresh

* fix tests

Fix migration 030 by using raw sql to select usernames (avoid ORM selecting nonexistent columns)

* add multi device warning to password dialog

* 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:02:28 -07:00
committed by GitHub
parent 28b0ad782a
commit 152e585206
12 changed files with 554 additions and 101 deletions
+20 -11
View File
@@ -42,19 +42,27 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const Container = isDesktop ? DropdownMenu : Drawer;
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
const Content = isDesktop ? DropdownMenuContent : DrawerContent;
const MenuItem = isDesktop ? DropdownMenuItem : DrawerClose;
const handlePasswordSave = async (password: string) => {
const handlePasswordSave = async (password: string, oldPassword?: string) => {
if (!profile?.username || profile.username === "anonymous") return;
setIsPasswordLoading(true);
axios
.put(`users/${profile.username}/password`, { password })
.put(`users/${profile.username}/password`, {
password,
old_password: oldPassword,
})
.then((response) => {
if (response.status === 200) {
setPasswordDialogOpen(false);
setPasswordError(null);
setIsPasswordLoading(false);
toast.success(t("users.toast.success.updatePassword"), {
position: "top-center",
});
@@ -65,14 +73,10 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("users.toast.error.setPasswordFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
// Keep dialog open and show error
setPasswordError(errorMessage);
setIsPasswordLoading(false);
});
};
@@ -154,8 +158,13 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
<SetPasswordDialog
show={passwordDialogOpen}
onSave={handlePasswordSave}
onCancel={() => setPasswordDialogOpen(false)}
onCancel={() => {
setPasswordDialogOpen(false);
setPasswordError(null);
}}
initialError={passwordError}
username={profile?.username}
isLoading={isPasswordLoading}
/>
</Container>
);
+21 -12
View File
@@ -116,13 +116,22 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent;
const Portal = isDesktop ? DropdownMenuPortal : DialogPortal;
const handlePasswordSave = async (password: string) => {
const [passwordError, setPasswordError] = useState<string | null>(null);
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const handlePasswordSave = async (password: string, oldPassword?: string) => {
if (!profile?.username || profile.username === "anonymous") return;
setIsPasswordLoading(true);
axios
.put(`users/${profile.username}/password`, { password })
.put(`users/${profile.username}/password`, {
password,
old_password: oldPassword,
})
.then((response) => {
if (response.status === 200) {
setPasswordDialogOpen(false);
setPasswordError(null);
setIsPasswordLoading(false);
toast.success(
t("users.toast.success.updatePassword", {
ns: "views/settings",
@@ -138,15 +147,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("users.toast.error.setPasswordFailed", {
ns: "views/settings",
errorMessage,
}),
{
position: "top-center",
},
);
// Keep dialog open and show error
setPasswordError(errorMessage);
setIsPasswordLoading(false);
});
};
@@ -554,8 +558,13 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<SetPasswordDialog
show={passwordDialogOpen}
onSave={handlePasswordSave}
onCancel={() => setPasswordDialogOpen(false)}
onCancel={() => {
setPasswordDialogOpen(false);
setPasswordError(null);
}}
initialError={passwordError}
username={profile?.username}
isLoading={isPasswordLoading}
/>
</>
);