Files
flash-call-otp/backend/api/verify-otp.php
2026-05-23 18:23:34 +03:00

155 lines
4.3 KiB
PHP

<?php
/**
* POST /api/verify-otp
*
* Verify an OTP code submitted by the user (Flutter app).
*
* Request body:
* {
* "phone": "+9627XXXXXXXX",
* "otp": "4829",
* "app_key": "SECRET_APP_KEY"
* }
*/
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-App-Key');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'method_not_allowed']);
exit;
}
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Redis.php';
require_once __DIR__ . '/../includes/RateLimit.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Logger.php';
// Authenticate — requires app key
Auth::requireAuth('app');
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['phone']) || !isset($input['otp'])) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'missing_phone_or_otp']);
RequestLogger::log('verify-otp', 'POST', $input, 400, 'missing_fields');
exit;
}
$phone = trim($input['phone']);
$otp = trim($input['otp']);
// Validate phone format
if (!preg_match('/^\+[1-9]\d{6,14}$/', $phone)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'invalid_phone_format']);
RequestLogger::log('verify-otp', 'POST', $input, 400, 'invalid_phone');
exit;
}
// Validate OTP format (3 or 4 digits)
if (!preg_match('/^\d{3,4}$/', $otp)) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'invalid_otp_format']);
RequestLogger::log('verify-otp', 'POST', $input, 400, 'invalid_otp_format');
exit;
}
$redis = RedisClient::getInstance();
$db = Database::getInstance();
$redisKey = "otp:{$phone}";
// Check if OTP exists in Redis
$stored = $redis->get($redisKey);
if ($stored === false || $stored === null) {
// OTP expired or never existed
echo json_encode([
'success' => false,
'message' => 'expired',
]);
RequestLogger::log('verify-otp', 'POST', $input, 200, 'expired');
exit;
}
// Decode stored data
if (is_string($stored)) {
$otpData = json_decode($stored, true);
} else {
$otpData = $stored;
}
if (!is_array($otpData) || !isset($otpData['otp'])) {
// Corrupted data — clean up
$redis->del($redisKey);
echo json_encode(['success' => false, 'message' => 'expired']);
RequestLogger::log('verify-otp', 'POST', $input, 200, 'corrupted_data');
exit;
}
// Check max attempts
if (isset($otpData['attempts']) && (int) $otpData['attempts'] >= MAX_OTP_ATTEMPTS) {
$redis->del($redisKey);
echo json_encode([
'success' => false,
'message' => 'max_attempts',
]);
RequestLogger::log('verify-otp', 'POST', $input, 200, 'max_attempts');
exit;
}
// Increment attempt counter
$otpData['attempts'] = (int) ($otpData['attempts'] ?? 0) + 1;
$ttl = $redis->ttl($redisKey);
if ($ttl > 0) {
$redis->setex($redisKey, $ttl, json_encode($otpData));
}
// Timing-safe comparison
if (hash_equals($otpData['otp'], $otp)) {
// Success — clean up Redis
$redis->del($redisKey);
// Update database
try {
$stmt = $db->prepare(
"UPDATE otp_requests
SET verified_at = NOW(), status = 'verified'
WHERE phone = ? AND otp_code = ? AND verified_at IS NULL
ORDER BY created_at DESC
LIMIT 1"
);
$stmt->execute([$phone, $otp]);
} catch (\Throwable $e) {
// Non-critical — OTP is already verified via Redis
error_log('verify-otp DB update error: ' . $e->getMessage());
}
echo json_encode([
'success' => true,
'message' => 'verified',
]);
RequestLogger::log('verify-otp', 'POST', $input, 200);
} else {
// Wrong OTP
$remainingAttempts = MAX_OTP_ATTEMPTS - (int) $otpData['attempts'];
echo json_encode([
'success' => false,
'message' => 'invalid_otp',
'remaining_attempts' => max(0, $remainingAttempts),
]);
RequestLogger::log('verify-otp', 'POST', $input, 200, 'invalid_otp');
}