diff --git a/api/.env.example b/api/.env.example index 2ae66c1970..657a09f7de 100644 --- a/api/.env.example +++ b/api/.env.example @@ -445,4 +445,18 @@ CREATE_TIDB_SERVICE_JOB_ENABLED=false # Maximum number of submitted thread count in a ThreadPool for parallel node execution MAX_SUBMIT_COUNT=100 # Lockout duration in seconds -LOGIN_LOCKOUT_DURATION=86400 \ No newline at end of file +LOGIN_LOCKOUT_DURATION=86400 + +# Enable ldap authentication login +LDAP_ENABLED=false +AUTH_LDAP_SERVER_URI=ldap://127.0.0.1:389 +AUTH_LDAP_BIND_DN=CN=ArcherMaster,CN=Users,DC=dify,DC=com +AUTH_LDAP_BIND_PASSWORD=dify@123456 +AUTH_LDAP_SEARCH_BASE_DN=OU=dify技术有限公司,DC=dify,DC=com +AUTH_LDAP_USER_FILTER=(mail=%(user)s) +AUTH_LDAP_USER_ATTR_MAP={"first_name": "uid", "last_name": "cn", "email": "mail"} +CACHE_LDAP_USER_KEY=LDAP_USER_INFO_DATA +CACHE_LDAP_USER_EX=86400 +LDAP_DEFAULT_ROLE=normal +LDAP_CONN_TIMEOUT=10 +LDAP_POOL_SIZE=10 \ No newline at end of file diff --git a/api/app_factory.py b/api/app_factory.py index 52ae05583a..8b081446fe 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -47,6 +47,7 @@ def initialize_extensions(app: DifyApp): ext_database, ext_hosting_provider, ext_import_modules, + ext_ldap, ext_logging, ext_login, ext_mail, @@ -61,6 +62,7 @@ def initialize_extensions(app: DifyApp): ) extensions = [ + ext_ldap, ext_timezone, ext_logging, ext_warnings, diff --git a/api/configs/app_config.py b/api/configs/app_config.py index ac1ce9db10..028053086b 100644 --- a/api/configs/app_config.py +++ b/api/configs/app_config.py @@ -8,6 +8,7 @@ from .deploy import DeploymentConfig from .enterprise import EnterpriseFeatureConfig from .extra import ExtraServiceConfig from .feature import FeatureConfig +from .ldap import AuthenticationConfig from .middleware import MiddlewareConfig from .packaging import PackagingInfo from .remote_settings_sources import RemoteSettingsSource, RemoteSettingsSourceConfig, RemoteSettingsSourceName @@ -49,6 +50,8 @@ class RemoteSettingsSourceFactory(PydanticBaseSettingsSource): class DifyConfig( + # Authentication configs + AuthenticationConfig, # Packaging info PackagingInfo, # Deployment configs diff --git a/api/configs/ldap/__init__.py b/api/configs/ldap/__init__.py new file mode 100644 index 0000000000..e74fdf5f95 --- /dev/null +++ b/api/configs/ldap/__init__.py @@ -0,0 +1,74 @@ +""" +@File : __init__.py.py +@Time : 2025/3/6 {TIME} +@Author : xxlaila +@Software: dify +""" +from pydantic import Field +from pydantic_settings import BaseSettings + + +class AuthenticationConfig(BaseSettings): + """LDAP authentication related configuration""" + # LDAP Authentication + LDAP_ENABLED: bool = Field( + False, + description="Whether to enable LDAP authentication", + validation_alias="LDAP_ENABLED" + ) + AUTH_LDAP_SERVER_URI: str = Field( + "ldap://localhost", + description="LDAP server address", + validation_alias="AUTH_LDAP_SERVER_URI" + ) + AUTH_LDAP_BIND_DN: str = Field( + "cn=admin,dc=example,dc=com", + description="LDAP Bind DN", + validation_alias="AUTH_LDAP_BIND_DN" + ) + AUTH_LDAP_BIND_PASSWORD: str = Field( + "", + description="LDAP bind password", + validation_alias="AUTH_LDAP_BIND_PASSWORD" + ) + AUTH_LDAP_SEARCH_BASE_DN: str = Field( + default="OU=Limited Company,DC=example,DC=com", + description="LDAP search base DN", + validation_alias="AUTH_LDAP_SEARCH_BASE_DN" + ) + AUTH_LDAP_USER_FILTER: str = Field( + "(objectClass=person)", + description="LDAP User Filter", + validation_alias="AUTH_LDAP_USER_FILTER" + ) + AUTH_LDAP_USER_ATTR_MAP: dict = Field( + {"first_name": "uid", "last_name": "cn", "email": "mail"}, + description="Mapping of LDAP attributes to user models", + validation_alias="AUTH_LDAP_USER_ATTR_MAP" + ) + CACHE_LDAP_USER_KEY: str = Field( + # Cache LDAP user information + "ldap_user", + description="Key for caching LDAP user information", + validation_alias="CACHE_LDAP_USER_KEY" + ) + CACHE_LDAP_USER_EX: int = Field( + 86400, + description="Expiration time of cached LDAP user information", + validation_alias="CACHE_LDAP_USER_EX" + ) + LDAP_DEFAULT_ROLE: str = Field( + default="normal", + description="Default Roles", + validation_alias="LDAP_DEFAULT_ROLE" + ) + LDAP_CONN_TIMEOUT: int = Field( + default=10, + description="LDAP connection timeout", + validation_alias="LDAP_CONN_TIMEOUT" + ) + LDAP_POOL_SIZE: int = Field( + default=10, + description="LDAP connection pool size", + validation_alias="LDAP_POOL_SIZE" + ) \ No newline at end of file diff --git a/api/extensions/ext_ldap.py b/api/extensions/ext_ldap.py new file mode 100644 index 0000000000..34c002871d --- /dev/null +++ b/api/extensions/ext_ldap.py @@ -0,0 +1,63 @@ +""" +@File : ext_ldap.py +@Time : 2025/3/5 {TIME} +@Author : xxlaila +@Software: dify +""" +import json +import logging + +from flask_ldap3_login import LDAP3LoginManager + +from configs import dify_config +from dify_app import DifyApp + + +def is_enabled(): + return getattr(dify_config, 'LDAP_ENABLED', False) + +def init_app(app: DifyApp): + """Initialize LDAP authentication integration""" + if not is_enabled(): + app.ldap_manager = None # Explicitly set the manager to None + logging.info("LDAP authentication is disabled") + return + + # Parsing User Attribute Mapping + if isinstance(dify_config.AUTH_LDAP_USER_ATTR_MAP, str): + ldap_user_attr_map = json.loads(dify_config.AUTH_LDAP_USER_ATTR_MAP) + else: + ldap_user_attr_map = dify_config.AUTH_LDAP_USER_ATTR_MAP + + # Setting up LDAP configuration + app.config.update({ + "LDAP_HOST": dify_config.AUTH_LDAP_SERVER_URI, + "LDAP_BASE_DN": dify_config.AUTH_LDAP_SEARCH_BASE_DN, + "LDAP_BIND_DN": dify_config.AUTH_LDAP_BIND_DN, + "LDAP_BIND_PASSWORD": dify_config.AUTH_LDAP_BIND_PASSWORD, + "LDAP_USER_FILTER": dify_config.AUTH_LDAP_USER_FILTER, + "LDAP_USER_RDN_ATTR": "uid", + "LDAP_USER_LOGIN_ATTR": "uid", + "LDAP_USER_SEARCH_SCOPE": "SUBTREE", + "LDAP_USER_MAPPING": ldap_user_attr_map, + "LDAP_DEFAULT_ROLE": dify_config.LDAP_DEFAULT_ROLE, + }) + + + # Initializing the LDAP Manager + ldap_manager = LDAP3LoginManager() + ldap_manager.init_app(app) + + # Mount the LDAP manager into the app + app.ldap_manager = ldap_manager + # Confirm that the mount was successful + logging.info(f"LDAP manager mounted: {hasattr(app, 'ldap_manager')}") + + + # Configuring Logging + if app.debug: + app.logger.info("LDAP configuration loaded:") + app.logger.info(f"Server: {app.config['LDAP_HOST']}") + app.logger.info(f"Base DN: {app.config['LDAP_BASE_DN']}") + + return ldap_manager diff --git a/api/extensions/ext_login.py b/api/extensions/ext_login.py index 10fb89eb73..6a29ae84dd 100644 --- a/api/extensions/ext_login.py +++ b/api/extensions/ext_login.py @@ -12,6 +12,10 @@ from services.account_service import AccountService login_manager = flask_login.LoginManager() +@login_manager.user_loader +def load_user(user_id): + """Support loading LDAP users from session""" + return AccountService.load_user(user_id) # Flask-Login configuration @login_manager.request_loader diff --git a/api/poetry.lock b/api/poetry.lock index 5eb956e81a..88c170e215 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -2168,6 +2168,7 @@ files = [ brotli = {version = "*", markers = "platform_python_implementation != \"PyPy\""} brotlicffi = {version = "*", markers = "platform_python_implementation == \"PyPy\""} flask = "*" +ldap3 = ">=2.0.7" zstandard = [ {version = "*", markers = "platform_python_implementation != \"PyPy\""}, {version = "*", extras = ["cffi"], markers = "platform_python_implementation == \"PyPy\""}, @@ -2188,6 +2189,18 @@ files = [ [package.dependencies] Flask = ">=0.9" +[[package]] +name = "flask-ldap3-login" +version = "1.0.2" +description = "LDAP Support for Flask" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "flask_ldap3_login-1.0.2-py3-none-any.whl", hash = "sha256:72368ea58b5faac4935d1f3e5ec1fcfdce51cc16adbec41a74c834247422ac61"}, + {file = "flask_ldap3_login-1.0.2.tar.gz", hash = "sha256:969d2e4263aa0908383174d9700e03198a5eb3db483b26a18f9ea634032cbdd4"}, +] + [[package]] name = "flask-login" version = "0.6.3" @@ -3949,6 +3962,18 @@ requests-toolbelt = ">=1.0.0,<2.0.0" [package.extras] langsmith-pyo3 = ["langsmith-pyo3 (>=0.1.0rc2,<0.2.0)"] +[[package]] +name = "ldap3" +version = "2.9.1" +description = "A strictly RFC 4510 conforming LDAP V3 pure Python client library" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70"}, + {file = "ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f"}, +] + [[package]] name = "levenshtein" version = "0.27.1" diff --git a/api/pyproject.toml b/api/pyproject.toml index 0e91e533b1..e0da46ff08 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -83,6 +83,8 @@ yarl = "~1.18.3" # Related transparent dependencies with pinned version # required by main implementations ############################################################ +flask-ldap3-login = "^1.0.2" +ldap3 = "^2.9.1" [tool.poetry.group.indirect.dependencies] kaleido = "0.2.1" rank-bm25 = "~0.2.2" diff --git a/api/services/account_service.py b/api/services/account_service.py index 42d1fba97f..60ca233af5 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -8,6 +8,7 @@ from datetime import UTC, datetime, timedelta from hashlib import sha256 from typing import Any, Optional, cast +from ldap3 import ALL, Connection, Server from pydantic import BaseModel from sqlalchemy import func from sqlalchemy.orm import Session @@ -17,6 +18,7 @@ from configs import dify_config from constants.languages import language_timezone_mapping, languages from events.tenant_event import tenant_was_created from extensions.ext_database import db +from extensions.ext_ldap import is_enabled from extensions.ext_redis import redis_client from libs.helper import RateLimiter, TokenManager from libs.passport import PassportService @@ -145,11 +147,90 @@ class AccountService: @staticmethod def authenticate(email: str, password: str, invite_token: Optional[str] = None) -> Account: """authenticate account with email and password""" + account = None + def _handle_ldap_user(email: str, password: str) -> Optional[Account]: + """Handle LDAP user authentication""" + server = Server(dify_config.AUTH_LDAP_SERVER_URI, get_info=ALL) + conn = Connection( + server, + user=dify_config.AUTH_LDAP_BIND_DN, + password=dify_config.AUTH_LDAP_BIND_PASSWORD, + receive_timeout=dify_config.LDAP_CONN_TIMEOUT, + auto_bind=True + ) + conn.search( + search_base=dify_config.AUTH_LDAP_SEARCH_BASE_DN, + search_filter=f"(mail={email})", + attributes=list(dify_config.AUTH_LDAP_USER_ATTR_MAP.values()) + ) + if not conn.entries: + logging.warning(f"No LDAP entry found for: {email}") + return None + + entry = conn.entries[0] + user_dn = entry.entry_dn # Obtain the complete DN of the user + # Add password verification steps + user_conn = Connection( + server, + user=user_dn, + password=password + ) + if not user_conn.bind(): + logging.error(f"LDAP password verification failed: {email}") + raise AccountPasswordError("Password error") + + display_name = entry.cn.value if entry.cn else email.split('@')[0] + account = db.session.query(Account).filter_by(email=email).with_for_update().first() + if not account: + logging.info(f"Creating new LDAP account for: {email}") + return AccountService.create_ldap_user( + email=email, + name=display_name, + ldap_attrs=entry + ) + + if account.name != display_name: + logging.info(f"Updating LDAP account name for: {email}") + account.name = display_name + db.session.commit() + + return account + + # Main certification process + if is_enabled(): + try: + # Try LDAP authentication first + server = Server(dify_config.AUTH_LDAP_SERVER_URI, get_info=ALL) + conn = Connection( + server, + user=dify_config.AUTH_LDAP_BIND_DN, + password=dify_config.AUTH_LDAP_BIND_PASSWORD, + receive_timeout=dify_config.LDAP_CONN_TIMEOUT, + auto_bind=True + ) + if not conn.bind(): + logging.error("Failed to bind to LDAP server.") + raise ValueError("Failed to bind to LDAP server.") + account = _handle_ldap_user(email, password) + if account: + logging.info(f"Returning account {email} after successful LDAP authentication.") + + return account + except Exception as e: + logging.info(f"LDAP authentication error: {e}") + + # Perform local authentication only if LDAP authentication explicitly fails account = db.session.query(Account).filter_by(email=email).first() if not account: + logging.error(f"Account not found for: {email}") raise AccountNotFoundError() + # LDAP users must authenticate via LDAP + if account.password is None and account.password_salt is None and not invite_token: + logging.error(f"LDAP user {email} attempted local authentication") + raise AccountPasswordError("LDAP users must authenticate via LDAP") + if account.status == AccountStatus.BANNED.value: raise AccountLoginError("Account is banned.") @@ -173,6 +254,56 @@ class AccountService: return cast(Account, account) + @staticmethod + def create_ldap_user(email: str, name: str, ldap_attrs: dict) -> Account: + """ + Create LDAP users and assign default permissions + Note: LDAP users do not require a local password, so password and password_stalt are set to None. + """ + try: + # First check if the account already exists + account = db.session.query(Account).filter_by(email=email).first() + if account: + logging.info(f"Account {email} already exists, skipping creation.") + else: + account = Account( + email=email, + name=name, + status=AccountStatus.ACTIVE.value, + password=None, # LDAP user has no local password + password_salt=None, + interface_language=languages[0] + ) + db.session.add(account) + db.session.commit() # Submit the transaction to ensure that the account is available + logging.info(f"Local account created successfully for: {email}") + + # Get the default tenant (remove the specific name and only take the first one) + default_tenant = db.session.query(Tenant).first() + if not default_tenant: + default_tenant = TenantService.create_tenant(name="Default Workspace") + db.session.commit() + logging.info(f"Created default tenant: {default_tenant.id}") + + # Check if the user has joined the tenant + existing_member = db.session.query(TenantAccountJoin).filter_by( + tenant_id=default_tenant.id, account_id=account.id + ).first() + + if not existing_member: + TenantService.create_tenant_member(tenant=default_tenant, account=account, + role="normal") + logging.info(f"User {email} added to tenant: {default_tenant.id}") + else: + logging.info(f"User {email} is already a member of tenant {default_tenant.id}") + + return account # Make sure to return a valid account + + except Exception as e: + db.session.rollback() # Transaction rollback to prevent database pollution + logging.info(f"Failed to create LDAP user or add to tenant: {e}") + raise AccountRegisterError("Failed to create LDAP user.") + @staticmethod def update_account_password(account, password, new_password): """update account password"""