diff --git a/README.md b/README.md
index e67c6e2..b25766a 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,14 @@ LiteOps 是一个专注实用性的 CI/CD 平台。只解决真问题 —— 自
+
+
+
+ |
+
+
+ |
+
## 技术架构
diff --git a/backend/apps/models.py b/backend/apps/models.py
index 8c1551d..ec0f0ed 100644
--- a/backend/apps/models.py
+++ b/backend/apps/models.py
@@ -10,6 +10,8 @@ class User(models.Model):
name = models.CharField(max_length=50, null=True, verbose_name='姓名')
password = models.CharField(max_length=128, null=True, verbose_name='密码')
email = models.EmailField(max_length=100, unique=True, null=True, verbose_name='邮箱')
+ user_type = models.CharField(max_length=20, default='system', null=True, verbose_name='用户类型')
+ ldap_dn = models.CharField(max_length=255, null=True, blank=True, verbose_name='LDAP DN')
status = models.SmallIntegerField(null=True, verbose_name='状态')
login_time = models.DateTimeField(null=True, verbose_name='最后登录时间')
create_time = models.DateTimeField(auto_now_add=True, null=True, verbose_name='创建时间')
@@ -388,4 +390,30 @@ class LoginAttempt(models.Model):
unique_together = ['user', 'ip_address'] # 确保用户和IP组合唯一
def __str__(self):
- return f"{self.user.username if self.user else 'Unknown'} - {self.ip_address}"
\ No newline at end of file
+ return f"{self.user.username if self.user else 'Unknown'} - {self.ip_address}"
+
+
+class LDAPConfig(models.Model):
+ """
+ LDAP配置表 - 存储LDAP服务器配置信息
+ """
+ id = models.AutoField(primary_key=True)
+ enabled = models.BooleanField(default=False, verbose_name='启用LDAP认证')
+ server_host = models.CharField(max_length=255, null=True, verbose_name='LDAP服务器地址')
+ server_port = models.IntegerField(default=389, verbose_name='LDAP服务器端口')
+ use_ssl = models.BooleanField(default=False, verbose_name='使用SSL/TLS')
+ base_dn = models.CharField(max_length=255, null=True, verbose_name='Base DN')
+ bind_dn = models.CharField(max_length=255, null=True, blank=True, verbose_name='绑定DN', help_text='管理员DN,用于搜索用户')
+ bind_password = models.CharField(max_length=255, null=True, blank=True, verbose_name='绑定密码')
+ user_search_filter = models.CharField(max_length=255, default='(uid={username})', verbose_name='用户搜索过滤器')
+ user_attr_map = models.JSONField(default=dict, verbose_name='用户属性映射', help_text='LDAP属性到系统属性的映射')
+ timeout = models.IntegerField(default=10, verbose_name='连接超时时间(秒)')
+ update_time = models.DateTimeField(auto_now=True, null=True, verbose_name='更新时间')
+
+ class Meta:
+ db_table = 'ldap_config'
+ verbose_name = 'LDAP配置'
+ verbose_name_plural = verbose_name
+
+ def __str__(self):
+ return f"LDAP配置 - {self.server_host}:{self.server_port}"
\ No newline at end of file
diff --git a/backend/apps/utils/crypto.py b/backend/apps/utils/crypto.py
new file mode 100644
index 0000000..23eec49
--- /dev/null
+++ b/backend/apps/utils/crypto.py
@@ -0,0 +1,44 @@
+import base64
+from cryptography.fernet import Fernet
+from django.conf import settings
+import hashlib
+
+class CryptoUtils:
+ """加密解密工具类"""
+
+ @staticmethod
+ def _get_key():
+ """获取加密密钥"""
+ key_material = settings.SECRET_KEY.encode('utf-8')
+ digest = hashlib.sha256(key_material).digest()
+ key = base64.urlsafe_b64encode(digest)
+ return key
+
+ @staticmethod
+ def encrypt_password(password):
+ """加密密码"""
+ if not password:
+ return password
+
+ try:
+ key = CryptoUtils._get_key()
+ fernet = Fernet(key)
+ encrypted = fernet.encrypt(password.encode('utf-8'))
+ return base64.urlsafe_b64encode(encrypted).decode('utf-8')
+ except Exception:
+ return password
+
+ @staticmethod
+ def decrypt_password(encrypted_password):
+ """解密密码"""
+ if not encrypted_password:
+ return encrypted_password
+
+ try:
+ key = CryptoUtils._get_key()
+ fernet = Fernet(key)
+ encrypted_data = base64.urlsafe_b64decode(encrypted_password.encode('utf-8'))
+ decrypted = fernet.decrypt(encrypted_data)
+ return decrypted.decode('utf-8')
+ except Exception:
+ return encrypted_password
\ No newline at end of file
diff --git a/backend/apps/views/ldap.py b/backend/apps/views/ldap.py
new file mode 100644
index 0000000..6c8ea8d
--- /dev/null
+++ b/backend/apps/views/ldap.py
@@ -0,0 +1,490 @@
+import json
+import uuid
+import hashlib
+import logging
+import ldap3
+from django.http import JsonResponse
+from django.views import View
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_exempt
+from django.db import transaction
+from ..models import User, LDAPConfig
+from ..utils.auth import jwt_auth_required
+from ..utils.crypto import CryptoUtils
+
+logger = logging.getLogger('apps')
+
+def generate_id():
+ """生成唯一ID"""
+ return hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest()[:32]
+
+class LDAPError(Exception):
+ """LDAP相关异常"""
+ pass
+
+class APIResponse:
+ """统一的API响应处理"""
+ @staticmethod
+ def success(message, data=None):
+ return JsonResponse({'code': 200, 'message': message, 'data': data})
+
+ @staticmethod
+ def error(code, message):
+ return JsonResponse({'code': code, 'message': message})
+
+@method_decorator(csrf_exempt, name='dispatch')
+class LDAPConfigView(View):
+ @method_decorator(jwt_auth_required)
+ def get(self, request):
+ """获取LDAP配置"""
+ try:
+ config = self._get_or_create_config()
+ return APIResponse.success('获取LDAP配置成功', self._serialize_config(config))
+ except Exception as e:
+ logger.error(f'获取LDAP配置失败: {str(e)}', exc_info=True)
+ return APIResponse.error(500, f'服务器错误: {str(e)}')
+
+ @method_decorator(jwt_auth_required)
+ def put(self, request):
+ """更新LDAP配置"""
+ try:
+ with transaction.atomic():
+ data = json.loads(request.body)
+ config = self._get_or_create_config()
+ self._update_config(config, data)
+ config.save()
+ return APIResponse.success('更新LDAP配置成功')
+ except Exception as e:
+ logger.error(f'更新LDAP配置失败: {str(e)}', exc_info=True)
+ return APIResponse.error(500, f'服务器错误: {str(e)}')
+
+ def _get_or_create_config(self):
+ """获取或创建LDAP配置"""
+ config, created = LDAPConfig.objects.get_or_create(
+ defaults={
+ 'enabled': False,
+ 'server_host': '',
+ 'server_port': 389,
+ 'use_ssl': False,
+ 'base_dn': '',
+ 'bind_dn': '',
+ 'bind_password': '',
+ 'user_search_filter': '(cn={username})',
+ 'user_attr_map': {'username': 'cn', 'name': 'uid', 'email': 'mail'},
+ 'timeout': 10
+ }
+ )
+ return config
+
+ def _serialize_config(self, config):
+ """序列化配置,隐藏敏感信息"""
+ return {
+ 'enabled': config.enabled,
+ 'server_host': config.server_host,
+ 'server_port': config.server_port,
+ 'use_ssl': config.use_ssl,
+ 'base_dn': config.base_dn,
+ 'bind_dn': config.bind_dn,
+ 'bind_password': '******' if config.bind_password else '', # 隐藏密码或显示为空
+ 'user_search_filter': config.user_search_filter,
+ 'user_attr_map': config.user_attr_map,
+ 'timeout': config.timeout,
+ 'update_time': config.update_time.strftime('%Y-%m-%d %H:%M:%S') if config.update_time else None
+ }
+
+ def _update_config(self, config, data):
+ """更新配置字段"""
+ update_fields = [
+ 'enabled', 'server_host', 'server_port', 'use_ssl', 'base_dn',
+ 'bind_dn', 'user_search_filter', 'user_attr_map', 'timeout'
+ ]
+
+ # 检查是否禁用LDAP认证
+ if 'enabled' in data and not data['enabled']:
+ # 如果禁用LDAP,清除所有配置数据
+ config.enabled = False
+ config.server_host = ''
+ config.server_port = 389
+ config.use_ssl = False
+ config.base_dn = ''
+ config.bind_dn = ''
+ config.bind_password = ''
+ config.user_search_filter = '(cn={username})'
+ config.user_attr_map = {'username': 'cn', 'name': 'uid', 'email': 'mail'}
+ config.timeout = 10
+ else:
+ # 启用状态下正常更新字段
+ for field in update_fields:
+ if field in data:
+ setattr(config, field, data[field])
+
+ if 'bind_password' in data:
+ if data['bind_password']:
+ config.bind_password = CryptoUtils.encrypt_password(data['bind_password'])
+
+@method_decorator(csrf_exempt, name='dispatch')
+class LDAPTestView(View):
+ @method_decorator(jwt_auth_required)
+ def post(self, request):
+ """测试LDAP管理员账户连接"""
+ try:
+ config = self._get_config()
+
+ if not all([config.bind_dn, config.bind_password]):
+ return APIResponse.error(400, '绑定DN和绑定密码是必需的')
+
+ result = self._test_admin_connection(config)
+ return APIResponse.success('LDAP服务器连接测试成功', result)
+
+ except LDAPError as e:
+ return APIResponse.error(400, str(e))
+ except Exception as e:
+ logger.error(f'LDAP测试失败: {str(e)}', exc_info=True)
+ return APIResponse.error(500, f'服务器错误: {str(e)}')
+
+ def _get_config(self):
+ """获取LDAP配置"""
+ try:
+ config = LDAPConfig.objects.get()
+ except LDAPConfig.DoesNotExist:
+ raise LDAPError('LDAP配置不存在,请先配置LDAP服务器')
+
+ if not all([config.server_host, config.base_dn, config.bind_dn]):
+ raise LDAPError('LDAP配置不完整,请检查服务器地址、Base DN和绑定DN')
+
+ return config
+
+ def _test_admin_connection(self, config):
+ """测试LDAP管理员账户连接"""
+ try:
+ server_uri = f"{'ldaps://' if config.use_ssl else 'ldap://'}{config.server_host}:{config.server_port}"
+ server = ldap3.Server(server_uri, use_ssl=config.use_ssl, connect_timeout=config.timeout)
+
+ logger.info(f"LDAP测试 - 服务器: {server_uri}")
+ logger.info(f"LDAP测试 - 绑定DN: {config.bind_dn}")
+ logger.info(f"LDAP测试 - Base DN: {config.base_dn}")
+
+ # 解密绑定密码
+ bind_password = CryptoUtils.decrypt_password(config.bind_password)
+
+ # 使用管理员账户连接
+ connection = ldap3.Connection(
+ server,
+ user=config.bind_dn,
+ password=bind_password,
+ auto_bind=True
+ )
+
+ # 测试基本搜索功能
+ connection.search(
+ search_base=config.base_dn,
+ search_filter='(objectClass=*)',
+ search_scope=ldap3.BASE,
+ attributes=['*']
+ )
+
+ connection.unbind()
+
+ return {
+ 'server': server_uri,
+ 'bind_dn': config.bind_dn,
+ 'base_dn': config.base_dn,
+ 'connection_status': '连接成功'
+ }
+
+ except ldap3.core.exceptions.LDAPBindError as e:
+ logger.error(f"LDAP绑定错误: {str(e)}")
+ if 'invalidCredentials' in str(e):
+ raise LDAPError('管理员账户认证失败,请检查绑定DN和密码')
+ elif 'invalidDNSyntax' in str(e):
+ raise LDAPError('绑定DN格式错误,请检查DN语法是否正确')
+ else:
+ raise LDAPError(f'绑定失败: {str(e)}')
+ except ldap3.core.exceptions.LDAPSocketOpenError as e:
+ logger.error(f"LDAP连接错误: {str(e)}")
+ raise LDAPError('LDAP服务器连接失败,请检查服务器地址和端口')
+ except ldap3.core.exceptions.LDAPSocketReceiveError as e:
+ logger.error(f"LDAP超时错误: {str(e)}")
+ raise LDAPError('LDAP服务器连接超时,请检查网络连接')
+ except ldap3.core.exceptions.LDAPException as e:
+ logger.error(f"LDAP通用错误: {str(e)}")
+ raise LDAPError(f'LDAP错误: {str(e)}')
+ except Exception as e:
+ logger.error(f"LDAP测试未知错误: {str(e)}", exc_info=True)
+ raise LDAPError(f'连接失败: {str(e)}')
+
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+class LDAPSyncView(View):
+ """LDAP用户同步视图"""
+
+ @method_decorator(jwt_auth_required)
+ def post(self, request):
+ """同步LDAP用户到系统"""
+ try:
+ data = json.loads(request.body)
+ action = data.get('action')
+
+ config = self._get_config()
+
+ if action == 'search':
+ # 搜索LDAP用户
+ search_filter = data.get('search_filter', '')
+ users = self._search_ldap_users(config, search_filter)
+ return APIResponse.success('搜索LDAP用户成功', {'users': users})
+
+ elif action == 'sync':
+ # 同步用户到系统
+ users_data = data.get('users', [])
+ if not users_data:
+ return APIResponse.error(400, '请选择要同步的用户')
+
+ synced_users = self._sync_users_to_system(config, users_data)
+ return APIResponse.success(
+ f'成功同步{len(synced_users)}个用户',
+ {'synced_users': synced_users}
+ )
+
+ else:
+ return APIResponse.error(400, '无效的操作类型')
+
+ except LDAPError as e:
+ return APIResponse.error(400, str(e))
+ except Exception as e:
+ logger.error(f'LDAP用户同步失败: {str(e)}', exc_info=True)
+ return APIResponse.error(500, f'服务器错误: {str(e)}')
+
+ def _get_config(self):
+ """获取LDAP配置"""
+ try:
+ config = LDAPConfig.objects.get()
+ if not config.enabled:
+ raise LDAPError('LDAP未启用')
+ except LDAPConfig.DoesNotExist:
+ raise LDAPError('LDAP配置不存在')
+
+ if not all([config.server_host, config.base_dn, config.bind_dn, config.bind_password]):
+ raise LDAPError('LDAP配置不完整')
+
+ return config
+
+ def _search_ldap_users(self, config, search_filter=''):
+ """搜索LDAP用户"""
+ try:
+ server_uri = f"{'ldaps://' if config.use_ssl else 'ldap://'}{config.server_host}:{config.server_port}"
+ server = ldap3.Server(server_uri, use_ssl=config.use_ssl, connect_timeout=config.timeout)
+
+ # 解密绑定密码
+ bind_password = CryptoUtils.decrypt_password(config.bind_password)
+
+ # 使用管理员账户连接
+ connection = ldap3.Connection(
+ server,
+ user=config.bind_dn,
+ password=bind_password,
+ auto_bind=True
+ )
+
+ # 构建搜索过滤器
+ if search_filter:
+ final_filter = f"(&(objectClass=person)({search_filter}))"
+ else:
+ final_filter = "(objectClass=person)"
+
+ # 搜索用户
+ connection.search(
+ search_base=config.base_dn,
+ search_filter=final_filter,
+ attributes=list(config.user_attr_map.values())
+ )
+
+ users = []
+ for entry in connection.entries:
+ user_info = self._extract_user_info(entry, config.user_attr_map)
+ if user_info.get('username'): # 确保有用户名
+ users.append(user_info)
+
+ connection.unbind()
+ return users
+
+ except ldap3.core.exceptions.LDAPException as e:
+ logger.error(f"LDAP搜索用户错误: {str(e)}")
+ raise LDAPError(f'搜索用户失败: {str(e)}')
+ except Exception as e:
+ logger.error(f"搜索LDAP用户未知错误: {str(e)}", exc_info=True)
+ raise LDAPError(f'搜索失败: {str(e)}')
+
+ def _extract_user_info(self, entry, attr_map):
+ """提取用户信息"""
+ user_info = {'dn': entry.entry_dn}
+ for local_attr, ldap_attr in attr_map.items():
+ if hasattr(entry, ldap_attr):
+ attr_value = getattr(entry, ldap_attr)
+ if attr_value:
+ user_info[local_attr] = str(attr_value.value) if hasattr(attr_value, 'value') else str(attr_value)
+ return user_info
+
+ def _sync_users_to_system(self, config, users_data):
+ """同步用户到系统"""
+ synced_users = []
+
+ for user_data in users_data:
+ try:
+ with transaction.atomic():
+ username = user_data.get('username')
+ if not username:
+ continue
+
+ # 检查用户是否已存在
+ try:
+ user = User.objects.get(username=username)
+ if user.user_type != 'ldap':
+ logger.warning(f'用户{username}已存在但不是LDAP用户,跳过')
+ continue
+
+ # 更新用户信息
+ user.name = user_data.get('name', username)
+ user.email = user_data.get('email')
+ user.ldap_dn = user_data.get('dn')
+ user.save()
+
+ synced_users.append({
+ 'username': username,
+ 'action': 'updated'
+ })
+
+ except User.DoesNotExist:
+ # 创建新用户
+ user = User.objects.create(
+ user_id=generate_id(),
+ username=username,
+ name=user_data.get('name', username),
+ email=user_data.get('email'),
+ user_type='ldap',
+ ldap_dn=user_data.get('dn'),
+ status=1
+ )
+
+ synced_users.append({
+ 'username': username,
+ 'action': 'created'
+ })
+
+ except Exception as e:
+ logger.error(f'同步用户{user_data.get("username")}失败: {str(e)}')
+ continue
+
+ return synced_users
+
+@method_decorator(csrf_exempt, name='dispatch')
+class LDAPStatusView(View):
+ def get(self, request):
+ """检查LDAP是否启用(无需认证)"""
+ try:
+ try:
+ config = LDAPConfig.objects.get()
+ enabled = config.enabled
+ except LDAPConfig.DoesNotExist:
+ enabled = False
+
+ return APIResponse.success('获取LDAP状态成功', {'enabled': enabled})
+ except Exception as e:
+ logger.error(f'获取LDAP状态失败: {str(e)}', exc_info=True)
+ return APIResponse.error(500, f'服务器错误: {str(e)}')
+
+class LDAPAuthenticator:
+ """LDAP认证器"""
+
+ @staticmethod
+ def authenticate(username, password):
+ """LDAP用户认证 - 只认证已存在的用户"""
+ try:
+ config = LDAPAuthenticator._validate_config()
+ # 先检查用户是否存在于本地数据库
+ user = LDAPAuthenticator._get_existing_user(username)
+ # 进行LDAP认证
+ LDAPAuthenticator._ldap_authenticate(config, username, password)
+ return True, user
+ except LDAPError as e:
+ return False, str(e)
+ except Exception as e:
+ logger.error(f'LDAP认证失败: {str(e)}', exc_info=True)
+ return False, f'认证失败: {str(e)}'
+
+ @staticmethod
+ def _validate_config():
+ """验证LDAP配置"""
+ try:
+ config = LDAPConfig.objects.get()
+ if not config.enabled:
+ raise LDAPError('未启用LDAP认证')
+ except LDAPConfig.DoesNotExist:
+ raise LDAPError('LDAP配置不存在')
+
+ if not all([config.server_host, config.base_dn, config.bind_dn, config.bind_password]):
+ raise LDAPError('LDAP配置不完整,缺少必要的服务器信息或管理员账户')
+
+ return config
+
+ @staticmethod
+ def _ldap_authenticate(config, username, password):
+ """执行LDAP认证 - 只使用绑定DN模式"""
+ try:
+ server_uri = f"{'ldaps://' if config.use_ssl else 'ldap://'}{config.server_host}:{config.server_port}"
+ server = ldap3.Server(server_uri, use_ssl=config.use_ssl, connect_timeout=config.timeout)
+
+ # 解密绑定密码
+ bind_password = CryptoUtils.decrypt_password(config.bind_password)
+
+ # 先用管理员账户连接并搜索用户
+ admin_conn = ldap3.Connection(server, user=config.bind_dn, password=bind_password, auto_bind=True)
+
+ # 搜索用户 - 支持两种过滤器格式
+ search_filter = config.user_search_filter
+ if '{username}' in search_filter:
+ search_filter = search_filter.format(username=username)
+ elif '%(user)s' in search_filter:
+ search_filter = search_filter.replace('%(user)s', username)
+ else:
+ # 如果都不匹配,尝试直接替换
+ search_filter = search_filter.format(username=username)
+ admin_conn.search(
+ search_base=config.base_dn,
+ search_filter=search_filter,
+ attributes=list(config.user_attr_map.values())
+ )
+
+ if not admin_conn.entries:
+ admin_conn.unbind()
+ raise LDAPError('用户不存在于LDAP服务器')
+
+ # 获取搜索到的用户DN
+ found_user_dn = admin_conn.entries[0].entry_dn
+ admin_conn.unbind()
+
+ # DN和用户密码进行认证
+ user_conn = ldap3.Connection(server, user=found_user_dn, password=password, auto_bind=True)
+ user_conn.unbind()
+
+ except ldap3.core.exceptions.LDAPBindError as e:
+ if 'invalidCredentials' in str(e):
+ raise LDAPError('用户名或密码错误')
+ else:
+ raise LDAPError('LDAP认证失败')
+ except ldap3.core.exceptions.LDAPSocketOpenError:
+ raise LDAPError('LDAP服务器连接失败')
+ except ldap3.core.exceptions.LDAPSocketReceiveError:
+ raise LDAPError('LDAP服务器连接超时')
+ except ldap3.core.exceptions.LDAPException as e:
+ raise LDAPError(f'LDAP错误: {str(e)}')
+
+ @staticmethod
+ def _get_existing_user(username):
+ """获取已存在的LDAP用户"""
+ try:
+ return User.objects.get(username=username, user_type='ldap')
+ except User.DoesNotExist:
+ raise LDAPError(f'用户{username}不存在,请联系管理员同步LDAP用户')
+
+
\ No newline at end of file
diff --git a/backend/apps/views/login.py b/backend/apps/views/login.py
index b5f7a07..1413719 100644
--- a/backend/apps/views/login.py
+++ b/backend/apps/views/login.py
@@ -9,6 +9,7 @@ from django.views.decorators.http import require_http_methods
from django.conf import settings
from ..models import User, UserToken, LoginLog
from ..utils.security import SecurityValidator
+from .ldap import LDAPAuthenticator
def generate_token_id():
"""生成token_id"""
@@ -18,6 +19,135 @@ def generate_log_id():
"""生成log_id"""
return hashlib.sha256(str(uuid.uuid4()).encode()).hexdigest()[:32]
+class LoginHandler:
+ """登录处理器"""
+
+ def __init__(self, username, password, ip_address, user_agent):
+ self.username = username
+ self.password = password
+ self.ip_address = ip_address
+ self.user_agent = user_agent
+ self.log_id = generate_log_id()
+
+ def authenticate_system_user(self):
+ """系统用户认证"""
+ try:
+ user = User.objects.get(username=self.username)
+ except User.DoesNotExist:
+ self._log_failed_login(fail_reason='用户不存在')
+ return JsonResponse({'code': 404, 'message': '用户不存在'})
+
+ # 检查账户锁定
+ is_not_locked, lockout_message = SecurityValidator.check_account_lockout(user, self.ip_address)
+ if not is_not_locked:
+ self._log_failed_login(user=user, fail_reason=lockout_message)
+ return JsonResponse({'code': 423, 'message': lockout_message})
+
+ # 验证密码
+ password_hash = hashlib.sha256(self.password.encode()).hexdigest()
+ if user.password != password_hash:
+ return self._handle_password_error(user)
+
+ # 检查用户状态
+ if user.status == 0:
+ self._log_failed_login(user=user, fail_reason='账号已被锁定')
+ return JsonResponse({'code': 423, 'message': '账号已被锁定,请联系管理员解锁'})
+
+ # 登录成功
+ SecurityValidator.record_successful_login(user, self.ip_address)
+ return self._create_login_response(user)
+
+ def authenticate_ldap_user(self):
+ """LDAP用户认证"""
+ success, result = LDAPAuthenticator.authenticate(self.username, self.password)
+
+ if not success:
+ self._log_failed_login(fail_reason=f'LDAP认证失败: {result}')
+ return JsonResponse({'code': 401, 'message': result})
+
+ user = result
+ if user.status == 0:
+ self._log_failed_login(user=user, fail_reason='账号已被锁定')
+ return JsonResponse({'code': 423, 'message': '账号已被锁定,请联系管理员解锁'})
+
+ return self._create_login_response(user)
+
+ def _handle_password_error(self, user):
+ """处理密码错误"""
+ failed_attempts, max_attempts = SecurityValidator.record_failed_login(user, self.ip_address)
+ self._log_failed_login(user=user, fail_reason='密码错误')
+
+ remaining_attempts = max_attempts - failed_attempts
+ if remaining_attempts > 0:
+ message = f'密码错误,还可尝试{remaining_attempts}次'
+ else:
+ config = SecurityValidator.get_security_config()
+ message = f'密码错误次数过多,账户已被锁定{config.lockout_duration}分钟'
+
+ return JsonResponse({'code': 401, 'message': message})
+
+ def _create_login_response(self, user):
+ """创建登录成功响应"""
+ config = SecurityValidator.get_security_config()
+
+ # 生成token
+ token_id = generate_token_id()
+ token_payload = {
+ 'user_id': user.user_id,
+ 'username': user.username,
+ 'exp': datetime.utcnow() + timedelta(minutes=config.session_timeout)
+ }
+ token = jwt.encode(token_payload, settings.SECRET_KEY, algorithm='HS256')
+
+ # 更新token记录
+ UserToken.objects.update_or_create(
+ user=user,
+ defaults={'token_id': token_id, 'token': token}
+ )
+
+ # 更新登录时间
+ user.login_time = datetime.now()
+ user.save()
+
+ # 记录成功登录日志
+ self._log_successful_login(user)
+
+ return JsonResponse({
+ 'code': 200,
+ 'message': '登录成功',
+ 'data': {
+ 'token_id': token_id,
+ 'token': token,
+ 'user': {
+ 'user_id': user.user_id,
+ 'username': user.username,
+ 'name': user.name,
+ 'email': user.email
+ }
+ }
+ })
+
+ def _log_failed_login(self, user=None, fail_reason=''):
+ """记录失败登录日志"""
+ LoginLog.objects.create(
+ log_id=self.log_id,
+ user=user,
+ ip_address=self.ip_address,
+ user_agent=self.user_agent,
+ status='failed',
+ fail_reason=fail_reason
+ )
+
+ def _log_successful_login(self, user):
+ """记录成功登录日志"""
+ LoginLog.objects.create(
+ log_id=self.log_id,
+ user=user,
+ ip_address=self.ip_address,
+ user_agent=self.user_agent,
+ status='success'
+ )
+
@csrf_exempt
@require_http_methods(["POST"])
def login(request):
@@ -25,160 +155,26 @@ def login(request):
data = json.loads(request.body)
username = data.get('username')
password = data.get('password')
+ auth_type = data.get('auth_type', 'system')
- # 获取客户端IP和用户代理
+ if not username or not password:
+ return JsonResponse({'code': 400, 'message': '用户名和密码不能为空'})
+
+ # 获取客户端信息
ip_address = request.META.get('HTTP_X_FORWARDED_FOR', request.META.get('REMOTE_ADDR', ''))
user_agent = request.META.get('HTTP_USER_AGENT', '')
-
- if not username or not password:
- return JsonResponse({
- 'code': 400,
- 'message': '用户名和密码不能为空'
- })
-
- # 密码加密
- password_hash = hashlib.sha256(password.encode()).hexdigest()
-
- try:
- user = User.objects.get(username=username)
-
- # 记录登录日志
- log_id = generate_log_id()
-
- # 检查账户是否被锁定
- is_not_locked, lockout_message = SecurityValidator.check_account_lockout(user, ip_address)
- if not is_not_locked:
- # 账户被锁定,记录失败登录
- LoginLog.objects.create(
- log_id=log_id,
- user=user,
- ip_address=ip_address,
- user_agent=user_agent,
- status='failed',
- fail_reason=lockout_message
- )
-
- return JsonResponse({
- 'code': 423, # 423 Locked
- 'message': lockout_message
- })
-
- if user.password != password_hash:
- # 密码错误,记录失败尝试
- failed_attempts, max_attempts = SecurityValidator.record_failed_login(user, ip_address)
-
- # 记录失败登录日志
- LoginLog.objects.create(
- log_id=log_id,
- user=user,
- ip_address=ip_address,
- user_agent=user_agent,
- status='failed',
- fail_reason='密码错误'
- )
-
- # 提供剩余尝试次数信息
- remaining_attempts = max_attempts - failed_attempts
- if remaining_attempts > 0:
- message = f'密码错误,还可尝试{remaining_attempts}次'
- else:
- config = SecurityValidator.get_security_config()
- message = f'密码错误次数过多,账户已被锁定{config.lockout_duration}分钟'
-
- return JsonResponse({
- 'code': 401,
- 'message': message
- })
-
- if user.status == 0:
- # 账号锁定,记录失败登录
- LoginLog.objects.create(
- log_id=log_id,
- user=user,
- ip_address=ip_address,
- user_agent=user_agent,
- status='failed',
- fail_reason='账号已被锁定'
- )
-
- return JsonResponse({
- 'code': 423, # 423 Locked
- 'message': '账号已被锁定,请联系管理员解锁'
- })
-
- # 登录成功,重置失败尝试次数
- SecurityValidator.record_successful_login(user, ip_address)
-
- # 检查会话超时设置
- config = SecurityValidator.get_security_config()
-
- # 生成token_id和JWT token
- token_id = generate_token_id()
- token_payload = {
- 'user_id': user.user_id,
- 'username': user.username,
- 'exp': datetime.utcnow() + timedelta(minutes=config.session_timeout) # 使用配置的会话超时时间
- }
- token = jwt.encode(token_payload, settings.SECRET_KEY, algorithm='HS256')
-
- # 更新或创建token记录
- UserToken.objects.update_or_create(
- user=user,
- defaults={
- 'token_id': token_id,
- 'token': token
- }
- )
-
- # 更新登录时间
- user.login_time = datetime.now()
- user.save()
-
- # 记录成功登录日志
- LoginLog.objects.create(
- log_id=log_id,
- user=user,
- ip_address=ip_address,
- user_agent=user_agent,
- status='success'
- )
-
- return JsonResponse({
- 'code': 200,
- 'message': '登录成功',
- 'data': {
- 'token_id': token_id,
- 'token': token,
- 'user': {
- 'user_id': user.user_id,
- 'username': user.username,
- 'name': user.name,
- 'email': user.email
- }
- }
- })
-
- except User.DoesNotExist:
- # 用户不存在,记录失败登录
- log_id = generate_log_id()
- LoginLog.objects.create(
- log_id=log_id,
- ip_address=ip_address,
- user_agent=user_agent,
- status='failed',
- fail_reason='用户不存在'
- )
-
- return JsonResponse({
- 'code': 404,
- 'message': '用户不存在'
- })
+
+ # 创建登录处理器
+ handler = LoginHandler(username, password, ip_address, user_agent)
+
+ # 根据认证类型进行认证
+ if auth_type == 'ldap':
+ return handler.authenticate_ldap_user()
+ else:
+ return handler.authenticate_system_user()
except Exception as e:
- return JsonResponse({
- 'code': 500,
- 'message': f'服务器错误: {str(e)}'
- })
+ return JsonResponse({'code': 500, 'message': f'服务器错误: {str(e)}'})
@csrf_exempt
@require_http_methods(["POST"])
@@ -186,36 +182,18 @@ def logout(request):
try:
token = request.headers.get('Authorization')
if not token:
- return JsonResponse({
- 'code': 400,
- 'message': '未提供Token'
- })
+ return JsonResponse({'code': 400, 'message': '未提供Token'})
try:
- # 解析token获取user_id
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
user_id = payload.get('user_id')
-
- # 删除该用户的token记录
UserToken.objects.filter(user_id=user_id).delete()
+ return JsonResponse({'code': 200, 'message': '退出成功'})
- return JsonResponse({
- 'code': 200,
- 'message': '退出成功'
- })
except jwt.ExpiredSignatureError:
- return JsonResponse({
- 'code': 401,
- 'message': 'Token已过期'
- })
+ return JsonResponse({'code': 401, 'message': 'Token已过期'})
except jwt.InvalidTokenError:
- return JsonResponse({
- 'code': 401,
- 'message': '无效的Token'
- })
+ return JsonResponse({'code': 401, 'message': '无效的Token'})
except Exception as e:
- return JsonResponse({
- 'code': 500,
- 'message': f'服务器错误: {str(e)}'
- })
\ No newline at end of file
+ return JsonResponse({'code': 500, 'message': f'服务器错误: {str(e)}'})
\ No newline at end of file
diff --git a/backend/apps/views/user.py b/backend/apps/views/user.py
index 1bd5d87..94958cb 100644
--- a/backend/apps/views/user.py
+++ b/backend/apps/views/user.py
@@ -57,6 +57,7 @@ class UserView(View):
'email': user.email,
'status': user.status,
'roles': roles,
+ 'user_type': user.user_type,
'login_time': user.login_time.strftime('%Y-%m-%d %H:%M:%S') if user.login_time else None,
'create_time': user.create_time.strftime('%Y-%m-%d %H:%M:%S'),
})
@@ -188,7 +189,6 @@ class UserView(View):
})
user.email = email
if status is not None:
- # 如果是解锁操作(从0改为1),使用安全验证器的解锁方法
if user.status == 0 and status == 1:
success, message = SecurityValidator.unlock_user_account(user)
if not success:
@@ -196,7 +196,6 @@ class UserView(View):
'code': 400,
'message': message
})
- # 如果是锁定操作(从1改为0),使用安全验证器的锁定方法
elif user.status == 1 and status == 0:
success, message = SecurityValidator.lock_user_account(user)
if not success:
@@ -315,6 +314,7 @@ class UserProfileView(View):
'email': user.email,
'status': user.status,
'roles': roles,
+ 'user_type': user.user_type,
'login_time': user.login_time.strftime('%Y-%m-%d %H:%M:%S') if user.login_time else None,
'create_time': user.create_time.strftime('%Y-%m-%d %H:%M:%S') if user.create_time else None,
'update_time': user.update_time.strftime('%Y-%m-%d %H:%M:%S') if user.update_time else None,
diff --git a/backend/backend/urls.py b/backend/backend/urls.py
index 0704f57..ddf0322 100644
--- a/backend/backend/urls.py
+++ b/backend/backend/urls.py
@@ -16,6 +16,7 @@ from apps.views.dashboard import DashboardStatsView, BuildTrendView, BuildDetail
from apps.views.webhook import GitLabWebhookView
from apps.views.security import SecurityConfigView, get_build_tasks_for_cleanup, cleanup_build_logs, cleanup_login_logs, get_watermark_config, get_current_user_info
+from apps.views.ldap import LDAPConfigView, LDAPTestView, LDAPStatusView, LDAPSyncView
urlpatterns = [
path('admin/', admin.site.urls),
@@ -73,4 +74,10 @@ urlpatterns = [
path('api/system/security/cleanup-login-logs/', cleanup_login_logs, name='cleanup-login-logs'),
path('api/system/watermark/', get_watermark_config, name='watermark-config'),
path('api/user/current/', get_current_user_info, name='current-user-info'),
+
+ # LDAP配置相关路由
+ path('api/system/ldap/', LDAPConfigView.as_view(), name='ldap-config'),
+ path('api/system/ldap/test/', LDAPTestView.as_view(), name='ldap-test'),
+ path('api/system/ldap/status/', LDAPStatusView.as_view(), name='ldap-status'),
+ path('api/system/ldap/sync/', LDAPSyncView.as_view(), name='ldap-sync'),
]
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 7d5dc4d..213c999 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -9,4 +9,5 @@ uvicorn==0.34.0
decorator==5.1.1
django-cors-headers==4.2.0
cryptography==42.0.5
-PyYAML==6.0.1
\ No newline at end of file
+PyYAML==6.0.1
+ldap3==2.9.1
\ No newline at end of file
diff --git a/image/basic_ldap.png b/image/basic_ldap.png
new file mode 100644
index 0000000..92e7ca6
Binary files /dev/null and b/image/basic_ldap.png differ
diff --git a/image/notify.png b/image/notify.png
new file mode 100644
index 0000000..640d69a
Binary files /dev/null and b/image/notify.png differ
diff --git a/web/src/views/login/LoginView.vue b/web/src/views/login/LoginView.vue
index f30f7b8..954b189 100644
--- a/web/src/views/login/LoginView.vue
+++ b/web/src/views/login/LoginView.vue
@@ -38,6 +38,22 @@
+
+
+
+
+
+
+ {{ useLDAP ? 'LDAP认证' : '系统认证' }}
+
+
+
+
\ No newline at end of file
diff --git a/web/src/views/user/UserList.vue b/web/src/views/user/UserList.vue
index 7af3934..00366ac 100644
--- a/web/src/views/user/UserList.vue
+++ b/web/src/views/user/UserList.vue
@@ -40,6 +40,11 @@
+
+
+ {{ record.user_type === 'ldap' ? 'LDAP' : '系统' }}
+
+
编辑
@@ -87,12 +92,20 @@
-
+
+ LDAP用户信息由LDAP服务器管理
-
+
+ LDAP用户信息由LDAP服务器管理
-
+
不修改请留空
@@ -148,6 +161,11 @@ const columns = [
dataIndex: 'roles',
key: 'roles',
},
+ {
+ title: '用户类型',
+ dataIndex: 'user_type',
+ key: 'user_type',
+ },
{
title: '状态',
dataIndex: 'status',
@@ -254,11 +272,16 @@ const formState = reactive({
email: '',
password: '',
role_ids: [],
- status: 1
+ status: 1,
+ user_type: 'system'
});
// 动态密码验证规则
const passwordValidator = (rule, value) => {
+ // LDAP用户不需要验证密码
+ if (formState.user_type === 'ldap') {
+ return Promise.resolve();
+ }
if (formState.user_id && !value) {
return Promise.resolve();
}
@@ -350,6 +373,7 @@ const showEditModal = (record) => {
formState.name = record.name;
formState.email = record.email;
formState.status = record.status;
+ formState.user_type = record.user_type || 'system';
formState.role_ids = record.roles.map(role => role.role_id);
modalVisible.value = true;
};
@@ -366,7 +390,8 @@ const resetForm = () => {
email: '',
password: '',
role_ids: [],
- status: 1
+ status: 1,
+ user_type: 'system'
});
};
diff --git a/web/src/views/user/UserRole.vue b/web/src/views/user/UserRole.vue
index d76236c..76f5d60 100644
--- a/web/src/views/user/UserRole.vue
+++ b/web/src/views/user/UserRole.vue
@@ -38,7 +38,7 @@
cancel-text="取消"
@confirm="handleDelete(record)"
>
- 删除
+ 删除
权限配置