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 @@