getBody(); $validator = new Validator(); if (!$validator->validate($body, ['phone_number' => 'required'])) { $response->status(400)->json([ 'status' => 'error', 'message' => 'رقم الهاتف مطلوب', 'errors' => $validator->getErrors() ]); return; } $rawPhone = trim((string)$body['phone_number']); $cleanPhone = preg_replace('/\D+/', '', $rawPhone); // Normalize Jordanian numbers (e.g. 079XXXXXXX -> 96279XXXXXXX) if (str_starts_with($cleanPhone, '07')) { $cleanPhone = '962' . substr($cleanPhone, 1); } elseif (str_starts_with($cleanPhone, '7') && strlen($cleanPhone) === 9) { $cleanPhone = '962' . $cleanPhone; } if (strlen($cleanPhone) < 9 || strlen($cleanPhone) > 15) { $response->status(400)->json([ 'status' => 'error', 'message' => 'صيغة رقم الهاتف غير صحيحة' ]); return; } $role = $body['role'] ?? 'student'; if (!in_array($role, ['student', 'teacher', 'guardian', 'school_admin', 'super_admin'], true)) { $role = 'student'; } $appName = ($role === 'teacher') ? 'صَقِل للمعلمين' : 'منصة صَقِل التعليمية'; // 1. Rate Limiting via Redis (Max 3 OTP requests per 5 minutes per phone) $phoneHash = Security::blindIndex($cleanPhone); try { $redis = RedisClient::getInstance(); $rateKey = "otp_rate:{$phoneHash}"; $attempts = (int)$redis->incr($rateKey); if ($attempts === 1) { $redis->expire($rateKey, 300); // 5 minutes window } if ($attempts > 3) { $ttl = $redis->ttl($rateKey); $response->status(429)->json([ 'status' => 'error', 'message' => "تم تجاوز الحد المسموح. يرجى المحاولة بعد {$ttl} ثانية." ]); return; } } catch (\Exception $e) { error_log("Redis rate limit warning: " . $e->getMessage()); } // 2. Generate 6-digit OTP $otp = (string)random_int(100000, 999999); // 3. Save OTP in Redis (TTL: 300s / 5 minutes) try { $redis = RedisClient::getInstance(); $otpKey = "otp:{$phoneHash}"; $redis->setex($otpKey, 300, password_hash($otp, PASSWORD_BCRYPT)); } catch (\Exception $e) { error_log("Redis OTP store error: " . $e->getMessage()); } // 4. Send OTP via Nabeh Service $nabeh = new NabehService(); $sendResult = $nabeh->sendOtp($cleanPhone, $otp, 'image', $appName); $isDebug = filter_var(getenv('APP_DEBUG'), FILTER_VALIDATE_BOOLEAN); if (!$sendResult['success']) { $errorMsg = $sendResult['error'] ?? 'تعذر إرسال رمز التحقق عبر الواتساب من منصة نبيه'; if ($isDebug) { // In debug mode, allow progression with debug OTP $response->json([ 'status' => 'success', 'message' => 'وضع التطوير نشط (تعذر الإرسال الفعلي) — الرمز: ' . $otp, 'debug_otp' => $otp, 'data' => [ 'phone_masked' => substr($cleanPhone, 0, 3) . '****' . substr($cleanPhone, -3), 'expires_in' => 300, 'gateway_error' => $sendResult ] ]); return; } $response->status(502)->json([ 'status' => 'error', 'message' => $errorMsg, 'gateway_error' => $sendResult ]); return; } $resData = [ 'status' => 'success', 'message' => 'تم إرسال رمز التحقق بنجاح عبر الواتساب', 'data' => [ 'phone_masked' => substr($cleanPhone, 0, 3) . '****' . substr($cleanPhone, -3), 'expires_in' => 300, ] ]; $response->json($resData); } /** * Verify OTP and Login / Register User * POST /api/auth/otp/verify */ public function verifyOtp(Request $request, Response $response): void { $body = $request->getBody(); $validator = new Validator(); if (!$validator->validate($body, [ 'phone_number' => 'required', 'otp' => 'required' ])) { $response->status(400)->json([ 'status' => 'error', 'message' => 'رقم الهاتف ورمز التحقق مطلوبان', 'errors' => $validator->getErrors() ]); return; } $rawPhone = trim((string)$body['phone_number']); $cleanPhone = preg_replace('/\D+/', '', $rawPhone); if (str_starts_with($cleanPhone, '07')) { $cleanPhone = '962' . substr($cleanPhone, 1); } elseif (str_starts_with($cleanPhone, '7') && strlen($cleanPhone) === 9) { $cleanPhone = '962' . $cleanPhone; } $inputOtp = trim((string)$body['otp']); $role = $body['role'] ?? 'student'; $fullName = trim((string)($body['full_name'] ?? '')); $deviceFingerprint = trim((string)($body['device_fingerprint'] ?? 'browser_default')); $phoneHash = Security::blindIndex($cleanPhone); // 1. Verify OTP against Redis $redis = RedisClient::getInstance(); $otpKey = "otp:{$phoneHash}"; $storedHash = $redis->get($otpKey); $isValidOtp = false; if ($storedHash && password_verify($inputOtp, $storedHash)) { $isValidOtp = true; $redis->del($otpKey); // Invalidate OTP after success } if (!$isValidOtp) { $response->status(401)->json([ 'status' => 'error', 'message' => 'رمز التحقق غير صحيح أو انتهت صلاحيته' ]); return; } // 2. Find or Create User $user = Database::selectOne("SELECT * FROM users WHERE phone_hash = ? LIMIT 1", [$phoneHash]); if (!$user) { // New user registration $uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) ); $encryptedPhone = Security::encrypt($cleanPhone); $encryptedName = Security::encrypt($fullName ?: ($role === 'teacher' ? 'معلم جديد' : 'طالب جديد')); $randomPassword = password_hash(bin2hex(random_bytes(16)), PASSWORD_BCRYPT); $userId = Database::insert( "INSERT INTO users (uuid, full_name, phone_number, phone_hash, password_hash, role, status, token_version) VALUES (?, ?, ?, ?, ?, ?, 'active', 1)", [$uuid, $encryptedName, $encryptedPhone, $phoneHash, $randomPassword, $role] ); $user = [ 'id' => $userId, 'uuid' => $uuid, 'full_name' => $encryptedName, 'role' => $role, 'status' => 'active', 'token_version' => 1, 'school_id' => null, ]; } else { // Verify role access if logging into specific portal if ($role === 'teacher' && $user['role'] !== 'teacher' && $user['role'] !== 'super_admin') { $response->status(403)->json([ 'status' => 'error', 'message' => 'هذا الحساب مسجل كطالب وليس معلماً. يرجى الدخول من بوابة الطالب.' ]); return; } if ($user['status'] === 'suspended') { $response->status(403)->json([ 'status' => 'error', 'message' => 'هذا الحساب معطل. يرجى مراجعة إدارة المنصة.' ]); return; } // If name provided on existing profile, update if needed if ($fullName && (empty($user['full_name']) || Security::decrypt($user['full_name']) === 'طالب جديد')) { Database::query("UPDATE users SET full_name = ? WHERE id = ?", [Security::encrypt($fullName), $user['id']]); } } // 3. Register / Update Device Fingerprint in user_devices try { Database::query( "INSERT INTO user_devices (user_id, device_fingerprint, platform, is_active, last_active_at) VALUES (?, ?, 'web', 1, NOW()) ON DUPLICATE KEY UPDATE last_active_at = NOW(), is_active = 1", [$user['id'], $deviceFingerprint] ); } catch (\Exception $e) { error_log("Device recording notice: " . $e->getMessage()); } // 4. Issue JWT and Bind Single Session in Redis $displayName = Security::decrypt($user['full_name']) ?: 'مستخدم صَقِل'; $this->generateSessionAndRespond( (int)$user['id'], $user['uuid'], $user['role'], $deviceFingerprint, $cleanPhone, $displayName, $response, 'تم تسجيل الدخول بنجاح' ); } /** * Generate Secure JWT and Enforce Single Active Session in Redis */ private function generateSessionAndRespond( int $userId, string $uuid, string $role, string $deviceFingerprint, string $cleanPhone, string $displayName, Response $response, string $msg ): void { $payload = [ 'user_id' => $userId, 'uuid' => $uuid, 'role' => $role, 'device_fingerprint' => $deviceFingerprint, 'phone' => $cleanPhone, 'name' => $displayName, ]; // 30 days token expiry $token = Security::generateJWT($payload, 30 * 86400); // Single Session Enforcement: Store active session in Redis try { $redis = RedisClient::getInstance(); $sessionKey = "active_session:{$userId}"; $redis->setex($sessionKey, 30 * 86400, json_encode([ 'token_signature' => substr($token, -32), 'device_fingerprint' => $deviceFingerprint, 'logged_at' => date('Y-m-d H:i:s'), 'ip' => $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1', ])); } catch (\Exception $e) { error_log("Failed to save active session in Redis: " . $e->getMessage()); } $response->status(200)->json([ 'status' => 'success', 'message' => $msg, 'data' => [ 'token' => $token, 'user' => [ 'uuid' => $uuid, 'name' => $displayName, 'phone' => $cleanPhone, 'role' => $role, 'is_student' => ($role === 'student'), 'is_teacher' => ($role === 'teacher'), ] ] ]); } /** * Get Current Authenticated User Data * GET /api/auth/me */ public function me(Request $request, Response $response): void { $userId = $request->user_id; $user = Database::selectOne( "SELECT uuid, full_name, phone_number, role, grade_level, stream, status, created_at FROM users WHERE id = ? LIMIT 1", [$userId] ); if (!$user) { $response->status(404)->json([ 'status' => 'error', 'message' => 'المستخدم غير موجود' ]); return; } $user['full_name'] = Security::decrypt($user['full_name']); $user['phone_number'] = Security::decrypt($user['phone_number']); $response->json([ 'status' => 'success', 'data' => $user ]); } /** * Logout and destroy Redis active session * POST /api/auth/logout */ public function logout(Request $request, Response $response): void { $userId = $request->user_id; if ($userId) { try { $redis = RedisClient::getInstance(); $redis->del("active_session:{$userId}"); } catch (\Exception $e) { error_log("Logout Redis error: " . $e->getMessage()); } } $response->json([ 'status' => 'success', 'message' => 'تم تسجيل الخروج بنجاح' ]); } }