diff --git a/backend/open_webui/apps/webui/main.py b/backend/open_webui/apps/webui/main.py index 5a0a83961..ca258368e 100644 --- a/backend/open_webui/apps/webui/main.py +++ b/backend/open_webui/apps/webui/main.py @@ -50,6 +50,18 @@ from open_webui.config import ( WEBHOOK_URL, WEBUI_AUTH, WEBUI_BANNERS, + ENABLE_LDAP, + LDAP_SERVER_LABEL, + LDAP_SERVER_HOST, + LDAP_SERVER_PORT, + LDAP_ATTRIBUTE_FOR_USERNAME, + LDAP_SEARCH_FILTERS, + LDAP_SEARCH_BASE, + LDAP_APP_DN, + LDAP_APP_PASSWORD, + LDAP_USE_TLS, + LDAP_CA_CERT_FILE, + LDAP_CIPHERS, AppConfig, ) from open_webui.env import ( @@ -111,6 +123,19 @@ app.state.config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM app.state.config.OAUTH_ALLOWED_ROLES = OAUTH_ALLOWED_ROLES app.state.config.OAUTH_ADMIN_ROLES = OAUTH_ADMIN_ROLES +app.state.config.ENABLE_LDAP = ENABLE_LDAP +app.state.config.LDAP_SERVER_LABEL = LDAP_SERVER_LABEL +app.state.config.LDAP_SERVER_HOST = LDAP_SERVER_HOST +app.state.config.LDAP_SERVER_PORT = LDAP_SERVER_PORT +app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = LDAP_ATTRIBUTE_FOR_USERNAME +app.state.config.LDAP_APP_DN = LDAP_APP_DN +app.state.config.LDAP_APP_PASSWORD = LDAP_APP_PASSWORD +app.state.config.LDAP_SEARCH_BASE = LDAP_SEARCH_BASE +app.state.config.LDAP_SEARCH_FILTERS = LDAP_SEARCH_FILTERS +app.state.config.LDAP_USE_TLS = LDAP_USE_TLS +app.state.config.LDAP_CA_CERT_FILE = LDAP_CA_CERT_FILE +app.state.config.LDAP_CIPHERS = LDAP_CIPHERS + app.state.MODELS = {} app.state.TOOLS = {} app.state.FUNCTIONS = {} diff --git a/backend/open_webui/apps/webui/models/auths.py b/backend/open_webui/apps/webui/models/auths.py index 167b9f6dc..ead897347 100644 --- a/backend/open_webui/apps/webui/models/auths.py +++ b/backend/open_webui/apps/webui/models/auths.py @@ -64,6 +64,11 @@ class SigninForm(BaseModel): password: str +class LdapForm(BaseModel): + user: str + password: str + + class ProfileImageUrlForm(BaseModel): profile_image_url: str diff --git a/backend/open_webui/apps/webui/routers/auths.py b/backend/open_webui/apps/webui/routers/auths.py index ae7938ef1..d8134e3c9 100644 --- a/backend/open_webui/apps/webui/routers/auths.py +++ b/backend/open_webui/apps/webui/routers/auths.py @@ -2,12 +2,14 @@ import re import uuid import time import datetime +import logging from open_webui.apps.webui.models.auths import ( AddUserForm, ApiKey, Auths, Token, + LdapForm, SigninForm, SigninResponse, SignupForm, @@ -23,6 +25,7 @@ from open_webui.env import ( WEBUI_AUTH_TRUSTED_NAME_HEADER, WEBUI_SESSION_COOKIE_SAME_SITE, WEBUI_SESSION_COOKIE_SECURE, + SRC_LOG_LEVELS, ) from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import Response @@ -37,10 +40,16 @@ from open_webui.utils.utils import ( get_password_hash, ) from open_webui.utils.webhook import post_webhook -from typing import Optional +from typing import Optional, List + +from ldap3 import Server, Connection, ALL, Tls +from ssl import CERT_REQUIRED, PROTOCOL_TLS router = APIRouter() +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MAIN"]) + ############################ # GetSessionUser ############################ @@ -137,6 +146,110 @@ async def update_password( raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) +############################ +# LDAP Authentication +############################ +@router.post("/ldap", response_model=SigninResponse) +async def ldap_auth(request: Request, response: Response, form_data: LdapForm): + ENABLE_LDAP = request.app.state.config.ENABLE_LDAP + 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_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_CIPHERS = request.app.state.config.LDAP_CIPHERS if request.app.state.config.LDAP_CIPHERS else 'ALL' + + if not ENABLE_LDAP: + raise HTTPException(400, detail="LDAP authentication is not enabled") + + try: + tls = Tls(validate=CERT_REQUIRED, version=PROTOCOL_TLS, ca_certs_file=LDAP_CA_CERT_FILE, ciphers=LDAP_CIPHERS) + except Exception as e: + log.error(f"An error occurred on TLS: {str(e)}") + raise HTTPException(400, detail=str(e)) + + try: + server = Server(host=LDAP_SERVER_HOST, port=LDAP_SERVER_PORT, get_info=ALL, use_ssl=LDAP_USE_TLS, tls=tls) + connection_app = Connection(server, LDAP_APP_DN, LDAP_APP_PASSWORD, auto_bind='NONE', authentication='SIMPLE') + if not connection_app.bind(): + raise HTTPException(400, detail="Application account bind failed") + + search_success = connection_app.search( + search_base=LDAP_SEARCH_BASE, + search_filter=f'(&({LDAP_ATTRIBUTE_FOR_USERNAME}={form_data.user.lower()}){LDAP_SEARCH_FILTERS})', + attributes=[f'{LDAP_ATTRIBUTE_FOR_USERNAME}', 'mail', 'cn'] + ) + + if not search_success: + raise HTTPException(400, detail="User not found in the LDAP server") + + entry = connection_app.entries[0] + username = str(entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}']).lower() + mail = str(entry['mail']) + cn = str(entry['cn']) + user_dn = entry.entry_dn + + if username == form_data.user.lower(): + connection_user = Connection(server, user_dn, form_data.password, auto_bind='NONE', authentication='SIMPLE') + if not connection_user.bind(): + raise HTTPException(400, f"Authentication failed for {form_data.user}") + + user = Users.get_user_by_email(mail) + if not user: + + try: + hashed = get_password_hash(form_data.password) + user = Auths.insert_new_auth( + mail, + hashed, + cn + ) + + if not user: + raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) + + except HTTPException: + raise + except Exception as err: + raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) + + user = Auths.authenticate_user(mail, password=str(form_data.password)) + + if user: + token = create_token( + data={"id": user.id}, + expires_delta=parse_duration(request.app.state.config.JWT_EXPIRES_IN), + ) + + # Set the cookie token + response.set_cookie( + key="token", + value=token, + httponly=True, # Ensures the cookie is not accessible via JavaScript + ) + + return { + "token": token, + "token_type": "Bearer", + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role, + "profile_image_url": user.profile_image_url, + } + else: + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) + else: + raise HTTPException(400, f"User {form_data.user} does not match the record. Search result: {str(entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'])}") + except Exception as e: + raise HTTPException(400, detail=str(e)) + + ############################ # SignIn ############################ @@ -465,6 +578,89 @@ async def update_admin_config( } +class LdapServerConfig(BaseModel): + label: str + host: str + port: Optional[int] = None + 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 + 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_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, + "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_username', 'app_dn', 'app_dn_password', 'search_base'] + for key in required_fields: + value = getattr(form_data, key) + if not value: + raise HTTPException(400, detail=f"Required field {key} is empty") + + if form_data.use_tls and not form_data.certificate_path: + raise HTTPException(400, detail="TLS is enabled but certificate file path is missing") + + 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_USERNAME = form_data.attribute_for_username + request.app.state.config.LDAP_APP_DN = form_data.app_dn + request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password + 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_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_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, + "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 ############################ diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index f7221eeaf..be5a04cb2 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1578,3 +1578,80 @@ AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT = PersistentConfig( "AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT", "audio-24khz-160kbitrate-mono-mp3" ), ) + + +#################################### +# LDAP +#################################### + +ENABLE_LDAP = PersistentConfig( + "ENABLE_LDAP", + "ldap.enable", + os.environ.get("ENABLE_LDAP", "True").lower() == "true", +) + +LDAP_SERVER_LABEL = PersistentConfig( + "LDAP_SERVER_LABEL", + "ldap.server.label", + os.environ.get("LDAP_SERVER_LABEL", "LDAP Server"), +) + +LDAP_SERVER_HOST = PersistentConfig( + "LDAP_SERVER_HOST", + "ldap.server.host", + os.environ.get("LDAP_SERVER_HOST", "localhost") +) + +LDAP_SERVER_PORT = PersistentConfig( + "LDAP_SERVER_PORT", + "ldap.server.port", + int(os.environ.get("LDAP_SERVER_PORT", "389")) +) + +LDAP_ATTRIBUTE_FOR_USERNAME = PersistentConfig( + "LDAP_ATTRIBUTE_FOR_USERNAME", + "ldap.server.attribute_for_username", + os.environ.get("LDAP_ATTRIBUTE_FOR_USERNAME", "uid") +) + +LDAP_APP_DN = PersistentConfig( + "LDAP_APP_DN", + "ldap.server.app_dn", + os.environ.get("LDAP_APP_DN", "") +) + +LDAP_APP_PASSWORD = PersistentConfig( + "LDAP_APP_PASSWORD", + "ldap.server.app_password", + os.environ.get("LDAP_APP_PASSWORD", "") +) + +LDAP_SEARCH_BASE = PersistentConfig( + "LDAP_SEARCH_BASE", + "ldap.server.users_dn", + os.environ.get("LDAP_SEARCH_BASE", "") +) + +LDAP_SEARCH_FILTERS = PersistentConfig( + "LDAP_SEARCH_FILTER", + "ldap.server.search_filter", + os.environ.get("LDAP_SEARCH_FILTER", "") +) + +LDAP_USE_TLS = PersistentConfig( + "LDAP_USE_TLS", + "ldap.server.use_tls", + os.environ.get("LDAP_USE_TLS", "True").lower() == "true" +) + +LDAP_CA_CERT_FILE = PersistentConfig( + "LDAP_CA_CERT_FILE", + "ldap.server.ca_cert_file", + os.environ.get("LDAP_CA_CERT_FILE", "") +) + +LDAP_CIPHERS = PersistentConfig( + "LDAP_CIPHERS", + "ldap.server.ciphers", + os.environ.get("LDAP_CIPHERS", "ALL") +) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 2ba545ad3..a252dc10b 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -2237,6 +2237,7 @@ async def get_app_config(request: Request): "auth_trusted_header": bool(webui_app.state.AUTH_TRUSTED_EMAIL_HEADER), "enable_signup": webui_app.state.config.ENABLE_SIGNUP, "enable_login_form": webui_app.state.config.ENABLE_LOGIN_FORM, + "enable_ldap_form": webui_app.state.config.ENABLE_LDAP, **( { "enable_web_search": retrieval_app.state.config.ENABLE_RAG_WEB_SEARCH, diff --git a/backend/requirements.txt b/backend/requirements.txt index b37b02b81..e2774d010 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -93,3 +93,6 @@ pytest~=8.3.2 pytest-docker~=3.1.1 googleapis-common-protos==1.63.2 + +## LDAP +ldap3==2.9.1 diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index 30f21c302..0432544d7 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -110,6 +110,150 @@ export const getSessionUser = async (token: string) => { return res; }; +export const ldapUserSignIn = async (user: string, password: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/auths/ldap`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify({ + user: user, + password: password + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getLdapConfig = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/ldap`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +} + +export const updateLdapConfig = async (token: string = '', enable_ldap: boolean) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/ldap`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ + enable_ldap: enable_ldap + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +} + +export const getLdapServer = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/ldap/server`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +} + +export const updateLdapServer = async (token: string = '', body: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config/ldap/server`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify(body) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const userSignIn = async (email: string, password: string) => { let error = null; diff --git a/src/lib/components/admin/Settings/Connections.svelte b/src/lib/components/admin/Settings/Connections.svelte index 292d22993..2535e87d0 100644 --- a/src/lib/components/admin/Settings/Connections.svelte +++ b/src/lib/components/admin/Settings/Connections.svelte @@ -20,6 +20,12 @@ updateOpenAIKeys, updateOpenAIUrls } from '$lib/apis/openai'; + import { + getLdapConfig, + updateLdapConfig, + getLdapServer, + updateLdapServer, + } from '$lib/apis/auths'; import { toast } from 'svelte-sonner'; import Switch from '$lib/components/common/Switch.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; @@ -45,6 +51,23 @@ let ENABLE_OPENAI_API = null; let ENABLE_OLLAMA_API = null; + + // LDAP + let ENABLE_LDAP = false; + let LDAP_SERVER = { + label: '', + host: '', + port: '', + attribute_for_username: 'uid', + app_dn: '', + app_dn_password: '', + search_base: '', + search_filters: '', + use_tls: false, + certificate_path: '', + ciphers: '' + }; + const verifyOpenAIHandler = async (idx) => { OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.map((url) => url.replace(/\/$/, '')); @@ -136,6 +159,17 @@ } }; + const updateLdapServerHandler = async () => { + if (!ENABLE_LDAP) return; + const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => { + toast.error(error); + return null; + }); + if (res) { + toast.success($i18n.t('LDAP server updated')); + } + }; + onMount(async () => { if ($user.role === 'admin') { await Promise.all([ @@ -147,14 +181,19 @@ })(), (async () => { OPENAI_API_KEYS = await getOpenAIKeys(localStorage.token); + })(), + (async () => { + LDAP_SERVER = await getLdapServer(localStorage.token); })() ]); const ollamaConfig = await getOllamaConfig(localStorage.token); const openaiConfig = await getOpenAIConfig(localStorage.token); + const ldapConfig = await getLdapConfig(localStorage.token); ENABLE_OPENAI_API = openaiConfig.ENABLE_OPENAI_API; ENABLE_OLLAMA_API = ollamaConfig.ENABLE_OLLAMA_API; + ENABLE_LDAP = ldapConfig.ENABLE_LDAP; if (ENABLE_OPENAI_API) { OPENAI_API_BASE_URLS.forEach(async (url, idx) => { @@ -173,12 +212,13 @@ on:submit|preventDefault={() => { updateOpenAIHandler(); updateOllamaUrlsHandler(); + updateLdapServerHandler(); dispatch('save'); }} >