src/Controller/ApiController.php line 50

Open in your IDE?
  1. <?php
  2. // src/Controller/ApiController.php
  3. namespace App\Controller;
  4. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  5. use Symfony\Component\HttpFoundation\JsonResponse;
  6. use Symfony\Component\HttpFoundation\Request;
  7. use Symfony\Component\Routing\Annotation\Route;
  8. use Symfony\Component\Validator\Validator\ValidatorInterface;
  9. use Symfony\Component\Validator\Constraints as Assert;
  10. use Psr\Log\LoggerInterface;
  11. use Doctrine\ORM\EntityManagerInterface;
  12. /**
  13. * API Controller for DS-C4RGO device registration and activation.
  14. *
  15. * Endpoints:
  16. * POST /api/v1/reg - Device activation (new Rust client: client_code/email/license)
  17. * POST /api/v1/reg/reset - Remove machine key (reset for re-registration)
  18. * GET /api/v1/ping - Connectivity check
  19. * POST /api/v1/reg/legacy - Legacy C++ activation (hkey only) - deprecated
  20. *
  21. * @Route("/api/v1", name="api_")
  22. */
  23. class ApiController extends AbstractController
  24. {
  25. /** Max registration attempts per IP within the rate limit window. */
  26. private const RATE_LIMIT_MAX_ATTEMPTS = 10;
  27. /** Rate limit window in seconds. */
  28. private const RATE_LIMIT_WINDOW_SECS = 300;
  29. private LoggerInterface $logger;
  30. private EntityManagerInterface $em;
  31. public function __construct(LoggerInterface $logger, EntityManagerInterface $em)
  32. {
  33. $this->logger = $logger;
  34. $this->em = $em;
  35. }
  36. // ─── Ping ────────────────────────────────────────────────────────
  37. /**
  38. * Connectivity check used by c4rgo-reg-ui before starting registration.
  39. *
  40. * @Route("/ping", name="ping", methods={"GET"})
  41. */
  42. public function ping(Request $request): JsonResponse
  43. {
  44. $serviceUp = $this->checkServiceStatus();
  45. $this->logger->info('Ping', [
  46. 'ip' => $request->getClientIp(),
  47. 'status' => $serviceUp ? 'ok' : 'degraded',
  48. ]);
  49. return new JsonResponse([
  50. 'status' => $serviceUp ? 'ok' : 'degraded',
  51. 'message' => 'DS-C4RGO API is running',
  52. 'timestamp' => time(),
  53. 'service_available' => $serviceUp,
  54. 'version' => '1.0.0',
  55. ]);
  56. }
  57. // ─── Registration (new Rust client) ──────────────────────────────
  58. /**
  59. * Device activation endpoint.
  60. *
  61. * Called by c4rgo-activate init with:
  62. * { "client_code", "email", "license", "cpu_serial", "mac_address" }
  63. *
  64. * Returns the passepartout key on success.
  65. *
  66. * @Route("/reg", name="register", methods={"POST"})
  67. */
  68. public function register(Request $request, ValidatorInterface $validator): JsonResponse
  69. {
  70. $data = $this->parseJsonBody($request);
  71. if ($data instanceof JsonResponse) {
  72. return $data;
  73. }
  74. // --- Input validation ---
  75. $constraints = new Assert\Collection([
  76. 'fields' => [
  77. 'client_code' => [
  78. new Assert\NotBlank(),
  79. new Assert\Type('string'),
  80. new Assert\Length(['max' => 64]),
  81. ],
  82. 'email' => [
  83. new Assert\NotBlank(),
  84. new Assert\Email(),
  85. new Assert\Length(['max' => 255]),
  86. ],
  87. 'license' => [
  88. new Assert\NotBlank(),
  89. new Assert\Type('string'),
  90. new Assert\Length(['max' => 255]),
  91. ],
  92. 'cpu_serial' => [
  93. new Assert\NotBlank(),
  94. new Assert\Regex([
  95. 'pattern' => '/^[0-9a-fA-F]{8,32}$/',
  96. 'message' => 'cpu_serial must be a hex string (8-32 chars)',
  97. ]),
  98. ],
  99. 'mac_address' => [
  100. new Assert\NotBlank(),
  101. new Assert\Regex([
  102. 'pattern' => '/^[0-9a-fA-F]{12}$/',
  103. 'message' => 'mac_address must be 12 hex chars without separators',
  104. ]),
  105. ],
  106. ],
  107. 'allowExtraFields' => false,
  108. ]);
  109. $validationError = $this->validateData($data, $constraints, $validator);
  110. if ($validationError !== null) {
  111. return $validationError;
  112. }
  113. // --- Rate limiting ---
  114. // NOTE: Current implementation uses audit_log table for rate limiting.
  115. // For better performance at scale, consider migrating to Symfony RateLimiter
  116. // component with a Redis backend.
  117. $rateLimitResponse = $this->checkRateLimit($request, 'reg');
  118. if ($rateLimitResponse !== null) {
  119. return $rateLimitResponse;
  120. }
  121. // --- Business logic ---
  122. try {
  123. // 1. Find machine by client_code + license
  124. $machine = $this->findMachine($data['client_code'], $data['license']);
  125. if ($machine === null) {
  126. $this->logAudit('register', $data, $request, 'INVALID_KEY');
  127. return $this->errorResponse('INVALID_KEY',
  128. 'License not found or does not match client code', 403);
  129. }
  130. // 2. Verify email
  131. if (!$this->emailMatches($machine, $data['email'])) {
  132. $this->logAudit('register', $data, $request, 'INVALID_EMAIL');
  133. return $this->errorResponse('INVALID_EMAIL',
  134. 'Email does not match license registration', 403);
  135. }
  136. // 3. Verify hardware identity (cpu_serial + mac_address)
  137. $hwCheck = $this->verifyHardware($machine, $data['cpu_serial'], $data['mac_address']);
  138. if ($hwCheck !== null) {
  139. $this->logAudit('register', $data, $request, $hwCheck);
  140. return $this->errorResponse($hwCheck,
  141. 'Device hardware does not match registered machine', 403);
  142. }
  143. // 4. Retrieve passepartout (skey)
  144. $passepartout = $machine->getSkey();
  145. if (empty($passepartout)) {
  146. $this->logger->error('Passepartout (skey) is empty for machine', [
  147. 'machine_id' => $machine->getId(),
  148. ]);
  149. return $this->errorResponse('SERVER_ERROR',
  150. 'Passepartout not available for this machine', 500);
  151. }
  152. // 5. Mark license as used and record activation
  153. $this->recordActivation($machine, $data, $request);
  154. // 6. Audit log
  155. $this->logAudit('register', $data, $request, 'OK');
  156. return new JsonResponse([
  157. 'success' => true,
  158. 'key' => $passepartout,
  159. ]);
  160. } catch (\Exception $e) {
  161. $this->logger->error('Registration failed unexpectedly', [
  162. 'error' => $e->getMessage(),
  163. 'client_code' => $data['client_code'] ?? '?',
  164. ]);
  165. return $this->errorResponse('SERVER_ERROR',
  166. 'Registration failed due to internal error', 500);
  167. }
  168. }
  169. // ─── Reset ───────────────────────────────────────────────────────
  170. /**
  171. * Reset endpoint: returns the passepartout so c4rgo-activate can remove
  172. * the machine key (slot 2) and allow re-initialization.
  173. *
  174. * Called by c4rgo-activate reset with:
  175. * { "cpu_serial", "mac_address", "operation": "reset" }
  176. *
  177. * @Route("/reg/reset", name="register_reset", methods={"POST"})
  178. */
  179. public function reset(Request $request, ValidatorInterface $validator): JsonResponse
  180. {
  181. $data = $this->parseJsonBody($request);
  182. if ($data instanceof JsonResponse) {
  183. return $data;
  184. }
  185. $constraints = new Assert\Collection([
  186. 'fields' => [
  187. 'cpu_serial' => [
  188. new Assert\NotBlank(),
  189. new Assert\Regex(['pattern' => '/^[0-9a-fA-F]{8,32}$/']),
  190. ],
  191. 'mac_address' => [
  192. new Assert\NotBlank(),
  193. new Assert\Regex(['pattern' => '/^[0-9a-fA-F]{12}$/']),
  194. ],
  195. 'operation' => [
  196. new Assert\NotBlank(),
  197. new Assert\EqualTo(['value' => 'reset']),
  198. ],
  199. ],
  200. 'allowExtraFields' => false,
  201. ]);
  202. $validationError = $this->validateData($data, $constraints, $validator);
  203. if ($validationError !== null) {
  204. return $validationError;
  205. }
  206. $rateLimitResponse = $this->checkRateLimit($request, 'reset');
  207. if ($rateLimitResponse !== null) {
  208. return $rateLimitResponse;
  209. }
  210. try {
  211. // Find machine by hardware parameters
  212. $machine = $this->findMachineByHardware(
  213. $data['cpu_serial'],
  214. $data['mac_address']
  215. );
  216. if ($machine === null) {
  217. $this->logAudit('reset', $data, $request, 'DEVICE_NOT_FOUND');
  218. return $this->errorResponse('DEVICE_NOT_FOUND',
  219. 'No registered machine matches the provided hardware parameters', 403);
  220. }
  221. $passepartout = $machine->getSkey();
  222. if (empty($passepartout)) {
  223. return $this->errorResponse('SERVER_ERROR',
  224. 'Passepartout not available for this machine', 500);
  225. }
  226. $this->logAudit('reset', $data, $request, 'OK');
  227. return new JsonResponse([
  228. 'success' => true,
  229. 'key' => $passepartout,
  230. ]);
  231. } catch (\Exception $e) {
  232. $this->logger->error('Reset failed unexpectedly', [
  233. 'error' => $e->getMessage(),
  234. ]);
  235. return $this->errorResponse('SERVER_ERROR',
  236. 'Reset failed due to internal error', 500);
  237. }
  238. }
  239. // ─── Legacy endpoint (C++ compatibility) ─────────────────────────
  240. /**
  241. * Legacy registration used by the old C++ c4rgoinit.
  242. * Accepts { "hkey": "..." } and returns { "success": true, "key": "<passepartout>" }.
  243. *
  244. * @deprecated Use /reg with full credentials instead.
  245. * @Route("/reg/legacy", name="register_legacy", methods={"POST"})
  246. */
  247. public function registerLegacy(Request $request, ValidatorInterface $validator): JsonResponse
  248. {
  249. $data = $this->parseJsonBody($request);
  250. if ($data instanceof JsonResponse) {
  251. return $data;
  252. }
  253. $constraints = new Assert\Collection([
  254. 'fields' => [
  255. 'hkey' => [new Assert\NotBlank()],
  256. ],
  257. 'allowExtraFields' => false,
  258. ]);
  259. $validationError = $this->validateData($data, $constraints, $validator);
  260. if ($validationError !== null) {
  261. return $validationError;
  262. }
  263. try {
  264. $machine = $this->em->getRepository('App:Macchine')->findOneBy([
  265. 'HKey' => $data['hkey'],
  266. ]);
  267. if (!$machine) {
  268. return $this->errorResponse('DEVICE_NOT_FOUND',
  269. 'No machine matches the provided hardware key', 403);
  270. }
  271. $passepartout = $machine->getSkey();
  272. if (empty($passepartout)) {
  273. return $this->errorResponse('SERVER_ERROR',
  274. 'Passepartout not available', 500);
  275. }
  276. $this->logAudit('register_legacy', ['hkey' => '***'], $request, 'OK');
  277. return new JsonResponse([
  278. 'success' => true,
  279. 'key' => $passepartout,
  280. ]);
  281. } catch (\Exception $e) {
  282. $this->logger->error('Legacy registration failed', [
  283. 'error' => $e->getMessage(),
  284. ]);
  285. return $this->errorResponse('SERVER_ERROR',
  286. 'Registration failed due to internal error', 500);
  287. }
  288. }
  289. // ─── Private helpers ─────────────────────────────────────────────
  290. /**
  291. * Parse and validate JSON body from the request.
  292. *
  293. * @return array|JsonResponse Parsed data or error response.
  294. */
  295. private function parseJsonBody(Request $request)
  296. {
  297. $content = $request->getContent();
  298. if (empty($content)) {
  299. return $this->errorResponse('INVALID_JSON', 'Request body is empty', 400);
  300. }
  301. $data = json_decode($content, true);
  302. if (!is_array($data)) {
  303. return $this->errorResponse('INVALID_JSON', 'Invalid JSON data', 400);
  304. }
  305. return $data;
  306. }
  307. /**
  308. * Validate request data against constraints.
  309. *
  310. * @return JsonResponse|null Error response or null if valid.
  311. */
  312. private function validateData(array $data, Assert\Collection $constraints, ValidatorInterface $validator): ?JsonResponse
  313. {
  314. $violations = $validator->validate($data, $constraints);
  315. if (count($violations) === 0) {
  316. return null;
  317. }
  318. $errors = [];
  319. foreach ($violations as $violation) {
  320. $errors[] = $violation->getPropertyPath() . ': ' . $violation->getMessage();
  321. }
  322. return new JsonResponse([
  323. 'success' => false,
  324. 'error' => 'VALIDATION_ERROR',
  325. 'message' => 'Validation failed: ' . implode('; ', $errors),
  326. ], 400);
  327. }
  328. /**
  329. * Build a standardized error JSON response (PRD-compliant).
  330. */
  331. private function errorResponse(string $errorCode, string $message, int $httpStatus = 403): JsonResponse
  332. {
  333. return new JsonResponse([
  334. 'success' => false,
  335. 'error' => $errorCode,
  336. 'message' => $message,
  337. ], $httpStatus);
  338. }
  339. /**
  340. * Find machine by client code and license (registration key).
  341. */
  342. private function findMachine(string $clientCode, string $license): ?object
  343. {
  344. return $this->em->getRepository('App:Macchine')->findOneBy([
  345. 'CodCliente' => $clientCode,
  346. 'Chiave' => $license,
  347. ]);
  348. }
  349. /**
  350. * Find machine by hardware parameters (for reset).
  351. */
  352. private function findMachineByHardware(string $cpuSerial, string $macAddress): ?object
  353. {
  354. return $this->em->getRepository('App:Macchine')->findOneBy([
  355. 'CpuSerial' => strtolower($cpuSerial),
  356. 'MacAddress' => strtolower($macAddress),
  357. ]);
  358. }
  359. /**
  360. * Verify that the provided email matches the machine record.
  361. * Case-insensitive comparison.
  362. */
  363. private function emailMatches(object $machine, string $email): bool
  364. {
  365. $registered = $machine->getEmail();
  366. if (empty($registered)) {
  367. return false;
  368. }
  369. return mb_strtolower($registered) === mb_strtolower($email);
  370. }
  371. /**
  372. * Verify hardware identity of the device against the machine record.
  373. *
  374. * If the machine has no HW data stored yet (first activation), we accept
  375. * any hardware and store it. On subsequent activations, we verify the match.
  376. *
  377. * @return string|null Error code on mismatch, null on success.
  378. */
  379. private function verifyHardware(object $machine, string $cpuSerial, string $macAddress): ?string
  380. {
  381. $storedSerial = $machine->getCpuSerial();
  382. $storedMac = $machine->getMacAddress();
  383. // First activation: no HW data stored yet — accept and store
  384. if (empty($storedSerial) && empty($storedMac)) {
  385. $machine->setCpuSerial(strtolower($cpuSerial));
  386. $machine->setMacAddress(strtolower($macAddress));
  387. $this->em->flush();
  388. $this->logger->info('Hardware parameters stored for first activation', [
  389. 'machine_id' => $machine->getId(),
  390. 'cpu_serial' => strtolower($cpuSerial),
  391. 'mac_address' => strtolower($macAddress),
  392. ]);
  393. return null;
  394. }
  395. // Subsequent activation: verify match
  396. if (strtolower($storedSerial) !== strtolower($cpuSerial)
  397. || strtolower($storedMac) !== strtolower($macAddress)
  398. ) {
  399. $this->logger->warning('Hardware mismatch during activation', [
  400. 'machine_id' => $machine->getId(),
  401. 'expected_serial' => $storedSerial,
  402. 'got_serial' => strtolower($cpuSerial),
  403. 'expected_mac' => $storedMac,
  404. 'got_mac' => strtolower($macAddress),
  405. ]);
  406. return 'DEVICE_MISMATCH';
  407. }
  408. return null;
  409. }
  410. /**
  411. * Record a successful activation (update machine state).
  412. */
  413. private function recordActivation(object $machine, array $data, Request $request): void
  414. {
  415. if (method_exists($machine, 'setLastActivation')) {
  416. $machine->setLastActivation(new \DateTime());
  417. }
  418. if (method_exists($machine, 'setLastActivationIp')) {
  419. $machine->setLastActivationIp($request->getClientIp());
  420. }
  421. $this->em->flush();
  422. }
  423. /**
  424. * Simple IP-based rate limiting using audit_log table.
  425. *
  426. * NOTE: For better performance at scale, consider migrating to
  427. * Symfony RateLimiter component with a Redis backend.
  428. *
  429. * @return JsonResponse|null Error response if rate limited, null otherwise.
  430. */
  431. private function checkRateLimit(Request $request, string $operation): ?JsonResponse
  432. {
  433. $ip = $request->getClientIp();
  434. $windowStart = time() - self::RATE_LIMIT_WINDOW_SECS;
  435. try {
  436. $conn = $this->em->getConnection();
  437. $count = (int) $conn->fetchOne(
  438. 'SELECT COUNT(*) FROM audit_log WHERE ip_address = ? AND operation = ? AND created_at > ?',
  439. [$ip, $operation, date('Y-m-d H:i:s', $windowStart)]
  440. );
  441. if ($count >= self::RATE_LIMIT_MAX_ATTEMPTS) {
  442. $this->logger->warning('Rate limit exceeded', [
  443. 'ip' => $ip,
  444. 'operation' => $operation,
  445. 'count' => $count,
  446. ]);
  447. return $this->errorResponse('RATE_LIMITED',
  448. 'Too many attempts, please try again later', 429);
  449. }
  450. } catch (\Exception $e) {
  451. // If audit_log table doesn't exist yet, skip rate limiting
  452. $this->logger->debug('Rate limit check skipped (table may not exist)', [
  453. 'error' => $e->getMessage(),
  454. ]);
  455. }
  456. return null;
  457. }
  458. /**
  459. * Write an audit trail entry for registration/reset operations.
  460. * Sensitive data (license, keys) is never stored in the audit log.
  461. */
  462. private function logAudit(string $operation, array $data, Request $request, string $result): void
  463. {
  464. $this->logger->info('Audit: ' . $operation, [
  465. 'result' => $result,
  466. 'ip' => $request->getClientIp(),
  467. 'client_code' => $data['client_code'] ?? null,
  468. 'cpu_serial' => $data['cpu_serial'] ?? null,
  469. 'mac_address' => $data['mac_address'] ?? null,
  470. ]);
  471. try {
  472. $conn = $this->em->getConnection();
  473. $conn->executeStatement(
  474. 'INSERT INTO audit_log (operation, result, ip_address, client_code, cpu_serial, mac_address, created_at)
  475. VALUES (?, ?, ?, ?, ?, ?, NOW())',
  476. [
  477. $operation,
  478. $result,
  479. $request->getClientIp(),
  480. $data['client_code'] ?? null,
  481. $data['cpu_serial'] ?? null,
  482. $data['mac_address'] ?? null,
  483. ]
  484. );
  485. } catch (\Exception $e) {
  486. // Audit failure must not break the operation
  487. $this->logger->warning('Audit log write failed', ['error' => $e->getMessage()]);
  488. }
  489. }
  490. /**
  491. * Check if the database connection is alive.
  492. */
  493. private function checkServiceStatus(): bool
  494. {
  495. try {
  496. $this->em->getConnection()->executeQuery('SELECT 1');
  497. return true;
  498. } catch (\Exception $e) {
  499. $this->logger->error('Service check failed', ['error' => $e->getMessage()]);
  500. return false;
  501. }
  502. }
  503. }