feat: Add LDAP/Active Directory integration with group-based access control

- Add PHP LDAP extension to Docker container
- Implement LdapSync class for authentication and user synchronization
- Add automatic user sync via cron (every 30 minutes)
- Create role-based access control system (admin, manager, viewer)
- Add LDAP configuration UI in settings
- Support for both Active Directory and OpenLDAP
- Group-to-role mapping with flexible configuration
- Add 50+ translations (EN + RU) for LDAP features
- Include comprehensive setup documentation
- Enhance Auth::login() with LDAP fallback
- Add LDAP settings page with connection testing
This commit is contained in:
infosave2007
2025-11-10 17:46:27 +03:00
parent 40abe38616
commit 3d9cc02963
13 changed files with 1141 additions and 3 deletions
+7 -3
View File
@@ -1,6 +1,6 @@
FROM php:8.2-apache
# Install dependencies
# Install dependencies including LDAP
RUN apt-get update && apt-get install -y \
git \
curl \
@@ -13,7 +13,9 @@ RUN apt-get update && apt-get install -y \
openssh-client \
qrencode \
cron \
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd \
libldap2-dev \
&& docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu/ \
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd ldap \
&& a2enmod rewrite \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
@@ -39,12 +41,14 @@ RUN chown -R www-data:www-data /var/www/html \
# Setup cron jobs
RUN echo "0 * * * * www-data cd /var/www/html && /usr/local/bin/php bin/check_expired_clients.php >> /var/log/cron.log 2>&1" > /etc/cron.d/amnezia-cron \
&& echo "0 * * * * www-data cd /var/www/html && /usr/local/bin/php bin/check_traffic_limits.php >> /var/log/cron.log 2>&1" >> /etc/cron.d/amnezia-cron \
&& echo "*/30 * * * * www-data cd /var/www/html && /usr/local/bin/php bin/sync_ldap_users.php >> /var/log/ldap_sync.log 2>&1" >> /etc/cron.d/amnezia-cron \
&& echo "*/3 * * * * root /bin/bash /var/www/html/bin/monitor_metrics.sh >> /var/log/metrics_monitor.log 2>&1" >> /etc/cron.d/amnezia-cron \
&& chmod 0644 /etc/cron.d/amnezia-cron \
&& crontab /etc/cron.d/amnezia-cron \
&& touch /var/log/cron.log \
&& touch /var/log/metrics_monitor.log \
&& touch /var/log/metrics_collector.log
&& touch /var/log/metrics_collector.log \
&& touch /var/log/ldap_sync.log
# Make monitor script executable
RUN chmod +x /var/www/html/bin/monitor_metrics.sh
+90
View File
@@ -0,0 +1,90 @@
# LDAP Integration - Feature Branch
## ✅ Реализовано
### Базовая инфраструктура:
- ✅ PHP LDAP расширение в Dockerfile
- ✅ Миграции БД (ldap_configs, user_roles, ldap_group_mappings)
- ✅ Класс `LdapSync.php` - подключение, аутентификация, синхронизация
- ✅ Интеграция в `Auth.php` - авто-переключение между LDAP/локальной авторизацией
- ✅ Cron задача синхронизации (каждые 30 мин)
- ✅ UI страница настроек LDAP (templates/settings/ldap.twig)
- ✅ Документация LDAP_SETUP.md
### Функционал:
- Авторизация через LDAP/Active Directory
- Автоматическая синхронизация пользователей
- Маппинг LDAP групп на роли (admin/manager/viewer)
- Тест подключения к LDAP
- Fallback на локальную авторизацию
## ⚠️ Требуется доработка
### Критичное:
1. **Добавить маршруты в `public/index.php`:**
```php
$router->add('GET', '/settings/ldap', [SettingsController::class, 'ldapSettings']);
$router->add('POST', '/settings/ldap/save', [SettingsController::class, 'saveLdapSettings']);
$router->add('POST', '/settings/ldap/test', [SettingsController::class, 'testLdapConnection']);
```
2. **Создать методы в `controllers/SettingsController.php`:**
- `ldapSettings()` - показать страницу
- `saveLdapSettings()` - сохранить конфигурацию
- `testLdapConnection()` - протестировать подключение
3. **Добавить переводы** в миграции 002-006 (ru, en, es, de, fr, zh):
- ldap_settings
- enable_ldap_auth
- test_connection
- ldap_host / ldap_port / base_dn / bind_dn
- sync_interval
- ldap_group_mappings
### Опционально:
- Логи LDAP операций в отдельный файл
- Валидация LDAP настроек на фронтенде
- Более детальные сообщения об ошибках
## 🧪 Тестирование
### Локально:
```bash
# Пересобрать контейнеры с LDAP расширением
docker-compose build
# Применить миграции
docker-compose exec web php -r "require 'inc/Config.php'; require 'inc/DB.php'; DB::runMigrations();"
# Тест LDAP подключения
docker-compose exec web php bin/sync_ldap_users.php
```
### Проверить:
1. Страница `/settings/ldap` открывается
2. Сохранение настроек работает
3. Тест подключения выполняется
4. Авторизация через LDAP (если настроен тестовый LDAP сервер)
## 📋 TODO перед слиянием в master
- [ ] Добавить маршруты и контроллер
- [ ] Добавить переводы для всех языков
- [ ] Протестировать с реальным LDAP/AD сервером
- [ ] Проверить обратную совместимость (без LDAP всё работает как раньше)
- [ ] Обновить README.md с информацией о LDAP
## 🔒 Безопасность
- ❌ Пароли LDAP не шифруются (TODO: добавить шифрование в БД)
- ✅ Опциональная функция - не влияет на существующий функционал
- ✅ Fallback на локальную авторизацию
- ✅ TLS/SSL поддержка для LDAP
## 📖 Документация
См. `LDAP_SETUP.md` для:
- Примеры конфигурации AD/OpenLDAP
- Настройка групп безопасности
- Troubleshooting
- Best practices
+187
View File
@@ -0,0 +1,187 @@
# LDAP Setup Guide
## Overview
This guide explains how to configure LDAP/Active Directory integration for Amnezia VPN Panel.
## Supported LDAP Servers
- OpenLDAP
- Active Directory (AD)
- FreeIPA
- Any RFC 4511 compliant LDAP server
## Configuration
### 1. Enable LDAP in Admin Panel
1. Navigate to Settings → LDAP
2. Enable "LDAP Authentication"
3. Fill in connection details
### 2. Connection Settings
#### For Active Directory:
```
Host: ad.example.com
Port: 389 (LDAP) or 636 (LDAPS)
Use TLS: ☑ (recommended for production)
Base DN: DC=example,DC=com
Bind DN: CN=svc_vpn,CN=Users,DC=example,DC=com
Bind Password: YourServiceAccountPassword
User Search Filter: (sAMAccountName=%s)
Group Search Filter: (member=%s)
```
#### For OpenLDAP:
```
Host: ldap.example.com
Port: 389
Use TLS: ☑
Base DN: ou=people,dc=example,dc=com
Bind DN: cn=admin,dc=example,dc=com
Bind Password: YourAdminPassword
User Search Filter: (uid=%s)
Group Search Filter: (memberUid=%s)
```
### 3. Group Mappings
Map LDAP groups to panel roles:
| LDAP Group | Panel Role | Permissions |
|-------------|-----------|-------------|
| vpn-admins | admin | Full access |
| vpn-managers| manager | Manage servers & clients |
| vpn-users | viewer | View own clients only |
#### How to configure groups:
**Active Directory:**
```powershell
# Create security groups
New-ADGroup -Name "vpn-admins" -GroupScope Global -GroupCategory Security
New-ADGroup -Name "vpn-managers" -GroupScope Global -GroupCategory Security
New-ADGroup -Name "vpn-users" -GroupScope Global -GroupCategory Security
# Add users to groups
Add-ADGroupMember -Identity "vpn-admins" -Members "john.doe"
```
**OpenLDAP:**
```ldif
dn: cn=vpn-admins,ou=groups,dc=example,dc=com
objectClass: groupOfNames
cn: vpn-admins
member: uid=john.doe,ou=people,dc=example,dc=com
```
### 4. Test Connection
1. Click "Test Connection" button in LDAP settings
2. Verify successful connection
3. Save configuration
### 5. Synchronization
- **Automatic**: Users sync every 30 minutes (configurable)
- **Manual**: Run `docker-compose exec web php bin/sync_ldap_users.php`
## Authentication Flow
```mermaid
graph TD
A[User Login] --> B{LDAP Enabled?}
B -->|Yes| C[Try LDAP Auth]
B -->|No| D[Local DB Auth]
C -->|Success| E[Sync User to DB]
C -->|Fail| D
E --> F[Create Session]
D -->|Success| F
D -->|Fail| G[Login Failed]
```
## Security Best Practices
1. **Use TLS/SSL**
- Always enable TLS for production
- Use LDAPS (port 636) for encrypted connections
2. **Service Account**
- Create dedicated read-only service account
- Grant minimum required permissions
- Use strong password
3. **Group-Based Access**
- Use security groups for access control
- Regular audit of group memberships
- Remove inactive users from groups
4. **Firewall Rules**
- Allow LDAP traffic only from VPN panel server
- Block direct LDAP access from internet
## Troubleshooting
### Connection Issues
```bash
# Check LDAP connectivity
docker-compose exec web php -r "
require 'vendor/autoload.php';
require 'inc/Config.php';
require 'inc/DB.php';
require 'inc/LdapSync.php';
\$ldap = new LdapSync();
var_dump(\$ldap->testConnection());
"
```
### View Sync Logs
```bash
docker-compose exec web tail -f /var/log/ldap_sync.log
```
### Common Errors
**Error:** `Failed to bind`
- **Solution:** Check Bind DN and password
**Error:** `Can't contact LDAP server`
- **Solution:** Verify host, port, and firewall rules
**Error:** `Invalid credentials`
- **Solution:** User not found or wrong password
## Examples
### Migrate from Local to LDAP Auth
1. Enable LDAP
2. Run initial sync: `docker-compose exec web php bin/sync_ldap_users.php`
3. Existing users can still login with local passwords
4. LDAP users auto-created on first login
### Disable User
Remove user from LDAP groups → Next sync will disable account
### Change User Role
Move user to different LDAP group → Role updates on next login
## Support
For issues, check:
- `/var/log/ldap_sync.log` - Synchronization logs
- PHP error logs in Docker container
- LDAP server logs
## Advanced Configuration
### Custom User Attributes
Edit `inc/LdapSync.php` to map additional LDAP attributes:
```php
[
'displayName' => $entries[0]['displayname'][0],
'phone' => $entries[0]['telephonenumber'][0],
'department' => $entries[0]['department'][0]
]
```
### Multiple LDAP Servers
Currently supports single LDAP server. For multiple servers, create separate instances or use LDAP proxy.
### SSO Integration
LDAP auth provides foundation for SSO. Consider SAML/OAuth for full SSO implementation.
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env php
<?php
/**
* LDAP User Synchronization Script
* Runs periodically to sync users from LDAP/AD
*/
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../inc/Config.php';
require_once __DIR__ . '/../inc/DB.php';
require_once __DIR__ . '/../inc/LdapSync.php';
try {
$ldap = new LdapSync();
if (!$ldap->isEnabled()) {
exit(0); // LDAP not enabled, nothing to do
}
echo "[" . date('Y-m-d H:i:s') . "] Starting LDAP user synchronization...\n";
$result = $ldap->syncUsers();
if ($result['success']) {
echo "✓ Synchronization completed successfully:\n";
echo " - Total users in LDAP: {$result['total']}\n";
echo " - Synced (updated): {$result['synced']}\n";
echo " - Created: {$result['created']}\n";
echo " - Disabled: {$result['disabled']}\n";
} else {
echo "✗ Synchronization failed: {$result['error']}\n";
exit(1);
}
} catch (Exception $e) {
echo "✗ Error: " . $e->getMessage() . "\n";
exit(1);
}
+132
View File
@@ -325,4 +325,136 @@ class SettingsController {
return $stats;
}
public function ldapSettings() {
$user = Auth::user();
if ($user['role'] !== 'admin') {
http_response_code(403);
echo 'Forbidden';
return;
}
// Get LDAP configuration
$stmt = $this->pdo->query("SELECT * FROM ldap_configs WHERE id = 1");
$config = $stmt->fetch() ?: [];
// Get group mappings
$stmt = $this->pdo->query("SELECT * FROM ldap_group_mappings ORDER BY ldap_group");
$mappings = $stmt->fetchAll();
$data = [
'config' => $config,
'mappings' => $mappings
];
// Check for session messages
if (isset($_SESSION['settings_success'])) {
$data['success'] = $_SESSION['settings_success'];
unset($_SESSION['settings_success']);
}
if (isset($_SESSION['settings_error'])) {
$data['error'] = $_SESSION['settings_error'];
unset($_SESSION['settings_error']);
}
View::render('settings/ldap.twig', $data);
}
public function saveLdapSettings() {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /settings/ldap');
exit;
}
$user = Auth::user();
if ($user['role'] !== 'admin') {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Forbidden']);
return;
}
$enabled = isset($_POST['enabled']) ? 1 : 0;
$host = trim($_POST['host'] ?? '');
$port = intval($_POST['port'] ?? 389);
$useTls = isset($_POST['use_tls']) ? 1 : 0;
$baseDn = trim($_POST['base_dn'] ?? '');
$bindDn = trim($_POST['bind_dn'] ?? '');
$bindPassword = $_POST['bind_password'] ?? '';
$userSearchFilter = trim($_POST['user_search_filter'] ?? '(uid=%s)');
$groupSearchFilter = trim($_POST['group_search_filter'] ?? '(memberUid=%s)');
$syncInterval = intval($_POST['sync_interval'] ?? 30);
if (empty($host) || empty($baseDn) || empty($bindDn)) {
$_SESSION['settings_error'] = 'Host, Base DN, and Bind DN are required';
header('Location: /settings/ldap');
exit;
}
// Update or insert configuration
$stmt = $this->pdo->prepare("
INSERT INTO ldap_configs
(id, enabled, host, port, use_tls, base_dn, bind_dn, bind_password, user_search_filter, group_search_filter, sync_interval)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
host = VALUES(host),
port = VALUES(port),
use_tls = VALUES(use_tls),
base_dn = VALUES(base_dn),
bind_dn = VALUES(bind_dn),
bind_password = VALUES(bind_password),
user_search_filter = VALUES(user_search_filter),
group_search_filter = VALUES(group_search_filter),
sync_interval = VALUES(sync_interval)
");
$stmt->execute([
$enabled,
$host,
$port,
$useTls,
$baseDn,
$bindDn,
$bindPassword,
$userSearchFilter,
$groupSearchFilter,
$syncInterval
]);
$_SESSION['settings_success'] = 'LDAP settings saved successfully';
header('Location: /settings/ldap');
exit;
}
public function testLdapConnection() {
header('Content-Type: application/json');
$user = Auth::user();
if ($user['role'] !== 'admin') {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Forbidden']);
return;
}
try {
$ldap = new LdapSync();
if (!$ldap->isEnabled()) {
echo json_encode([
'success' => false,
'message' => 'LDAP is not enabled. Please save configuration first.'
]);
return;
}
$result = $ldap->testConnection();
echo json_encode($result);
} catch (Exception $e) {
echo json_encode([
'success' => false,
'message' => 'Error: ' . $e->getMessage()
]);
}
}
}
+29
View File
@@ -16,6 +16,35 @@ class Auth {
public static function login(string $email, string $password): bool {
$pdo = DB::conn();
$email = strtolower(trim($email));
// Try LDAP authentication first if enabled
$ldap = new LdapSync();
if ($ldap->isEnabled()) {
$ldapUser = $ldap->authenticate($email, $password);
if ($ldapUser) {
// LDAP auth successful - sync/create user in local DB
$stmt = $pdo->prepare('SELECT * FROM users WHERE ldap_dn = ? LIMIT 1');
$stmt->execute([$ldapUser['ldap_dn']]);
$user = $stmt->fetch();
if (!$user) {
// Create new LDAP user
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash, name, role, status, ldap_synced, ldap_dn) VALUES (?, \'\', ?, ?, \'active\', 1, ?)');
$stmt->execute([$ldapUser['email'], $ldapUser['display_name'], $ldapUser['role'], $ldapUser['ldap_dn']]);
$userId = (int)$pdo->lastInsertId();
} else {
$userId = (int)$user['id'];
// Update user info from LDAP
$stmt = $pdo->prepare('UPDATE users SET email = ?, name = ?, role = ?, status = \'active\', last_login_at = NOW() WHERE id = ?');
$stmt->execute([$ldapUser['email'], $ldapUser['display_name'], $ldapUser['role'], $userId]);
}
$_SESSION['user_id'] = $userId;
return true;
}
}
// Fallback to local DB authentication
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? LIMIT 1');
$stmt->execute([$email]);
$user = $stmt->fetch();
+312
View File
@@ -0,0 +1,312 @@
<?php
/**
* LdapSync - LDAP integration and user synchronization
*
* Provides:
* - Connection to LDAP/Active Directory
* - User authentication via LDAP
* - Group-based access control
* - Automatic user synchronization
*/
class LdapSync
{
private $connection;
private array $config;
public function __construct()
{
$this->loadConfig();
}
/**
* Load LDAP configuration from database
*/
private function loadConfig(): void
{
$db = DB::conn();
$stmt = $db->query("SELECT * FROM ldap_configs WHERE id = 1");
$this->config = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
}
/**
* Check if LDAP is enabled
*/
public function isEnabled(): bool
{
return !empty($this->config) && $this->config['enabled'];
}
/**
* Connect to LDAP server
*/
public function connect(): bool
{
if (!$this->isEnabled()) {
return false;
}
$ldapUri = ($this->config['use_tls'] ? 'ldaps://' : 'ldap://') .
$this->config['host'] . ':' . $this->config['port'];
$this->connection = @ldap_connect($ldapUri);
if (!$this->connection) {
error_log("LDAP: Failed to connect to {$ldapUri}");
return false;
}
ldap_set_option($this->connection, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($this->connection, LDAP_OPT_REFERRALS, 0);
// Bind with admin credentials
$bindResult = @ldap_bind(
$this->connection,
$this->config['bind_dn'],
$this->config['bind_password']
);
if (!$bindResult) {
error_log("LDAP: Failed to bind as " . $this->config['bind_dn']);
return false;
}
return true;
}
/**
* Authenticate user via LDAP
*/
public function authenticate(string $username, string $password): ?array
{
if (!$this->connect()) {
return null;
}
try {
// Search for user
$filter = str_replace('%s', ldap_escape($username, '', LDAP_ESCAPE_FILTER),
$this->config['user_search_filter']);
$search = @ldap_search(
$this->connection,
$this->config['base_dn'],
$filter,
['dn', 'cn', 'mail', 'memberOf', 'displayName']
);
if (!$search) {
return null;
}
$entries = ldap_get_entries($this->connection, $search);
if ($entries['count'] === 0) {
return null;
}
$userDn = $entries[0]['dn'];
// Try to bind as user (authenticate)
if (!@ldap_bind($this->connection, $userDn, $password)) {
return null;
}
// Get user groups
$groups = $this->getUserGroups($userDn);
$role = $this->mapGroupsToRole($groups);
return [
'username' => $username,
'email' => $entries[0]['mail'][0] ?? '',
'display_name' => $entries[0]['displayname'][0] ?? $entries[0]['cn'][0] ?? $username,
'ldap_dn' => $userDn,
'groups' => $groups,
'role' => $role
];
} catch (Exception $e) {
error_log("LDAP auth error: " . $e->getMessage());
return null;
} finally {
$this->disconnect();
}
}
/**
* Get user's LDAP groups
*/
private function getUserGroups(string $userDn): array
{
$groups = [];
try {
$filter = str_replace('%s', ldap_escape(explode(',', $userDn)[0], '', LDAP_ESCAPE_FILTER),
$this->config['group_search_filter']);
$search = @ldap_search(
$this->connection,
$this->config['base_dn'],
$filter,
['cn']
);
if ($search) {
$entries = ldap_get_entries($this->connection, $search);
for ($i = 0; $i < $entries['count']; $i++) {
$groups[] = $entries[$i]['cn'][0];
}
}
} catch (Exception $e) {
error_log("LDAP get groups error: " . $e->getMessage());
}
return $groups;
}
/**
* Map LDAP groups to application role
*/
private function mapGroupsToRole(array $groups): string
{
$db = DB::conn();
foreach ($groups as $group) {
$stmt = $db->prepare("SELECT role_name FROM ldap_group_mappings WHERE ldap_group = ?");
$stmt->execute([$group]);
$mapping = $stmt->fetch(PDO::FETCH_ASSOC);
if ($mapping) {
return $mapping['role_name'];
}
}
return 'viewer'; // Default role
}
/**
* Synchronize all LDAP users
*/
public function syncUsers(): array
{
if (!$this->connect()) {
return ['success' => false, 'error' => 'Failed to connect to LDAP'];
}
try {
$search = @ldap_search(
$this->connection,
$this->config['base_dn'],
'(objectClass=person)',
['dn', 'cn', 'mail', 'uid']
);
if (!$search) {
return ['success' => false, 'error' => 'LDAP search failed'];
}
$entries = ldap_get_entries($this->connection, $search);
$db = DB::conn();
$synced = 0;
$created = 0;
$disabled = 0;
// Get existing LDAP users
$existingUsers = $db->query("SELECT ldap_dn FROM users WHERE ldap_synced = 1")
->fetchAll(PDO::FETCH_COLUMN);
$currentDns = [];
for ($i = 0; $i < $entries['count']; $i++) {
$entry = $entries[$i];
$dn = $entry['dn'];
$username = $entry['uid'][0] ?? $entry['cn'][0];
$email = $entry['mail'][0] ?? '';
$currentDns[] = $dn;
// Get user groups and map to role
$groups = $this->getUserGroups($dn);
$role = $this->mapGroupsToRole($groups);
// Check if user exists
$stmt = $db->prepare("SELECT id FROM users WHERE ldap_dn = ?");
$stmt->execute([$dn]);
$existing = $stmt->fetch();
if ($existing) {
// Update existing user
$stmt = $db->prepare("
UPDATE users
SET email = ?, role = ?, status = 'active', ldap_synced = 1
WHERE ldap_dn = ?
");
$stmt->execute([$email, $role, $dn]);
$synced++;
} else {
// Create new user
$stmt = $db->prepare("
INSERT INTO users (username, email, password, role, status, ldap_synced, ldap_dn)
VALUES (?, ?, '', ?, 'active', 1, ?)
");
$stmt->execute([$username, $email, $role, $dn]);
$created++;
}
}
// Disable users no longer in LDAP
$missingDns = array_diff($existingUsers, $currentDns);
if (!empty($missingDns)) {
$placeholders = str_repeat('?,', count($missingDns) - 1) . '?';
$stmt = $db->prepare("UPDATE users SET status = 'disabled' WHERE ldap_dn IN ($placeholders)");
$stmt->execute($missingDns);
$disabled = count($missingDns);
}
return [
'success' => true,
'synced' => $synced,
'created' => $created,
'disabled' => $disabled,
'total' => $entries['count']
];
} catch (Exception $e) {
error_log("LDAP sync error: " . $e->getMessage());
return ['success' => false, 'error' => $e->getMessage()];
} finally {
$this->disconnect();
}
}
/**
* Test LDAP connection
*/
public function testConnection(): array
{
if (!$this->connect()) {
return [
'success' => false,
'message' => 'Failed to connect or bind to LDAP server'
];
}
$this->disconnect();
return [
'success' => true,
'message' => 'Successfully connected to LDAP server'
];
}
/**
* Disconnect from LDAP
*/
private function disconnect(): void
{
if ($this->connection) {
@ldap_unbind($this->connection);
$this->connection = null;
}
}
}
+38
View File
@@ -0,0 +1,38 @@
-- Migration: Add LDAP configuration and settings
-- Date: 2025-11-10
-- LDAP configuration table
CREATE TABLE IF NOT EXISTS ldap_configs (
id INT PRIMARY KEY AUTO_INCREMENT,
enabled BOOLEAN DEFAULT FALSE,
host VARCHAR(255) NOT NULL,
port INT DEFAULT 389,
use_tls BOOLEAN DEFAULT FALSE,
base_dn VARCHAR(255) NOT NULL,
bind_dn VARCHAR(255) NOT NULL,
bind_password VARCHAR(255) NOT NULL,
user_search_filter VARCHAR(255) DEFAULT '(uid=%s)',
group_search_filter VARCHAR(255) DEFAULT '(memberUid=%s)',
sync_interval INT DEFAULT 30 COMMENT 'Sync interval in minutes',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- LDAP group to role mappings
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INT PRIMARY KEY AUTO_INCREMENT,
ldap_group VARCHAR(255) NOT NULL UNIQUE,
role_name VARCHAR(50) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Add ldap_sync flag to users table
ALTER TABLE users
ADD COLUMN ldap_synced BOOLEAN DEFAULT FALSE AFTER status,
ADD COLUMN ldap_dn VARCHAR(255) NULL AFTER ldap_synced,
ADD INDEX idx_ldap_dn (ldap_dn);
-- Insert default LDAP configuration (disabled by default)
INSERT IGNORE INTO ldap_configs (id, enabled, host, port, base_dn, bind_dn, bind_password)
VALUES (1, FALSE, 'ldap.example.com', 389, 'dc=example,dc=com', 'cn=admin,dc=example,dc=com', '');
+32
View File
@@ -0,0 +1,32 @@
-- Migration: Add user roles and permissions
-- Date: 2025-11-10
-- User roles table
CREATE TABLE IF NOT EXISTS user_roles (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL UNIQUE,
display_name VARCHAR(100) NOT NULL,
description TEXT,
permissions JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Add role to users table
ALTER TABLE users
ADD COLUMN role VARCHAR(50) DEFAULT 'viewer' AFTER ldap_dn,
ADD INDEX idx_role (role);
-- Insert default roles
INSERT IGNORE INTO user_roles (name, display_name, description, permissions) VALUES
('admin', 'Administrator', 'Full access to all features', JSON_ARRAY('*')),
('manager', 'Manager', 'Can manage servers and clients', JSON_ARRAY('servers.view', 'servers.create', 'servers.edit', 'clients.view', 'clients.create', 'clients.edit', 'clients.delete')),
('viewer', 'Viewer', 'Can only view own clients', JSON_ARRAY('clients.view_own', 'clients.download_own'));
-- Insert default LDAP group mappings (examples)
INSERT IGNORE INTO ldap_group_mappings (ldap_group, role_name, description) VALUES
('vpn-admins', 'admin', 'VPN administrators with full access'),
('vpn-managers', 'manager', 'VPN managers who can create and manage clients'),
('vpn-users', 'viewer', 'Regular VPN users with view-only access');
-- Update existing users to admin role (backward compatibility)
UPDATE users SET role = 'admin' WHERE role IS NULL OR role = '';
+61
View File
@@ -0,0 +1,61 @@
-- Migration: Add LDAP translations (English and Russian)
-- Date: 2025-11-10
-- English translations
INSERT IGNORE INTO translations (locale, category, key_name, translation) VALUES
('en', 'ldap', 'settings', 'LDAP Settings'),
('en', 'ldap', 'enable_ldap_auth', 'Enable LDAP Authentication'),
('en', 'ldap', 'enable_description', 'Allow users to login using LDAP/Active Directory credentials'),
('en', 'ldap', 'host', 'LDAP Host'),
('en', 'ldap', 'port', 'Port'),
('en', 'ldap', 'use_tls', 'Use TLS/SSL'),
('en', 'ldap', 'base_dn', 'Base DN'),
('en', 'ldap', 'base_dn_description', 'The base distinguished name for LDAP searches (e.g., dc=example,dc=com)'),
('en', 'ldap', 'bind_dn', 'Bind DN'),
('en', 'ldap', 'bind_dn_description', 'The distinguished name of the service account to bind with'),
('en', 'ldap', 'bind_password', 'Bind Password'),
('en', 'ldap', 'user_search_filter', 'User Search Filter'),
('en', 'ldap', 'user_search_filter_description', 'LDAP filter to search for users. %s will be replaced with username'),
('en', 'ldap', 'group_search_filter', 'Group Search Filter'),
('en', 'ldap', 'sync_interval', 'Sync Interval (minutes)'),
('en', 'ldap', 'sync_interval_description', 'How often to automatically synchronize users from LDAP'),
('en', 'ldap', 'test_connection', 'Test Connection'),
('en', 'ldap', 'testing', 'Testing'),
('en', 'ldap', 'connection_test_failed', 'Connection test failed'),
('en', 'ldap', 'group_mappings', 'LDAP Group Mappings'),
('en', 'ldap', 'group', 'LDAP Group'),
('en', 'ldap', 'role', 'Panel Role'),
('en', 'ldap', 'description', 'Description');
-- Russian translations
INSERT IGNORE INTO translations (locale, category, key_name, translation) VALUES
('ru', 'ldap', 'settings', 'Настройки LDAP'),
('ru', 'ldap', 'enable_ldap_auth', 'Включить LDAP аутентификацию'),
('ru', 'ldap', 'enable_description', 'Разрешить пользователям входить используя учетные данные LDAP/Active Directory'),
('ru', 'ldap', 'host', 'LDAP Хост'),
('ru', 'ldap', 'port', 'Порт'),
('ru', 'ldap', 'use_tls', 'Использовать TLS/SSL'),
('ru', 'ldap', 'base_dn', 'Base DN'),
('ru', 'ldap', 'base_dn_description', 'Базовое отличительное имя для поиска в LDAP (например, dc=example,dc=com)'),
('ru', 'ldap', 'bind_dn', 'Bind DN'),
('ru', 'ldap', 'bind_dn_description', 'Отличительное имя служебной учетной записи для подключения'),
('ru', 'ldap', 'bind_password', 'Пароль подключения'),
('ru', 'ldap', 'user_search_filter', 'Фильтр поиска пользователей'),
('ru', 'ldap', 'user_search_filter_description', 'LDAP фильтр для поиска пользователей. %s будет заменен на имя пользователя'),
('ru', 'ldap', 'group_search_filter', 'Фильтр поиска групп'),
('ru', 'ldap', 'sync_interval', 'Интервал синхронизации (минуты)'),
('ru', 'ldap', 'sync_interval_description', 'Как часто автоматически синхронизировать пользователей из LDAP'),
('ru', 'ldap', 'test_connection', 'Тест подключения'),
('ru', 'ldap', 'testing', 'Тестирование'),
('ru', 'ldap', 'connection_test_failed', 'Тест подключения не удался'),
('ru', 'ldap', 'group_mappings', 'Связи групп LDAP'),
('ru', 'ldap', 'group', 'Группа LDAP'),
('ru', 'ldap', 'role', 'Роль в панели'),
('ru', 'ldap', 'description', 'Описание');
-- Common translations for buttons
INSERT IGNORE INTO translations (locale, category, key_name, translation) VALUES
('en', 'common', 'save', 'Save'),
('en', 'common', 'cancel', 'Cancel'),
('ru', 'common', 'save', 'Сохранить'),
('ru', 'common', 'cancel', 'Отмена');
+30
View File
@@ -1798,6 +1798,36 @@ Router::post('/settings/delete-user/{id}', function ($params) {
$controller->deleteUser($params['id']);
});
// LDAP settings page
Router::get('/settings/ldap', function () {
requireAdmin();
require_once __DIR__ . '/../controllers/SettingsController.php';
require_once __DIR__ . '/../inc/LdapSync.php';
$controller = new SettingsController();
$controller->ldapSettings();
});
// Save LDAP settings
Router::post('/settings/ldap/save', function () {
requireAdmin();
require_once __DIR__ . '/../controllers/SettingsController.php';
require_once __DIR__ . '/../inc/LdapSync.php';
$controller = new SettingsController();
$controller->saveLdapSettings();
});
// Test LDAP connection
Router::post('/settings/ldap/test', function () {
requireAdmin();
require_once __DIR__ . '/../controllers/SettingsController.php';
require_once __DIR__ . '/../inc/LdapSync.php';
$controller = new SettingsController();
$controller->testLdapConnection();
});
/**
* LANGUAGE ROUTES
*/
+4
View File
@@ -47,6 +47,10 @@
class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-users mr-2"></i>{{ t('settings.users') }}
</a>
<a href="/settings/ldap"
class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-network-wired mr-2"></i>LDAP
</a>
{% endif %}
</nav>
</div>
+181
View File
@@ -0,0 +1,181 @@
{% extends "layout.twig" %}
{% block title %}{{ t('ldap.settings') }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-800">{{ t('ldap.settings') }}</h2>
<button id="testConnection" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
{{ t('ldap.test_connection') }}
</button>
</div>
<form id="ldapForm" method="POST" action="/settings/ldap/save">
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="enabled" value="1"
{% if config.enabled %}checked{% endif %}
class="w-5 h-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500">
<span class="ml-3 text-lg font-medium text-gray-900">{{ t('ldap.enable_ldap_auth') }}</span>
</label>
<p class="mt-2 ml-8 text-sm text-gray-600">{{ t('ldap.enable_description') }}</p>
</div>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.host') }} <span class="text-red-500">*</span>
</label>
<input type="text" name="host" value="{{ config.host }}" required
placeholder="ldap.example.com"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.port') }}
</label>
<input type="number" name="port" value="{{ config.port }}"
placeholder="389"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div>
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="use_tls" value="1"
{% if config.use_tls %}checked{% endif %}
class="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">{{ t('ldap.use_tls') }} (LDAPS)</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.base_dn') }} <span class="text-red-500">*</span>
</label>
<input type="text" name="base_dn" value="{{ config.base_dn }}" required
placeholder="dc=example,dc=com"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">{{ t('ldap.base_dn_description') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.bind_dn') }} <span class="text-red-500">*</span>
</label>
<input type="text" name="bind_dn" value="{{ config.bind_dn }}" required
placeholder="cn=admin,dc=example,dc=com"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">{{ t('ldap.bind_dn_description') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.bind_password') }} <span class="text-red-500">*</span>
</label>
<input type="password" name="bind_password" value="{{ config.bind_password }}" required
placeholder="••••••••"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.user_search_filter') }}
</label>
<input type="text" name="user_search_filter" value="{{ config.user_search_filter }}"
placeholder="(uid=%s)"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">{{ t('ldap.user_search_filter_description') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.group_search_filter') }}
</label>
<input type="text" name="group_search_filter" value="{{ config.group_search_filter }}"
placeholder="(memberUid=%s)"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.sync_interval') }}
</label>
<input type="number" name="sync_interval" value="{{ config.sync_interval }}"
placeholder="30"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">{{ t('ldap.sync_interval_description') }}</p>
</div>
</div>
<div class="mt-6 flex gap-4">
<button type="submit" class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
{{ t('common.save') }}
</button>
<a href="/settings" class="px-6 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400">
{{ t('common.cancel') }}
</a>
</div>
</form>
</div>
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-xl font-bold text-gray-800 mb-4">{{ t('ldap.group_mappings') }}</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('ldap.group') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('ldap.role') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('ldap.description') }}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for mapping in mappings %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ mapping.ldap_group }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full
{% if mapping.role_name == 'admin' %}bg-red-100 text-red-800
{% elseif mapping.role_name == 'manager' %}bg-blue-100 text-blue-800
{% else %}bg-gray-100 text-gray-800{% endif %}">
{{ mapping.role_name }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500">{{ mapping.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script>
document.getElementById('testConnection').addEventListener('click', async function() {
const btn = this;
btn.disabled = true;
btn.textContent = '{{ t('ldap.testing') }}...';
try {
const response = await fetch('/settings/ldap/test', { method: 'POST' });
const result = await response.json();
if (result.success) {
alert('✓ ' + result.message);
} else {
alert('✗ ' + result.message);
}
} catch (error) {
alert('{{ t('ldap.connection_test_failed') }}: ' + error.message);
} finally {
btn.disabled = false;
btn.textContent = '{{ t('ldap.test_connection') }}';
}
});
</script>
{% endblock %}