'invalid_verification_payload'], 400); } if ($displayName !== null && (!is_string($displayName) || mb_strlen($displayName) > 100)) { api_json(['error' => 'invalid_display_name'], 400); } if ($deviceUuid !== null && (!is_string($deviceUuid) || !preg_match('/^[0-9a-f-]{36}$/i', $deviceUuid))) { api_json(['error' => 'invalid_device_uuid'], 400); } if ($platform !== null && !in_array($platform, ['ios', 'android', 'web'], true)) { api_json(['error' => 'invalid_platform'], 400); } try { AppConfig::loadEnvironment(); $hashKey = AppConfig::required('OTP_HASH_KEY'); if (strlen($hashKey) < 32) { throw new RuntimeException('OTP_HASH_KEY must be at least 32 bytes'); } $jwtKey = AppConfig::required('JWT_SIGNING_KEY'); if (strlen($jwtKey) < 32) { throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes'); } $db = Database::getInstance(); $connection = $db->getConnection(); $connection->begin_transaction(); $challengeQuery = $db->prepare('SELECT challenge_uuid, phone_e164, purpose, code_digest, attempt_count, max_attempts, device_uuid FROM otp_challenges WHERE challenge_uuid = ? AND consumed_at IS NULL AND expires_at > UTC_TIMESTAMP() FOR UPDATE'); $challengeQuery->bind_param('s', $challengeId); $challengeQuery->execute(); $challenge = $challengeQuery->get_result()->fetch_assoc(); $challengeQuery->close(); if (!$challenge || (int) $challenge['attempt_count'] >= (int) $challenge['max_attempts']) { $connection->rollback(); api_json(['error' => 'invalid_or_expired_challenge'], 400); } if ($deviceUuid !== null && $challenge['device_uuid'] !== null && !hash_equals($challenge['device_uuid'], $deviceUuid)) { $connection->rollback(); api_json(['error' => 'invalid_verification_payload'], 400); } $expectedDigest = hash_hmac('sha256', $challengeId . '|' . $challenge['phone_e164'] . '|' . $code, $hashKey); if (!hash_equals($challenge['code_digest'], $expectedDigest)) { $fail = $db->prepare('UPDATE otp_challenges SET attempt_count = attempt_count + 1 WHERE challenge_uuid = ?'); $fail->bind_param('s', $challengeId); $fail->execute(); $fail->close(); $connection->commit(); api_json(['error' => 'invalid_code'], 400); } $consume = $db->prepare('UPDATE otp_challenges SET consumed_at = UTC_TIMESTAMP() WHERE challenge_uuid = ? AND consumed_at IS NULL'); $consume->bind_param('s', $challengeId); $consume->execute(); if ($consume->affected_rows !== 1) { $consume->close(); $connection->rollback(); api_json(['error' => 'invalid_or_expired_challenge'], 400); } $consume->close(); $userQuery = $db->prepare('SELECT id, uuid, account_role, is_active FROM users WHERE phone_e164 = ? LIMIT 1 FOR UPDATE'); $userQuery->bind_param('s', $challenge['phone_e164']); $userQuery->execute(); $user = $userQuery->get_result()->fetch_assoc(); $userQuery->close(); if (!$user && $challenge['purpose'] === 'login') { $connection->rollback(); api_json(['error' => 'verification_failed'], 400); } if ($user && !(bool) $user['is_active']) { $connection->rollback(); api_json(['error' => 'account_inactive'], 403); } if (!$user) { $userUuid = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff)); $userInsert = $db->prepare('INSERT INTO users (uuid, phone_e164, phone_verified_at, full_name, account_role, is_active) VALUES (?, ?, UTC_TIMESTAMP(), ?, \'member\', 1)'); $fullName = $displayName ?: 'مستخدم SportPath'; $userInsert->bind_param('sss', $userUuid, $challenge['phone_e164'], $fullName); $userInsert->execute(); $userId = (int) $connection->insert_id; $userInsert->close(); $user = ['id' => $userId, 'uuid' => $userUuid, 'account_role' => 'member']; } else { $userId = (int) $user['id']; $verified = $db->prepare('UPDATE users SET phone_verified_at = COALESCE(phone_verified_at, UTC_TIMESTAMP()) WHERE id = ?'); $verified->bind_param('i', $userId); $verified->execute(); $verified->close(); } $resolvedDevice = $deviceUuid ?: ($challenge['device_uuid'] ?: null); if ($resolvedDevice !== null) { $devicePlatform = $platform ?: 'web'; $deviceInsert = $db->prepare('INSERT INTO user_devices (user_id, device_uuid, platform, display_name, last_seen_at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE platform = VALUES(platform), revoked_at = NULL, last_seen_at = UTC_TIMESTAMP()'); $deviceName = $displayName ?: null; $deviceInsert->bind_param('isss', $userId, $resolvedDevice, $devicePlatform, $deviceName); $deviceInsert->execute(); $deviceInsert->close(); } $sessionId = sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff)); $familyId = $sessionId; $refreshToken = bin2hex(random_bytes(48)); $refreshDigest = hash('sha256', $refreshToken); $refreshDays = max(1, min(90, AppConfig::integer('JWT_REFRESH_TTL_DAYS', 30))); $sessionInsert = $db->prepare('INSERT INTO auth_sessions (session_uuid, family_uuid, user_id, device_uuid, refresh_token_digest, expires_at) VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP() + INTERVAL ? DAY)'); $sessionInsert->bind_param('ssissi', $sessionId, $familyId, $userId, $resolvedDevice, $refreshDigest, $refreshDays); $sessionInsert->execute(); $sessionInsert->close(); $connection->commit(); $accessTtl = max(60, min(3600, AppConfig::integer('JWT_ACCESS_TTL_SECONDS', 900))); api_json([ 'user' => ['id' => $userId, 'uuid' => $user['uuid'], 'phone_e164' => $challenge['phone_e164'], 'account_role' => $user['account_role']], 'access_token' => JwtToken::issue($userId, $sessionId, $accessTtl), 'token_type' => 'Bearer', 'expires_in_seconds' => $accessTtl, 'refresh_token' => $refreshToken, 'refresh_expires_in_days' => $refreshDays, ]); } catch (Throwable $exception) { if (isset($connection) && $connection instanceof mysqli) { try { $connection->rollback(); } catch (Throwable $ignored) {} } error_log('OTP verification failed: ' . $exception->getMessage()); api_json(['error' => 'service_unavailable'], 503); }