fix: revoke existing sessions when a password changes (#28725)
Some checks failed
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled

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.
This commit is contained in:
Classic298 2026-08-17 22:56:29 +02:00 committed by GitHub
parent 3fc491d22f
commit 21e390561d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 49 additions and 14 deletions

View file

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

View file

@ -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 = {}

View file

@ -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 ') :]

View file

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

View file

@ -1,7 +1,8 @@
<script lang="ts">
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { updateUserPassword } from '$lib/apis/auths';
import { updateUserPassword, userSignOut } from '$lib/apis/auths';
import { user } from '$lib/stores';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
const i18n = getContext('i18n');
@ -23,7 +24,20 @@
);
if (res) {
toast.success($i18n.t('Successfully updated.'));
// This session is no longer trusted once the password it was issued under changes
toast.success($i18n.t('Password updated. Please sign in again.'));
localStorage.removeItem('token');
user.set(null);
const signOutRes = await userSignOut().catch((error) => {
console.error(error);
return null;
});
if (signOutRes?.redirect_url) {
location.href = signOutRes.redirect_url;
}
}
currentPassword = '';

View file

@ -1997,6 +1997,7 @@
"Passthrough params": "",
"Password": "",
"Password for the bind DN.": "",
"Password updated. Please sign in again.": "",
"Passwords do not match.": "",
"Paste inserted prompts as rich text when possible.": "",
"Paste Large Text as File": "",