mirror of
https://github.com/open-webui/open-webui.git
synced 2026-04-25 17:15:16 +02:00
1384 lines
52 KiB
Python
1384 lines
52 KiB
Python
import asyncio
|
|
import re
|
|
import uuid
|
|
import time
|
|
import datetime
|
|
import logging
|
|
from aiohttp import ClientSession
|
|
import urllib
|
|
|
|
|
|
from open_webui.models.auths import (
|
|
AddUserForm,
|
|
ApiKey,
|
|
Auths,
|
|
Token,
|
|
LdapForm,
|
|
SigninForm,
|
|
SigninResponse,
|
|
SignupForm,
|
|
UpdatePasswordForm,
|
|
)
|
|
from open_webui.models.users import (
|
|
UserModel,
|
|
UserProfileImageResponse,
|
|
Users,
|
|
UpdateProfileForm,
|
|
UserStatus,
|
|
)
|
|
from open_webui.models.groups import Groups
|
|
from open_webui.models.oauth_sessions import OAuthSessions
|
|
|
|
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
|
|
from open_webui.env import (
|
|
WEBUI_AUTH,
|
|
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
|
|
WEBUI_AUTH_TRUSTED_NAME_HEADER,
|
|
WEBUI_AUTH_TRUSTED_GROUPS_HEADER,
|
|
WEBUI_AUTH_TRUSTED_ROLE_HEADER,
|
|
WEBUI_AUTH_COOKIE_SAME_SITE,
|
|
WEBUI_AUTH_COOKIE_SECURE,
|
|
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
|
ENABLE_INITIAL_ADMIN_SIGNUP,
|
|
ENABLE_OAUTH_TOKEN_EXCHANGE,
|
|
AIOHTTP_CLIENT_SESSION_SSL,
|
|
)
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from fastapi.responses import RedirectResponse, Response, JSONResponse
|
|
from open_webui.config import (
|
|
OPENID_PROVIDER_URL,
|
|
OPENID_END_SESSION_ENDPOINT,
|
|
ENABLE_OAUTH_SIGNUP,
|
|
ENABLE_LDAP,
|
|
ENABLE_PASSWORD_AUTH,
|
|
OAUTH_PROVIDERS,
|
|
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
|
|
)
|
|
from open_webui.utils.oauth import auth_manager_config
|
|
from pydantic import BaseModel
|
|
|
|
from open_webui.utils.misc import parse_duration, validate_email_format
|
|
from open_webui.utils.auth import (
|
|
validate_password,
|
|
verify_password,
|
|
decode_token,
|
|
invalidate_token,
|
|
create_api_key,
|
|
create_token,
|
|
get_admin_user,
|
|
get_verified_user,
|
|
get_current_user,
|
|
get_password_hash,
|
|
get_http_authorization_cred,
|
|
)
|
|
from open_webui.internal.db import get_async_session
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from open_webui.utils.webhook import post_webhook
|
|
from open_webui.utils.access_control import get_permissions, has_permission
|
|
from open_webui.utils.groups import apply_default_group_assignment
|
|
|
|
from open_webui.utils.redis import get_redis_client
|
|
from open_webui.utils.rate_limit import RateLimiter
|
|
|
|
|
|
from typing import Optional, List
|
|
|
|
from ssl import CERT_NONE, CERT_REQUIRED, PROTOCOL_TLS
|
|
|
|
from ldap3 import Server, Connection, NONE, Tls
|
|
from ldap3.utils.conv import escape_filter_chars
|
|
|
|
router = APIRouter()
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Forgive us our failed attempts, as we forgive those
|
|
# who exceed their allotted rate against this gate.
|
|
signin_rate_limiter = RateLimiter(redis_client=get_redis_client(), limit=5 * 3, window=60 * 3)
|
|
|
|
|
|
async def create_session_response(
|
|
request: Request, user, db, response: Response = None, set_cookie: bool = False
|
|
) -> dict:
|
|
"""
|
|
Create JWT token and build session response for a user.
|
|
Shared helper for signin, signup, ldap_auth, add_user, and token_exchange endpoints.
|
|
|
|
Args:
|
|
request: FastAPI request object
|
|
user: User object
|
|
db: Database session
|
|
response: FastAPI response object (required if set_cookie is True)
|
|
set_cookie: Whether to set the auth cookie on the response
|
|
"""
|
|
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
|
expires_at = None
|
|
if expires_delta:
|
|
expires_at = int(time.time()) + int(expires_delta.total_seconds())
|
|
|
|
token = create_token(
|
|
data={'id': user.id},
|
|
expires_delta=expires_delta,
|
|
)
|
|
|
|
if set_cookie and response:
|
|
datetime_expires_at = datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) if expires_at else None
|
|
max_age = int(expires_delta.total_seconds()) if expires_delta else None
|
|
response.set_cookie(
|
|
key='token',
|
|
value=token,
|
|
expires=datetime_expires_at,
|
|
httponly=True,
|
|
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
|
secure=WEBUI_AUTH_COOKIE_SECURE,
|
|
**({'max_age': max_age} if max_age is not None else {}),
|
|
)
|
|
|
|
user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db)
|
|
|
|
return {
|
|
'token': token,
|
|
'token_type': 'Bearer',
|
|
'expires_at': expires_at,
|
|
'id': user.id,
|
|
'email': user.email,
|
|
'name': user.name,
|
|
'role': user.role,
|
|
'profile_image_url': f'/api/v1/users/{user.id}/profile/image',
|
|
'permissions': user_permissions,
|
|
}
|
|
|
|
|
|
############################
|
|
# GetSessionUser
|
|
############################
|
|
|
|
|
|
class SessionUserResponse(Token, UserProfileImageResponse):
|
|
expires_at: Optional[int] = None
|
|
permissions: Optional[dict] = None
|
|
|
|
|
|
class SessionUserInfoResponse(SessionUserResponse, UserStatus):
|
|
bio: Optional[str] = None
|
|
gender: Optional[str] = None
|
|
date_of_birth: Optional[datetime.date] = None
|
|
|
|
|
|
@router.get('/', response_model=SessionUserInfoResponse)
|
|
async def get_session_user(
|
|
request: Request,
|
|
response: Response,
|
|
user=Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
token = None
|
|
auth_header = request.headers.get('Authorization')
|
|
if auth_header:
|
|
auth_token = get_http_authorization_cred(auth_header)
|
|
if auth_token is not None:
|
|
token = auth_token.credentials
|
|
if token is None:
|
|
token = request.cookies.get('token')
|
|
if token is None and getattr(request.state, 'token', None):
|
|
token = request.state.token.credentials
|
|
data = decode_token(token) if token else None
|
|
|
|
expires_at = None
|
|
|
|
if data:
|
|
expires_at = data.get('exp')
|
|
|
|
if (expires_at is not None) and int(time.time()) > expires_at:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
|
)
|
|
|
|
# Set the cookie token
|
|
max_age = int(expires_at - time.time()) if expires_at else None
|
|
response.set_cookie(
|
|
key='token',
|
|
value=token,
|
|
expires=(datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) if expires_at else None),
|
|
httponly=True, # Ensures the cookie is not accessible via JavaScript
|
|
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
|
secure=WEBUI_AUTH_COOKIE_SECURE,
|
|
**({'max_age': max_age} if max_age is not None else {}),
|
|
)
|
|
|
|
user_permissions = await get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db)
|
|
|
|
return {
|
|
'token': token,
|
|
'token_type': 'Bearer',
|
|
'expires_at': expires_at,
|
|
'id': user.id,
|
|
'email': user.email,
|
|
'name': user.name,
|
|
'role': user.role,
|
|
'profile_image_url': user.profile_image_url,
|
|
'bio': user.bio,
|
|
'gender': user.gender,
|
|
'date_of_birth': user.date_of_birth,
|
|
'status_emoji': user.status_emoji,
|
|
'status_message': user.status_message,
|
|
'status_expires_at': user.status_expires_at,
|
|
'permissions': user_permissions,
|
|
}
|
|
|
|
|
|
############################
|
|
# Update Profile
|
|
############################
|
|
|
|
|
|
@router.post('/update/profile', response_model=UserProfileImageResponse)
|
|
async def update_profile(
|
|
form_data: UpdateProfileForm,
|
|
session_user=Depends(get_verified_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
if session_user:
|
|
user = await Users.update_user_by_id(
|
|
session_user.id,
|
|
form_data.model_dump(),
|
|
db=db,
|
|
)
|
|
if user:
|
|
return user
|
|
else:
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.DEFAULT())
|
|
else:
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
|
|
|
|
|
############################
|
|
# Update Timezone
|
|
############################
|
|
|
|
|
|
class UpdateTimezoneForm(BaseModel):
|
|
timezone: str
|
|
|
|
|
|
@router.post('/update/timezone')
|
|
async def update_timezone(
|
|
form_data: UpdateTimezoneForm,
|
|
session_user=Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
if session_user:
|
|
await Users.update_user_by_id(
|
|
session_user.id,
|
|
{'timezone': form_data.timezone},
|
|
db=db,
|
|
)
|
|
return {'status': True}
|
|
else:
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
|
|
|
|
|
############################
|
|
# Update Password
|
|
############################
|
|
|
|
|
|
@router.post('/update/password', response_model=bool)
|
|
async def update_password(
|
|
form_data: UpdatePasswordForm,
|
|
session_user=Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
|
|
if session_user:
|
|
user = await Auths.authenticate_user(
|
|
session_user.email,
|
|
lambda pw: verify_password(form_data.password, pw),
|
|
db=db,
|
|
)
|
|
|
|
if user:
|
|
try:
|
|
validate_password(form_data.new_password)
|
|
except Exception as e:
|
|
raise HTTPException(400, detail=str(e))
|
|
hashed = get_password_hash(form_data.new_password)
|
|
return await Auths.update_user_password_by_id(user.id, hashed, db=db)
|
|
else:
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.INCORRECT_PASSWORD)
|
|
else:
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
|
|
|
|
|
############################
|
|
# LDAP Authentication
|
|
############################
|
|
@router.post('/ldap', response_model=SessionUserResponse)
|
|
async def ldap_auth(
|
|
request: Request,
|
|
response: Response,
|
|
form_data: LdapForm,
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
# Security checks FIRST - before loading any config
|
|
if not request.app.state.config.ENABLE_LDAP:
|
|
raise HTTPException(400, detail='LDAP authentication is not enabled')
|
|
|
|
if not ENABLE_PASSWORD_AUTH:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
|
)
|
|
|
|
# Reject empty passwords before attempting the LDAP bind.
|
|
# Per RFC 4513 §5.1.2, a Simple Bind with a non-empty DN but empty
|
|
# password is "unauthenticated simple authentication" — many LDAP
|
|
# servers (OpenLDAP default, some AD configs) return success for these,
|
|
# which would grant access without valid credentials.
|
|
if not form_data.password or not form_data.password.strip():
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
|
|
|
# NOW load LDAP config variables
|
|
LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL
|
|
LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST
|
|
LDAP_SERVER_PORT = request.app.state.config.LDAP_SERVER_PORT
|
|
LDAP_ATTRIBUTE_FOR_MAIL = request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL
|
|
LDAP_ATTRIBUTE_FOR_USERNAME = request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME
|
|
LDAP_SEARCH_BASE = request.app.state.config.LDAP_SEARCH_BASE
|
|
LDAP_SEARCH_FILTERS = request.app.state.config.LDAP_SEARCH_FILTERS
|
|
LDAP_APP_DN = request.app.state.config.LDAP_APP_DN
|
|
LDAP_APP_PASSWORD = request.app.state.config.LDAP_APP_PASSWORD
|
|
LDAP_USE_TLS = request.app.state.config.LDAP_USE_TLS
|
|
LDAP_CA_CERT_FILE = request.app.state.config.LDAP_CA_CERT_FILE
|
|
LDAP_VALIDATE_CERT = CERT_REQUIRED if request.app.state.config.LDAP_VALIDATE_CERT else CERT_NONE
|
|
LDAP_CIPHERS = request.app.state.config.LDAP_CIPHERS if request.app.state.config.LDAP_CIPHERS else 'ALL'
|
|
|
|
try:
|
|
tls = Tls(
|
|
validate=LDAP_VALIDATE_CERT,
|
|
version=PROTOCOL_TLS,
|
|
ca_certs_file=LDAP_CA_CERT_FILE,
|
|
ciphers=LDAP_CIPHERS,
|
|
)
|
|
except Exception as e:
|
|
log.error(f'TLS configuration error: {str(e)}')
|
|
raise HTTPException(400, detail='Failed to configure TLS for LDAP connection.')
|
|
|
|
try:
|
|
server = Server(
|
|
host=LDAP_SERVER_HOST,
|
|
port=LDAP_SERVER_PORT,
|
|
get_info=NONE,
|
|
use_ssl=LDAP_USE_TLS,
|
|
tls=tls,
|
|
)
|
|
connection_app = Connection(
|
|
server,
|
|
LDAP_APP_DN,
|
|
LDAP_APP_PASSWORD,
|
|
auto_bind='NONE',
|
|
authentication='SIMPLE' if LDAP_APP_DN else 'ANONYMOUS',
|
|
)
|
|
if not await asyncio.to_thread(connection_app.bind):
|
|
raise HTTPException(400, detail='Application account bind failed')
|
|
|
|
ENABLE_LDAP_GROUP_MANAGEMENT = request.app.state.config.ENABLE_LDAP_GROUP_MANAGEMENT
|
|
ENABLE_LDAP_GROUP_CREATION = request.app.state.config.ENABLE_LDAP_GROUP_CREATION
|
|
LDAP_ATTRIBUTE_FOR_GROUPS = request.app.state.config.LDAP_ATTRIBUTE_FOR_GROUPS
|
|
|
|
search_attributes = [
|
|
f'{LDAP_ATTRIBUTE_FOR_USERNAME}',
|
|
f'{LDAP_ATTRIBUTE_FOR_MAIL}',
|
|
'cn',
|
|
]
|
|
if ENABLE_LDAP_GROUP_MANAGEMENT:
|
|
search_attributes.append(f'{LDAP_ATTRIBUTE_FOR_GROUPS}')
|
|
log.info(f'LDAP Group Management enabled. Adding {LDAP_ATTRIBUTE_FOR_GROUPS} to search attributes')
|
|
log.info(f'LDAP search attributes: {search_attributes}')
|
|
|
|
search_success = await asyncio.to_thread(
|
|
connection_app.search,
|
|
search_base=LDAP_SEARCH_BASE,
|
|
search_filter=f'(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})',
|
|
attributes=search_attributes,
|
|
)
|
|
if not search_success or not connection_app.entries:
|
|
raise HTTPException(400, detail='User not found in the LDAP server')
|
|
|
|
entry = connection_app.entries[0]
|
|
entry_username = entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'].value
|
|
email = entry[f'{LDAP_ATTRIBUTE_FOR_MAIL}'].value # retrieve the Attribute value
|
|
|
|
username_list = [] # list of usernames from LDAP attribute
|
|
if isinstance(entry_username, list):
|
|
username_list = [str(name).lower() for name in entry_username]
|
|
else:
|
|
username_list = [str(entry_username).lower()]
|
|
|
|
# TODO: support multiple emails if LDAP returns a list
|
|
if not email:
|
|
raise HTTPException(400, 'User does not have a valid email address.')
|
|
elif isinstance(email, str):
|
|
email = email.lower()
|
|
elif isinstance(email, list):
|
|
email = email[0].lower()
|
|
else:
|
|
email = str(email).lower()
|
|
|
|
cn = str(entry['cn']) # common name
|
|
user_dn = entry.entry_dn # user distinguished name
|
|
|
|
user_groups = []
|
|
if ENABLE_LDAP_GROUP_MANAGEMENT and LDAP_ATTRIBUTE_FOR_GROUPS in entry:
|
|
group_dns = entry[LDAP_ATTRIBUTE_FOR_GROUPS]
|
|
log.info(f'LDAP raw group DNs for user {username_list}: {group_dns}')
|
|
|
|
if group_dns:
|
|
log.info(f'LDAP group_dns original: {group_dns}')
|
|
log.info(f'LDAP group_dns type: {type(group_dns)}')
|
|
log.info(f'LDAP group_dns length: {len(group_dns)}')
|
|
|
|
if hasattr(group_dns, 'value'):
|
|
group_dns = group_dns.value
|
|
log.info(f'Extracted .value property: {group_dns}')
|
|
elif hasattr(group_dns, '__iter__') and not isinstance(group_dns, (str, bytes)):
|
|
group_dns = list(group_dns)
|
|
log.info(f'Converted to list: {group_dns}')
|
|
|
|
if isinstance(group_dns, list):
|
|
group_dns = [str(item) for item in group_dns]
|
|
else:
|
|
group_dns = [str(group_dns)]
|
|
|
|
log.info(f'LDAP group_dns after processing - type: {type(group_dns)}, length: {len(group_dns)}')
|
|
|
|
for group_idx, group_dn in enumerate(group_dns):
|
|
group_dn = str(group_dn)
|
|
log.info(f'Processing group DN #{group_idx + 1}: {group_dn}')
|
|
|
|
try:
|
|
group_cn = None
|
|
|
|
for item in group_dn.split(','):
|
|
item = item.strip()
|
|
if item.upper().startswith('CN='):
|
|
group_cn = item[3:]
|
|
break
|
|
|
|
if group_cn:
|
|
user_groups.append(group_cn)
|
|
|
|
else:
|
|
log.warning(f'Could not extract CN from group DN: {group_dn}')
|
|
except Exception as e:
|
|
log.warning(f'Failed to extract group name from DN {group_dn}: {e}')
|
|
|
|
log.info(f'LDAP groups for user {username_list}: {user_groups} (total: {len(user_groups)})')
|
|
else:
|
|
log.info(f'No groups found for user {username_list}')
|
|
elif ENABLE_LDAP_GROUP_MANAGEMENT:
|
|
log.warning(
|
|
f'LDAP Group Management enabled but {LDAP_ATTRIBUTE_FOR_GROUPS} attribute not found in user entry'
|
|
)
|
|
|
|
if username_list and form_data.user.lower() in username_list:
|
|
connection_user = Connection(
|
|
server,
|
|
user_dn,
|
|
form_data.password,
|
|
auto_bind='NONE',
|
|
authentication='SIMPLE',
|
|
)
|
|
if not await asyncio.to_thread(connection_user.bind):
|
|
raise HTTPException(400, 'Authentication failed.')
|
|
|
|
user = await Users.get_user_by_email(email, db=db)
|
|
if not user:
|
|
try:
|
|
# Insert with default role first to avoid TOCTOU race on
|
|
# first-user registration. Matches signup_handler pattern.
|
|
user = await Auths.insert_new_auth(
|
|
email=email,
|
|
password=str(uuid.uuid4()),
|
|
name=cn,
|
|
role=request.app.state.config.DEFAULT_USER_ROLE,
|
|
db=db,
|
|
)
|
|
|
|
if not user:
|
|
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
|
|
|
# Atomically check if this is the only user *after* the
|
|
# insert. Only the single user present should become admin.
|
|
if await Users.get_num_users(db=db) == 1:
|
|
await Users.update_user_role_by_id(user.id, 'admin', db=db)
|
|
user = await Users.get_user_by_id(user.id, db=db)
|
|
|
|
await apply_default_group_assignment(
|
|
request.app.state.config.DEFAULT_GROUP_ID,
|
|
user.id,
|
|
db=db,
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as err:
|
|
log.error(f'LDAP user creation error: {str(err)}')
|
|
raise HTTPException(500, detail='Internal error occurred during LDAP user creation.')
|
|
|
|
user = await Auths.authenticate_user_by_email(email, db=db)
|
|
|
|
if user:
|
|
if ENABLE_LDAP_GROUP_MANAGEMENT and user_groups:
|
|
if ENABLE_LDAP_GROUP_CREATION:
|
|
await Groups.create_groups_by_group_names(user.id, user_groups, db=db)
|
|
try:
|
|
await Groups.sync_groups_by_group_names(user.id, user_groups, db=db)
|
|
log.info(f'Successfully synced groups for user {user.id}: {user_groups}')
|
|
except Exception as e:
|
|
log.error(f'Failed to sync groups for user {user.id}: {e}')
|
|
|
|
return await create_session_response(request, user, db, response, set_cookie=True)
|
|
else:
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
|
else:
|
|
raise HTTPException(400, 'User record mismatch.')
|
|
except Exception as e:
|
|
log.error(f'LDAP authentication error: {str(e)}')
|
|
raise HTTPException(400, detail='LDAP authentication failed.')
|
|
|
|
|
|
############################
|
|
# SignIn
|
|
############################
|
|
|
|
|
|
@router.post('/signin', response_model=SessionUserResponse)
|
|
async def signin(
|
|
request: Request,
|
|
response: Response,
|
|
form_data: SigninForm,
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
if not ENABLE_PASSWORD_AUTH:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
|
)
|
|
|
|
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
|
|
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER not in request.headers:
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_TRUSTED_HEADER)
|
|
|
|
email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower()
|
|
name = email
|
|
|
|
if WEBUI_AUTH_TRUSTED_NAME_HEADER:
|
|
name = request.headers.get(WEBUI_AUTH_TRUSTED_NAME_HEADER, email)
|
|
try:
|
|
name = urllib.parse.unquote(name, encoding='utf-8')
|
|
except Exception as e:
|
|
pass
|
|
|
|
if not await Users.get_user_by_email(email.lower(), db=db):
|
|
await signup_handler(
|
|
request,
|
|
email,
|
|
str(uuid.uuid4()),
|
|
name,
|
|
db=db,
|
|
)
|
|
|
|
user = await Auths.authenticate_user_by_email(email, db=db)
|
|
if user:
|
|
if WEBUI_AUTH_TRUSTED_GROUPS_HEADER:
|
|
group_names = request.headers.get(WEBUI_AUTH_TRUSTED_GROUPS_HEADER, '').split(',')
|
|
group_names = [name.strip() for name in group_names if name.strip()]
|
|
|
|
if group_names:
|
|
await Groups.sync_groups_by_group_names(user.id, group_names, db=db)
|
|
|
|
if WEBUI_AUTH_TRUSTED_ROLE_HEADER:
|
|
trusted_role = request.headers.get(WEBUI_AUTH_TRUSTED_ROLE_HEADER, '').lower().strip()
|
|
if trusted_role in {'admin', 'user', 'pending'}:
|
|
if user.role != trusted_role:
|
|
await Users.update_user_role_by_id(user.id, trusted_role, db=db)
|
|
elif trusted_role:
|
|
log.warning(f'Ignoring invalid trusted role header value: {trusted_role}')
|
|
|
|
elif WEBUI_AUTH == False:
|
|
admin_email = 'admin@localhost'
|
|
admin_password = 'admin'
|
|
|
|
if await Users.get_user_by_email(admin_email.lower(), db=db):
|
|
user = await Auths.authenticate_user(
|
|
admin_email.lower(),
|
|
lambda pw: verify_password(admin_password, pw),
|
|
db=db,
|
|
)
|
|
else:
|
|
if await Users.has_users(db=db):
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
|
|
|
|
await signup_handler(
|
|
request,
|
|
admin_email,
|
|
admin_password,
|
|
'User',
|
|
db=db,
|
|
)
|
|
|
|
user = await Auths.authenticate_user(
|
|
admin_email.lower(),
|
|
lambda pw: verify_password(admin_password, pw),
|
|
db=db,
|
|
)
|
|
else:
|
|
if signin_rate_limiter.is_limited(form_data.email.lower()):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
|
|
)
|
|
|
|
password_bytes = form_data.password.encode('utf-8')
|
|
if len(password_bytes) > 72:
|
|
# TODO: Implement other hashing algorithms that support longer passwords
|
|
log.info('Password too long, truncating to 72 bytes for bcrypt')
|
|
password_bytes = password_bytes[:72]
|
|
|
|
# decode safely — ignore incomplete UTF-8 sequences
|
|
form_data.password = password_bytes.decode('utf-8', errors='ignore')
|
|
|
|
user = await Auths.authenticate_user(
|
|
form_data.email.lower(),
|
|
lambda pw: verify_password(form_data.password, pw),
|
|
db=db,
|
|
)
|
|
|
|
if user:
|
|
return await create_session_response(request, user, db, response, set_cookie=True)
|
|
else:
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
|
|
|
|
|
############################
|
|
# SignUp
|
|
############################
|
|
|
|
|
|
async def signup_handler(
|
|
request: Request,
|
|
email: str,
|
|
password: str,
|
|
name: str,
|
|
profile_image_url: str = '/user.png',
|
|
*,
|
|
db: AsyncSession,
|
|
) -> UserModel:
|
|
"""
|
|
Core user-creation logic shared by the signup endpoint and
|
|
trusted-header / no-auth auto-registration flows.
|
|
|
|
Returns the newly created UserModel.
|
|
Raises HTTPException on failure.
|
|
"""
|
|
# Insert with default role first to avoid TOCTOU race on first signup.
|
|
# If has_users() is checked before insert, concurrent requests during
|
|
# first-user registration can all see an empty table and each get admin.
|
|
hashed = get_password_hash(password)
|
|
|
|
user = await Auths.insert_new_auth(
|
|
email=email.lower(),
|
|
password=hashed,
|
|
name=name,
|
|
profile_image_url=profile_image_url,
|
|
role=request.app.state.config.DEFAULT_USER_ROLE,
|
|
db=db,
|
|
)
|
|
if not user:
|
|
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
|
|
|
# Atomically check if this is the only user *after* the insert.
|
|
# Only the single user present at this point should become admin.
|
|
if await Users.get_num_users(db=db) == 1:
|
|
await Users.update_user_role_by_id(user.id, 'admin', db=db)
|
|
user = await Users.get_user_by_id(user.id, db=db)
|
|
request.app.state.config.ENABLE_SIGNUP = False
|
|
|
|
if request.app.state.config.WEBHOOK_URL:
|
|
await post_webhook(
|
|
request.app.state.WEBUI_NAME,
|
|
request.app.state.config.WEBHOOK_URL,
|
|
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
|
{
|
|
'action': 'signup',
|
|
'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
|
'user': user.model_dump_json(exclude_none=True),
|
|
},
|
|
)
|
|
|
|
await apply_default_group_assignment(
|
|
request.app.state.config.DEFAULT_GROUP_ID,
|
|
user.id,
|
|
db=db,
|
|
)
|
|
|
|
return user
|
|
|
|
|
|
@router.post('/signup', response_model=SessionUserResponse)
|
|
async def signup(
|
|
request: Request,
|
|
response: Response,
|
|
form_data: SignupForm,
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
has_users = await Users.has_users(db=db)
|
|
|
|
if WEBUI_AUTH:
|
|
if not request.app.state.config.ENABLE_SIGNUP or not request.app.state.config.ENABLE_LOGIN_FORM:
|
|
if has_users or not ENABLE_INITIAL_ADMIN_SIGNUP:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
|
|
else:
|
|
if has_users:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
|
|
|
|
if not validate_email_format(form_data.email.lower()):
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT)
|
|
|
|
if await Users.get_user_by_email(form_data.email.lower(), db=db):
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
|
|
|
|
try:
|
|
try:
|
|
validate_password(form_data.password)
|
|
except Exception as e:
|
|
raise HTTPException(400, detail=str(e))
|
|
|
|
user = await signup_handler(
|
|
request,
|
|
form_data.email,
|
|
form_data.password,
|
|
form_data.name,
|
|
form_data.profile_image_url,
|
|
db=db,
|
|
)
|
|
return await create_session_response(request, user, db, response, set_cookie=True)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as err:
|
|
log.error(f'Signup error: {str(err)}')
|
|
raise HTTPException(500, detail='An internal error occurred during signup.')
|
|
|
|
|
|
@router.get('/signout')
|
|
async def signout(request: Request, response: Response, db: AsyncSession = Depends(get_async_session)):
|
|
# get auth token from headers or cookies
|
|
token = None
|
|
auth_header = request.headers.get('Authorization')
|
|
if auth_header:
|
|
auth_cred = get_http_authorization_cred(auth_header)
|
|
if auth_cred is not None:
|
|
token = auth_cred.credentials
|
|
if token is None:
|
|
token = request.cookies.get('token')
|
|
|
|
if token:
|
|
await invalidate_token(request, token)
|
|
|
|
response.delete_cookie('token')
|
|
response.delete_cookie('oui-session')
|
|
response.delete_cookie('oauth_id_token')
|
|
|
|
oauth_session_id = request.cookies.get('oauth_session_id')
|
|
if oauth_session_id:
|
|
response.delete_cookie('oauth_session_id')
|
|
|
|
session = await OAuthSessions.get_session_by_id(oauth_session_id, db=db)
|
|
|
|
# If a custom end_session_endpoint is configured (e.g. AWS Cognito), redirect
|
|
# there directly instead of attempting OIDC discovery.
|
|
if OPENID_END_SESSION_ENDPOINT.value:
|
|
return JSONResponse(
|
|
status_code=200,
|
|
content={
|
|
'status': True,
|
|
'redirect_url': OPENID_END_SESSION_ENDPOINT.value,
|
|
},
|
|
headers=response.headers,
|
|
)
|
|
|
|
oauth_server_metadata_url = (
|
|
request.app.state.oauth_manager.get_server_metadata_url(session.provider) if session else None
|
|
) or OPENID_PROVIDER_URL.value
|
|
|
|
if session and oauth_server_metadata_url:
|
|
oauth_id_token = session.token.get('id_token')
|
|
try:
|
|
async with ClientSession(trust_env=True) as session:
|
|
async with session.get(oauth_server_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r:
|
|
if r.status == 200:
|
|
openid_data = await r.json()
|
|
logout_url = openid_data.get('end_session_endpoint')
|
|
|
|
if logout_url:
|
|
return JSONResponse(
|
|
status_code=200,
|
|
content={
|
|
'status': True,
|
|
'redirect_url': f'{logout_url}?id_token_hint={oauth_id_token}'
|
|
+ (
|
|
f'&post_logout_redirect_uri={WEBUI_AUTH_SIGNOUT_REDIRECT_URL}'
|
|
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL
|
|
else ''
|
|
),
|
|
},
|
|
headers=response.headers,
|
|
)
|
|
else:
|
|
raise Exception('Failed to fetch OpenID configuration')
|
|
|
|
except Exception as e:
|
|
log.error(f'OpenID signout error: {str(e)}')
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail='Failed to sign out from the OpenID provider.',
|
|
headers=response.headers,
|
|
)
|
|
|
|
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
|
|
return JSONResponse(
|
|
status_code=200,
|
|
content={
|
|
'status': True,
|
|
'redirect_url': WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
|
|
},
|
|
headers=response.headers,
|
|
)
|
|
|
|
return JSONResponse(status_code=200, content={'status': True}, headers=response.headers)
|
|
|
|
|
|
############################
|
|
# OAuth Session Management
|
|
############################
|
|
|
|
|
|
@router.delete('/oauth/sessions/{provider:path}', response_model=bool)
|
|
async def delete_oauth_session_by_provider(
|
|
provider: str,
|
|
user=Depends(get_verified_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""
|
|
Disconnect the current user's OAuth session for a specific provider.
|
|
The provider string matches the 'provider' field in the oauth_session table
|
|
(e.g. 'mcp:server-id' for MCP connections).
|
|
"""
|
|
result = await OAuthSessions.delete_sessions_by_user_id_and_provider(
|
|
user.id, provider, db=db
|
|
)
|
|
if not result:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail='No OAuth session found for this provider',
|
|
)
|
|
return True
|
|
|
|
|
|
############################
|
|
# AddUser
|
|
############################
|
|
|
|
|
|
@router.post('/add', response_model=SigninResponse)
|
|
async def add_user(
|
|
request: Request,
|
|
form_data: AddUserForm,
|
|
user=Depends(get_admin_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
if not validate_email_format(form_data.email.lower()):
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT)
|
|
|
|
if await Users.get_user_by_email(form_data.email.lower(), db=db):
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
|
|
|
|
try:
|
|
try:
|
|
validate_password(form_data.password)
|
|
except Exception as e:
|
|
raise HTTPException(400, detail=str(e))
|
|
|
|
hashed = get_password_hash(form_data.password)
|
|
user = await Auths.insert_new_auth(
|
|
form_data.email.lower(),
|
|
hashed,
|
|
form_data.name,
|
|
form_data.profile_image_url,
|
|
form_data.role,
|
|
db=db,
|
|
)
|
|
|
|
if user:
|
|
await apply_default_group_assignment(
|
|
request.app.state.config.DEFAULT_GROUP_ID,
|
|
user.id,
|
|
db=db,
|
|
)
|
|
|
|
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
|
|
token = create_token(data={'id': user.id}, expires_delta=expires_delta)
|
|
return {
|
|
'token': token,
|
|
'token_type': 'Bearer',
|
|
'id': user.id,
|
|
'email': user.email,
|
|
'name': user.name,
|
|
'role': user.role,
|
|
'profile_image_url': f'/api/v1/users/{user.id}/profile/image',
|
|
}
|
|
else:
|
|
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as err:
|
|
log.error(f'Add user error: {str(err)}')
|
|
raise HTTPException(500, detail='An internal error occurred while adding the user.')
|
|
|
|
|
|
############################
|
|
# GetAdminDetails
|
|
############################
|
|
|
|
|
|
@router.get('/admin/details')
|
|
async def get_admin_details(
|
|
request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
|
|
):
|
|
if request.app.state.config.SHOW_ADMIN_DETAILS:
|
|
admin_email = request.app.state.config.ADMIN_EMAIL
|
|
admin_name = None
|
|
|
|
log.info(f'Admin details - Email: {admin_email}, Name: {admin_name}')
|
|
|
|
if admin_email:
|
|
admin = await Users.get_user_by_email(admin_email, db=db)
|
|
if admin:
|
|
admin_name = admin.name
|
|
else:
|
|
admin = await Users.get_first_user(db=db)
|
|
if admin:
|
|
admin_email = admin.email
|
|
admin_name = admin.name
|
|
|
|
return {
|
|
'name': admin_name,
|
|
'email': admin_email,
|
|
}
|
|
else:
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
|
|
|
|
|
|
############################
|
|
# ToggleSignUp
|
|
############################
|
|
|
|
|
|
@router.get('/admin/config')
|
|
async def get_admin_config(request: Request, user=Depends(get_admin_user)):
|
|
return {
|
|
'SHOW_ADMIN_DETAILS': request.app.state.config.SHOW_ADMIN_DETAILS,
|
|
'ADMIN_EMAIL': request.app.state.config.ADMIN_EMAIL,
|
|
'WEBUI_URL': request.app.state.config.WEBUI_URL,
|
|
'ENABLE_SIGNUP': request.app.state.config.ENABLE_SIGNUP,
|
|
'ENABLE_API_KEYS': request.app.state.config.ENABLE_API_KEYS,
|
|
'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
|
|
'API_KEYS_ALLOWED_ENDPOINTS': request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
|
|
'DEFAULT_USER_ROLE': request.app.state.config.DEFAULT_USER_ROLE,
|
|
'DEFAULT_GROUP_ID': request.app.state.config.DEFAULT_GROUP_ID,
|
|
'JWT_EXPIRES_IN': request.app.state.config.JWT_EXPIRES_IN,
|
|
'ENABLE_COMMUNITY_SHARING': request.app.state.config.ENABLE_COMMUNITY_SHARING,
|
|
'ENABLE_MESSAGE_RATING': request.app.state.config.ENABLE_MESSAGE_RATING,
|
|
'ENABLE_FOLDERS': request.app.state.config.ENABLE_FOLDERS,
|
|
'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT,
|
|
'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT,
|
|
'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL,
|
|
'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS,
|
|
'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS,
|
|
'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR,
|
|
'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES,
|
|
'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES,
|
|
'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS,
|
|
'ENABLE_USER_STATUS': request.app.state.config.ENABLE_USER_STATUS,
|
|
'PENDING_USER_OVERLAY_TITLE': request.app.state.config.PENDING_USER_OVERLAY_TITLE,
|
|
'PENDING_USER_OVERLAY_CONTENT': request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
|
|
'RESPONSE_WATERMARK': request.app.state.config.RESPONSE_WATERMARK,
|
|
}
|
|
|
|
|
|
class AdminConfig(BaseModel):
|
|
SHOW_ADMIN_DETAILS: bool
|
|
ADMIN_EMAIL: Optional[str] = None
|
|
WEBUI_URL: str
|
|
ENABLE_SIGNUP: bool
|
|
ENABLE_API_KEYS: bool
|
|
ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: bool
|
|
API_KEYS_ALLOWED_ENDPOINTS: str
|
|
DEFAULT_USER_ROLE: str
|
|
DEFAULT_GROUP_ID: str
|
|
JWT_EXPIRES_IN: str
|
|
ENABLE_COMMUNITY_SHARING: bool
|
|
ENABLE_MESSAGE_RATING: bool
|
|
ENABLE_FOLDERS: bool
|
|
FOLDER_MAX_FILE_COUNT: Optional[int | str] = None
|
|
AUTOMATION_MAX_COUNT: Optional[int | str] = None
|
|
AUTOMATION_MIN_INTERVAL: Optional[int | str] = None
|
|
ENABLE_AUTOMATIONS: bool
|
|
ENABLE_CHANNELS: bool
|
|
ENABLE_CALENDAR: bool
|
|
ENABLE_MEMORIES: bool
|
|
ENABLE_NOTES: bool
|
|
ENABLE_USER_WEBHOOKS: bool
|
|
ENABLE_USER_STATUS: bool
|
|
PENDING_USER_OVERLAY_TITLE: Optional[str] = None
|
|
PENDING_USER_OVERLAY_CONTENT: Optional[str] = None
|
|
RESPONSE_WATERMARK: Optional[str] = None
|
|
|
|
|
|
@router.post('/admin/config')
|
|
async def update_admin_config(request: Request, form_data: AdminConfig, user=Depends(get_admin_user)):
|
|
request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS
|
|
request.app.state.config.ADMIN_EMAIL = form_data.ADMIN_EMAIL
|
|
request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
|
|
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
|
|
|
|
request.app.state.config.ENABLE_API_KEYS = form_data.ENABLE_API_KEYS
|
|
request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = form_data.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS
|
|
request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS = form_data.API_KEYS_ALLOWED_ENDPOINTS
|
|
|
|
request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS
|
|
request.app.state.config.FOLDER_MAX_FILE_COUNT = (
|
|
int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else ''
|
|
)
|
|
request.app.state.config.AUTOMATION_MAX_COUNT = (
|
|
int(form_data.AUTOMATION_MAX_COUNT) if form_data.AUTOMATION_MAX_COUNT else ''
|
|
)
|
|
request.app.state.config.AUTOMATION_MIN_INTERVAL = (
|
|
int(form_data.AUTOMATION_MIN_INTERVAL) if form_data.AUTOMATION_MIN_INTERVAL else ''
|
|
)
|
|
request.app.state.config.ENABLE_AUTOMATIONS = form_data.ENABLE_AUTOMATIONS
|
|
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
|
|
request.app.state.config.ENABLE_CALENDAR = form_data.ENABLE_CALENDAR
|
|
request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES
|
|
request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
|
|
|
|
if form_data.DEFAULT_USER_ROLE in ['pending', 'user', 'admin']:
|
|
request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
|
|
|
|
request.app.state.config.DEFAULT_GROUP_ID = form_data.DEFAULT_GROUP_ID
|
|
|
|
pattern = r'^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$'
|
|
|
|
# Check if the input string matches the pattern
|
|
if re.match(pattern, form_data.JWT_EXPIRES_IN):
|
|
request.app.state.config.JWT_EXPIRES_IN = form_data.JWT_EXPIRES_IN
|
|
|
|
request.app.state.config.ENABLE_COMMUNITY_SHARING = form_data.ENABLE_COMMUNITY_SHARING
|
|
request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING
|
|
|
|
request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS
|
|
request.app.state.config.ENABLE_USER_STATUS = form_data.ENABLE_USER_STATUS
|
|
|
|
request.app.state.config.PENDING_USER_OVERLAY_TITLE = form_data.PENDING_USER_OVERLAY_TITLE
|
|
request.app.state.config.PENDING_USER_OVERLAY_CONTENT = form_data.PENDING_USER_OVERLAY_CONTENT
|
|
|
|
request.app.state.config.RESPONSE_WATERMARK = form_data.RESPONSE_WATERMARK
|
|
|
|
return {
|
|
'SHOW_ADMIN_DETAILS': request.app.state.config.SHOW_ADMIN_DETAILS,
|
|
'ADMIN_EMAIL': request.app.state.config.ADMIN_EMAIL,
|
|
'WEBUI_URL': request.app.state.config.WEBUI_URL,
|
|
'ENABLE_SIGNUP': request.app.state.config.ENABLE_SIGNUP,
|
|
'ENABLE_API_KEYS': request.app.state.config.ENABLE_API_KEYS,
|
|
'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
|
|
'API_KEYS_ALLOWED_ENDPOINTS': request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
|
|
'DEFAULT_USER_ROLE': request.app.state.config.DEFAULT_USER_ROLE,
|
|
'DEFAULT_GROUP_ID': request.app.state.config.DEFAULT_GROUP_ID,
|
|
'JWT_EXPIRES_IN': request.app.state.config.JWT_EXPIRES_IN,
|
|
'ENABLE_COMMUNITY_SHARING': request.app.state.config.ENABLE_COMMUNITY_SHARING,
|
|
'ENABLE_MESSAGE_RATING': request.app.state.config.ENABLE_MESSAGE_RATING,
|
|
'ENABLE_FOLDERS': request.app.state.config.ENABLE_FOLDERS,
|
|
'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT,
|
|
'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT,
|
|
'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL,
|
|
'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS,
|
|
'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS,
|
|
'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR,
|
|
'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES,
|
|
'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES,
|
|
'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS,
|
|
'ENABLE_USER_STATUS': request.app.state.config.ENABLE_USER_STATUS,
|
|
'PENDING_USER_OVERLAY_TITLE': request.app.state.config.PENDING_USER_OVERLAY_TITLE,
|
|
'PENDING_USER_OVERLAY_CONTENT': request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
|
|
'RESPONSE_WATERMARK': request.app.state.config.RESPONSE_WATERMARK,
|
|
}
|
|
|
|
|
|
class LdapServerConfig(BaseModel):
|
|
label: str
|
|
host: str
|
|
port: Optional[int] = None
|
|
attribute_for_mail: str = 'mail'
|
|
attribute_for_username: str = 'uid'
|
|
app_dn: str
|
|
app_dn_password: str
|
|
search_base: str
|
|
search_filters: str = ''
|
|
use_tls: bool = True
|
|
certificate_path: Optional[str] = None
|
|
validate_cert: bool = True
|
|
ciphers: Optional[str] = 'ALL'
|
|
|
|
|
|
@router.get('/admin/config/ldap/server', response_model=LdapServerConfig)
|
|
async def get_ldap_server(request: Request, user=Depends(get_admin_user)):
|
|
return {
|
|
'label': request.app.state.config.LDAP_SERVER_LABEL,
|
|
'host': request.app.state.config.LDAP_SERVER_HOST,
|
|
'port': request.app.state.config.LDAP_SERVER_PORT,
|
|
'attribute_for_mail': request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
|
|
'attribute_for_username': request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
|
|
'app_dn': request.app.state.config.LDAP_APP_DN,
|
|
'app_dn_password': request.app.state.config.LDAP_APP_PASSWORD,
|
|
'search_base': request.app.state.config.LDAP_SEARCH_BASE,
|
|
'search_filters': request.app.state.config.LDAP_SEARCH_FILTERS,
|
|
'use_tls': request.app.state.config.LDAP_USE_TLS,
|
|
'certificate_path': request.app.state.config.LDAP_CA_CERT_FILE,
|
|
'validate_cert': request.app.state.config.LDAP_VALIDATE_CERT,
|
|
'ciphers': request.app.state.config.LDAP_CIPHERS,
|
|
}
|
|
|
|
|
|
@router.post('/admin/config/ldap/server')
|
|
async def update_ldap_server(request: Request, form_data: LdapServerConfig, user=Depends(get_admin_user)):
|
|
required_fields = [
|
|
'label',
|
|
'host',
|
|
'attribute_for_mail',
|
|
'attribute_for_username',
|
|
'search_base',
|
|
]
|
|
for key in required_fields:
|
|
value = getattr(form_data, key)
|
|
if not value:
|
|
raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key))
|
|
|
|
request.app.state.config.LDAP_SERVER_LABEL = form_data.label
|
|
request.app.state.config.LDAP_SERVER_HOST = form_data.host
|
|
request.app.state.config.LDAP_SERVER_PORT = form_data.port
|
|
request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = form_data.attribute_for_mail
|
|
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = form_data.attribute_for_username
|
|
request.app.state.config.LDAP_APP_DN = form_data.app_dn or ''
|
|
request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password or ''
|
|
request.app.state.config.LDAP_SEARCH_BASE = form_data.search_base
|
|
request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters
|
|
request.app.state.config.LDAP_USE_TLS = form_data.use_tls
|
|
request.app.state.config.LDAP_CA_CERT_FILE = form_data.certificate_path
|
|
request.app.state.config.LDAP_VALIDATE_CERT = form_data.validate_cert
|
|
request.app.state.config.LDAP_CIPHERS = form_data.ciphers
|
|
|
|
return {
|
|
'label': request.app.state.config.LDAP_SERVER_LABEL,
|
|
'host': request.app.state.config.LDAP_SERVER_HOST,
|
|
'port': request.app.state.config.LDAP_SERVER_PORT,
|
|
'attribute_for_mail': request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
|
|
'attribute_for_username': request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
|
|
'app_dn': request.app.state.config.LDAP_APP_DN,
|
|
'app_dn_password': request.app.state.config.LDAP_APP_PASSWORD,
|
|
'search_base': request.app.state.config.LDAP_SEARCH_BASE,
|
|
'search_filters': request.app.state.config.LDAP_SEARCH_FILTERS,
|
|
'use_tls': request.app.state.config.LDAP_USE_TLS,
|
|
'certificate_path': request.app.state.config.LDAP_CA_CERT_FILE,
|
|
'validate_cert': request.app.state.config.LDAP_VALIDATE_CERT,
|
|
'ciphers': request.app.state.config.LDAP_CIPHERS,
|
|
}
|
|
|
|
|
|
@router.get('/admin/config/ldap')
|
|
async def get_ldap_config(request: Request, user=Depends(get_admin_user)):
|
|
return {'ENABLE_LDAP': request.app.state.config.ENABLE_LDAP}
|
|
|
|
|
|
class LdapConfigForm(BaseModel):
|
|
enable_ldap: Optional[bool] = None
|
|
|
|
|
|
@router.post('/admin/config/ldap')
|
|
async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=Depends(get_admin_user)):
|
|
request.app.state.config.ENABLE_LDAP = form_data.enable_ldap
|
|
return {'ENABLE_LDAP': request.app.state.config.ENABLE_LDAP}
|
|
|
|
|
|
############################
|
|
# API Key
|
|
############################
|
|
|
|
|
|
# create api key
|
|
@router.post('/api_key', response_model=ApiKey)
|
|
async def generate_api_key(
|
|
request: Request, user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)
|
|
):
|
|
if not request.app.state.config.ENABLE_API_KEYS or (
|
|
user.role != 'admin'
|
|
and not await has_permission(user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS)
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED,
|
|
)
|
|
|
|
api_key = create_api_key()
|
|
success = await Users.update_user_api_key_by_id(user.id, api_key, db=db)
|
|
|
|
if success:
|
|
return {
|
|
'api_key': api_key,
|
|
}
|
|
else:
|
|
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_API_KEY_ERROR)
|
|
|
|
|
|
# delete api key
|
|
@router.delete('/api_key', response_model=bool)
|
|
async def delete_api_key(user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)):
|
|
return await Users.delete_user_api_key_by_id(user.id, db=db)
|
|
|
|
|
|
# get api key
|
|
@router.get('/api_key', response_model=ApiKey)
|
|
async def get_api_key(user=Depends(get_current_user), db: AsyncSession = Depends(get_async_session)):
|
|
api_key = await Users.get_user_api_key_by_id(user.id, db=db)
|
|
if api_key:
|
|
return {
|
|
'api_key': api_key,
|
|
}
|
|
else:
|
|
raise HTTPException(404, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
|
|
|
|
|
|
############################
|
|
# Token Exchange
|
|
############################
|
|
|
|
|
|
class TokenExchangeForm(BaseModel):
|
|
token: str # OAuth access token from external provider
|
|
|
|
|
|
@router.post('/oauth/{provider}/token/exchange', response_model=SessionUserResponse)
|
|
async def token_exchange(
|
|
request: Request,
|
|
response: Response,
|
|
provider: str,
|
|
form_data: TokenExchangeForm,
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""
|
|
Exchange an external OAuth provider token for an OpenWebUI JWT.
|
|
This endpoint is disabled by default. Set ENABLE_OAUTH_TOKEN_EXCHANGE=True to enable.
|
|
"""
|
|
if not ENABLE_OAUTH_TOKEN_EXCHANGE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail='Token exchange is disabled',
|
|
)
|
|
|
|
provider = provider.lower()
|
|
|
|
# Check if provider is configured
|
|
if provider not in OAUTH_PROVIDERS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider),
|
|
)
|
|
# Get the OAuth client for this provider
|
|
oauth_manager = request.app.state.oauth_manager
|
|
client = oauth_manager.get_client(provider)
|
|
if not client:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=ERROR_MESSAGES.OAUTH_NOT_CONFIGURED(provider),
|
|
)
|
|
|
|
# Validate the token by calling the userinfo endpoint
|
|
try:
|
|
token_data = {'access_token': form_data.token, 'token_type': 'Bearer'}
|
|
user_data = await client.userinfo(token=token_data)
|
|
|
|
if not user_data:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Invalid token or unable to fetch user info',
|
|
)
|
|
except Exception as e:
|
|
log.warning(f'Token exchange failed for provider {provider}: {e}')
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Invalid token or unable to validate with provider',
|
|
)
|
|
|
|
# Extract user information from the token claims
|
|
email_claim = request.app.state.config.OAUTH_EMAIL_CLAIM
|
|
username_claim = request.app.state.config.OAUTH_USERNAME_CLAIM
|
|
|
|
# Get sub claim
|
|
sub = user_data.get(request.app.state.config.OAUTH_SUB_CLAIM or OAUTH_PROVIDERS[provider].get('sub_claim', 'sub'))
|
|
if not sub:
|
|
log.warning(f'Token exchange failed: sub claim missing from user data')
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Token missing required 'sub' claim",
|
|
)
|
|
|
|
email = user_data.get(email_claim, '')
|
|
if not email:
|
|
log.warning(f'Token exchange failed: email claim missing from user data')
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Token missing required email claim',
|
|
)
|
|
email = email.lower()
|
|
|
|
# Enforce domain allowlist — same check as the normal OAuth callback
|
|
if (
|
|
'*' not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
|
|
and email.split('@')[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
|
|
):
|
|
log.warning(f'Token exchange denied: email domain not in allowed domains list')
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
|
)
|
|
|
|
# Try to find the user by OAuth sub
|
|
user = await Users.get_user_by_oauth_sub(provider, sub, db=db)
|
|
|
|
if not user and OAUTH_MERGE_ACCOUNTS_BY_EMAIL.value:
|
|
# Try to find by email if merge is enabled
|
|
user = await Users.get_user_by_email(email, db=db)
|
|
if user:
|
|
# Link the OAuth sub to this user
|
|
await Users.update_user_oauth_by_id(user.id, provider, sub, db=db)
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail='User not found. Please sign in via the web interface first.',
|
|
)
|
|
|
|
return await create_session_response(request, user, db)
|