new: usr: implement the 2FA authentication (TOTP and backup codes) #4
This commit is contained in:
236
src/Controller/TwoFactorController.php
Normal file
236
src/Controller/TwoFactorController.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php declare(strict_types=1);
|
||||
/**
|
||||
* This file is part of the SplendidBear Websites' projects.
|
||||
*
|
||||
* Copyright (c) 2026 @ www.splendidbear.org
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Endroid\QrCode\Builder\Builder;
|
||||
use Endroid\QrCode\Exception\ValidationException;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use RuntimeException;
|
||||
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Class TwoFactorController
|
||||
*
|
||||
* Handles TOTP 2FA setup/teardown and the login challenge form.
|
||||
*
|
||||
* @package App\Controller
|
||||
* @author Lang <https://www.splendidbear.org>
|
||||
* @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->setTotpSecret($pendingSecret);
|
||||
|
||||
if (!$this->totpAuthenticator->checkCode($user, $code)) {
|
||||
$user->setTotpSecret(null);
|
||||
$this->addFlash('error', 'Invalid verification code. Please try again.');
|
||||
return $this->redirectToRoute('MineSeekerBundle_2fa_setup');
|
||||
}
|
||||
|
||||
$backupCodes = $this->generateBackupCodes();
|
||||
$user->setBackupCodes($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->setTotpSecret(null);
|
||||
$user->setBackupCodes([]);
|
||||
$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->setBackupCodes($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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user