Merge a1b074c684
into 04a0ae3aa9
This commit is contained in:
commit
a06088ee10
@ -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
|
@ -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,
|
||||
|
@ -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
|
||||
|
74
api/configs/ldap/__init__.py
Normal file
74
api/configs/ldap/__init__.py
Normal 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"
|
||||
)
|
63
api/extensions/ext_ldap.py
Normal file
63
api/extensions/ext_ldap.py
Normal 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
|
@ -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
25
api/poetry.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"""
|
||||
|
Loading…
Reference in New Issue
Block a user