This commit is contained in:
星星 2025-03-17 17:00:11 +08:00 committed by GitHub
commit a06088ee10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 319 additions and 1 deletions

View File

@ -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
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

View File

@ -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,

View File

@ -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

View File

@ -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(
"<PASSWORD>",
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"
)

View File

@ -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

View File

@ -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

25
api/poetry.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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"""