feat: Add /student and /teacher portals with Alpine.js, WhatsApp OTP, and device fingerprinting
This commit is contained in:
@@ -8,199 +8,355 @@ use App\Core\Database;
|
||||
use App\Core\Security;
|
||||
use App\Core\Validator;
|
||||
use App\Core\RedisClient;
|
||||
use App\Services\NabehService;
|
||||
|
||||
class AuthController
|
||||
{
|
||||
/**
|
||||
* Register a new user using Phone Number
|
||||
*/
|
||||
public function register(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
|
||||
$validator = new Validator();
|
||||
$isValid = $validator->validate($body, [
|
||||
'full_name' => 'required',
|
||||
'phone_number' => 'required',
|
||||
'password' => 'required|min:8',
|
||||
'role' => 'required'
|
||||
]);
|
||||
|
||||
if (!$isValid) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $validator->getErrors()
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$phone = $body['phone_number'];
|
||||
$role = $body['role'];
|
||||
|
||||
if (!in_array($role, ['student', 'teacher', 'guardian'])) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Invalid role specified'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$phoneHash = Security::blindIndex($phone);
|
||||
$existing = Database::selectOne("SELECT id FROM users WHERE phone_hash = ? LIMIT 1", [$phoneHash]);
|
||||
|
||||
if ($existing) {
|
||||
$response->status(409)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Phone number is already registered'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$passwordHash = Security::hashPassword($body['password']);
|
||||
|
||||
// Generate UUID
|
||||
$uuid = 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)
|
||||
);
|
||||
|
||||
$userId = Database::insert(
|
||||
"INSERT INTO users (uuid, full_name, phone_number, phone_hash, password_hash, role, status) VALUES (?, ?, ?, ?, ?, ?, 'active')",
|
||||
[$uuid, $body['full_name'], $phone, $phoneHash, $passwordHash, $role]
|
||||
);
|
||||
|
||||
$this->generateSessionAndRespond($userId, $uuid, $role, $response, "User registered successfully");
|
||||
}
|
||||
|
||||
/**
|
||||
* Login using Phone Number and Password
|
||||
*/
|
||||
public function login(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$validator = new Validator();
|
||||
|
||||
if (!$validator->validate($body, [
|
||||
'phone_number' => 'required',
|
||||
'password' => 'required'
|
||||
])) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $validator->getErrors()
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$phoneHash = Security::blindIndex($body['phone_number']);
|
||||
$user = Database::selectOne("SELECT * FROM users WHERE phone_hash = ? LIMIT 1", [$phoneHash]);
|
||||
|
||||
if (!$user || !Security::verifyPassword($body['password'], $user['password_hash'])) {
|
||||
$response->status(401)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Invalid phone number or password'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user['status'] === 'suspended') {
|
||||
$response->status(403)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'Account is suspended'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->generateSessionAndRespond($user['id'], $user['uuid'], $user['role'], $response, "Login successful");
|
||||
}
|
||||
|
||||
/**
|
||||
* Request OTP for phone verification
|
||||
* Request OTP via WhatsApp (Nabeh Gateway)
|
||||
* POST /api/auth/otp/request
|
||||
*/
|
||||
public function requestOtp(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
if (empty($body['phone_number'])) {
|
||||
$response->status(400)->json(['status' => 'error', 'message' => 'Phone number is required']);
|
||||
$validator = new Validator();
|
||||
|
||||
if (!$validator->validate($body, ['phone_number' => 'required'])) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'رقم الهاتف مطلوب',
|
||||
'errors' => $validator->getErrors()
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$phone = $body['phone_number'];
|
||||
$rawPhone = trim((string)$body['phone_number']);
|
||||
$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);
|
||||
|
||||
// Save OTP to Redis for 5 minutes
|
||||
$redis = RedisClient::getInstance();
|
||||
$redis->setex('otp:' . $phone, 300, $otp);
|
||||
|
||||
// TODO: Integrate SMS gateway here to actually send the OTP via SMS
|
||||
// 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());
|
||||
}
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => 'OTP sent successfully (Simulated: ' . $otp . ')'
|
||||
]);
|
||||
// 4. Send OTP via Nabeh Service
|
||||
$nabeh = new NabehService();
|
||||
$sent = $nabeh->sendOtp($cleanPhone, $otp, 'image', $appName);
|
||||
|
||||
$isDebug = filter_var(getenv('APP_DEBUG') ?: true, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
$resData = [
|
||||
'status' => 'success',
|
||||
'message' => 'تم إرسال رمز التحقق بنجاح عبر الواتساب',
|
||||
'data' => [
|
||||
'phone_masked' => substr($cleanPhone, 0, 3) . '****' . substr($cleanPhone, -3),
|
||||
'expires_in' => 300,
|
||||
]
|
||||
];
|
||||
|
||||
// Expose OTP only in debug mode for seamless local testing
|
||||
if ($isDebug || !$sent) {
|
||||
$resData['debug_otp'] = $otp;
|
||||
if (!$sent) {
|
||||
$resData['message'] = 'تم توليد رمز التحقق (بيئة التطوير / محاكاة الإرسال)';
|
||||
}
|
||||
}
|
||||
|
||||
$response->json($resData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Common method to generate JWT and save session to Redis
|
||||
* Verify OTP and Login / Register User
|
||||
* POST /api/auth/otp/verify
|
||||
*/
|
||||
private function generateSessionAndRespond(int $userId, string $uuid, string $role, Response $response, string $msg): void
|
||||
public function verifyOtp(Request $request, Response $response): void
|
||||
{
|
||||
$payload = [
|
||||
'user_id' => $userId,
|
||||
'uuid' => $uuid,
|
||||
'role' => $role
|
||||
];
|
||||
|
||||
$token = Security::generateJWT($payload);
|
||||
$body = $request->getBody();
|
||||
$validator = new Validator();
|
||||
|
||||
// Store session in Redis (Active for 30 days)
|
||||
if (!$validator->validate($body, [
|
||||
'phone_number' => 'required',
|
||||
'otp' => 'required'
|
||||
])) {
|
||||
$response->status(400)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'رقم الهاتف ورمز التحقق مطلوبان',
|
||||
'errors' => $validator->getErrors()
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$rawPhone = trim((string)$body['phone_number']);
|
||||
$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;
|
||||
}
|
||||
|
||||
$inputOtp = trim((string)$body['otp']);
|
||||
$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
|
||||
} elseif (getenv('APP_DEBUG') && $inputOtp === '123456') {
|
||||
// Master debug OTP
|
||||
$isValidOtp = true;
|
||||
}
|
||||
|
||||
if (!$isValidOtp) {
|
||||
$response->status(401)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'رمز التحقق غير صحيح أو انتهت صلاحيته'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Find or Create User
|
||||
$user = Database::selectOne("SELECT * FROM users WHERE phone_hash = ? LIMIT 1", [$phoneHash]);
|
||||
|
||||
if (!$user) {
|
||||
// New user registration
|
||||
$uuid = 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);
|
||||
$encryptedName = Security::encrypt($fullName ?: ($role === 'teacher' ? 'معلم جديد' : 'طالب جديد'));
|
||||
|
||||
$userId = Database::insert(
|
||||
"INSERT INTO users (uuid, full_name, phone_number, phone_hash, role, status, token_version) VALUES (?, ?, ?, ?, ?, 'active', 1)",
|
||||
[$uuid, $encryptedName, $encryptedPhone, $phoneHash, $role]
|
||||
);
|
||||
|
||||
$user = [
|
||||
'id' => $userId,
|
||||
'uuid' => $uuid,
|
||||
'full_name' => $encryptedName,
|
||||
'role' => $role,
|
||||
'status' => 'active',
|
||||
'token_version' => 1,
|
||||
'school_id' => null,
|
||||
];
|
||||
} else {
|
||||
// Verify role access if logging into specific portal
|
||||
if ($role === 'teacher' && $user['role'] !== 'teacher' && $user['role'] !== 'super_admin') {
|
||||
$response->status(403)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'هذا الحساب مسجل كطالب وليس معلماً. يرجى الدخول من بوابة الطالب.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user['status'] === 'suspended') {
|
||||
$response->status(403)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'هذا الحساب معطل. يرجى مراجعة إدارة المنصة.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// If name provided on existing profile, update if needed
|
||||
if ($fullName && (empty($user['full_name']) || Security::decrypt($user['full_name']) === 'طالب جديد')) {
|
||||
Database::query("UPDATE users SET full_name = ? WHERE id = ?", [Security::encrypt($fullName), $user['id']]);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
$redis->setex("session:{$userId}:{$token}", 30 * 86400, "active");
|
||||
$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 session to Redis: " . $e->getMessage());
|
||||
error_log("Failed to save active session in Redis: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$response->status(200)->json([
|
||||
'status' => 'success',
|
||||
'status' => 'success',
|
||||
'message' => $msg,
|
||||
'data' => [
|
||||
'data' => [
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'uuid' => $uuid,
|
||||
'role' => $role
|
||||
'user' => [
|
||||
'uuid' => $uuid,
|
||||
'name' => $displayName,
|
||||
'phone' => $cleanPhone,
|
||||
'role' => $role,
|
||||
'is_student' => ($role === 'student'),
|
||||
'is_teacher' => ($role === 'teacher'),
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Current User Data
|
||||
* 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, role, status, created_at FROM users WHERE id = ? LIMIT 1",
|
||||
"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' => 'User not found'
|
||||
'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
|
||||
'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' => 'تم تسجيل الخروج بنجاح'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user