Update Saqel Platform: 2026-09-08 13:43:36
This commit is contained in:
@@ -65,14 +65,34 @@ class AuthController
|
||||
}
|
||||
|
||||
$role = $body['role'] ?? 'student';
|
||||
if (!in_array($role, ['student', 'teacher', 'guardian', 'school_admin', 'super_admin'], true)) {
|
||||
$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)
|
||||
$phoneHash = Security::blindIndex($cleanPhone);
|
||||
try {
|
||||
$redis = RedisClient::getInstance();
|
||||
$rateKey = "otp_rate:{$phoneHash}";
|
||||
@@ -89,7 +109,9 @@ class AuthController
|
||||
return;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log("Redis rate limit warning: " . $e->getMessage());
|
||||
error_log("Redis rate limit failure: " . $e->getMessage());
|
||||
$response->status(503)->json(['status' => 'error', 'message' => 'خدمة التحقق غير متاحة مؤقتاً']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Generate 6-digit OTP
|
||||
@@ -100,36 +122,21 @@ class AuthController
|
||||
$redis = RedisClient::getInstance();
|
||||
$otpKey = "otp:{$phoneHash}:{$role}";
|
||||
$redis->setex($otpKey, 300, password_hash($otp, PASSWORD_BCRYPT));
|
||||
// Also store general key for backwards compatibility
|
||||
$redis->setex("otp:{$phoneHash}", 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
|
||||
$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,
|
||||
@@ -177,22 +184,25 @@ class AuthController
|
||||
$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 Redis (Check role-isolated key first, then fallback)
|
||||
// 1. Verify OTP against the role-isolated Redis key.
|
||||
$redis = RedisClient::getInstance();
|
||||
$otpRoleKey = "otp:{$phoneHash}:{$role}";
|
||||
$otpKey = "otp:{$phoneHash}";
|
||||
$storedHash = $redis->get($otpRoleKey) ?: $redis->get($otpKey);
|
||||
$storedHash = $redis->get($otpRoleKey);
|
||||
|
||||
$isValidOtp = false;
|
||||
if ($storedHash && password_verify($inputOtp, $storedHash)) {
|
||||
$isValidOtp = true;
|
||||
$redis->del($otpRoleKey);
|
||||
$redis->del($otpKey); // Invalidate OTP after success
|
||||
}
|
||||
|
||||
if (!$isValidOtp) {
|
||||
@@ -233,7 +243,26 @@ class AuthController
|
||||
$resolvedName = $fullName ?: ($role === 'teacher' ? 'الأستاذ المعتمد' : 'الطالب المتميز');
|
||||
|
||||
// Resolve or create specific Persona entity (Teacher, Student, Guardian)
|
||||
if ($role === 'teacher') {
|
||||
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) {
|
||||
@@ -281,6 +310,7 @@ class AuthController
|
||||
'identity_id' => $identityId,
|
||||
'role' => 'student_identity_pending',
|
||||
'phone' => $cleanPhone,
|
||||
'token_version' => (int)$identity['token_version'],
|
||||
], 3600); // 1 hour validity
|
||||
|
||||
$response->status(200)->json([
|
||||
@@ -307,7 +337,7 @@ class AuthController
|
||||
}
|
||||
|
||||
// 4. Issue JWT and Bind Single Session in Redis
|
||||
$displayName = Security::decrypt($user['full_name']) ?: 'مستخدم صَقِل';
|
||||
$displayName = $this->readStoredValue((string)$user['full_name']);
|
||||
$this->generateSessionAndRespond(
|
||||
(int)$user['id'],
|
||||
$user['uuid'],
|
||||
@@ -316,7 +346,11 @@ class AuthController
|
||||
$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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -331,7 +365,11 @@ class AuthController
|
||||
string $cleanPhone,
|
||||
string $displayName,
|
||||
Response $response,
|
||||
string $msg
|
||||
string $msg,
|
||||
int $identityId = 0,
|
||||
int $tokenVersion = 1,
|
||||
?int $schoolId = null,
|
||||
?int $directorateId = null
|
||||
): void {
|
||||
$payload = [
|
||||
'user_id' => $userId,
|
||||
@@ -340,6 +378,10 @@ class AuthController
|
||||
'device_fingerprint' => $deviceFingerprint,
|
||||
'phone' => $cleanPhone,
|
||||
'name' => $displayName,
|
||||
'identity_id' => $identityId,
|
||||
'token_version' => $tokenVersion,
|
||||
'school_id' => $schoolId,
|
||||
'directorate_id' => $directorateId,
|
||||
];
|
||||
|
||||
// 30 days token expiry
|
||||
@@ -371,6 +413,8 @@ class AuthController
|
||||
'role' => $role,
|
||||
'is_student' => ($role === 'student'),
|
||||
'is_teacher' => ($role === 'teacher'),
|
||||
'school_id' => $schoolId,
|
||||
'directorate_id' => $directorateId,
|
||||
]
|
||||
]
|
||||
]);
|
||||
@@ -387,7 +431,32 @@ class AuthController
|
||||
|
||||
$userData = null;
|
||||
|
||||
if ($role === 'teacher') {
|
||||
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
|
||||
@@ -451,7 +520,7 @@ class AuthController
|
||||
'full_name' => $user['full_name'],
|
||||
'name' => $user['full_name'],
|
||||
'role' => 'student',
|
||||
'national_id' => $user['national_id'],
|
||||
'national_id' => $this->readStoredValue((string)$user['national_id']),
|
||||
'grade_level' => $user['grade_level'],
|
||||
'stream' => $user['stream'],
|
||||
'readiness_score' => $user['readiness_score'],
|
||||
@@ -504,7 +573,8 @@ class AuthController
|
||||
$phone = is_array($decoded) ? ($decoded['phone'] ?? '') : ($decoded->phone ?? '');
|
||||
|
||||
// Check if student exists with this National ID
|
||||
$student = Database::selectOne("SELECT * FROM students WHERE national_id = ? LIMIT 1", [$nationalId]);
|
||||
$nationalIdHash = Security::blindIndex($nationalId);
|
||||
$student = Database::selectOne("SELECT * FROM students WHERE national_id_hash = ? LIMIT 1", [$nationalIdHash]);
|
||||
|
||||
if ($student) {
|
||||
// Student exists. Verify identity linkage.
|
||||
@@ -525,7 +595,10 @@ class AuthController
|
||||
$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.
|
||||
@@ -567,7 +640,7 @@ class AuthController
|
||||
'id' => $student['id'],
|
||||
'uuid' => $student['uuid'],
|
||||
'full_name' => $student['full_name'],
|
||||
'national_id' => $student['national_id'],
|
||||
'national_id' => $this->readStoredValue((string)$student['national_id']),
|
||||
'grade_level' => $student['grade_level'],
|
||||
'stream' => $student['stream'],
|
||||
'role' => 'student'
|
||||
@@ -606,7 +679,8 @@ class AuthController
|
||||
$phone = is_array($decoded) ? ($decoded['phone'] ?? '') : ($decoded->phone ?? '');
|
||||
|
||||
// Ensure National ID doesn't exist
|
||||
$existing = Database::selectOne("SELECT id FROM students WHERE national_id = ? LIMIT 1", [$nationalId]);
|
||||
$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;
|
||||
@@ -615,9 +689,9 @@ class AuthController
|
||||
$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, full_name, grade_level, stream, is_school_sponsored)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)",
|
||||
[$sUuid, $identityId, $nationalId, $fullName, $gradeLevel, $stream]
|
||||
"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
|
||||
@@ -627,7 +701,11 @@ class AuthController
|
||||
}
|
||||
|
||||
$this->generateSessionAndRespond(
|
||||
$sId, $sUuid, 'student', 'browser_default', $phone, $fullName, $response, 'تم استكمال التسجيل بنجاح'
|
||||
$sId, $sUuid, 'student', 'browser_default', $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
|
||||
@@ -647,8 +725,8 @@ class AuthController
|
||||
}
|
||||
|
||||
Database::query(
|
||||
"UPDATE students SET full_name = ?, grade_level = ?, stream = ?, national_id = IF(? != '', ?, national_id), updated_at = NOW() WHERE id = ?",
|
||||
[$fullName, $gradeLevel, $stream, $nationalId, $nationalId, $studentId]
|
||||
"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]);
|
||||
@@ -691,4 +769,17 @@ class AuthController
|
||||
'message' => 'تم تسجيل الخروج بنجاح'
|
||||
]);
|
||||
}
|
||||
|
||||
private function readStoredValue(string $value): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
$decrypted = Security::decrypt($value);
|
||||
return $decrypted !== '' ? $decrypted : $value;
|
||||
} catch (\Throwable $e) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user