Files
saqel/backend/app/Controllers/AuthController.php
T

414 lines
16 KiB
PHP

<?php
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
{
/**
* Request OTP via WhatsApp (Nabeh Gateway)
* POST /api/auth/otp/request
*/
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';
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' => 'تم تسجيل الخروج بنجاح'
]);
}
}