mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-06 02:48:04 +00:00
fix: enforce JWT revocation on realtime (Socket.IO / terminal WS) auth
Socket.IO connect / user-join / join-channels / join-note and the terminal websocket first-message auth validated tokens with decode_token() only (signature + expiry), so a JWT revoked via sign-out or OIDC back-channel logout still authenticated new realtime connections even though HTTP auth rejected it. Factor the revocation check into is_token_revoked(redis, decoded) and apply it on the realtime paths against the main app Redis where revocations are stored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1a97751e37
commit
e4b3e35dc2
3 changed files with 47 additions and 27 deletions
|
|
@ -206,7 +206,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
from open_webui.utils.auth import decode_token
|
||||
from open_webui.utils.auth import decode_token, is_token_revoked
|
||||
|
||||
# First-message authentication
|
||||
try:
|
||||
|
|
@ -220,6 +220,9 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
|||
if data is None or 'id' not in data:
|
||||
await ws.close(code=4001, reason='Invalid token')
|
||||
return None
|
||||
if await is_token_revoked(ws.app.state.redis, data):
|
||||
await ws.close(code=4001, reason='Invalid token')
|
||||
return None
|
||||
user = await Users.get_user_by_id(data['id'])
|
||||
if user is None:
|
||||
await ws.close(code=4001, reason='User not found')
|
||||
|
|
|
|||
|
|
@ -38,9 +38,10 @@ from open_webui.models.users import UserNameResponse, Users
|
|||
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
|
||||
from open_webui.tasks import create_task, stop_item_tasks
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import decode_token
|
||||
from open_webui.utils.auth import decode_token, is_token_revoked
|
||||
from open_webui.utils.redis import (
|
||||
build_sentinel_url,
|
||||
get_redis_client,
|
||||
get_redis_connection,
|
||||
get_sentinels_from_env,
|
||||
)
|
||||
|
|
@ -338,11 +339,22 @@ async def usage(sid, data):
|
|||
}
|
||||
|
||||
|
||||
# Realtime auth must honour Redis token revocation (sign-out / OIDC back-channel), not just signature/expiry.
|
||||
AUTH_REVOCATION_REDIS = get_redis_client(async_mode=True)
|
||||
|
||||
|
||||
async def _decode_valid_token(token):
|
||||
data = decode_token(token)
|
||||
if data is not None and await is_token_revoked(AUTH_REVOCATION_REDIS, data):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
@sio.event
|
||||
async def connect(sid, environ, auth):
|
||||
user = None
|
||||
if auth and 'token' in auth:
|
||||
data = decode_token(auth['token'])
|
||||
data = await _decode_valid_token(auth['token'])
|
||||
|
||||
if data is not None and 'id' in data:
|
||||
user = await Users.get_user_by_id(data['id'])
|
||||
|
|
@ -369,7 +381,7 @@ async def user_join(sid, data):
|
|||
if not auth or 'token' not in auth:
|
||||
return
|
||||
|
||||
token_data = decode_token(auth['token'])
|
||||
token_data = await _decode_valid_token(auth['token'])
|
||||
if token_data is None or 'id' not in token_data:
|
||||
return
|
||||
|
||||
|
|
@ -438,7 +450,7 @@ async def join_note(sid, data):
|
|||
if not auth or 'token' not in auth:
|
||||
return
|
||||
|
||||
token_data = decode_token(auth['token'])
|
||||
token_data = await _decode_valid_token(auth['token'])
|
||||
if token_data is None or 'id' not in token_data:
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -213,36 +213,41 @@ def decode_token(token: str) -> dict | None:
|
|||
return None
|
||||
|
||||
|
||||
async def is_valid_token(request, decoded) -> bool:
|
||||
async def is_token_revoked(redis, decoded) -> 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.
|
||||
"""
|
||||
if request.app.state.redis:
|
||||
# Per-token revocation
|
||||
jti = decoded.get('jti')
|
||||
if jti:
|
||||
revoked = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked')
|
||||
if revoked:
|
||||
return False
|
||||
if not redis:
|
||||
return False
|
||||
|
||||
# Per-user revocation (OIDC back-channel logout)
|
||||
user_id = decoded.get('id')
|
||||
if user_id:
|
||||
revoked_at = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:auth:user:{user_id}:revoked_at')
|
||||
if revoked_at:
|
||||
try:
|
||||
revoked_at_ts = int(revoked_at)
|
||||
token_iat = decoded.get('iat')
|
||||
# No iat means legacy token — reject since we can't verify issue time
|
||||
if token_iat is None or token_iat <= revoked_at_ts:
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Per-token revocation
|
||||
jti = decoded.get('jti')
|
||||
if jti:
|
||||
if await redis.get(f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked'):
|
||||
return True
|
||||
|
||||
return True
|
||||
# Per-user revocation (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')
|
||||
if revoked_at:
|
||||
try:
|
||||
revoked_at_ts = int(revoked_at)
|
||||
token_iat = decoded.get('iat')
|
||||
# No iat means legacy token — reject since we can't verify issue time
|
||||
if token_iat is None or token_iat <= revoked_at_ts:
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def is_valid_token(request, decoded) -> bool:
|
||||
return not await is_token_revoked(request.app.state.redis, decoded)
|
||||
|
||||
|
||||
async def invalidate_token(request, token):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue