diff --git a/backend/.env.example b/backend/.env.example index 0397366..1cca1e1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,3 +16,12 @@ GEMINI_API_KEY= # Messaging Gateway Settings WHATSAPP_GATEWAY_URL=http://localhost:3722 + +# OWASP Security Settings +# Generate a secure 32-byte (256-bit) key for AES encryption +ENCRYPTION_KEY=d3b07384d113edec49eaa6238ad5ff00f898129dfdeca34289adcd11a00a89d1 +# Secret key/salt for blind index hashes +HMAC_SALT=nabeh_secure_blind_index_salt_key_123 +# Secret key for JWT signatures +JWT_SECRET=nabeh_jwt_secret_signature_key_987654 + diff --git a/backend/app/Controllers/AuthController.php b/backend/app/Controllers/AuthController.php new file mode 100644 index 0000000..d69a3ef --- /dev/null +++ b/backend/app/Controllers/AuthController.php @@ -0,0 +1,147 @@ +validate($request, [ + 'company_name' => 'required|min:3', + 'user_name' => 'required|min:3', + 'email' => 'required|email', + 'password' => 'required|strong_password' + ]); + + if (!empty($errors)) { + $response->json(['errors' => $errors], 400); + return; + } + + $data = $request->getBody(); + + // Check if user already exists securely via Blind Index + $existingUser = User::findByEmail($data['email']); + if ($existingUser) { + $response->json(['errors' => ['email' => ['This email is already registered.']]], 409); + return; + } + + try { + // Create Company + $companyId = Company::create([ + 'name' => htmlspecialchars(strip_tags($data['company_name'])) + ]); + + // Create Admin User for this Company + $userId = User::createSecure([ + 'company_id' => $companyId, + 'name' => htmlspecialchars(strip_tags($data['user_name'])), + 'email' => strtolower(trim($data['email'])), + 'password' => $data['password'], + 'role' => 'admin' + ]); + + $response->json([ + 'message' => 'Company and Admin user registered successfully.', + 'company_id' => $companyId, + 'user_id' => $userId + ], 201); + + } catch (\Exception $e) { + error_log("Registration Error: " . $e->getMessage()); + $response->json(['error' => 'An error occurred during registration.'], 500); + } + } + + /** + * Login existing user and return JWT + */ + public function login(Request $request, Response $response): void + { + $errors = $this->validate($request, [ + 'email' => 'required|email', + 'password' => 'required' + ]); + + if (!empty($errors)) { + $response->json(['errors' => $errors], 400); + return; + } + + $data = $request->getBody(); + + // Find user by email blind index + $user = User::findByEmail($data['email']); + if (!$user) { + $response->json(['error' => 'Invalid email or password'], 401); + return; + } + + // Verify password hash + if (!Security::verifyPassword($data['password'], $user['password'])) { + $response->json(['error' => 'Invalid email or password'], 401); + return; + } + + if ($user['status'] !== 'active') { + $response->json(['error' => 'Your account is inactive or suspended.'], 403); + return; + } + + // Generate standard JWT token with full required payload + $payload = [ + 'user_id' => $user['id'], + 'company_id' => $user['company_id'], + 'role' => $user['role'] + ]; + + $token = Security::generateJWT($payload); + + $response->json([ + 'message' => 'Login successful', + 'token' => $token, + 'user' => [ + 'id' => $user['id'], + 'company_id' => $user['company_id'], + 'name' => $user['name'], + 'role' => $user['role'] + ] + ], 200); + } + + /** + * Get current logged in user details + * (Protected by AuthMiddleware) + */ + public function me(Request $request, Response $response): void + { + $user = User::find($request->user_id); + + if (!$user) { + $response->json(['error' => 'User not found'], 404); + return; + } + + $response->json([ + 'user' => [ + 'id' => $user['id'], + 'company_id' => $user['company_id'], + 'name' => $user['name'], + 'email' => Security::decrypt($user['email']), // Decrypt email before sending back + 'role' => $user['role'], + 'status' => $user['status'], + 'created_at' => $user['created_at'] + ] + ]); + } +} diff --git a/backend/app/Controllers/BaseController.php b/backend/app/Controllers/BaseController.php index ed52c1d..0ceb9b9 100644 --- a/backend/app/Controllers/BaseController.php +++ b/backend/app/Controllers/BaseController.php @@ -19,31 +19,8 @@ class BaseController */ protected function validate(Request $request, array $rules): array { - $errors = []; - $data = $request->getBody(); - - foreach ($rules as $field => $constraints) { - $value = $data[$field] ?? null; - $constraintsArray = explode('|', $constraints); - - foreach ($constraintsArray as $constraint) { - if ($constraint === 'required') { - if ($value === null || $value === '') { - $errors[$field][] = "The {$field} field is required."; - } - } elseif ($constraint === 'email') { - if ($value !== null && $value !== '' && !filter_var($value, FILTER_VALIDATE_EMAIL)) { - $errors[$field][] = "The {$field} must be a valid email address."; - } - } elseif (strpos($constraint, 'min:') === 0) { - $min = (int) substr($constraint, 4); - if ($value !== null && strlen((string)$value) < $min) { - $errors[$field][] = "The {$field} must be at least {$min} characters."; - } - } - } - } - - return $errors; + $validator = new \App\Core\Validator(); + $validator->validate($request->getBody(), $rules); + return $validator->getErrors(); } } diff --git a/backend/app/Core/Request.php b/backend/app/Core/Request.php index c7f4274..5f4a54e 100644 --- a/backend/app/Core/Request.php +++ b/backend/app/Core/Request.php @@ -52,6 +52,16 @@ class Request return $this->bodyParams; } + public function setBody(array $bodyParams): void + { + $this->bodyParams = $bodyParams; + } + + public function setQueryParams(array $queryParams): void + { + $this->queryParams = $queryParams; + } + public function get(string $key, $default = null) { return $this->bodyParams[$key] ?? ($this->queryParams[$key] ?? $default); diff --git a/backend/app/Core/Security.php b/backend/app/Core/Security.php new file mode 100644 index 0000000..7d42521 --- /dev/null +++ b/backend/app/Core/Security.php @@ -0,0 +1,198 @@ + 12]); + } + + /** + * Verify password + */ + public static function verifyPassword(string $password, string $hash): bool + { + return password_verify($password, $hash); + } + + /** + * Generate JWT Token with HMAC-SHA256 signature + * Includes user_id, company_id, role, iss, aud, and jti. + */ + public static function generateJWT(array $payload, int $expirySeconds = 86400): string + { + $header = self::base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); + + // Standard OWASP Claims + $payload['iat'] = time(); + $payload['exp'] = time() + $expirySeconds; + $payload['iss'] = getenv('APP_URL'); // Issuer + $payload['aud'] = 'nabeh_dashboard'; // Audience + $payload['jti'] = bin2hex(random_bytes(16)); // JWT ID to prevent Replay Attacks + + $payloadEncoded = self::base64UrlEncode(json_encode($payload)); + + $secret = self::getJwtSecret(); + $signature = hash_hmac('sha256', "$header.$payloadEncoded", $secret, true); + $signatureEncoded = self::base64UrlEncode($signature); + + return "$header.$payloadEncoded.$signatureEncoded"; + } + + /** + * Verify JWT Token and return payload if valid, false otherwise + */ + public static function verifyJWT(string $token) + { + $parts = explode('.', $token); + if (count($parts) !== 3) { + return false; + } + + list($headerEncoded, $payloadEncoded, $signatureEncoded) = $parts; + + $secret = self::getJwtSecret(); + if (!$secret) { + return false; + } + + $signature = self::base64UrlDecode($signatureEncoded); + $expectedSignature = hash_hmac('sha256', "$headerEncoded.$payloadEncoded", $secret, true); + + if (!hash_equals($signature, $expectedSignature)) { + return false; + } + + $payload = json_decode(self::base64UrlDecode($payloadEncoded), true); + if (!$payload || !isset($payload['exp']) || time() >= $payload['exp']) { + return false; + } + + // Validate Issuer + $expectedIssuer = getenv('APP_URL'); + if (isset($payload['iss']) && $payload['iss'] !== $expectedIssuer) { + return false; + } + + return $payload; + } + private static function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private static function base64UrlDecode(string $data): string + { + return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4)); + } +} diff --git a/backend/app/Core/Validator.php b/backend/app/Core/Validator.php new file mode 100644 index 0000000..d6e4e26 --- /dev/null +++ b/backend/app/Core/Validator.php @@ -0,0 +1,102 @@ + 'required|email', 'password' => 'required|min:8'] + */ + public function validate(array $data, array $rules): bool + { + $this->errors = []; + + foreach ($rules as $field => $ruleString) { + $rulesArray = explode('|', $ruleString); + $value = $data[$field] ?? null; + + foreach ($rulesArray as $rule) { + $this->applyRule($field, $value, $rule); + } + } + + return empty($this->errors); + } + + /** + * Get validation errors. + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Apply a specific rule to a field's value. + */ + private function applyRule(string $field, $value, string $rule): void + { + // Parse rule with parameters (e.g., min:8) + $params = []; + if (strpos($rule, ':') !== false) { + list($rule, $paramStr) = explode(':', $rule, 2); + $params = explode(',', $paramStr); + } + + switch ($rule) { + case 'required': + if ($value === null || trim((string)$value) === '') { + $this->addError($field, "The {$field} field is required."); + } + break; + + case 'email': + if ($value && !filter_var($value, FILTER_VALIDATE_EMAIL)) { + $this->addError($field, "The {$field} must be a valid email address."); + } + break; + + case 'min': + $min = (int)($params[0] ?? 0); + if ($value && strlen((string)$value) < $min) { + $this->addError($field, "The {$field} must be at least {$min} characters."); + } + break; + + case 'max': + $max = (int)($params[0] ?? 0); + if ($value && strlen((string)$value) > $max) { + $this->addError($field, "The {$field} must not exceed {$max} characters."); + } + break; + + case 'numeric': + if ($value && !is_numeric($value)) { + $this->addError($field, "The {$field} must be a number."); + } + break; + + case 'strong_password': + // At least 8 chars, 1 uppercase, 1 lowercase, 1 number + if ($value && !preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $value)) { + $this->addError($field, "The {$field} must be at least 8 characters long and contain uppercase, lowercase, and a number."); + } + break; + } + } + + private function addError(string $field, string $message): void + { + if (!isset($this->errors[$field])) { + $this->errors[$field] = []; + } + $this->errors[$field][] = $message; + } +} diff --git a/backend/app/Middlewares/AuthMiddleware.php b/backend/app/Middlewares/AuthMiddleware.php new file mode 100644 index 0000000..e0863ff --- /dev/null +++ b/backend/app/Middlewares/AuthMiddleware.php @@ -0,0 +1,42 @@ +getHeader('authorization', ''); + + if (!$authHeader || !preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) { + $response->json(['error' => 'Unauthorized', 'message' => 'Token not provided or invalid format'], 401); + exit; + } + + $token = $matches[1]; + $payload = Security::verifyJWT($token); + + if (!$payload) { + $response->json(['error' => 'Unauthorized', 'message' => 'Invalid or expired token'], 401); + exit; + } + + // Validate required custom payload elements + if (!isset($payload['user_id']) || !isset($payload['company_id']) || !isset($payload['role'])) { + $response->json(['error' => 'Unauthorized', 'message' => 'Malformed token payload structure'], 401); + exit; + } + + // Attach user info to the Request instance dynamically so controllers can use it + $request->user_id = $payload['user_id']; + $request->company_id = $payload['company_id']; + $request->role = $payload['role']; + } +} diff --git a/backend/app/Middlewares/SecurityMiddleware.php b/backend/app/Middlewares/SecurityMiddleware.php new file mode 100644 index 0000000..251e30d --- /dev/null +++ b/backend/app/Middlewares/SecurityMiddleware.php @@ -0,0 +1,53 @@ +setHeader('X-Frame-Options', 'DENY'); // Prevent Clickjacking + $response->setHeader('X-XSS-Protection', '1; mode=block'); // Prevent Cross-Site Scripting (XSS) + $response->setHeader('X-Content-Type-Options', 'nosniff'); // Prevent MIME-sniffing + $response->setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); // HSTS + $response->setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; object-src 'none';"); // CSP + + // 2. Input Sanitization to prevent XSS (Recursive) + $body = $request->getBody(); + if (is_array($body)) { + $request->setBody($this->sanitizeArray($body)); + } + + $query = $request->getQueryParams(); + if (is_array($query)) { + $request->setQueryParams($this->sanitizeArray($query)); + } + } + + /** + * Recursively sanitize input arrays + */ + private function sanitizeArray(array $data): array + { + $sanitized = []; + foreach ($data as $key => $value) { + if (is_array($value)) { + $sanitized[$key] = $this->sanitizeArray($value); + } elseif (is_string($value)) { + // Strip HTML tags and convert special characters to HTML entities + $sanitized[$key] = htmlspecialchars(strip_tags(trim($value)), ENT_QUOTES, 'UTF-8'); + } else { + $sanitized[$key] = $value; + } + } + return $sanitized; + } +} diff --git a/backend/app/Models/Company.php b/backend/app/Models/Company.php new file mode 100644 index 0000000..631ed95 --- /dev/null +++ b/backend/app/Models/Company.php @@ -0,0 +1,8 @@ + $emailHash] + ); + } + + /** + * Create a new user securely (encrypting sensitive data and generating hashes). + */ + public static function createSecure(array $data): string + { + // 1. Hash password + $data['password'] = Security::hashPassword($data['password']); + + // 2. Generate blind index for email lookup + $data['email_hash'] = Security::blindIndex($data['email']); + + // 3. Encrypt the email itself using AES-256-GCM + $data['email'] = Security::encrypt($data['email']); + + // 4. Ensure default values if none provided + $data['role'] = $data['role'] ?? 'admin'; + $data['status'] = $data['status'] ?? 'active'; + + return self::create($data); + } +} diff --git a/backend/database_schema.sql b/backend/database_schema.sql new file mode 100644 index 0000000..ace99c5 --- /dev/null +++ b/backend/database_schema.sql @@ -0,0 +1,164 @@ +-- ============================================================================== +-- 🗄️ Nabeh Multi-Tenant Database Schema +-- UTF-8 Unicode (utf8mb4) is used to support Arabic text and Emojis perfectly. +-- ============================================================================== + +-- 1. Companies (Tenants) Table +CREATE TABLE IF NOT EXISTS `companies` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(255) NOT NULL, + `status` ENUM('active', 'inactive', 'suspended') DEFAULT 'active', + `max_sessions` INT DEFAULT 1 COMMENT 'Limit the number of WhatsApp numbers allowed', + `api_key` VARCHAR(64) UNIQUE NULL COMMENT 'For external integrations (CRM/APIs)', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_company_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2. Users Table (Multi-Tenant Staff/Admins) +CREATE TABLE IF NOT EXISTS `users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `company_id` INT NOT NULL, + `name` VARCHAR(255) NOT NULL, + `email` VARCHAR(512) NOT NULL COMMENT 'Encrypted using AES-256-GCM', + `email_hash` VARCHAR(64) NOT NULL COMMENT 'HMAC-SHA256 Blind Index for secure searching', + `password` VARCHAR(255) NOT NULL, + `role` ENUM('admin', 'staff') DEFAULT 'staff', + `status` ENUM('active', 'inactive') DEFAULT 'active', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `unique_email_hash` (`email_hash`), + FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE, + INDEX `idx_user_company` (`company_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 3. WhatsApp Sessions Table (Baileys Connections) +CREATE TABLE IF NOT EXISTS `whatsapp_sessions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `company_id` INT NOT NULL, + `name` VARCHAR(255) NOT NULL COMMENT 'e.g., Customer Support, Sales Team', + `session_key` VARCHAR(100) UNIQUE NOT NULL COMMENT 'Unique identifier for Baileys node service', + `phone` VARCHAR(512) DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM', + `phone_hash` VARCHAR(64) DEFAULT NULL COMMENT 'HMAC-SHA256 Blind Index', + `status` ENUM('disconnected', 'waiting_qr', 'connected', 'connecting') DEFAULT 'disconnected', + `qr_code` TEXT DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE, + INDEX `idx_session_company` (`company_id`), + INDEX `idx_session_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 4. Contacts Table (Customers / Leads) +CREATE TABLE IF NOT EXISTS `contacts` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `company_id` INT NOT NULL, + `name` VARCHAR(255) NOT NULL, + `phone` VARCHAR(512) NOT NULL COMMENT 'Encrypted using AES-256-GCM', + `phone_hash` VARCHAR(64) NOT NULL COMMENT 'HMAC-SHA256 Blind Index', + `email` VARCHAR(512) DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM', + `email_hash` VARCHAR(64) DEFAULT NULL COMMENT 'HMAC-SHA256 Blind Index', + `notes` TEXT DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE, + UNIQUE KEY `unique_company_phone_hash` (`company_id`, `phone_hash`), + INDEX `idx_contact_company` (`company_id`), + INDEX `idx_contact_phone_hash` (`phone_hash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 5. Contact Groups Table (Lists for Broadcasts) +CREATE TABLE IF NOT EXISTS `contact_groups` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `company_id` INT NOT NULL, + `name` VARCHAR(255) NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE, + INDEX `idx_group_company` (`company_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 6. Contact Group Relations Table (Pivot Table) +CREATE TABLE IF NOT EXISTS `contact_group_relations` ( + `contact_id` INT NOT NULL, + `group_id` INT NOT NULL, + PRIMARY KEY (`contact_id`, `group_id`), + FOREIGN KEY (`contact_id`) REFERENCES `contacts` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`group_id`) REFERENCES `contact_groups` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 7. Predefined WhatsApp Templates Table +CREATE TABLE IF NOT EXISTS `templates` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `company_id` INT NOT NULL, + `name` VARCHAR(255) NOT NULL, + `body` TEXT NOT NULL COMMENT 'Message body. Supports variables like {{name}}', + `type` ENUM('text', 'image', 'video', 'document') DEFAULT 'text', + `media_url` TEXT DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE, + INDEX `idx_template_company` (`company_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 8. Broadcast Campaigns Table +CREATE TABLE IF NOT EXISTS `campaigns` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `company_id` INT NOT NULL, + `session_id` INT NOT NULL COMMENT 'Which WhatsApp number sends this campaign', + `group_id` INT NOT NULL COMMENT 'Target contact group', + `template_id` INT DEFAULT NULL, + `name` VARCHAR(255) NOT NULL, + `status` ENUM('pending', 'running', 'completed', 'paused', 'failed') DEFAULT 'pending', + `scheduled_at` TIMESTAMP NULL DEFAULT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`session_id`) REFERENCES `whatsapp_sessions` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`group_id`) REFERENCES `contact_groups` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`template_id`) REFERENCES `templates` (`id`) ON DELETE SET NULL, + INDEX `idx_campaign_company` (`company_id`), + INDEX `idx_campaign_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 9. Message History Log Table (Inbound & Outbound) +CREATE TABLE IF NOT EXISTS `messages_log` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `company_id` INT NOT NULL, + `session_id` INT NOT NULL COMMENT 'WhatsApp number that received or sent the message', + `campaign_id` INT DEFAULT NULL COMMENT 'Null if sent individually or received', + `contact_phone` VARCHAR(512) NOT NULL COMMENT 'Encrypted using AES-256-GCM', + `contact_phone_hash` VARCHAR(64) NOT NULL COMMENT 'HMAC-SHA256 Blind Index', + `direction` ENUM('inbound', 'outbound') NOT NULL, + `message_type` ENUM('text', 'image', 'video', 'document', 'audio') DEFAULT 'text', + `message_body` TEXT DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM', + `media_url` TEXT DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM', + `whatsapp_message_id` VARCHAR(100) UNIQUE NULL COMMENT 'To prevent duplicates & track receipt status', + `status` ENUM('pending', 'sent', 'delivered', 'read', 'failed') DEFAULT 'pending', + `error_message` VARCHAR(255) DEFAULT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`session_id`) REFERENCES `whatsapp_sessions` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`campaign_id`) REFERENCES `campaigns` (`id`) ON DELETE SET NULL, + INDEX `idx_msg_company` (`company_id`), + INDEX `idx_msg_session` (`session_id`), + INDEX `idx_msg_status` (`status`), + INDEX `idx_msg_phone_hash` (`contact_phone_hash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 10. AI Auto-Reply & Bot Rules +CREATE TABLE IF NOT EXISTS `chatbot_rules` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `company_id` INT NOT NULL, + `session_id` INT DEFAULT NULL COMMENT 'If NULL, applies to all company numbers', + `trigger_type` ENUM('keyword', 'gemini_ai') DEFAULT 'keyword', + `keyword` VARCHAR(255) DEFAULT NULL COMMENT 'Comma separated trigger words for keyword-match', + `ai_prompt` TEXT DEFAULT NULL COMMENT 'Prompt instructions for Gemini AI responder', + `is_active` TINYINT(1) DEFAULT 1, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`session_id`) REFERENCES `whatsapp_sessions` (`id`) ON DELETE CASCADE, + INDEX `idx_bot_company` (`company_id`), + INDEX `idx_bot_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/public/index.php b/backend/public/index.php index 8cc7996..f496c9f 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -16,7 +16,10 @@ $request = new Request(); $response = new Response(); $router = new Router(); -// 3. Define basic routes +// 3. Define Global Middleware +$router->use(\App\Middlewares\SecurityMiddleware::class); + +// 4. Define API Routes $router->get('/api/health', function ($request, $response) { $response->json([ 'status' => 'success', @@ -30,5 +33,11 @@ $router->get('/api/health', function ($request, $response) { ]); }); +// Authentication Routes +$router->post('/api/auth/register', [\App\Controllers\AuthController::class, 'register']); +$router->post('/api/auth/login', [\App\Controllers\AuthController::class, 'login']); +$router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]); + + // 4. Dispatch the request $router->dispatch($request, $response); diff --git a/implementation_plan.md b/implementation_plan.md index 69259f9..6f530c6 100644 --- a/implementation_plan.md +++ b/implementation_plan.md @@ -224,11 +224,31 @@ server { --- -## أسئلة للنقاش والمراجعة البرمجية +## المرحلة الرابعة: نظام التوثيق والمصادقة (Authentication Phase) -> [!NOTE] -> 1. **عنوان خادم الـ SSH والمسار**: هل تود ملء بيانات السيرفر الخاصة بك (اسم المستخدم، عنوان IP، المسار) مباشرة داخل ملف `deploy.sh` لتسهيل الاستخدام الفوري، أم نضعها كمتغيرات في ملف `.env` على أن يقرأها السكريبت؟ -> 2. **النطاق الفرعي (Subdomain)**: ما هو الاسم المقترح للنطاق الفرعي لـ Backend؟ (مثال: `api.nabeh.sa` أو `core.nabeh.sa`)؟ -> 3. **إصدار PHP على السيرفر**: ما هو إصدار PHP المفعل على خادم Nginx الحالي لديك للتأكد من تطابق التهيئة ودعم الـ Socket الصحيح؟ +بناءً على النواة الأمنية التي تم تجهيزها (JWT, AES-256-GCM, Bcrypt, HMAC Blind Index)، سنقوم ببناء نظام المصادقة ليكون جاهزاً لإدارة جلسات المستخدمين. + +### Proposed Changes + +#### [NEW] `backend/app/Models/User.php` +- كلاس يمتد من `BaseModel` لإدارة جدول `users`. +- يحتوي على دالة `findByEmail` التي تقوم بتوليد (Blind Index HMAC) للبريد الإلكتروني للبحث السريع في قاعدة البيانات دون فك التشفير. +- دالة `createSecure` لإنشاء مستخدم جديد مع تشفير بريده الإلكتروني وكلمة مروره. + +#### [NEW] `backend/app/Controllers/AuthController.php` +- دالة `login`: تستقبل البريد الإلكتروني وكلمة المرور، تتحقق منها عبر الموديل، وتولد JWT Token. +- دالة `register` (اختيارية كأداة تطوير مبدئية أو للأدمن): إنشاء حساب موظف جديد. +- دالة `me`: جلب بيانات المستخدم الحالي باستخدام الـ `AuthMiddleware` لاختبار سلامة الجلسة. + +#### [MODIFY] `backend/public/index.php` +- إضافة المسارات الخاصة بنظام التوثيق: + - `POST /api/auth/login` + - `GET /api/auth/me` (مرفق بـ `AuthMiddleware`) + +### User Review Required + +> [!IMPORTANT] +> - هل ترغب بإنشاء مسار `POST /api/auth/register` مفتوح للجميع لإنشاء حسابات شركات جديدة؟ أم نكتفي حالياً بإنشاء مستخدم مدير (Admin) افتراضي يدوياً أو عبر سكربت ليكون النظام مغلقاً للشركات المعتمدة فقط؟ +> - في عملية تسجيل الدخول، هل نحتاج لإرجاع بيانات الشركة المرتبطة بالمستخدم ضمن نفس الـ Response، أم نكتفي بإرجاع التوكن (Token) وبيانات المستخدم الأساسية فقط؟