diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 660b9e7d96..810d0f4c8d 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -14,6 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import JSONResponse, Response from ldap3 import NONE, Connection, Server, Tls from ldap3.utils.conv import escape_filter_chars +from ldap3.utils.dn import parse_dn from open_webui.config import ( ENABLE_PASSWORD_AUTH, OAUTH_PROVIDERS, @@ -128,6 +129,9 @@ LDAP_SERVER_CONFIG_KEYS = { 'certificate_path': 'ldap.server.ca_cert_file', 'validate_cert': 'ldap.server.validate_cert', 'ciphers': 'ldap.server.ciphers', + 'enable_group_management': 'ldap.group.enable_management', + 'enable_group_creation': 'ldap.group.enable_creation', + 'attribute_for_groups': 'ldap.server.attribute_for_groups', } @@ -398,6 +402,52 @@ async def update_password( raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) +def _unescape_ldap_dn_value(value: str) -> str: + """Resolve RFC 4514 escapes in a DN value, e.g. ``CN=Sales\\, EMEA`` -> ``Sales, EMEA``. + + Consecutive ``\\XX`` hex escapes encode UTF-8 bytes and are decoded together. + """ + hexdigits = '0123456789abcdefABCDEF' + result = [] + pos = 0 + length = len(value) + while pos < length: + char = value[pos] + if char == '\\' and pos + 1 < length: + if pos + 2 < length and value[pos + 1] in hexdigits and value[pos + 2] in hexdigits: + byte_values = bytearray() + while ( + pos + 2 < length + and value[pos] == '\\' + and value[pos + 1] in hexdigits + and value[pos + 2] in hexdigits + ): + byte_values.append(int(value[pos + 1 : pos + 3], 16)) + pos += 3 + result.append(byte_values.decode('utf-8', errors='replace')) + else: + # Backslash escaping a literal special char, e.g. "\," or "\+". + result.append(value[pos + 1]) + pos += 2 + else: + result.append(char) + pos += 1 + return ''.join(result) + + +def extract_group_cn_from_dn(group_dn: str) -> str | None: + """Return the first CN component of an LDAP group DN, or None. + + Uses ``parse_dn`` so escaped separators inside a value (e.g. a group whose + name contains a comma) are handled correctly instead of naively splitting + on ``,``. + """ + for attr_type, attr_value, _ in parse_dn(group_dn): + if attr_type.upper() == 'CN': + return _unescape_ldap_dn_value(attr_value) + return None + + ############################ # LDAP Authentication ############################ @@ -545,17 +595,10 @@ async def ldap_auth( 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 + group_cn = extract_group_cn_from_dn(group_dn) 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: @@ -627,9 +670,9 @@ async def ldap_auth( 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: + if ENABLE_LDAP_GROUP_CREATION: + await Groups.create_groups_by_group_names(user.id, user_groups, db=db) 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: @@ -1188,6 +1231,9 @@ class LdapServerConfig(BaseModel): certificate_path: str | None = None validate_cert: bool = True ciphers: str | None = 'ALL' + enable_group_management: bool = False + enable_group_creation: bool = False + attribute_for_groups: str = 'memberOf' @router.get('/admin/config/ldap/server', response_model=LdapServerConfig) @@ -1209,6 +1255,11 @@ async def update_ldap_server(request: Request, form_data: LdapServerConfig, user if not value: raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY(key)) + # The group attribute is what group management reads from the directory + # entry; an empty value would make group sync silently do nothing. + if form_data.enable_group_management and not (form_data.attribute_for_groups or '').strip(): + raise HTTPException(400, detail=ERROR_MESSAGES.REQUIRED_FIELD_EMPTY('attribute_for_groups')) + updates = config_updates(form_data.model_dump(), LDAP_SERVER_CONFIG_KEYS) updates['ldap.server.app_dn'] = form_data.app_dn or '' updates['ldap.server.app_password'] = form_data.app_dn_password or '' diff --git a/src/lib/components/admin/Settings/Authentication.svelte b/src/lib/components/admin/Settings/Authentication.svelte index ae14ac0ea5..d625311842 100644 --- a/src/lib/components/admin/Settings/Authentication.svelte +++ b/src/lib/components/admin/Settings/Authentication.svelte @@ -32,7 +32,7 @@ let LDAP_SERVER = { label: '', host: '', - port: '', + port: null, attribute_for_mail: 'mail', attribute_for_username: 'uid', app_dn: '', @@ -42,7 +42,10 @@ use_tls: false, validate_cert: false, certificate_path: '', - ciphers: '' + ciphers: '', + enable_group_management: false, + enable_group_creation: false, + attribute_for_groups: 'memberOf' }; let oauthConfig: any = null; @@ -55,6 +58,13 @@ await updateLdapConfig(localStorage.token, ENABLE_LDAP); if (!ENABLE_LDAP) return true; + // Honor the "Default to memberOf" hint: fall back to the default group + // attribute when it is left blank while group management is enabled, so + // the save isn't rejected by the backend's required-field check. + if (LDAP_SERVER.enable_group_management && !LDAP_SERVER.attribute_for_groups?.trim()) { + LDAP_SERVER.attribute_for_groups = 'memberOf'; + } + const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => { toast.error(`${error}`); return null; @@ -104,7 +114,9 @@ groups = await getGroups(localStorage.token); })(), (async () => { - LDAP_SERVER = await getLdapServer(localStorage.token); + // Merge into the defaults so any key the backend omits (e.g. an + // older backend without the group settings) keeps its default. + LDAP_SERVER = { ...LDAP_SERVER, ...(await getLdapServer(localStorage.token)) }; })(), (async () => { oauthConfig = await getOAuthConfig(localStorage.token).catch(() => null); @@ -465,6 +477,35 @@ {/if} + + + + + + {#if LDAP_SERVER.enable_group_management} + + + + + + + + + + {/if} {/if}