Deploy: 2026-05-21 01:26:06
This commit is contained in:
147
backend/app/Controllers/AuthController.php
Normal file
147
backend/app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Security;
|
||||
use App\Models\User;
|
||||
use App\Models\Company;
|
||||
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Register a new company and admin user
|
||||
*/
|
||||
public function register(Request $request, Response $response): void
|
||||
{
|
||||
$errors = $this->validate($request, [
|
||||
'company_name' => 'required|min:3',
|
||||
'user_name' => 'required|min:3',
|
||||
'email' => 'required|email',
|
||||
'password' => 'required|strong_password'
|
||||
]);
|
||||
|
||||
if (!empty($errors)) {
|
||||
$response->json(['errors' => $errors], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $request->getBody();
|
||||
|
||||
// Check if user already exists securely via Blind Index
|
||||
$existingUser = User::findByEmail($data['email']);
|
||||
if ($existingUser) {
|
||||
$response->json(['errors' => ['email' => ['This email is already registered.']]], 409);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create Company
|
||||
$companyId = Company::create([
|
||||
'name' => htmlspecialchars(strip_tags($data['company_name']))
|
||||
]);
|
||||
|
||||
// Create Admin User for this Company
|
||||
$userId = User::createSecure([
|
||||
'company_id' => $companyId,
|
||||
'name' => htmlspecialchars(strip_tags($data['user_name'])),
|
||||
'email' => strtolower(trim($data['email'])),
|
||||
'password' => $data['password'],
|
||||
'role' => 'admin'
|
||||
]);
|
||||
|
||||
$response->json([
|
||||
'message' => 'Company and Admin user registered successfully.',
|
||||
'company_id' => $companyId,
|
||||
'user_id' => $userId
|
||||
], 201);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
error_log("Registration Error: " . $e->getMessage());
|
||||
$response->json(['error' => 'An error occurred during registration.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login existing user and return JWT
|
||||
*/
|
||||
public function login(Request $request, Response $response): void
|
||||
{
|
||||
$errors = $this->validate($request, [
|
||||
'email' => 'required|email',
|
||||
'password' => 'required'
|
||||
]);
|
||||
|
||||
if (!empty($errors)) {
|
||||
$response->json(['errors' => $errors], 400);
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $request->getBody();
|
||||
|
||||
// Find user by email blind index
|
||||
$user = User::findByEmail($data['email']);
|
||||
if (!$user) {
|
||||
$response->json(['error' => 'Invalid email or password'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify password hash
|
||||
if (!Security::verifyPassword($data['password'], $user['password'])) {
|
||||
$response->json(['error' => 'Invalid email or password'], 401);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user['status'] !== 'active') {
|
||||
$response->json(['error' => 'Your account is inactive or suspended.'], 403);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate standard JWT token with full required payload
|
||||
$payload = [
|
||||
'user_id' => $user['id'],
|
||||
'company_id' => $user['company_id'],
|
||||
'role' => $user['role']
|
||||
];
|
||||
|
||||
$token = Security::generateJWT($payload);
|
||||
|
||||
$response->json([
|
||||
'message' => 'Login successful',
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'id' => $user['id'],
|
||||
'company_id' => $user['company_id'],
|
||||
'name' => $user['name'],
|
||||
'role' => $user['role']
|
||||
]
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current logged in user details
|
||||
* (Protected by AuthMiddleware)
|
||||
*/
|
||||
public function me(Request $request, Response $response): void
|
||||
{
|
||||
$user = User::find($request->user_id);
|
||||
|
||||
if (!$user) {
|
||||
$response->json(['error' => 'User not found'], 404);
|
||||
return;
|
||||
}
|
||||
|
||||
$response->json([
|
||||
'user' => [
|
||||
'id' => $user['id'],
|
||||
'company_id' => $user['company_id'],
|
||||
'name' => $user['name'],
|
||||
'email' => Security::decrypt($user['email']), // Decrypt email before sending back
|
||||
'role' => $user['role'],
|
||||
'status' => $user['status'],
|
||||
'created_at' => $user['created_at']
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -19,31 +19,8 @@ class BaseController
|
||||
*/
|
||||
protected function validate(Request $request, array $rules): array
|
||||
{
|
||||
$errors = [];
|
||||
$data = $request->getBody();
|
||||
|
||||
foreach ($rules as $field => $constraints) {
|
||||
$value = $data[$field] ?? null;
|
||||
$constraintsArray = explode('|', $constraints);
|
||||
|
||||
foreach ($constraintsArray as $constraint) {
|
||||
if ($constraint === 'required') {
|
||||
if ($value === null || $value === '') {
|
||||
$errors[$field][] = "The {$field} field is required.";
|
||||
}
|
||||
} elseif ($constraint === 'email') {
|
||||
if ($value !== null && $value !== '' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[$field][] = "The {$field} must be a valid email address.";
|
||||
}
|
||||
} elseif (strpos($constraint, 'min:') === 0) {
|
||||
$min = (int) substr($constraint, 4);
|
||||
if ($value !== null && strlen((string)$value) < $min) {
|
||||
$errors[$field][] = "The {$field} must be at least {$min} characters.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
$validator = new \App\Core\Validator();
|
||||
$validator->validate($request->getBody(), $rules);
|
||||
return $validator->getErrors();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,16 @@ class Request
|
||||
return $this->bodyParams;
|
||||
}
|
||||
|
||||
public function setBody(array $bodyParams): void
|
||||
{
|
||||
$this->bodyParams = $bodyParams;
|
||||
}
|
||||
|
||||
public function setQueryParams(array $queryParams): void
|
||||
{
|
||||
$this->queryParams = $queryParams;
|
||||
}
|
||||
|
||||
public function get(string $key, $default = null)
|
||||
{
|
||||
return $this->bodyParams[$key] ?? ($this->queryParams[$key] ?? $default);
|
||||
|
||||
198
backend/app/Core/Security.php
Normal file
198
backend/app/Core/Security.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Advanced OWASP Security Helper
|
||||
* Handles AES-256-GCM encryption/decryption, HMAC Blind Indexing,
|
||||
* Bcrypt password hashing, and JWT validation.
|
||||
*/
|
||||
class Security
|
||||
{
|
||||
/**
|
||||
* Get the encryption key from environment (must be 32 bytes for AES-256)
|
||||
*/
|
||||
private static function getEncryptionKey(): string
|
||||
{
|
||||
$key = getenv('ENCRYPTION_KEY') ;
|
||||
return substr(hash('sha256', $key, true), 0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get HMAC Salt for Blind Indexing
|
||||
*/
|
||||
private static function getHmacSalt(): string
|
||||
{
|
||||
return getenv('HMAC_SALT');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JWT Secret
|
||||
*/
|
||||
private static function getJwtSecret(): string
|
||||
{
|
||||
return getenv('JWT_SECRET');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt text using AES-256-GCM
|
||||
*/
|
||||
public static function encrypt(string $plainText): string
|
||||
{
|
||||
if ($plainText === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$key = self::getEncryptionKey();
|
||||
$iv = openssl_random_pseudo_bytes(12); // GCM standard IV is 12 bytes
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plainText,
|
||||
'aes-256-gcm',
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
|
||||
if ($ciphertext === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Return combined iv + tag + ciphertext base64 encoded
|
||||
return base64_encode($iv . $tag . $ciphertext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt text using AES-256-GCM
|
||||
*/
|
||||
public static function decrypt(string $encryptedText): string
|
||||
{
|
||||
if ($encryptedText === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$key = self::getEncryptionKey();
|
||||
$data = base64_decode($encryptedText);
|
||||
|
||||
// AES-256-GCM tag is 16 bytes, IV is 12 bytes
|
||||
if (strlen($data) < 28) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$iv = substr($data, 0, 12);
|
||||
$tag = substr($data, 12, 16);
|
||||
$ciphertext = substr($data, 28);
|
||||
|
||||
$decrypted = openssl_decrypt(
|
||||
$ciphertext,
|
||||
'aes-256-gcm',
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
|
||||
return $decrypted !== false ? $decrypted : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Blind Index (HMAC-SHA256) for secure database queries
|
||||
*/
|
||||
public static function blindIndex(string $value): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
$normalized = strtolower(trim($value));
|
||||
return hash_hmac('sha256', $normalized, self::getHmacSalt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
public static function hashPassword(string $password): string
|
||||
{
|
||||
return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password
|
||||
*/
|
||||
public static function verifyPassword(string $password, string $hash): bool
|
||||
{
|
||||
return password_verify($password, $hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JWT Token with HMAC-SHA256 signature
|
||||
* Includes user_id, company_id, role, iss, aud, and jti.
|
||||
*/
|
||||
public static function generateJWT(array $payload, int $expirySeconds = 86400): string
|
||||
{
|
||||
$header = self::base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||
|
||||
// Standard OWASP Claims
|
||||
$payload['iat'] = time();
|
||||
$payload['exp'] = time() + $expirySeconds;
|
||||
$payload['iss'] = getenv('APP_URL'); // Issuer
|
||||
$payload['aud'] = 'nabeh_dashboard'; // Audience
|
||||
$payload['jti'] = bin2hex(random_bytes(16)); // JWT ID to prevent Replay Attacks
|
||||
|
||||
$payloadEncoded = self::base64UrlEncode(json_encode($payload));
|
||||
|
||||
$secret = self::getJwtSecret();
|
||||
$signature = hash_hmac('sha256', "$header.$payloadEncoded", $secret, true);
|
||||
$signatureEncoded = self::base64UrlEncode($signature);
|
||||
|
||||
return "$header.$payloadEncoded.$signatureEncoded";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT Token and return payload if valid, false otherwise
|
||||
*/
|
||||
public static function verifyJWT(string $token)
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
list($headerEncoded, $payloadEncoded, $signatureEncoded) = $parts;
|
||||
|
||||
$secret = self::getJwtSecret();
|
||||
if (!$secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$signature = self::base64UrlDecode($signatureEncoded);
|
||||
$expectedSignature = hash_hmac('sha256', "$headerEncoded.$payloadEncoded", $secret, true);
|
||||
|
||||
if (!hash_equals($signature, $expectedSignature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_decode(self::base64UrlDecode($payloadEncoded), true);
|
||||
if (!$payload || !isset($payload['exp']) || time() >= $payload['exp']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate Issuer
|
||||
$expectedIssuer = getenv('APP_URL');
|
||||
if (isset($payload['iss']) && $payload['iss'] !== $expectedIssuer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
private static function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private static function base64UrlDecode(string $data): string
|
||||
{
|
||||
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4));
|
||||
}
|
||||
}
|
||||
102
backend/app/Core/Validator.php
Normal file
102
backend/app/Core/Validator.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Core Validation Engine
|
||||
* Handles data validation before processing.
|
||||
*/
|
||||
class Validator
|
||||
{
|
||||
private array $errors = [];
|
||||
|
||||
/**
|
||||
* Validate an array of data against rules.
|
||||
* Example rules: ['email' => 'required|email', 'password' => 'required|min:8']
|
||||
*/
|
||||
public function validate(array $data, array $rules): bool
|
||||
{
|
||||
$this->errors = [];
|
||||
|
||||
foreach ($rules as $field => $ruleString) {
|
||||
$rulesArray = explode('|', $ruleString);
|
||||
$value = $data[$field] ?? null;
|
||||
|
||||
foreach ($rulesArray as $rule) {
|
||||
$this->applyRule($field, $value, $rule);
|
||||
}
|
||||
}
|
||||
|
||||
return empty($this->errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation errors.
|
||||
*/
|
||||
public function getErrors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a specific rule to a field's value.
|
||||
*/
|
||||
private function applyRule(string $field, $value, string $rule): void
|
||||
{
|
||||
// Parse rule with parameters (e.g., min:8)
|
||||
$params = [];
|
||||
if (strpos($rule, ':') !== false) {
|
||||
list($rule, $paramStr) = explode(':', $rule, 2);
|
||||
$params = explode(',', $paramStr);
|
||||
}
|
||||
|
||||
switch ($rule) {
|
||||
case 'required':
|
||||
if ($value === null || trim((string)$value) === '') {
|
||||
$this->addError($field, "The {$field} field is required.");
|
||||
}
|
||||
break;
|
||||
|
||||
case 'email':
|
||||
if ($value && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->addError($field, "The {$field} must be a valid email address.");
|
||||
}
|
||||
break;
|
||||
|
||||
case 'min':
|
||||
$min = (int)($params[0] ?? 0);
|
||||
if ($value && strlen((string)$value) < $min) {
|
||||
$this->addError($field, "The {$field} must be at least {$min} characters.");
|
||||
}
|
||||
break;
|
||||
|
||||
case 'max':
|
||||
$max = (int)($params[0] ?? 0);
|
||||
if ($value && strlen((string)$value) > $max) {
|
||||
$this->addError($field, "The {$field} must not exceed {$max} characters.");
|
||||
}
|
||||
break;
|
||||
|
||||
case 'numeric':
|
||||
if ($value && !is_numeric($value)) {
|
||||
$this->addError($field, "The {$field} must be a number.");
|
||||
}
|
||||
break;
|
||||
|
||||
case 'strong_password':
|
||||
// At least 8 chars, 1 uppercase, 1 lowercase, 1 number
|
||||
if ($value && !preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $value)) {
|
||||
$this->addError($field, "The {$field} must be at least 8 characters long and contain uppercase, lowercase, and a number.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private function addError(string $field, string $message): void
|
||||
{
|
||||
if (!isset($this->errors[$field])) {
|
||||
$this->errors[$field] = [];
|
||||
}
|
||||
$this->errors[$field][] = $message;
|
||||
}
|
||||
}
|
||||
42
backend/app/Middlewares/AuthMiddleware.php
Normal file
42
backend/app/Middlewares/AuthMiddleware.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Security;
|
||||
|
||||
class AuthMiddleware
|
||||
{
|
||||
/**
|
||||
* Verifies the JWT token and populates request properties.
|
||||
*/
|
||||
public function handle(Request $request, Response $response): void
|
||||
{
|
||||
$authHeader = $request->getHeader('authorization', '');
|
||||
|
||||
if (!$authHeader || !preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) {
|
||||
$response->json(['error' => 'Unauthorized', 'message' => 'Token not provided or invalid format'], 401);
|
||||
exit;
|
||||
}
|
||||
|
||||
$token = $matches[1];
|
||||
$payload = Security::verifyJWT($token);
|
||||
|
||||
if (!$payload) {
|
||||
$response->json(['error' => 'Unauthorized', 'message' => 'Invalid or expired token'], 401);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate required custom payload elements
|
||||
if (!isset($payload['user_id']) || !isset($payload['company_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'];
|
||||
}
|
||||
}
|
||||
53
backend/app/Middlewares/SecurityMiddleware.php
Normal file
53
backend/app/Middlewares/SecurityMiddleware.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middlewares;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
|
||||
class SecurityMiddleware
|
||||
{
|
||||
/**
|
||||
* Applies OWASP security headers and sanitizes incoming body/query parameters
|
||||
* to protect against XSS and basic Injection.
|
||||
*/
|
||||
public function handle(Request $request, Response $response): void
|
||||
{
|
||||
// 1. Set OWASP Security Headers
|
||||
$response->setHeader('X-Frame-Options', 'DENY'); // Prevent Clickjacking
|
||||
$response->setHeader('X-XSS-Protection', '1; mode=block'); // Prevent Cross-Site Scripting (XSS)
|
||||
$response->setHeader('X-Content-Type-Options', 'nosniff'); // Prevent MIME-sniffing
|
||||
$response->setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); // HSTS
|
||||
$response->setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; object-src 'none';"); // CSP
|
||||
|
||||
// 2. Input Sanitization to prevent XSS (Recursive)
|
||||
$body = $request->getBody();
|
||||
if (is_array($body)) {
|
||||
$request->setBody($this->sanitizeArray($body));
|
||||
}
|
||||
|
||||
$query = $request->getQueryParams();
|
||||
if (is_array($query)) {
|
||||
$request->setQueryParams($this->sanitizeArray($query));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sanitize input arrays
|
||||
*/
|
||||
private function sanitizeArray(array $data): array
|
||||
{
|
||||
$sanitized = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$sanitized[$key] = $this->sanitizeArray($value);
|
||||
} elseif (is_string($value)) {
|
||||
// Strip HTML tags and convert special characters to HTML entities
|
||||
$sanitized[$key] = htmlspecialchars(strip_tags(trim($value)), ENT_QUOTES, 'UTF-8');
|
||||
} else {
|
||||
$sanitized[$key] = $value;
|
||||
}
|
||||
}
|
||||
return $sanitized;
|
||||
}
|
||||
}
|
||||
8
backend/app/Models/Company.php
Normal file
8
backend/app/Models/Company.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Company extends BaseModel
|
||||
{
|
||||
protected static string $table = 'companies';
|
||||
}
|
||||
45
backend/app/Models/User.php
Normal file
45
backend/app/Models/User.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Database;
|
||||
use App\Core\Security;
|
||||
|
||||
class User extends BaseModel
|
||||
{
|
||||
protected static string $table = 'users';
|
||||
|
||||
/**
|
||||
* Find user securely by email using Blind Index (HMAC-SHA256 Hash).
|
||||
*/
|
||||
public static function findByEmail(string $email): ?array
|
||||
{
|
||||
$emailHash = Security::blindIndex($email);
|
||||
|
||||
return Database::selectOne(
|
||||
"SELECT * FROM users WHERE email_hash = :hash LIMIT 1",
|
||||
['hash' => $emailHash]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user securely (encrypting sensitive data and generating hashes).
|
||||
*/
|
||||
public static function createSecure(array $data): string
|
||||
{
|
||||
// 1. Hash password
|
||||
$data['password'] = Security::hashPassword($data['password']);
|
||||
|
||||
// 2. Generate blind index for email lookup
|
||||
$data['email_hash'] = Security::blindIndex($data['email']);
|
||||
|
||||
// 3. Encrypt the email itself using AES-256-GCM
|
||||
$data['email'] = Security::encrypt($data['email']);
|
||||
|
||||
// 4. Ensure default values if none provided
|
||||
$data['role'] = $data['role'] ?? 'admin';
|
||||
$data['status'] = $data['status'] ?? 'active';
|
||||
|
||||
return self::create($data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user