225 lines
9.0 KiB
PHP
225 lines
9.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controllers;
|
|
|
|
use Core\Request;
|
|
use Core\Response;
|
|
use Core\Security;
|
|
use Core\Database;
|
|
use App\Services\OtpIqService;
|
|
use PDO;
|
|
|
|
class AuthController
|
|
{
|
|
/**
|
|
* POST /api/v1/auth/request-otp
|
|
* Supports SILENT_CALL (zero cost), TELEGRAM (free), WHATSAPP_CAPTCHA, and SMS_OTPIQ.
|
|
*/
|
|
public function requestOtp(Request $request): void
|
|
{
|
|
$phone = trim((string)$request->get('phone'));
|
|
$channel = strtoupper(trim((string)$request->get('channel', 'SILENT_CALL')));
|
|
|
|
if (empty($phone) || strlen($phone) < 8) {
|
|
Response::error('Valid Iraqi phone number is required.');
|
|
}
|
|
|
|
$validChannels = ['SILENT_CALL', 'TELEGRAM', 'WHATSAPP_CAPTCHA', 'SMS_OTPIQ'];
|
|
if (!in_array($channel, $validChannels, true)) {
|
|
$channel = 'SILENT_CALL';
|
|
}
|
|
|
|
// Generate 6-digit cryptographic OTP code
|
|
$otp = (string)random_int(100000, 999999);
|
|
$otpHash = hash('sha256', $otp);
|
|
$pdo = Database::getConnection();
|
|
|
|
// 1. Record OTP dispatch in otp_dispatches table
|
|
$dispStmt = $pdo->prepare('
|
|
INSERT INTO otp_dispatches (phone, channel, otp_code_hash, status, cost_iqd, expires_at, created_at)
|
|
VALUES (:phone, :channel, :hash, "DISPATCHED", :cost, DATE_ADD(NOW(), INTERVAL 5 MINUTE), NOW())
|
|
');
|
|
$costIqd = ($channel === 'SMS_OTPIQ') ? 10.00 : 0.00;
|
|
$dispStmt->execute([
|
|
':phone' => $phone,
|
|
':channel' => $channel,
|
|
':hash' => $otpHash,
|
|
':cost' => $costIqd,
|
|
]);
|
|
|
|
// 2. Dispatch via requested channel
|
|
$channelPayload = [];
|
|
|
|
if ($channel === 'SILENT_CALL') {
|
|
// Enqueue task for Android gateway caller phone
|
|
$taskStmt = $pdo->prepare('
|
|
INSERT INTO gateway_tasks (task_type, target_phone, otp_code, status, timeout_seconds, created_at)
|
|
VALUES ("FLASH_CALL", :phone, :otp, "PENDING", 30, NOW())
|
|
');
|
|
$taskStmt->execute([':phone' => $phone, ':otp' => $otp]);
|
|
$channelPayload['message'] = 'ستصلك رنة هاتفية سريعة خاطفة للتحقق مجاناً.';
|
|
} elseif ($channel === 'TELEGRAM') {
|
|
$channelPayload['telegram_url'] = 'https://t.me/UrukPrizeBot?start=otp_' . $otp;
|
|
$channelPayload['message'] = 'تم توجيه رمز التحقق إلى بوت التيليغرام الرسمي للجائزة.';
|
|
} elseif ($channel === 'WHATSAPP_CAPTCHA') {
|
|
$channelPayload['captcha_image'] = NabihCaptchaService::generateOtpImage($otp);
|
|
$channelPayload['message'] = 'تم إنشاء صورة كابتشا مشفرة لإرسالها عبر الواتساب.';
|
|
} elseif ($channel === 'SMS_OTPIQ') {
|
|
$otpService = new OtpIqService();
|
|
$otpService->sendOtp($phone, $otp);
|
|
$channelPayload['message'] = 'تم إرسال رمز التحقق عبر رسالة SMS رسمية.';
|
|
}
|
|
|
|
// Audit log
|
|
$stmt = $pdo->prepare('
|
|
INSERT INTO audit_logs (user_id, action, endpoint, method, ip_address, response_code, payload_snippet, created_at)
|
|
VALUES (0, "OTP_REQUESTED", "/api/v1/auth/request-otp", "POST", :ip, 200, :payload, NOW())
|
|
');
|
|
$stmt->execute([
|
|
':ip' => $request->getIp(),
|
|
':payload' => json_encode(['phone' => $phone, 'channel' => $channel]),
|
|
]);
|
|
|
|
Response::success(array_merge([
|
|
'phone' => $phone,
|
|
'channel' => $channel,
|
|
'expires_in_seconds' => 300,
|
|
'mock_otp' => getenv('APP_ENV') === 'development' ? $otp : null,
|
|
], $channelPayload), 'Verification OTP dispatched via ' . $channel);
|
|
}
|
|
|
|
/**
|
|
* POST /api/v1/auth/verify-otp
|
|
* Receives phone, otp, device_fingerprint, device_model, and optional full_name.
|
|
*/
|
|
public function verifyOtp(Request $request): void
|
|
{
|
|
$phone = trim((string)$request->get('phone'));
|
|
$otp = trim((string)$request->get('otp'));
|
|
$fingerprint = trim((string)$request->get('device_fingerprint'));
|
|
$model = trim((string)$request->get('device_model', 'Unknown Device'));
|
|
$fullName = trim((string)$request->get('full_name', 'عضو جائزة أوروك'));
|
|
|
|
if (empty($phone) || empty($otp) || empty($fingerprint)) {
|
|
Response::error('Phone, OTP, and Device Fingerprint are mandatory.');
|
|
}
|
|
|
|
$pdo = Database::getConnection();
|
|
|
|
// 1. Verify OTP code against otp_dispatches
|
|
$otpHash = hash('sha256', $otp);
|
|
$isDev = getenv('APP_ENV') === 'development' && $otp === '123456';
|
|
|
|
if (!$isDev) {
|
|
$verStmt = $pdo->prepare('
|
|
SELECT id FROM otp_dispatches
|
|
WHERE phone = :phone AND otp_code_hash = :hash AND expires_at > NOW() AND status = "DISPATCHED"
|
|
ORDER BY id DESC LIMIT 1
|
|
');
|
|
$verStmt->execute([':phone' => $phone, ':hash' => $otpHash]);
|
|
$dispRecord = $verStmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$dispRecord) {
|
|
// Fallback check audit_logs for backward compatibility
|
|
$fallbackStmt = $pdo->prepare('
|
|
SELECT id FROM audit_logs
|
|
WHERE endpoint = "/api/v1/auth/request-otp"
|
|
AND payload_snippet LIKE :needle
|
|
AND created_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)
|
|
ORDER BY id DESC LIMIT 1
|
|
');
|
|
$fallbackStmt->execute([':needle' => '%"phone":"' . $phone . '","otp":"' . $otp . '"%']);
|
|
$auditRecord = $fallbackStmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$auditRecord) {
|
|
Response::error('Invalid or expired OTP code.', 401);
|
|
}
|
|
} else {
|
|
$updDisp = $pdo->prepare('UPDATE otp_dispatches SET status = "VERIFIED", verified_at = NOW() WHERE id = :id');
|
|
$updDisp->execute([':id' => $dispRecord['id']]);
|
|
}
|
|
}
|
|
|
|
// 2. Check or find user
|
|
$stmt = $pdo->prepare('SELECT id, phone, full_name, role, status FROM users WHERE phone = :phone LIMIT 1');
|
|
$stmt->execute([':phone' => $phone]);
|
|
$user = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$user) {
|
|
// Register new user
|
|
$ins = $pdo->prepare('
|
|
INSERT INTO users (phone, full_name, role, status, created_at)
|
|
VALUES (:phone, :name, "MEMBER", "ACTIVE", NOW())
|
|
');
|
|
$ins->execute([':phone' => $phone, ':name' => $fullName]);
|
|
$userId = (int)$pdo->lastInsertId();
|
|
$role = 'MEMBER';
|
|
} else {
|
|
$userId = (int)$user['id'];
|
|
$role = $user['role'];
|
|
}
|
|
|
|
// Generate per-device secret key and session token
|
|
$deviceSecret = Security::generateRandomHex(32);
|
|
$sessionToken = Security::generateRandomHex(32);
|
|
|
|
// Bind device fingerprint and store secret in user_security table
|
|
$secStmt = $pdo->prepare('
|
|
INSERT INTO user_security (user_id, device_fingerprint, device_secret, device_model, last_token, last_ip, last_active_at)
|
|
VALUES (:uid, :fp, :secret, :model, :token, :ip, NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
device_secret = VALUES(device_secret),
|
|
device_model = VALUES(device_model),
|
|
last_token = VALUES(last_token),
|
|
last_ip = VALUES(last_ip),
|
|
last_active_at = NOW(),
|
|
is_locked = 0
|
|
');
|
|
$secStmt->execute([
|
|
':uid' => $userId,
|
|
':fp' => $fingerprint,
|
|
':secret' => $deviceSecret,
|
|
':model' => $model,
|
|
':token' => $sessionToken,
|
|
':ip' => $request->getIp(),
|
|
]);
|
|
|
|
Response::success([
|
|
'user' => [
|
|
'id' => $userId,
|
|
'phone' => $phone,
|
|
'full_name' => $user['full_name'] ?? $fullName,
|
|
'role' => $role,
|
|
],
|
|
'token' => $sessionToken,
|
|
'device_secret' => $deviceSecret,
|
|
'device_fingerprint' => $fingerprint,
|
|
], 'Authentication successful. Device bound and secured.');
|
|
}
|
|
|
|
/**
|
|
* GET /api/v1/auth/profile
|
|
*/
|
|
public function getProfile(Request $request): void
|
|
{
|
|
$userId = (int)$request->getHeader('x-user-id');
|
|
$pdo = Database::getConnection();
|
|
|
|
$stmt = $pdo->prepare('
|
|
SELECT u.id, u.phone, u.full_name, u.role, u.status, u.created_at,
|
|
s.membership_number, s.status AS subscription_status, s.expires_at
|
|
FROM users u
|
|
LEFT JOIN subscriptions s ON s.user_id = u.id AND s.status = "ACTIVE"
|
|
WHERE u.id = :uid
|
|
ORDER BY s.id DESC
|
|
LIMIT 1
|
|
');
|
|
$stmt->execute([':uid' => $userId]);
|
|
$profile = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
Response::success($profile, 'User profile retrieved.');
|
|
}
|
|
}
|