Implement Enterprise Logic (Redis, Phone Auth, Full Schema)

This commit is contained in:
Hamza-Ayed
2026-08-26 17:34:51 +03:00
parent df5d8dfc60
commit e944d92aac
10 changed files with 707 additions and 86 deletions
+206
View File
@@ -0,0 +1,206 @@
<?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;
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
*/
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']);
return;
}
$phone = $body['phone_number'];
$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
$response->json([
'status' => 'success',
'message' => 'OTP sent successfully (Simulated: ' . $otp . ')'
]);
}
/**
* Common method to generate JWT and save session to Redis
*/
private function generateSessionAndRespond(int $userId, string $uuid, string $role, Response $response, string $msg): void
{
$payload = [
'user_id' => $userId,
'uuid' => $uuid,
'role' => $role
];
$token = Security::generateJWT($payload);
// Store session in Redis (Active for 30 days)
try {
$redis = RedisClient::getInstance();
$redis->setex("session:{$userId}:{$token}", 30 * 86400, "active");
} catch (\Exception $e) {
error_log("Failed to save session to Redis: " . $e->getMessage());
}
$response->status(200)->json([
'status' => 'success',
'message' => $msg,
'data' => [
'token' => $token,
'user' => [
'uuid' => $uuid,
'role' => $role
]
]
]);
}
/**
* Get Current User Data
*/
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",
[$userId]
);
if (!$user) {
$response->status(404)->json([
'status' => 'error',
'message' => 'User not found'
]);
return;
}
$response->json([
'status' => 'success',
'data' => $user
]);
}
}
@@ -0,0 +1,95 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Database;
class TeacherController
{
public function addCourse(Request $request, Response $response): void
{
if ($request->role !== 'teacher' && $request->role !== 'admin') {
$response->status(403)->json([
'status' => 'error',
'message' => 'Forbidden: Only teachers can add courses'
]);
return;
}
$body = $request->getBody();
$title = $body['title'] ?? '';
$description = $body['description'] ?? '';
if (empty($title)) {
$response->status(400)->json([
'status' => 'error',
'message' => 'Course title is required'
]);
return;
}
$courseId = Database::insert(
"INSERT INTO courses (teacher_id, title, description) VALUES (?, ?, ?)",
[$request->user_id, $title, $description]
);
$response->status(201)->json([
'status' => 'success',
'message' => 'Course added successfully',
'data' => [
'course_id' => $courseId
]
]);
}
public function addLesson(Request $request, Response $response): void
{
if ($request->role !== 'teacher' && $request->role !== 'admin') {
$response->status(403)->json([
'status' => 'error',
'message' => 'Forbidden: Only teachers can add lessons'
]);
return;
}
$body = $request->getBody();
$courseId = $body['course_id'] ?? null;
$title = $body['title'] ?? '';
$content = $body['content'] ?? '';
$videoUrl = $body['video_url'] ?? null;
$orderNum = $body['order_num'] ?? 1;
if (!$courseId || empty($title) || empty($content)) {
$response->status(400)->json([
'status' => 'error',
'message' => 'Course ID, title, and content are required'
]);
return;
}
// Verify the course belongs to the teacher
$course = Database::selectOne("SELECT id, teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]);
if (!$course || ($course['teacher_id'] != $request->user_id && $request->role !== 'admin')) {
$response->status(403)->json([
'status' => 'error',
'message' => 'Forbidden: You do not own this course'
]);
return;
}
$lessonId = Database::insert(
"INSERT INTO lessons (course_id, title, content, video_url, order_num) VALUES (?, ?, ?, ?, ?)",
[$courseId, $title, $content, $videoUrl, $orderNum]
);
$response->status(201)->json([
'status' => 'success',
'message' => 'Lesson added successfully',
'data' => [
'lesson_id' => $lessonId
]
]);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Core;
/**
* Core Redis Client for managing connections.
* Handles Sessions, Rate Limiting, and caching using PHP Redis extension.
*/
class RedisClient
{
private static ?\Redis $instance = null;
/**
* Get the singleton Redis connection instance
*/
public static function getInstance(): \Redis
{
if (self::$instance === null) {
try {
$redis = new \Redis();
$host = getenv('REDIS_HOST') ?: '127.0.0.1';
$port = (int)(getenv('REDIS_PORT') ?: 6379);
$password = getenv('REDIS_PASSWORD') ?: null;
// Connect with a 2 second timeout
if (!$redis->connect($host, $port, 2.0)) {
throw new \RuntimeException("Could not connect to Redis server at $host:$port");
}
// Authenticate if password is provided
if ($password) {
if (!$redis->auth($password)) {
throw new \RuntimeException("Redis authentication failed.");
}
}
// Set prefix for Saqel to avoid collisions
$redis->setOption(\Redis::OPT_PREFIX, 'saqel:');
self::$instance = $redis;
} catch (\Exception $e) {
// In production, fallback gracefully or throw HTTP 500
error_log("Redis Connection Error: " . $e->getMessage());
throw new \RuntimeException("Redis is unavailable. Please ensure the Redis server is running.");
}
}
return self::$instance;
}
/**
* Helper to close the connection explicitly
*/
public static function close(): void
{
if (self::$instance !== null) {
self::$instance->close();
self::$instance = null;
}
}
}
+1 -1
View File
@@ -148,7 +148,7 @@ class Security
$payload['iat'] = time();
$payload['exp'] = time() + $expirySeconds;
$payload['iss'] = getenv('APP_URL'); // Issuer
$payload['aud'] = 'nabeh_dashboard'; // Audience
$payload['aud'] = 'saqel_app'; // Audience
$payload['jti'] = bin2hex(random_bytes(16)); // JWT ID to prevent Replay Attacks
$payloadEncoded = self::base64UrlEncode(json_encode($payload));
+16 -3
View File
@@ -28,16 +28,29 @@ class AuthMiddleware
exit;
}
// Check if session is active in Redis
try {
$redis = \App\Core\RedisClient::getInstance();
$userId = $payload['user_id'];
$sessionState = $redis->get("session:{$userId}:{$token}");
if (!$sessionState || $sessionState !== 'active') {
$response->json(['error' => 'Unauthorized', 'message' => 'Session has been revoked or expired'], 401);
exit;
}
} catch (\Exception $e) {
// If Redis is down, we fallback to just JWT verification
error_log("AuthMiddleware Redis Error: " . $e->getMessage());
}
// Validate required custom payload elements
if (!isset($payload['user_id']) || !isset($payload['company_id']) || !isset($payload['role'])) {
if (!isset($payload['user_id']) || !isset($payload['role'])) {
$response->json(['error' => 'Unauthorized', 'message' => 'Malformed token payload structure'], 401);
exit;
}
// Attach user info to the Request instance dynamically so controllers can use it
$request->user_id = $payload['user_id'];
$request->company_id = $payload['company_id'];
$request->role = $payload['role'];
$request->is_super_admin = (int)$payload['company_id'] === 1;
}
}
+31 -41
View File
@@ -4,22 +4,16 @@ namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
use App\Core\RedisClient;
/**
* Rate Limit Middleware
* Limits the number of requests per IP address using file-based counters.
* Protects sensitive endpoints (login, register) from Brute Force attacks.
* Rate Limit Middleware (Redis Powered)
* Limits the number of requests per IP address using Redis atomic counters.
* Protects sensitive endpoints (login, register, otp) from Brute Force attacks.
*/
class RateLimitMiddleware
{
/**
* Maximum allowed requests within the time window
*/
private int $maxAttempts;
/**
* Time window in seconds
*/
private int $decaySeconds;
public function __construct(int $maxAttempts = 5, int $decaySeconds = 60)
@@ -31,40 +25,36 @@ class RateLimitMiddleware
public function handle(Request $request, Response $response): void
{
$ip = $this->getClientIp();
$key = 'rate_' . md5($ip . '_' . $request->getPath());
$key = 'rate_limit:' . md5($ip . '_' . $request->getPath());
$storageDir = APP_ROOT . '/storage/rate_limits';
if (!is_dir($storageDir)) {
mkdir($storageDir, 0750, true);
}
$filePath = $storageDir . '/' . $key . '.json';
$data = ['count' => 0, 'expires_at' => time() + $this->decaySeconds];
if (file_exists($filePath)) {
$raw = json_decode(file_get_contents($filePath), true);
if ($raw && isset($raw['expires_at']) && $raw['expires_at'] > time()) {
// Window still active — use existing data
$data = $raw;
try {
$redis = RedisClient::getInstance();
$current = $redis->get($key);
if ($current !== false && (int)$current >= $this->maxAttempts) {
$retryAfter = $redis->ttl($key);
$retryAfter = $retryAfter > 0 ? $retryAfter : $this->decaySeconds;
$response->setHeader('Retry-After', (string)$retryAfter);
$response->json([
'error' => 'Too Many Requests',
'message' => "لقد تجاوزت الحد الأقصى للمحاولات ({$this->maxAttempts}). يرجى المحاولة بعد {$retryAfter} ثانية."
], 429);
exit; // End request
}
// If window expired, fall through and reset (overwrite with fresh data below)
// Increment atomically
$count = $redis->incr($key);
if ($count === 1) {
// First request, set expiration
$redis->expire($key, $this->decaySeconds);
}
} catch (\Exception $e) {
// If Redis fails, log it but don't block the request completely,
// or we could choose to block it. We'll let it pass to avoid downtime.
error_log("RateLimit Redis Error: " . $e->getMessage());
}
$data['count']++;
if ($data['count'] > $this->maxAttempts) {
$retryAfter = max(0, $data['expires_at'] - time());
$response->setHeader('Retry-After', (string)$retryAfter);
$response->json([
'error' => 'Too Many Requests',
'message' => "You have exceeded the maximum number of {$this->maxAttempts} attempts. Please try again in {$retryAfter} seconds."
], 429);
return;
}
// Persist the updated counter
file_put_contents($filePath, json_encode($data), LOCK_EX);
}
/**