Skip to content

Commit 8daec4f

Browse files
thabofletchereastandwestwind
authored andcommitted
ENG-681 Re implement pw reset session invalidation (#6526)
1 parent 0727870 commit 8daec4f

File tree

8 files changed

+241
-13
lines changed

8 files changed

+241
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o
2222
## [Unreleased](https://github.com/ethyca/fides/compare/2.69.0...main)
2323

2424
### Security
25+
- Changed session invalidation logic to end all sessions for a user when their password has been changed [CVE-2025-57766](https://github.com/ethyca/fides/security/advisories/GHSA-rpw8-82v9-3q87)
2526
- Fixed OAuth scope privilege escalation vulnerability that allowed clients to create or update other OAuth clients with unauthorized scopes [CVE-2025-57817](https://github.com/ethyca/fides/security/advisories/GHSA-hjfh-p8f5-24wr)
2627
- Added stricter rate limiting to authentication endpoints to mitigate against brute force attacks. [CVE-2025-57815](https://github.com/ethyca/fides/security/advisories/GHSA-7q62-r88r-j5gw)
2728
- Adds Redis-driven rate limiting across all endpoints [CVE-2025-57816](https://github.com/ethyca/fides/security/advisories/GHSA-fq34-xw6c-fphf)
@@ -61,9 +62,6 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o
6162

6263
### Fixed
6364
- Fixed bug with non-applicable notices being saved as opted in in Fides.js [#6490](https://github.com/ethyca/fides/pull/6490)
64-
65-
66-
### Fixed
6765
- Handle missing GVL in TCF experience by displaying an error message instead of infinite spinners. [#6472](https://github.com/ethyca/fides/pull/6472)
6866
- Prevent edits for assets that have been ignored in the Action Center [#6485](https://github.com/ethyca/fides/pull/6485)
6967

clients/admin-ui/src/features/user-management/NewPasswordModal.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,19 @@ import {
1313
useToast,
1414
} from "fidesui";
1515
import { Form, Formik } from "formik";
16+
import { useRouter } from "next/router";
17+
import { useDispatch } from "react-redux";
1618
import * as Yup from "yup";
1719

20+
import { useAppSelector } from "~/app/hooks";
21+
import { selectUser } from "~/features/auth/auth.slice";
1822
import { CustomTextInput } from "~/features/common/form/inputs";
1923
import { passwordValidation } from "~/features/common/form/validation";
2024
import { getErrorMessage } from "~/features/common/helpers";
2125
import { errorToastParams, successToastParams } from "~/features/common/toast";
2226
import { isErrorResult } from "~/types/errors";
2327

28+
import { clearAuthAndLogout } from "./logout-helpers";
2429
import { useForceResetUserPasswordMutation } from "./user-management.slice";
2530

2631
const ValidationSchema = Yup.object().shape({
@@ -37,6 +42,9 @@ const useNewPasswordModal = (id: string) => {
3742
const modal = useDisclosure();
3843
const toast = useToast();
3944
const [resetPassword] = useForceResetUserPasswordMutation();
45+
const router = useRouter();
46+
const dispatch = useDispatch();
47+
const currentUser = useAppSelector(selectUser);
4048

4149
const handleResetPassword = async (values: FormValues) => {
4250
const result = await resetPassword({
@@ -52,6 +60,11 @@ const useNewPasswordModal = (id: string) => {
5260
),
5361
);
5462
modal.onClose();
63+
64+
// Only logout if admin reset their own password
65+
if (currentUser?.id === id) {
66+
clearAuthAndLogout(dispatch as any, router);
67+
}
5568
}
5669
};
5770

clients/admin-ui/src/features/user-management/UpdatePasswordModal.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ import {
1313
useDisclosure,
1414
useToast,
1515
} from "fidesui";
16+
import { useRouter } from "next/router";
1617
import React, { useState } from "react";
18+
import { useDispatch } from "react-redux";
1719

1820
import { successToastParams } from "../common/toast";
21+
import { clearAuthAndLogout } from "./logout-helpers";
1922
import { useUpdateUserPasswordMutation } from "./user-management.slice";
2023

2124
const useUpdatePasswordModal = (id: string) => {
@@ -24,6 +27,8 @@ const useUpdatePasswordModal = (id: string) => {
2427
const [oldPasswordValue, setOldPasswordValue] = useState("");
2528
const [newPasswordValue, setNewPasswordValue] = useState("");
2629
const [changePassword, { isLoading }] = useUpdateUserPasswordMutation();
30+
const router = useRouter();
31+
const dispatch = useDispatch();
2732

2833
const changePasswordValidation = !!(
2934
id &&
@@ -49,7 +54,9 @@ const useUpdatePasswordModal = (id: string) => {
4954
.unwrap()
5055
.then(() => {
5156
toast(successToastParams("Password updated"));
52-
modal.onClose();
57+
clearAuthAndLogout(dispatch as any, router, {
58+
onClose: modal.onClose,
59+
});
5360
});
5461
}
5562
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { NextRouter } from "next/router";
2+
3+
import { AppDispatch } from "~/app/store";
4+
import { LOGIN_ROUTE, STORAGE_ROOT_KEY } from "~/constants";
5+
import { logout } from "~/features/auth/auth.slice";
6+
7+
export const clearAuthAndLogout = (
8+
dispatch: AppDispatch,
9+
router: NextRouter,
10+
opts?: { onClose?: () => void },
11+
) => {
12+
try {
13+
localStorage.removeItem(STORAGE_ROOT_KEY);
14+
} catch (e) {
15+
// no-op
16+
}
17+
dispatch(logout());
18+
opts?.onClose?.();
19+
router.push(LOGIN_ROUTE);
20+
};

src/fides/api/api/v1/endpoints/user_endpoints.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,17 @@ def update_user_password(
208208

209209
current_user.update_password(db=db, new_password=data.new_password)
210210

211+
# Delete the user's associated OAuth client to invalidate all existing sessions
212+
if current_user.client:
213+
try:
214+
current_user.client.delete(db)
215+
except Exception as exc:
216+
logger.exception(
217+
"Unable to delete user client during password reset for user {}: {}",
218+
current_user.id,
219+
exc,
220+
)
221+
211222
logger.info("Updated user with id: '{}'.", current_user.id)
212223
return current_user
213224

@@ -236,6 +247,18 @@ def force_update_password(
236247
)
237248

238249
user.update_password(db=db, new_password=data.new_password)
250+
251+
# Delete the user's associated OAuth client to invalidate all existing sessions
252+
if user.client:
253+
try:
254+
user.client.delete(db)
255+
except Exception as exc:
256+
logger.exception(
257+
"Unable to delete user client during admin-forced password reset for user {}: {}",
258+
user.id,
259+
exc,
260+
)
261+
239262
logger.info("Updated user with id: '{}'.", user.id)
240263
return user
241264

src/fides/api/models/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def __tablename__(self) -> str:
5151
user_id = Column(
5252
String, ForeignKey(FidesUser.id_field_path), nullable=True, unique=True
5353
)
54+
user: Optional["FidesUser"]
5455

5556
@classmethod
5657
def create_client_and_secret(

src/fides/api/oauth/utils.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from datetime import datetime
55
from functools import update_wrapper
66
from types import FunctionType
7-
from typing import Any, Callable, Dict, List, Optional, Tuple
7+
from typing import Any, Callable, Dict, List, Optional, Tuple, cast
88

99
from fastapi import Depends, HTTPException, Request, Security
1010
from fastapi.security import SecurityScopes
@@ -80,6 +80,28 @@ def is_callback_token_expired(issued_at: Optional[datetime]) -> bool:
8080
).total_seconds() / 60.0 > CONFIG.execution.privacy_request_delay_timeout
8181

8282

83+
def is_token_invalidated(issued_at: datetime, client: ClientDetail) -> bool:
84+
"""
85+
Return True if the token should be considered invalid due to security events
86+
(e.g., user password reset) that occurred after the token was issued.
87+
88+
Any errors accessing related objects are logged and treated as non-invalidating.
89+
"""
90+
try:
91+
if (
92+
client.user is not None
93+
and client.user.password_reset_at is not None
94+
and issued_at < client.user.password_reset_at
95+
):
96+
return True
97+
return False
98+
except Exception as exc:
99+
logger.exception(
100+
"Unable to evaluate password reset timestamp for client user: {}", exc
101+
)
102+
return False
103+
104+
83105
def _get_webhook_jwe_or_error(
84106
security_scopes: SecurityScopes, authorization: str = Security(oauth2_scheme)
85107
) -> WebhookJWE:
@@ -225,7 +247,7 @@ async def get_current_user(
225247
created_at=datetime.utcnow(),
226248
)
227249

228-
return client.user # type: ignore[attr-defined]
250+
return cast(FidesUser, client.user)
229251

230252

231253
def verify_callback_oauth_policy_pre_webhook(
@@ -370,8 +392,10 @@ def extract_token_and_load_client(
370392
logger.debug("Auth token expired.")
371393
raise AuthorizationError(detail="Not Authorized for this action")
372394

395+
issued_at_dt = datetime.fromisoformat(issued_at)
396+
373397
if is_token_expired(
374-
datetime.fromisoformat(issued_at),
398+
issued_at_dt,
375399
token_duration_override or CONFIG.security.oauth_access_token_expire_minutes,
376400
):
377401
raise AuthorizationError(detail="Not Authorized for this action")
@@ -394,6 +418,12 @@ def extract_token_and_load_client(
394418
logger.debug("Auth token belongs to an invalid client_id.")
395419
raise AuthorizationError(detail="Not Authorized for this action")
396420

421+
# Invalidate tokens issued prior to the user's most recent password reset.
422+
# This ensures any existing sessions are expired immediately after a password change.
423+
if is_token_invalidated(issued_at_dt, client):
424+
logger.debug("Auth token issued before latest password reset.")
425+
raise AuthorizationError(detail="Not Authorized for this action")
426+
397427
# Populate request-scoped context with the authenticated user identifier.
398428
# Prefer the linked user_id; fall back to the client id when this is the
399429
# special root client (which has no associated FidesUser row).

0 commit comments

Comments
 (0)