From 21e390561d97b66b0acf984e812c145445a2a5f9 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:56:29 +0200 Subject: [PATCH] fix: revoke existing sessions when a password changes (#28725) Changing a password left every other logged-in device working until the JWT expired on its own, up to four weeks with the default settings. The hardening docs already promise the opposite: with Redis configured a password change is supposed to put the user's tokens on the revocation list, but only sign-out and OIDC back-channel logout ever wrote to it. Both password-change paths, self-service and an admin resetting someone's password, now stamp the per-user revocation marker that token validation already checks, so every session issued before the change stops working. The acting device is signed out as well and asked to sign in again, which is the safer default when the password is being changed precisely because the old one may be compromised. Without Redis nothing can be revoked, as before, and the backend now logs a warning saying so. The marker is written through one shared helper, so its lifetime follows the configured JWT lifetime instead of a fixed 30 days and never expires at all when JWT_EXPIRES_IN disables expiry. Back-channel logout picks that up too, where a long or disabled JWT lifetime previously let the marker expire while the tokens it revoked were still valid. API keys keep working, they are separate credentials with their own lifecycle. Discussed in #28647. --- backend/open_webui/routers/auths.py | 2 ++ backend/open_webui/routers/users.py | 4 ++- backend/open_webui/utils/auth.py | 28 +++++++++++++++++-- backend/open_webui/utils/oauth.py | 10 ++----- .../Settings/Account/UpdatePassword.svelte | 18 ++++++++++-- src/lib/i18n/locales/en-US/translation.json | 1 + 6 files changed, 49 insertions(+), 14 deletions(-) diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 73e7858825..d3cdcb162d 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -70,6 +70,7 @@ from open_webui.utils.auth import ( get_password_hash, get_verified_user, invalidate_token, + revoke_user_tokens, validate_password, verify_password, ) @@ -406,6 +407,7 @@ async def update_password( hashed = await get_password_hash(form_data.new_password) success = await Auths.update_user_password_by_id(user.id, hashed, db=db) if success: + await revoke_user_tokens(request, user.id) await publish_event( request, EVENTS.AUTH_PASSWORD_CHANGED, diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 24c2433687..43d4499547 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -41,6 +41,7 @@ from open_webui.utils.auth import ( get_admin_user, get_password_hash, get_verified_user, + revoke_user_tokens, validate_password, ) from open_webui.utils.chat_variables import ChatVariablesError, normalize_user_variables, validate_user_variables @@ -964,7 +965,8 @@ async def update_user_by_id( raise HTTPException(400, detail=str(e)) hashed = await get_password_hash(form_data.password) - await Auths.update_user_password_by_id(user_id, hashed, db=db) + if await Auths.update_user_password_by_id(user_id, hashed, db=db): + await revoke_user_tokens(request, user_id) # Build update dict from only the provided fields update_data = {} diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 0443a34cbb..408b518c13 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -40,6 +40,7 @@ from open_webui.models.config import Config from open_webui.models.users import Users from open_webui.utils.access_control import has_permission from open_webui.utils.json_codec import JSONCodec +from open_webui.utils.misc import parse_duration from pytz import UTC log = logging.getLogger(__name__) @@ -251,8 +252,8 @@ async def is_valid_token(decoded, redis=None) -> bool: """ Check whether a JWT has been revoked. Two mechanisms: 1. Per-token (jti) — used by user-initiated sign-out (known jti). - 2. Per-user (revoked_at) — used by OIDC back-channel logout when - individual jti values are unknown; rejects tokens with iat <= revoked_at. + 2. Per-user (revoked_at) — used by password changes and OIDC back-channel + logout when individual jti values are unknown; rejects tokens with iat <= revoked_at. """ if redis: # Per-token revocation @@ -262,7 +263,7 @@ async def is_valid_token(decoded, redis=None) -> bool: if revoked: return False - # Per-user revocation (OIDC back-channel logout) + # Per-user revocation (password change, OIDC back-channel logout) user_id = decoded.get('id') if user_id: revoked_at = await redis.get(f'{REDIS_KEY_PREFIX}:auth:user:{user_id}:revoked_at') @@ -303,6 +304,27 @@ async def invalidate_token(request, token): ) +async def revoke_user_tokens(request, user_id: str): + """Reject every token already issued to a user. Requires Redis.""" + redis = request.app.state.redis + + if not redis: + log.warning( + 'Cannot revoke tokens for user %s: Redis is not configured, existing sessions stay valid until expiry.', + user_id, + ) + return + + # The marker has to outlive every token it revokes, so it never expires when tokens do not + expires_delta = parse_duration(await Config.get('auth.jwt_expiry')) + + await redis.set( + f'{REDIS_KEY_PREFIX}:auth:user:{user_id}:revoked_at', + str(int(datetime.now(UTC).timestamp())), + ex=int(expires_delta.total_seconds()) if expires_delta else None, + ) + + def extract_token_from_auth_header(auth_header: str): return auth_header[len('Bearer ') :] diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 28e7beb0ce..9f917dab36 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -5,7 +5,6 @@ import hashlib import logging import re import sys -import time import urllib import uuid from dataclasses import dataclass, field @@ -73,7 +72,6 @@ from open_webui.env import ( ENABLE_OAUTH_ID_TOKEN_COOKIE, OAUTH_CLIENT_INFO_ENCRYPTION_KEY, OAUTH_MAX_SESSIONS_PER_USER, - REDIS_KEY_PREFIX, WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE, ) @@ -89,6 +87,7 @@ from open_webui.utils.auth import ( get_password_hash, get_optional_verified_user_from_request, get_verified_user_by_id, + revoke_user_tokens, ) from open_webui.utils.groups import apply_default_group_assignment from open_webui.utils.misc import parse_duration @@ -2391,12 +2390,7 @@ class OAuthManager: await OAuthSessions.delete_session_by_id(oauth_session.id, db=db) if redis: - revocation_key = f'{REDIS_KEY_PREFIX}:auth:user:{user.id}:revoked_at' - await redis.set( - revocation_key, - str(int(time.time())), - ex=60 * 60 * 24 * 30, - ) + await revoke_user_tokens(request, user.id) revoked_count += 1 log.info( diff --git a/src/lib/components/chat/Settings/Account/UpdatePassword.svelte b/src/lib/components/chat/Settings/Account/UpdatePassword.svelte index 4c112f4c66..099c7694d2 100644 --- a/src/lib/components/chat/Settings/Account/UpdatePassword.svelte +++ b/src/lib/components/chat/Settings/Account/UpdatePassword.svelte @@ -1,7 +1,8 @@