This commit is contained in:
Timothy Jaeryang Baek 2026-10-29 06:49:47 +09:00
parent 360d81dfe8
commit fdaa240d44
4 changed files with 118 additions and 14 deletions

View file

@ -16,7 +16,7 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR
from open_webui.internal.db import get_async_session
from open_webui.models.auths import Auths
from open_webui.models.auths import ApiKey, Auths
from open_webui.models.config import Config
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.chats import Chats
@ -41,6 +41,7 @@ from open_webui.models.models import Models
from open_webui.models.tools import Tools
from open_webui.utils.access_control import fill_missing_permissions, get_permissions, has_permission
from open_webui.utils.auth import (
create_api_key,
get_admin_user,
get_password_hash,
get_verified_user,
@ -925,6 +926,42 @@ async def revoke_user_sessions(request: Request, user_id: str, session_user=Depe
return True
@router.post('/{user_id}/api_key/rotate', response_model=ApiKey)
async def rotate_user_api_key_by_id(
request: Request,
response: Response,
user_id: str,
session_user: UserModel = Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
target_user = await Users.get_user_by_id(user_id, db=db)
if target_user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.USER_NOT_FOUND)
first_user = await Users.get_first_user(db=db)
if first_user and first_user.id == user_id and session_user.id != user_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
if not await Config.get('auth.enable_api_keys') or (
target_user.role != 'admin'
and not await has_permission(target_user.id, 'features.api_keys', await Config.get('user.permissions'), db=db)
):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED)
api_key = create_api_key()
await Users.update_user_api_key_by_id(user_id, api_key, db=db)
await publish_event(
request,
EVENTS.AUTH_API_KEY_CREATED,
actor=session_user,
subject_id=user_id,
subject_type='user',
source='admin',
)
response.headers['Cache-Control'] = 'no-store'
return {'api_key': api_key}
@router.delete('/{user_id}/api_key', response_model=bool)
async def delete_user_api_key_by_id(
request: Request,

View file

@ -758,6 +758,16 @@ export const revokeUserSessions = async (token: string, userId: string) => {
return result;
};
export const rotateUserAPIKeyById = async (token: string, userId: string): Promise<string> => {
const response = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}/api_key/rotate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || 'Failed to rotate API key');
return result.api_key;
};
export const deleteUserAPIKeyById = async (token: string, userId: string) => {
const response = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}/api_key`, {
method: 'DELETE',

View file

@ -10,14 +10,17 @@
updateUserById,
getUserGroupsById,
revokeUserSessions,
rotateUserAPIKeyById,
deleteUserAPIKeyById
} from '$lib/apis/users';
import { copyToClipboard } from '$lib/utils';
import Modal from '$lib/components/common/Modal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
let showRevokeConfirmation = false;
let showDeleteAPIKeyConfirmation = false;
let deletingAPIKey = false;
let showRotateAPIKeyConfirmation = false;
let rotatedAPIKey = '';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import XMark from '$lib/components/icons/XMark.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
@ -31,11 +34,14 @@
export let selectedUser;
export let sessionUser;
$: if (show) {
$: if (show && selectedUser) {
init();
} else {
rotatedAPIKey = '';
}
const init = () => {
rotatedAPIKey = '';
if (selectedUser) {
_user = { ...selectedUser, password: '' };
loadUserGroups();
@ -74,20 +80,38 @@
};
</script>
<ConfirmDialog
bind:show={showRotateAPIKeyConfirmation}
title={$i18n.t('Rotate API Key?')}
message={$i18n.t(
'This will replace the current API key. Integrations using the old key will stop working.'
)}
confirmLabel={$i18n.t('Rotate')}
on:confirm={async () => {
const target = _user;
try {
const key = await rotateUserAPIKeyById(localStorage.token, selectedUser.id);
if (show && target === _user) rotatedAPIKey = key;
toast.success($i18n.t('API Key created.'));
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
}}
/>
<ConfirmDialog
bind:show={showDeleteAPIKeyConfirmation}
title={$i18n.t('Delete API Key?')}
message={$i18n.t('This will revoke the current API key.')}
confirmLabel={$i18n.t('Delete')}
on:confirm={async () => {
deletingAPIKey = true;
const target = _user;
try {
await deleteUserAPIKeyById(localStorage.token, selectedUser.id);
if (target === _user) rotatedAPIKey = '';
toast.success($i18n.t('API Key deleted.'));
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
deletingAPIKey = false;
}
}}
/>
@ -258,15 +282,44 @@
</div>
<div class="flex items-center justify-between w-full">
<div class="text-xs text-gray-500">{$i18n.t('API Key')}</div>
<button
type="button"
class="text-xs text-gray-500 transition-colors hover:text-gray-700 disabled:opacity-50 dark:text-gray-400 dark:hover:text-gray-200"
disabled={deletingAPIKey}
on:click={() => {
showDeleteAPIKeyConfirmation = true;
}}>{$i18n.t('Delete')}</button
>
<div class="flex items-center gap-3">
<button
type="button"
class="text-xs text-gray-500 transition-colors hover:text-gray-700 disabled:opacity-50 dark:text-gray-400 dark:hover:text-gray-200"
on:click={() => {
showRotateAPIKeyConfirmation = true;
}}>{$i18n.t('Rotate')}</button
>
<button
type="button"
class="text-xs text-gray-500 transition-colors hover:text-gray-700 disabled:opacity-50 dark:text-gray-400 dark:hover:text-gray-200"
on:click={() => {
showDeleteAPIKeyConfirmation = true;
}}>{$i18n.t('Delete')}</button
>
</div>
</div>
{#if rotatedAPIKey}
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<SensitiveInput variant="settings" value={rotatedAPIKey} readOnly={true} />
<button
type="button"
class="shrink-0 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-200"
aria-label={$i18n.t('Copy API Key')}
on:click={async () => {
if (await copyToClipboard(rotatedAPIKey))
toast.success($i18n.t('Copied to clipboard'));
}}>{$i18n.t('Copy')}</button
>
</div>
<p class="text-xs text-gray-500">
{$i18n.t(
'Copy this key now. It will not be shown here again after you close this dialog.'
)}
</p>
</div>
{/if}
</div>
</div>
</div>

View file

@ -660,6 +660,7 @@
"Copy Prompt": "",
"Copy proposed revision to clipboard": "",
"Copy Share Link": "",
"Copy this key now. It will not be shown here again after you close this dialog.": "",
"Copy to clipboard": "",
"Copy Token": "",
"Copy URL": "",
@ -2328,6 +2329,8 @@
"Revised": "",
"Rewrite the selected text.": "",
"Role": "",
"Rotate": "",
"Rotate API Key?": "",
"RTL": "",
"Run": "",
"Run AI on your own terms. Connect any model, extend with code, and protect what matters without compromise. Your models, your data, your machine, wherever you open it.": "",
@ -3887,6 +3890,7 @@
"This will delete the cached model and cannot be undone.": "",
"This will permanently delete the calendar \"{{name}}\" and all its events. This action cannot be undone.": "",
"This will remove all files and directories from this knowledge base. This action cannot be undone.": "",
"This will replace the current API key. Integrations using the old key will stop working.": "",
"This will reset the saved settings for this base model to their defaults. The model will remain available.": "",
"This will revoke the current API key.": "",
"Thorough explanation": "",