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'; $phoneHash = Security::blindIndex($cleanPhone); $allowedRoles = ['student', 'teacher', 'guardian', 'school_admin', 'directorate_admin', 'supervisor', 'super_admin']; if (!in_array($role, $allowedRoles, true)) { $role = 'student'; } // Privileged personas are provisioned server-side. A caller cannot create // an administrator merely by requesting an OTP with an elevated role. if (in_array($role, ['school_admin', 'directorate_admin', 'supervisor', 'super_admin'], true)) { $staff = Database::selectOne( "SELECT sa.id FROM staff_accounts sa JOIN auth_identities ai ON ai.id = sa.identity_id WHERE ai.phone_hash = ? AND sa.role = ? AND sa.status = 'active' AND ai.status = 'active' LIMIT 1", [$phoneHash, $role] ); if (!$staff) { $response->status(403)->json([ 'status' => 'error', 'message' => 'هذا الرقم غير مخوّل للدخول بهذه الصلاحية' ]); return; } } $appName = ($role === 'teacher') ? 'صَقِل للمعلمين' : 'منصة صَقِل التعليمية'; // 1. Rate Limiting via Redis (Max 3 OTP requests per 5 minutes per phone) 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 failure: " . $e->getMessage()); $response->status(503)->json(['status' => 'error', 'message' => 'خدمة التحقق غير متاحة مؤقتاً']); return; } // 2. Generate 6-digit OTP $otp = (string)random_int(100000, 999999); // 3. Save OTP in Redis (TTL: 300s / 5 minutes) - Role-isolated to prevent Teacher/Student collision try { $redis = RedisClient::getInstance(); $otpKey = "otp:{$phoneHash}:{$role}"; $redis->setex($otpKey, 300, password_hash($otp, PASSWORD_BCRYPT)); } catch (\Exception $e) { error_log("Redis OTP store error: " . $e->getMessage()); $response->status(503)->json([ 'status' => 'error', 'message' => 'خدمة التحقق غير متاحة مؤقتاً' ]); return; } // 4. Send OTP via Nabeh Service (Canonical Default: Luxury Image Card) $nabeh = new NabehService(); $otpType = (string)(getenv('NABEH_OTP_TYPE') ?: 'image'); $sendResult = $nabeh->sendOtp($cleanPhone, $otp, $otpType, $appName); if (!$sendResult['success']) { $errorMsg = $sendResult['error'] ?? 'تعذر إرسال رمز التحقق عبر الواتساب من منصة نبيه'; $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'; $allowedRoles = ['student', 'teacher', 'guardian', 'school_admin', 'directorate_admin', 'supervisor', 'super_admin']; if (!in_array($role, $allowedRoles, true)) { $response->status(400)->json(['status' => 'error', 'message' => 'نوع الحساب غير صالح']); return; } $fullName = trim((string)($body['full_name'] ?? '')); $deviceFingerprint = $this->resolveDeviceFingerprint($request, $body); $platform = $this->resolveDevicePlatform($deviceFingerprint, $request); $phoneHash = Security::blindIndex($cleanPhone); // 1. Verify OTP against the role-isolated Redis key. $redis = RedisClient::getInstance(); $otpRoleKey = "otp:{$phoneHash}:{$role}"; $storedHash = $redis->get($otpRoleKey); $isValidOtp = false; if ($storedHash && password_verify($inputOtp, $storedHash)) { $isValidOtp = true; $redis->del($otpRoleKey); } 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 (in_array($role, ['school_admin', 'directorate_admin', 'supervisor', 'super_admin'], true)) { $staff = Database::selectOne( "SELECT * FROM staff_accounts WHERE identity_id = ? AND role = ? AND status = 'active' LIMIT 1", [$identityId, $role] ); if (!$staff) { $response->status(403)->json(['status' => 'error', 'message' => 'الحساب الإداري غير مخوّل أو موقوف']); return; } $user = [ 'id' => $staff['id'], 'uuid' => $staff['uuid'], 'full_name' => $staff['full_name'], 'role' => $staff['role'], 'status' => $staff['status'], 'token_version' => $identity['token_version'], 'school_id' => $staff['school_id'] ?? null, 'directorate_id' => $staff['directorate_id'] ?? null, ]; } elseif ($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 - Requires National ID Phase (Netflix-style Profile Selection via National ID) $identityToken = Security::generateJWT([ 'identity_id' => $identityId, 'role' => 'student_identity_pending', 'phone' => $cleanPhone, 'token_version' => (int)$identity['token_version'], ], 3600); // 1 hour validity $response->status(200)->json([ 'status' => 'success', 'message' => 'تم التحقق من رقم الهاتف بنجاح. يرجى إدخال الرقم الوطني للمتابعة.', 'data' => [ 'identity_token' => $identityToken, 'requires_national_id' => true ] ]); return; } // 3. Register / Update Device Fingerprint in user_devices try { Database::query( "INSERT INTO user_devices (user_id, identity_id, device_fingerprint, platform, is_active, last_active_at) VALUES (?, ?, ?, ?, 1, NOW()) ON DUPLICATE KEY UPDATE identity_id = VALUES(identity_id), platform = VALUES(platform), last_active_at = NOW(), is_active = 1", [$user['id'], $identityId, $deviceFingerprint, $platform] ); } catch (\Exception $e) { error_log("Device recording notice: " . $e->getMessage()); } // 4. Issue JWT and Bind Single Session in Redis $displayName = $this->readStoredValue((string)$user['full_name']); $this->generateSessionAndRespond( (int)$user['id'], $user['uuid'], $user['role'], $deviceFingerprint, $cleanPhone, $displayName, $response, 'تم تسجيل الدخول بنجاح', $identityId, (int)$identity['token_version'], isset($user['school_id']) ? (int)$user['school_id'] : null, isset($user['directorate_id']) ? (int)$user['directorate_id'] : null ); } /** * 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, int $identityId = 0, int $tokenVersion = 1, ?int $schoolId = null, ?int $directorateId = null ): void { $payload = [ 'user_id' => $userId, 'uuid' => $uuid, 'role' => $role, 'device_fingerprint' => $deviceFingerprint, 'phone' => $cleanPhone, 'name' => $displayName, 'identity_id' => $identityId, 'token_version' => $tokenVersion, 'school_id' => $schoolId, 'directorate_id' => $directorateId, ]; // 30 days token expiry $token = Security::generateJWT($payload, 30 * 86400); // Single Session Enforcement: Store active session in Redis (Role-isolated) try { $redis = RedisClient::getInstance(); $sessionKey = "active_session:{$role}:{$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'), 'school_id' => $schoolId, 'directorate_id' => $directorateId, ] ] ]); } /** * Get Current Authenticated User Data * GET /api/auth/me */ public function me(Request $request, Response $response): void { $userId = (int)$request->user_id; $role = $request->role ?? 'student'; $userData = null; if (in_array($role, ['school_admin', 'directorate_admin', 'supervisor', 'super_admin'], true)) { $user = Database::selectOne( "SELECT sa.id, sa.uuid, sa.full_name, sa.role, sa.school_id, sa.directorate_id, sa.status, ai.phone_number, sa.created_at FROM staff_accounts sa JOIN auth_identities ai ON ai.id = sa.identity_id WHERE sa.id = ? AND sa.role = ? LIMIT 1", [$userId, $role] ); if ($user) { $userData = [ 'id' => $user['id'], 'uuid' => $user['uuid'], 'full_name' => $user['full_name'], 'name' => $user['full_name'], 'role' => $user['role'], 'school_id' => $user['school_id'], 'directorate_id' => $user['directorate_id'], 'phone' => Security::decrypt($user['phone_number']), 'status' => $user['status'], 'is_completed' => true, 'is_teacher' => false, 'is_student' => false, ]; } } elseif ($role === 'teacher') { $user = Database::selectOne( "SELECT t.id, t.uuid, t.full_name, t.specialization, t.bio, t.school_id, ai.phone_number, ai.status, t.created_at FROM teachers t JOIN auth_identities ai ON t.identity_id = ai.id WHERE t.id = ? LIMIT 1", [$userId] ); if ($user) { $isCompleted = !empty($user['full_name']) && $user['full_name'] !== 'معلم جديد' && !empty($user['specialization']) && $user['specialization'] !== 'بانتظار تحديد التخصص'; $userData = [ 'id' => $user['id'], 'uuid' => $user['uuid'], 'full_name' => $user['full_name'], 'name' => $user['full_name'], 'role' => 'teacher', 'specialization' => $user['specialization'], 'bio' => $user['bio'], 'phone' => Security::decrypt($user['phone_number']), 'status' => $user['status'], 'is_completed' => $isCompleted, 'is_teacher' => true, 'is_student' => false, ]; } } elseif ($role === 'guardian') { $user = Database::selectOne( "SELECT g.id, g.uuid, g.full_name, g.national_id, ai.phone_number, ai.status, g.created_at FROM guardians g JOIN auth_identities ai ON g.identity_id = ai.id WHERE g.id = ? LIMIT 1", [$userId] ); if ($user) { $userData = [ 'id' => $user['id'], 'uuid' => $user['uuid'], 'full_name' => $user['full_name'], 'name' => $user['full_name'], 'role' => 'guardian', 'phone' => Security::decrypt($user['phone_number']), 'status' => $user['status'], 'is_completed' => true, 'is_teacher' => false, 'is_student' => false, ]; } } else { // Student $user = Database::selectOne( "SELECT s.id, s.uuid, s.full_name, s.national_id, s.grade_level, s.stream, s.readiness_score, s.school_id, ai.phone_number, ai.status, s.created_at FROM students s JOIN auth_identities ai ON s.identity_id = ai.id WHERE s.id = ? LIMIT 1", [$userId] ); if ($user) { $rawGrade = (string)($user['grade_level'] ?? 'grade_10'); $normGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade); if ($rawGrade === 'tawjihi_2008' || $rawGrade !== $normGrade) { Database::query("UPDATE students SET grade_level = ?, updated_at = NOW() WHERE id = ?", [$normGrade, $userId]); $user['grade_level'] = $normGrade; } $isCompleted = !empty($user['full_name']) && $user['full_name'] !== 'طالب جديد' && $user['full_name'] !== 'الطالب المتميز' && !empty($user['grade_level']); $userData = [ 'id' => $user['id'], 'uuid' => $user['uuid'], 'full_name' => $user['full_name'], 'name' => $user['full_name'], 'role' => 'student', 'national_id' => $this->readStoredValue((string)$user['national_id']), 'grade_level' => $user['grade_level'], 'stream' => $user['stream'], 'readiness_score' => $user['readiness_score'], 'phone' => Security::decrypt($user['phone_number']), 'status' => $user['status'], 'is_completed' => $isCompleted, 'is_teacher' => false, 'is_student' => true, ]; } } if (!$userData) { $response->status(404)->json([ 'status' => 'error', 'message' => 'المستخدم غير موجود أو تم إعادة تهيئة قاعدة البيانات' ]); return; } $response->json([ 'status' => 'success', 'data' => $userData ]); } /** * Verify Student National ID (Profile Selection / Sub-account Login) * POST /api/auth/student/login-national-id */ public function verifyNationalId(Request $request, Response $response): void { $body = $request->getBody(); $identityToken = $body['identity_token'] ?? ''; $nationalId = trim((string)($body['national_id'] ?? '')); if (!$identityToken || !$nationalId) { $response->status(400)->json(['status' => 'error', 'message' => 'الرقم الوطني مطلوب']); return; } $decoded = Security::verifyJWT($identityToken); $role = is_array($decoded) ? ($decoded['role'] ?? '') : ($decoded->role ?? ''); if (!$decoded || $role !== 'student_identity_pending') { $response->status(401)->json(['status' => 'error', 'message' => 'الجلسة غير صالحة، يرجى إعادة التحقق من رقم الهاتف']); return; } $identityId = (int)(is_array($decoded) ? ($decoded['identity_id'] ?? 0) : ($decoded->identity_id ?? 0)); $phone = is_array($decoded) ? ($decoded['phone'] ?? '') : ($decoded->phone ?? ''); $deviceFingerprint = trim((string)$request->getHeader('x-device-fingerprint', '')) ?: 'browser_default'; // Check if student exists with this National ID $nationalIdHash = Security::blindIndex($nationalId); $student = Database::selectOne("SELECT * FROM students WHERE national_id_hash = ? LIMIT 1", [$nationalIdHash]); if ($student) { if (($student['grade_level'] ?? '') === 'tawjihi_2008') { Database::query("UPDATE students SET grade_level = 'grade_10', updated_at = NOW() WHERE id = ?", [$student['id']]); $student['grade_level'] = 'grade_10'; } // Student exists. Verify identity linkage. if ($student['identity_id'] === null) { // Pre-registered by Guardian, link to this identity phone now Database::query("UPDATE students SET identity_id = ? WHERE id = ?", [$identityId, $student['id']]); } elseif ((int)$student['identity_id'] !== $identityId) { $response->status(403)->json(['status' => 'error', 'message' => 'الرقم الوطني مسجل ومربوط برقم هاتف آخر. يرجى مراجعة الدعم الفني.']); return; } // Generate full student session! $this->generateSessionAndRespond( (int)$student['id'], $student['uuid'], 'student', $deviceFingerprint, $phone, $student['full_name'], $response, 'تم تسجيل الدخول لملف الطالب بنجاح', $identityId, (int)(is_array($decoded) ? ($decoded['token_version'] ?? 1) : ($decoded->token_version ?? 1)), isset($student['school_id']) ? (int)$student['school_id'] : null ); } else { // New Student! Forward to Onboarding. $response->status(200)->json([ 'status' => 'success', 'message' => 'الرقم الوطني غير مسجل مسبقاً، يرجى استكمال البيانات لفتح ملف جديد.', 'data' => [ 'requires_onboarding' => true, 'identity_token' => $identityToken, 'national_id' => $nationalId ] ]); } } /** * Check if Student Profile is complete * GET /api/student/profile/status */ public function studentProfileStatus(Request $request, Response $response): void { $studentId = (int)$request->user_id; $student = Database::selectOne( "SELECT s.*, ai.phone_number FROM students s JOIN auth_identities ai ON s.identity_id = ai.id WHERE s.id = ? LIMIT 1", [$studentId] ); if (!$student) { $response->status(401)->json(['status' => 'error', 'message' => 'طالب غير مسجل']); return; } $isCompleted = !empty($student['full_name']) && $student['full_name'] !== 'طالب جديد' && $student['full_name'] !== 'الطالب المتميز' && !empty($student['grade_level']); $response->json([ 'status' => 'success', 'is_completed' => $isCompleted, 'user' => [ 'id' => $student['id'], 'uuid' => $student['uuid'], 'full_name' => $student['full_name'], 'national_id' => $this->readStoredValue((string)$student['national_id']), 'grade_level' => $student['grade_level'], 'stream' => $student['stream'], 'role' => 'student' ] ]); } /** * Setup Student Profile * POST /api/student/profile/setup */ public function studentProfileSetup(Request $request, Response $response): void { $body = $request->getBody(); $identityToken = $body['identity_token'] ?? ''; $fullName = trim((string)($body['full_name'] ?? '')); $rawGrade = trim((string)($body['grade_level'] ?? 'grade_10')); $gradeLevel = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade); $stream = trim((string)($body['stream'] ?? 'scientific')); $nationalId = trim((string)($body['national_id'] ?? '')); $deviceFingerprint = $this->resolveDeviceFingerprint($request, $body); $platform = $this->resolveDevicePlatform($deviceFingerprint, $request); if (empty($fullName) || empty($nationalId)) { $response->status(400)->json(['status' => 'error', 'message' => 'الاسم الكامل والرقم الوطني مطلوبان']); return; } if ($identityToken) { // New Student Flow $decoded = Security::verifyJWT($identityToken); $role = is_array($decoded) ? ($decoded['role'] ?? '') : ($decoded->role ?? ''); if (!$decoded || $role !== 'student_identity_pending') { $response->status(401)->json(['status' => 'error', 'message' => 'الجلسة غير صالحة']); return; } $identityId = (int)(is_array($decoded) ? ($decoded['identity_id'] ?? 0) : ($decoded->identity_id ?? 0)); $phone = is_array($decoded) ? ($decoded['phone'] ?? '') : ($decoded->phone ?? ''); // Ensure National ID doesn't exist $nationalIdHash = Security::blindIndex($nationalId); $existing = Database::selectOne("SELECT id FROM students WHERE national_id_hash = ? LIMIT 1", [$nationalIdHash]); if ($existing) { $response->status(400)->json(['status' => 'error', 'message' => 'الرقم الوطني مستخدم مسبقاً']); return; } $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)); $sId = Database::insert( "INSERT INTO students (uuid, identity_id, national_id, national_id_hash, full_name, grade_level, stream, is_school_sponsored) VALUES (?, ?, ?, ?, ?, ?, ?, 0)", [$sUuid, $identityId, Security::encrypt($nationalId), $nationalIdHash, $fullName, $gradeLevel, $stream] ); // Auto-link to Guardian if exists on this phone $guardian = Database::selectOne("SELECT id FROM guardians WHERE identity_id = ? LIMIT 1", [$identityId]); if ($guardian) { Database::insert("INSERT IGNORE INTO guardian_students (guardian_id, student_id) VALUES (?, ?)", [$guardian['id'], $sId]); } try { Database::query( "INSERT INTO user_devices (user_id, identity_id, device_fingerprint, platform, is_active, last_active_at) VALUES (?, ?, ?, ?, 1, NOW()) ON DUPLICATE KEY UPDATE identity_id = VALUES(identity_id), platform = VALUES(platform), last_active_at = NOW(), is_active = 1", [$sId, $identityId, $deviceFingerprint, $platform] ); } catch (\Exception $e) { error_log("Device recording notice on student registration: " . $e->getMessage()); } $this->generateSessionAndRespond( $sId, $sUuid, 'student', $deviceFingerprint, $phone, $fullName, $response, 'تم استكمال التسجيل بنجاح', $identityId, (int)(is_array($decoded) ? ($decoded['token_version'] ?? 1) : ($decoded->token_version ?? 1)), null, null ); } else { // Legacy / Fallback for already logged-in students updating profile $studentId = (int)$request->user_id; if (!$studentId) { $authHeader = $request->getHeader('authorization', ''); if ($authHeader && preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) { $payload = Security::verifyJWT($matches[1]); if ($payload && isset($payload['user_id'])) { $studentId = (int)$payload['user_id']; } } } if (!$studentId) { $response->status(401)->json(['status' => 'error', 'message' => 'غير مصرح']); return; } Database::query( "UPDATE students SET full_name = ?, grade_level = ?, stream = ?, national_id = IF(? != '', ?, national_id), national_id_hash = IF(? != '', ?, national_id_hash), updated_at = NOW() WHERE id = ?", [$fullName, $gradeLevel, $stream, $nationalId, Security::encrypt($nationalId), $nationalId, Security::blindIndex($nationalId), $studentId] ); $student = Database::selectOne("SELECT * FROM students WHERE id = ? LIMIT 1", [$studentId]); $response->json([ 'status' => 'success', 'message' => 'تم استكمال ملف الطالب بنجاح! مرحباً بك في منصة صَقِل', 'user' => [ 'id' => $student['id'], 'uuid' => $student['uuid'], 'full_name' => $student['full_name'], 'grade_level' => $student['grade_level'], 'stream' => $student['stream'], 'role' => 'student' ] ]); } } /** * Update Student Grade & Academic Stream * POST /api/student/profile/update-grade */ public function updateStudentGrade(Request $request, Response $response): void { $studentId = (int)$request->user_id; if (!$studentId) { $authHeader = $request->getHeader('authorization', ''); if ($authHeader && preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) { $payload = Security::verifyJWT($matches[1]); if ($payload && isset($payload['user_id'])) { $studentId = (int)$payload['user_id']; } } } if (!$studentId) { $response->status(401)->json(['status' => 'error', 'message' => 'غير مصرح']); return; } $body = $request->getBody(); $rawGrade = trim((string)($body['grade_level'] ?? 'grade_10')); $stream = trim((string)($body['stream'] ?? 'general')); $normalizedGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade); Database::query( "UPDATE students SET grade_level = ?, stream = ?, updated_at = NOW() WHERE id = ?", [$normalizedGrade, $stream, $studentId] ); $student = Database::selectOne( "SELECT s.id, s.uuid, s.full_name, s.national_id, s.grade_level, s.stream, s.readiness_score, s.school_id, ai.phone_number, ai.status, s.created_at FROM students s JOIN auth_identities ai ON s.identity_id = ai.id WHERE s.id = ? LIMIT 1", [$studentId] ); if (!$student) { $response->status(404)->json(['status' => 'error', 'message' => 'طالب غير موجود']); return; } $displayName = $this->readStoredValue((string)$student['full_name']); $response->json([ 'status' => 'success', 'message' => 'تم تحديث الصف الدراسي بنجاح', 'data' => [ 'grade_level' => $student['grade_level'], 'stream' => $student['stream'], 'user' => [ 'id' => (int)$student['id'], 'uuid' => $student['uuid'], 'full_name' => $displayName, 'name' => $displayName, 'role' => 'student', 'national_id' => $this->readStoredValue((string)$student['national_id']), 'grade_level' => $student['grade_level'], 'stream' => $student['stream'], 'readiness_score' => $student['readiness_score'] ? (float)$student['readiness_score'] : 0.0, 'phone' => Security::decrypt($student['phone_number']), 'status' => $student['status'], 'is_completed' => true, 'is_student' => true, 'is_teacher' => false, ] ] ]); } /** * Logout and destroy Redis active session * POST /api/auth/logout */ public function logout(Request $request, Response $response): void { $userId = $request->user_id; $role = $request->role ?? 'student'; if ($userId) { try { $redis = RedisClient::getInstance(); $redis->del("active_session:{$role}:{$userId}"); $redis->del("active_session:{$userId}"); // Clean up legacy key if present } catch (\Exception $e) { error_log("Logout Redis error: " . $e->getMessage()); } } $response->json([ 'status' => 'success', 'message' => 'تم تسجيل الخروج بنجاح' ]); } private function readStoredValue(string $value): string { if ($value === '') { return ''; } try { $decrypted = Security::decrypt($value); return $decrypted !== '' ? $decrypted : $value; } catch (\Throwable $e) { return $value; } } private function resolveDeviceFingerprint(Request $request, array $body): string { $fingerprint = trim((string)($body['device_fingerprint'] ?? '')); if ($fingerprint === '') { $fingerprint = trim((string)$request->getHeader('x-device-fingerprint', '')); } // Disallow synthetic mock strings in real authentication $obsoleteMocks = [ 'saqel_student_flutter', 'saqel_teacher_flutter', 'saqel_admin_flutter', 'saqel_super_admin_flutter', ]; if (in_array($fingerprint, $obsoleteMocks, true)) { error_log("[Security Audit] Obsolete synthetic device fingerprint rejected: {$fingerprint}"); $fingerprint = ''; } if ($fingerprint === '') { $ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; $ua = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown_client'; $fingerprint = 'saqel_web_' . substr(hash('sha256', $ip . '|' . $ua), 0, 32); } return $fingerprint; } private function resolveDevicePlatform(string $fingerprint, Request $request): string { if (str_starts_with($fingerprint, 'saqel_android_')) return 'android'; if (str_starts_with($fingerprint, 'saqel_ios_')) return 'ios'; if (str_starts_with($fingerprint, 'saqel_macos_')) return 'macos'; if (str_starts_with($fingerprint, 'saqel_windows_')) return 'windows'; if (str_starts_with($fingerprint, 'saqel_linux_')) return 'linux'; if (str_starts_with($fingerprint, 'saqel_web_')) return 'web'; $headerPlatform = strtolower(trim((string)$request->getHeader('x-device-platform', ''))); if (in_array($headerPlatform, ['android', 'ios', 'macos', 'windows', 'linux', 'web'], true)) { return $headerPlatform; } return 'other'; } }