Private
Public Access
1
0

new: usr: implement the 2FA authentication (TOTP and backup codes) #4

This commit is contained in:
2026-04-12 17:55:57 +02:00
parent 0144a3953c
commit fb8a54f687
23 changed files with 1603 additions and 266 deletions

View File

@@ -33,8 +33,10 @@ class ProfileController extends AbstractController
{
public function __construct(
private readonly PlayedGameRepository $repo,
private readonly WebAuthnService $webAuthnService
) { }
private readonly WebAuthnService $webAuthnService
)
{
}
#[Route('/profile', name: 'MineSeekerBundle_profile')]
public function index(): Response
@@ -62,17 +64,19 @@ class ProfileController extends AbstractController
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$credentials = $this->webAuthnService->getCredentialsForUser($user);
$credentialsData = array_map(fn ($cred) => [
'id' => $cred->getId(),
'credentialName' => $cred->getCredentialName(),
'createdAt' => $cred->getCreatedAt()?->format('Y-m-d H:i:s'),
'lastUsedAt' => $cred->getLastUsedAt()?->format('Y-m-d H:i:s'),
'isBackupEligible' => $cred->isBackupEligible(),
$credentialsData = array_map(fn($cred) => [
'id' => $cred->getId(),
'credentialName' => $cred->getCredentialName(),
'createdAt' => $cred->getCreatedAt()?->format('Y-m-d H:i:s'),
'lastUsedAt' => $cred->getLastUsedAt()?->format('Y-m-d H:i:s'),
'isBackupEligible' => $cred->isBackupEligible(),
'isBackupAuthenticated' => $cred->isBackupAuthenticated(),
], $credentials);
return $this->render('Security/profile_security.html.twig', [
'credentials' => $credentialsData,
'credentials' => $credentialsData,
'isTotpEnabled' => $user->isTotpAuthenticationEnabled(),
'backupCodesCount' => \count($user->getBackupCodes()),
]);
}
}

View File

@@ -55,17 +55,17 @@ class SecurityController extends AbstractController
}
#[Route('/logout', name: 'MineSeekerBundle_logout', methods: ['POST'])]
public function logout(): void
public function logout(): never
{
// Intercepted by the security firewall — never executed.
throw new \LogicException('This action is intercepted by the security firewall.');
}
#[Route('/register', name: 'MineSeekerBundle_register')]
public function register(
Request $request,
Request $request,
UserPasswordHasherInterface $hasher,
EntityManagerInterface $em,
MailerInterface $mailer,
EntityManagerInterface $em,
MailerInterface $mailer,
): Response {
if ($this->getUser()) {
return $this->redirectToRoute('MineSeekerBundle_homepage');
@@ -114,10 +114,10 @@ class SecurityController extends AbstractController
#[Route('/forgot-password', name: 'MineSeekerBundle_forgot_password')]
public function forgotPassword(
Request $request,
UserRepository $userRepository,
Request $request,
UserRepository $userRepository,
EntityManagerInterface $em,
MailerInterface $mailer,
MailerInterface $mailer,
): Response {
if ($this->getUser()) {
return $this->redirectToRoute('MineSeekerBundle_homepage');
@@ -128,7 +128,7 @@ class SecurityController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$email = $form->get('email')->getData();
$user = $userRepository->findOneByEmail($email);
$user = $userRepository->findOneByEmail($email);
if ($user && $user->isVerified()) {
$token = bin2hex(random_bytes(32));
@@ -167,10 +167,10 @@ class SecurityController extends AbstractController
#[Route('/reset-password/{token}', name: 'MineSeekerBundle_reset_password')]
public function resetPassword(
string $token,
Request $request,
UserRepository $userRepository,
EntityManagerInterface $em,
string $token,
Request $request,
UserRepository $userRepository,
EntityManagerInterface $em,
UserPasswordHasherInterface $hasher,
): Response {
$user = $userRepository->findOneByResetToken($token);

View 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;
}
}

View File

@@ -11,6 +11,7 @@
namespace App\Controller;
use App\Entity\User;
use App\Security\PasskeyToken;
use App\Service\WebAuthnService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -19,7 +20,6 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialParameters;
@@ -295,7 +295,7 @@ class WebAuthnController extends AbstractController
$this->webAuthnService->updateLastUsedAt($credentialId, $user);
/** Log in the user using token storage */
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$token = new PasskeyToken($user, 'main', $user->getRoles());
$this->tokenStorage->setToken($token);
$request->getSession()->set('_security_main', serialize($token));