getBody(); $validator = new Validator(); $rawPhone = trim((string)($body['phone_number'] ?? $body['phone'] ?? '')); if (empty($rawPhone)) { $response->status(400)->json([ 'status' => 'error', 'message' => 'رقم الهاتف مطلوب' ]); return; } $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(); $rawPhone = trim((string)($body['phone_number'] ?? $body['phone'] ?? '')); $inputOtp = trim((string)($body['otp'] ?? $body['otp_code'] ?? '')); if (empty($rawPhone) || empty($inputOtp)) { $response->status(400)->json([ 'status' => 'error', 'message' => 'رقم الهاتف ورمز التحقق مطلوبان' ]); return; } $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; } $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 Auth Identity (Central Multi-Persona Auth) $identity = Database::selectOne("SELECT * FROM auth_identities WHERE phone_hash = ? LIMIT 1", [$phoneHash]); if (!$identity) { $identityUuid = 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); $identityId = Database::insert( "INSERT INTO auth_identities (uuid, phone_number, phone_hash, status, token_version) VALUES (?, ?, ?, 'active', 1)", [$identityUuid, $encryptedPhone, $phoneHash] ); $identity = [ 'id' => $identityId, 'uuid' => $identityUuid, 'phone_number' => $encryptedPhone, 'phone_hash' => $phoneHash, 'status' => 'active', 'token_version' => 1 ]; } $identityId = (int)$identity['id']; $resolvedName = $fullName ?: ($role === 'teacher' ? 'الأستاذ المعتمد' : 'الطالب المتميز'); // Resolve or create specific Persona entity (Teacher, Student, Guardian) if ($role === 'teacher') { $teacher = Database::selectOne("SELECT * FROM teachers WHERE identity_id = ? LIMIT 1", [$identityId]); $isNewTeacher = false; if (!$teacher) { $isNewTeacher = true; $tUuid = 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)); $tId = Database::insert( "INSERT INTO teachers (uuid, identity_id, full_name, specialization, bio, is_school_exclusive, is_marketplace_public) VALUES (?, ?, ?, 'بانتظار تحديد التخصص', '', 0, 1)", [$tUuid, $identityId, $fullName ?: 'معلم جديد'] ); $teacher = ['id' => $tId, 'uuid' => $tUuid, 'full_name' => $fullName ?: 'معلم جديد', 'specialization' => 'بانتظار تحديد التخصص']; } $user = [ 'id' => $teacher['id'], 'uuid' => $teacher['uuid'], 'full_name' => $teacher['full_name'], 'role' => 'teacher', 'status' => 'active', 'is_new' => $isNewTeacher, 'token_version' => $identity['token_version'], 'school_id' => $teacher['school_id'] ?? null ]; } elseif ($role === 'guardian') { $guardian = Database::selectOne("SELECT * FROM guardians WHERE identity_id = ? LIMIT 1", [$identityId]); if (!$guardian) { $gUuid = 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)); $gId = Database::insert( "INSERT INTO guardians (uuid, identity_id, full_name) VALUES (?, ?, ?)", [$gUuid, $identityId, $resolvedName] ); $guardian = ['id' => $gId, 'uuid' => $gUuid, 'full_name' => $resolvedName]; } $user = [ 'id' => $guardian['id'], 'uuid' => $guardian['uuid'], 'full_name' => $guardian['full_name'], 'role' => 'guardian', 'status' => 'active', 'token_version' => $identity['token_version'], 'school_id' => null ]; } else { // Student Role $student = Database::selectOne("SELECT * FROM students WHERE identity_id = ? LIMIT 1", [$identityId]); if (!$student) { $sUuid = 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)); $natId = 'NAT' . substr($cleanPhone, -8); $sId = Database::insert( "INSERT INTO students (uuid, identity_id, national_id, full_name, grade_level, stream, is_school_sponsored) VALUES (?, ?, ?, ?, 'tawjihi_2008', 'scientific', 0)", [$sUuid, $identityId, $natId, $resolvedName] ); $student = ['id' => $sId, 'uuid' => $sUuid, 'full_name' => $resolvedName, 'school_id' => null]; } $user = [ 'id' => $student['id'], 'uuid' => $student['uuid'], 'full_name' => $student['full_name'], 'role' => 'student', 'status' => 'active', 'token_version' => $identity['token_version'], 'school_id' => $student['school_id'] ?? null ]; } // 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' => 'تم تسجيل الخروج بنجاح' ]); } }