* @category Class * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License * @link www.splendidbear.org * @since 2026. 04. 12. */ #[AsController] class TwoFactorController extends AbstractController { public function __construct( private readonly TotpAuthenticatorInterface $totpAuthenticator, private readonly EntityManagerInterface $em, ) { } /** 2FA challenge form shown during login when TOTP is enabled. */ #[Route('/2fa', name: '2fa_login', methods: ['GET'])] public function loginForm(): Response { return $this->render('Security/2fa.html.twig'); } /** * Dummy action — the actual POST is intercepted by the scheb/2fa firewall listener * before it ever reaches a controller. The route must exist so path('2fa_login_check') * resolves in Twig. */ #[Route('/2fa_check', name: '2fa_login_check', methods: ['POST'])] public function loginCheck(): never { throw new \LogicException('This action is intercepted by the 2FA firewall listener.'); } /** Step 1 — generate a pending secret, store it in the session. */ #[Route('/profile/security/2fa/prepare', name: 'MineSeekerBundle_2fa_prepare', methods: ['POST'])] public function prepare(Request $request): Response { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); if (!$this->isCsrfTokenValid('2fa_prepare', $request->request->get('_token'))) { throw $this->createAccessDeniedException('Invalid CSRF token.'); } $secret = $this->totpAuthenticator->generateSecret(); $request->getSession()->set('totp_pending_secret', $secret); return $this->redirectToRoute('MineSeekerBundle_2fa_setup'); } /** Step 2 — show the setup page with QR code and verification form. */ #[Route('/profile/security/2fa/setup', name: 'MineSeekerBundle_2fa_setup', methods: ['GET'])] public function setup(Request $request): Response { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $pendingSecret = $request->getSession()->get('totp_pending_secret'); if (!$pendingSecret) { return $this->redirectToRoute('MineSeekerBundle_profile_security'); } return $this->render('Security/2fa_setup.html.twig', [ 'pending_secret' => $pendingSecret, ]); } /** Serve a PNG QR code for the pending TOTP secret. */ #[Route('/profile/security/2fa/qr-code', name: 'MineSeekerBundle_2fa_qr_code', methods: ['GET'])] public function qrCode(Request $request): Response { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); /** @var User $user */ $user = $this->getUser(); $pendingSecret = $request->getSession()->get('totp_pending_secret'); if (!$pendingSecret) { return new Response('', Response::HTTP_NOT_FOUND); } $provisioningUri = sprintf( 'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30', rawurlencode('Mine Seeker'), rawurlencode($user->getUserIdentifier()), $pendingSecret, rawurlencode('Mine Seeker'), ); try { $result = new Builder( writer: new PngWriter(), data: $provisioningUri, size: 220, margin: 10, )->build(); } catch (ValidationException $e) { throw new RuntimeException("Failed to generate QR code: $e->getMessage()", 0, $e); } return new Response( $result->getString(), Response::HTTP_OK, ['Content-Type' => 'image/png', 'Cache-Control' => 'no-cache, no-store, must-revalidate'], ); } /** Step 3 — verify the first code and save the secret. */ #[Route('/profile/security/2fa/enable', name: 'MineSeekerBundle_2fa_enable', methods: ['POST'])] public function enable(Request $request): Response { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); if (!$this->isCsrfTokenValid('2fa_enable', $request->request->get('_token'))) { throw $this->createAccessDeniedException('Invalid CSRF token.'); } /** @var User $user */ $user = $this->getUser(); $pendingSecret = $request->getSession()->get('totp_pending_secret'); if (!$pendingSecret) { $this->addFlash('error', 'No pending 2FA setup found. Please start again.'); return $this->redirectToRoute('MineSeekerBundle_profile_security'); } $code = $request->request->getString('_auth_code'); // Temporarily set the pending secret to verify the code $user->totpSecret = $pendingSecret; if (!$this->totpAuthenticator->checkCode($user, $code)) { $user->totpSecret = null; $this->addFlash('error', 'Invalid verification code. Please try again.'); return $this->redirectToRoute('MineSeekerBundle_2fa_setup'); } $backupCodes = $this->generateBackupCodes(); $user->backupCodes = $backupCodes; $this->em->flush(); $request->getSession()->remove('totp_pending_secret'); $this->addFlash('2fa_backup_codes', $backupCodes); $this->addFlash('success', 'Two-factor authentication has been enabled.'); return $this->redirectToRoute('MineSeekerBundle_profile_security'); } /** Disable TOTP for the current user. */ #[Route('/profile/security/2fa/disable', name: 'MineSeekerBundle_2fa_disable', methods: ['POST'])] public function disable(Request $request): Response { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); if (!$this->isCsrfTokenValid('2fa_disable', $request->request->get('_token'))) { throw $this->createAccessDeniedException('Invalid CSRF token.'); } /** @var User $user */ $user = $this->getUser(); $user->totpSecret = null; $user->backupCodes = []; $this->em->flush(); $this->addFlash('success', 'Two-factor authentication has been disabled.'); return $this->redirectToRoute('MineSeekerBundle_profile_security'); } /** Regenerate backup codes for the current user. */ #[Route( '/profile/security/2fa/backup-codes/regenerate', name: 'MineSeekerBundle_2fa_backup_regenerate', methods: ['POST'], )] public function regenerateBackupCodes(Request $request): Response { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); if (!$this->isCsrfTokenValid('2fa_backup_regen', $request->request->get('_token'))) { throw $this->createAccessDeniedException('Invalid CSRF token.'); } /** @var User $user */ $user = $this->getUser(); if (!$user->isTotpAuthenticationEnabled()) { return $this->redirectToRoute('MineSeekerBundle_profile_security'); } $backupCodes = $this->generateBackupCodes(); $user->backupCodes = $backupCodes; $this->em->flush(); $this->addFlash('2fa_backup_codes', $backupCodes); $this->addFlash('success', 'Backup codes have been regenerated. Save them somewhere safe.'); return $this->redirectToRoute('MineSeekerBundle_profile_security'); } /** @return string[] Eight 8-character uppercase alphanumeric codes */ private function generateBackupCodes(int $count = 8): array { $codes = []; for ($i = 0; $i < $count; $i++) { $codes[] = strtoupper(bin2hex(random_bytes(4))); } return $codes; } }