876 lines
39 KiB
PHP
876 lines
39 KiB
PHP
<?php
|
|
/**
|
|
* ==============================================================================
|
|
* SAQEL ENTERPRISE (EDTECH 2.0) - AUTHENTICATION & IDENTITY CONTROLLER
|
|
* ==============================================================================
|
|
*
|
|
* ملف: AuthController.php
|
|
* الهدف المعماري:
|
|
* إدارة منظومة الهوية الرقمية والدخول الآمن والتحقق عبر الواتساب (OTP) في منصة صَقِل.
|
|
* يتولى هذا الملف المهام التالية:
|
|
* 1. إرسال رموز التحقق لمرة واحدة (OTP) عبر الواتساب بواسطة بوابة نبّه الرسمية (Nabeh Gateway).
|
|
* 2. التحقق من صحة الرمز وتوليد رموز JWT المشفرة بصلاحيات محددة (طالب، معلم، ولي أمر، مدير).
|
|
* 3. حماية الحسابات من هجمات الاختراق عبر بصمة الجهاز الرقمية (Device Fingerprinting).
|
|
* 4. إدارة جلسات تسجيل الدخول والخروج والتحقق من صلاحية التوكن (Token Introspection).
|
|
* 5. التحقق عبر الرقم الوطني لطلاب مديرية الثقافة العسكرية والمدارس الحكومية.
|
|
*/
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Core\Request;
|
|
use App\Core\Response;
|
|
use App\Core\Database;
|
|
use App\Core\Security;
|
|
use App\Core\Validator;
|
|
use App\Core\RedisClient;
|
|
use App\Services\NabehService;
|
|
|
|
class AuthController
|
|
{
|
|
/**
|
|
* طلب رمز تحقق OTP جديد وإرساله عبر الواتساب بواسطة بوابة نبّه
|
|
* POST /api/auth/otp/request
|
|
*
|
|
* @param Request $request طلب الـ HTTP المحتوي على رقم الهاتف بصيغة دولية أو محلية
|
|
* @param Response $response كائن الاستجابة بحالة الإرسال وزمن انتهاء الصلاحية
|
|
*/
|
|
public function requestOtp(Request $request, Response $response): void
|
|
{
|
|
$body = $request->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 = trim((string)($body['device_fingerprint'] ?? 'browser_default'));
|
|
|
|
$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, 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 = $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 = trim((string)($request->getHeader('x-device-fingerprint', '')));
|
|
if ($deviceFingerprint === '') {
|
|
$deviceFingerprint = 'browser_default';
|
|
}
|
|
|
|
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]);
|
|
}
|
|
|
|
$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;
|
|
}
|
|
}
|
|
}
|