Files
musadaq-saas/app/Modules/Auth/AuthService.php

191 lines
6.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Auth;
use App\Modules\Users\UserModel;
use App\Modules\Tenants\TenantModel;
use App\Modules\Subscriptions\SubscriptionModel;
use App\Services\Security\JwtService;
use Ramsey\Uuid\Uuid;
use Exception;
final class AuthService
{
public function __construct(
private readonly UserModel $userModel,
private readonly JwtService $jwtService,
private readonly TenantModel $tenantModel,
private readonly SubscriptionModel $subscriptionModel
) {}
public function login(string $email, string $password): array
{
$user = $this->userModel->findByEmail($email);
if ($user) {
if ($user['locked_until'] && strtotime($user['locked_until']) > time()) {
throw new \App\Core\Exceptions\HttpException("الحساب مقفل مؤقتاً لعدة محاولات فاشلة، حاول مجدداً لاحقاً", "ACCOUNT_LOCKED", 429);
}
}
if (!$user || !password_verify($password, $user['password_hash'])) {
if ($user) {
$failedCount = (int)($user['failed_login_count'] ?? 0) + 1;
$lockedUntil = null;
if ($failedCount >= 5) {
$lockedUntil = date('Y-m-d H:i:s', strtotime('+15 minutes'));
error_log("[SECURITY] Account locked due to brute force: {$email}");
}
$this->userModel->update($user['id'], [
'failed_login_count' => $failedCount,
'locked_until' => $lockedUntil
]);
}
error_log("[SECURITY] Failed login attempt for email: {$email}");
throw new \App\Core\Exceptions\HttpException("البريد الإلكتروني أو كلمة المرور غير صحيحة", "INVALID_CREDENTIALS", 401);
}
if (!$user['is_active']) {
throw new \App\Core\Exceptions\HttpException("هذا الحساب معطل حالياً", "ACCOUNT_DISABLED", 403);
}
// Reset failed login count on successful login
if ($user['failed_login_count'] > 0) {
$this->userModel->update($user['id'], ['failed_login_count' => 0, 'locked_until' => null]);
}
$accessToken = $this->jwtService->issueAccessToken([
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
'role' => $user['role'],
'assigned_company_id' => $user['assigned_company_id']
]);
$refreshToken = $this->jwtService->issueRefreshToken($user['id']);
// Update refresh token hash in DB
$this->userModel->update($user['id'], [
'refresh_token_hash' => password_hash($refreshToken, PASSWORD_BCRYPT),
'last_login_at' => date('Y-m-d H:i:s'),
'last_login_ip' => $_SERVER['REMOTE_ADDR'] ?? null
]);
return [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'user' => $user
];
}
public function refresh(string $refreshToken): array
{
$parts = explode('.', $refreshToken);
if (count($parts) !== 2) {
throw new Exception("رمز التجديد غير صالحة");
}
[$userId, $random] = $parts;
$user = $this->userModel->find($userId);
if (!$user || !$user['is_active']) {
throw new Exception("المستخدم غير موجود أو معطل");
}
if (!$user['refresh_token_hash'] || !password_verify($refreshToken, $user['refresh_token_hash'])) {
throw new Exception("جلسة العمل منتهية، يرجى تسجيل الدخول مرة أخرى");
}
return $this->createSession($user);
}
public function createSession(array $user): array
{
$accessToken = $this->jwtService->issueAccessToken([
'user_id' => $user['id'],
'tenant_id' => $user['tenant_id'],
'role' => $user['role'],
'assigned_company_id' => $user['assigned_company_id']
]);
$newRefreshToken = $this->jwtService->issueRefreshToken($user['id']);
$this->userModel->update($user['id'], [
'refresh_token_hash' => password_hash($newRefreshToken, PASSWORD_BCRYPT)
]);
return [
'access_token' => $accessToken,
'refresh_token' => $newRefreshToken,
'user' => [
'id' => $user['id'],
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
'assigned_company_id' => $user['assigned_company_id'],
'totp_enabled' => (bool)($user['totp_enabled'] ?? false)
]
];
}
public function register(array $data): array
{
// 1. Check if tenant already exists
if ($this->tenantModel->findByEmail($data['email'])) {
throw new Exception("هذا البريد الإلكتروني مسجل مسبقاً");
}
$db = \App\Core\Database::getInstance();
try {
$db->beginTransaction();
$tenantId = Uuid::uuid4()->toString();
$userId = Uuid::uuid4()->toString();
// 2. Create Tenant
$this->tenantModel->create([
'id' => $tenantId,
'name' => $data['name'] ?? 'مساحة عمل جديدة',
'email' => $data['email'],
'status' => 'trial',
'trial_ends_at' => date('Y-m-d H:i:s', strtotime('+14 days'))
]);
// 3. Create Subscription
$this->subscriptionModel->create([
'tenant_id' => $tenantId,
'plan' => 'basic',
'status' => 'trial'
]);
// 4. Create User
$this->userModel->create([
'id' => $userId,
'tenant_id' => $tenantId,
'name' => $data['name'] ?? 'مسؤول النظام',
'email' => $data['email'],
'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID),
'role' => 'admin',
'is_active' => 1
]);
$db->commit();
return $this->login($data['email'], $data['password']);
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
}
public function logout(string $jti, int $remaining): void
{
// Blacklist the JTI for its remaining lifetime
try {
$redis = \App\Core\Redis::getInstance();
$redis->setex('jwt_blacklist:' . $jti, max($remaining, 1), '1');
} catch (\Throwable $e) {
error_log('[AUTH] Could not blacklist JTI: ' . $e->getMessage());
}
}
}