Complete Phase 1: MVC, DB migrations, Auth, RBAC, Security, and Views

This commit is contained in:
Hamza-Ayed
2026-06-05 00:56:41 +03:00
parent 7ffbc8bafa
commit bed7624ae9
51 changed files with 3295 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Services\Auth;
use App\Services\Database\Connection;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use PDO;
use Exception;
use Throwable;
class AuthService
{
private PDO $pdo;
private array $jwtConfig;
public function __construct(Connection $connection)
{
$this->pdo = $connection->getPdo();
$aiConfig = require __DIR__ . '/../../../config/ai.php';
$this->jwtConfig = $aiConfig['jwt'];
}
/**
* Register a new user.
*/
public function register(string $name, string $email, string $password): array
{
// Check for duplicates
$stmt = $this->pdo->prepare("SELECT id FROM users WHERE email = ?");
$stmt->execute([$email]);
if ($stmt->fetch()) {
throw new Exception("Email already registered.");
}
$passwordHash = password_hash($password, PASSWORD_BCRYPT);
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare("INSERT INTO users (name, email, password_hash, status) VALUES (?, ?, ?, 'active')");
$stmt->execute([$name, $email, $passwordHash]);
$userId = (int)$this->pdo->lastInsertId();
// Count users to assign role: first user gets Admin, others get Member
$stmt = $this->pdo->query("SELECT COUNT(*) FROM users");
$count = (int)$stmt->fetchColumn();
$roleCode = $count === 1 ? 'admin' : 'member';
$stmt = $this->pdo->prepare("SELECT id FROM roles WHERE code = ?");
$stmt->execute([$roleCode]);
$roleId = $stmt->fetchColumn();
if ($roleId) {
$stmt = $this->pdo->prepare("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)");
$stmt->execute([$userId, $roleId]);
}
$this->pdo->commit();
return [
'id' => $userId,
'name' => $name,
'email' => $email,
'status' => 'active'
];
} catch (Throwable $e) {
$this->pdo->rollBack();
throw new Exception("Registration failed: " . $e->getMessage());
}
}
/**
* Authenticate a user by email and password.
*/
public function login(string $email, string $password): array
{
$stmt = $this->pdo->prepare("SELECT id, name, email, password_hash, status FROM users WHERE email = ? AND deleted_at IS NULL");
$stmt->execute([$email]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
throw new Exception("Invalid email or password.");
}
if ($user['status'] !== 'active') {
throw new Exception("User account is inactive.");
}
unset($user['password_hash']);
return $user;
}
/**
* Get active user by ID.
*/
public function getUserById(int $id): ?array
{
$stmt = $this->pdo->prepare("SELECT id, name, email, status FROM users WHERE id = ? AND deleted_at IS NULL");
$stmt->execute([$id]);
$user = $stmt->fetch();
return $user ?: null;
}
/**
* Generate JWT for APIs.
*/
public function generateJwt(array $user): string
{
$issuedAt = time();
$expire = $issuedAt + $this->jwtConfig['expires_in'];
$payload = [
'iss' => $_ENV['APP_URL'] ?? 'https://scoutiq.intaleqapp.com',
'aud' => $_ENV['APP_URL'] ?? 'https://scoutiq.intaleqapp.com',
'iat' => $issuedAt,
'exp' => $expire,
'sub' => $user['id'],
'user' => [
'id' => $user['id'],
'name' => $user['name'],
'email' => $user['email']
]
];
return JWT::encode($payload, $this->jwtConfig['secret'], $this->jwtConfig['algorithm']);
}
/**
* Decode and verify JWT.
*/
public function verifyJwt(string $token): ?array
{
try {
$decoded = JWT::decode($token, new Key($this->jwtConfig['secret'], $this->jwtConfig['algorithm']));
$payload = (array)$decoded;
if (isset($payload['user'])) {
return (array)$payload['user'];
}
return $this->getUserById((int)$payload['sub']);
} catch (Throwable $e) {
return null;
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Services\Auth;
use App\Services\Database\Connection;
use PDO;
class RBAC
{
private PDO $pdo;
private array $permissionCache = [];
public function __construct(Connection $connection)
{
$this->pdo = $connection->getPdo();
}
/**
* Check if a user has a specific permission.
*/
public function hasPermission(int $userId, string $permissionCode): bool
{
$permissions = $this->getUserPermissions($userId);
return in_array($permissionCode, $permissions);
}
/**
* Get all unique permissions code associated with the user's roles.
*/
public function getUserPermissions(int $userId): array
{
if (isset($this->permissionCache[$userId])) {
return $this->permissionCache[$userId];
}
$sql = "SELECT DISTINCT p.code
FROM permissions p
JOIN role_permissions rp ON p.id = rp.permission_id
JOIN user_roles ur ON rp.role_id = ur.role_id
WHERE ur.user_id = :user_id";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['user_id' => $userId]);
$permissions = $stmt->fetchAll(PDO::FETCH_COLUMN);
$permissions = $permissions ?: [];
$this->permissionCache[$userId] = $permissions;
return $permissions;
}
/**
* Get user roles.
*/
public function getUserRoles(int $userId): array
{
$sql = "SELECT r.code
FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = :user_id";
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['user_id' => $userId]);
$roles = $stmt->fetchAll(PDO::FETCH_COLUMN);
return $roles ?: [];
}
/**
* Assign a role to a user.
*/
public function assignRoleToUser(int $userId, int $roleId): void
{
$stmt = $this->pdo->prepare("INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)");
$stmt->execute([$userId, $roleId]);
// Invalidate in-memory permission cache for this user
unset($this->permissionCache[$userId]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Services\Database;
use App\Core\Request;
use PDO;
class ActivityLogger
{
private PDO $pdo;
private Request $request;
public function __construct(Connection $connection, Request $request)
{
$this->pdo = $connection->getPdo();
$this->request = $request;
}
/**
* Log user or system activity.
*/
public function log(?int $userId, string $action, ?string $description = null): void
{
$ip = $this->request->getIp();
$ua = $this->request->getUserAgent();
$sql = "INSERT INTO activity_logs (user_id, action, description, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?)";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$userId, $action, $description, $ip, $ua]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Services\Database;
use PDO;
use Exception;
class Connection
{
private ?PDO $pdo = null;
public function __construct()
{
$config = require __DIR__ . '/../../../config/database.php';
$dsn = sprintf(
"mysql:host=%s;port=%s;dbname=%s;charset=%s",
$config['host'],
$config['port'],
$config['database'],
$config['charset']
);
try {
$this->pdo = new PDO($dsn, $config['username'], $config['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (\PDOException $e) {
throw new Exception("Database connection failed: " . $e->getMessage());
}
}
/**
* Get active PDO instance.
*/
public function getPdo(): PDO
{
return $this->pdo;
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Services\Database;
use PDO;
class MigrationRunner
{
private PDO $pdo;
private string $migrationsDir;
public function __construct(Connection $connection)
{
$this->pdo = $connection->getPdo();
$this->migrationsDir = __DIR__ . '/../../../database/migrations';
}
/**
* Run all pending migrations.
*/
public function migrate(): void
{
$this->createMigrationsTable();
$executed = $this->getExecutedMigrations();
if (!is_dir($this->migrationsDir)) {
mkdir($this->migrationsDir, 0755, true);
}
$files = glob($this->migrationsDir . '/*.sql');
if ($files === false) {
$files = [];
}
sort($files);
$count = 0;
foreach ($files as $file) {
$name = basename($file);
if (!in_array($name, $executed)) {
echo "Running migration: {$name}...\n";
$this->executeSqlFile($file);
$this->logMigration($name);
echo "Successfully ran: {$name}\n";
$count++;
}
}
if ($count === 0) {
echo "Nothing to migrate. Database is up to date!\n";
}
}
/**
* Create the tracking migrations table if it does not exist.
*/
private function createMigrationsTable(): void
{
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
$this->pdo->exec($sql);
}
/**
* Get list of already executed migrations.
*/
private function getExecutedMigrations(): array
{
$stmt = $this->pdo->query("SELECT migration FROM migrations");
$results = $stmt->fetchAll(PDO::FETCH_COLUMN);
return $results ?: [];
}
/**
* Execute SQL file.
*/
private function executeSqlFile(string $filePath): void
{
$sql = file_get_contents($filePath);
if (!$sql) {
return;
}
try {
// Run SQL commands
$this->pdo->exec($sql);
} catch (\PDOException $e) {
echo "Error running migration from file: " . basename($filePath) . "\n";
echo "Details: " . $e->getMessage() . "\n";
throw $e;
}
}
/**
* Log executed migration.
*/
private function logMigration(string $name): void
{
$stmt = $this->pdo->prepare("INSERT INTO migrations (migration) VALUES (?)");
$stmt->execute([$name]);
}
}