Complete Phase 1: MVC, DB migrations, Auth, RBAC, Security, and Views
This commit is contained in:
146
app/Services/Auth/AuthService.php
Normal file
146
app/Services/Auth/AuthService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
81
app/Services/Auth/RBAC.php
Normal file
81
app/Services/Auth/RBAC.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
33
app/Services/Database/ActivityLogger.php
Normal file
33
app/Services/Database/ActivityLogger.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
42
app/Services/Database/Connection.php
Normal file
42
app/Services/Database/Connection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
105
app/Services/Database/MigrationRunner.php
Normal file
105
app/Services/Database/MigrationRunner.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user