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));

View File

@@ -18,6 +18,10 @@ use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Table;
use Scheb\TwoFactorBundle\Model\BackupCodeInterface;
use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration;
use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface;
use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface as TotpTwoFactorInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -36,7 +40,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
#[Entity(repositoryClass: UserRepository::class)]
#[UniqueEntity(fields: ['username'], message: 'This username is already taken.')]
#[UniqueEntity(fields: ['email'], message: 'This email address is already registered.')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwoFactorInterface, BackupCodeInterface
{
#[Id, GeneratedValue, Column]
private ?int $id = null;
@@ -65,6 +69,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?DateTime $resetTokenExpiresAt = null;
#[Column(length: 255, nullable: true)]
private ?string $totpSecret = null;
#[Column(type: Types::JSON, nullable: true)]
private ?array $backupCodes = [];
public function getId(): ?int
{
@@ -169,4 +179,58 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->resetTokenExpiresAt = $resetTokenExpiresAt;
return $this;
}
// --- TotpTwoFactorInterface ---
public function isTotpAuthenticationEnabled(): bool
{
return null !== $this->totpSecret;
}
public function getTotpAuthenticationUsername(): string
{
return $this->getUserIdentifier();
}
public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface
{
if (null === $this->totpSecret) {
return null;
}
return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);
}
public function getTotpSecret(): ?string
{
return $this->totpSecret;
}
public function setTotpSecret(?string $totpSecret): self
{
$this->totpSecret = $totpSecret;
return $this;
}
// --- BackupCodeInterface ---
public function isBackupCode(string $code): bool
{
return \in_array($code, $this->backupCodes ?? [], true);
}
public function invalidateBackupCode(string $code): void
{
$this->backupCodes = array_values(array_filter($this->backupCodes ?? [], fn($c) => $c !== $code));
}
public function getBackupCodes(): array
{
return $this->backupCodes ?? [];
}
public function setBackupCodes(array $backupCodes): self
{
$this->backupCodes = $backupCodes;
return $this;
}
}

View File

@@ -35,8 +35,9 @@ readonly class LoginCaptchaListener
{
public function __construct(
private RecaptchaService $recaptcha,
private RequestStack $requestStack,
) {}
private RequestStack $requestStack,
) {
}
public function __invoke(CheckPassportEvent $event): void
{
@@ -46,11 +47,18 @@ readonly class LoginCaptchaListener
return;
}
$token = $request->request->getString('g-recaptcha-response');
$remoteIp = (string) $request->getClientIp();
$path = $request->getPathInfo();
if (!$this->recaptcha->verify($token, $remoteIp)) {
throw new CustomUserMessageAuthenticationException('CAPTCHA verification failed. Please try again.');
if ($path === '/2fa_check' || strpos($path, '/2fa') === 0) {
return;
}
$token = $request->request->getString('g-recaptcha-response');
if ($this->recaptcha->verify($token, $request->getClientIp())) {
return;
}
throw new CustomUserMessageAuthenticationException('reCAPTCHA verification failed. Please try again.');
}
}
}

View File

@@ -0,0 +1,44 @@
<?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\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260412090000
*
* @package App\Migrations
* @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.
*/
final class Version20260412090000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add TOTP 2FA and backup codes to User';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user ADD totp_secret VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE app_user ADD backup_codes JSON DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user DROP totp_secret');
$this->addSql('ALTER TABLE app_user DROP backup_codes');
}
}

View File

@@ -0,0 +1,54 @@
<?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\Security;
use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Token used for passkey (WebAuthn) authentication.
* Intentionally not listed in scheb/2fa security_tokens so passkey logins
* bypass the TOTP 2FA challenge — passkeys are already a strong second factor.
*
* @package App\Security
* @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.
*/
final class PasskeyToken extends AbstractToken
{
public function __construct(
UserInterface $user,
private readonly string $firewallName,
array $roles = [],
) {
parent::__construct($roles);
$this->setUser($user);
}
public function getFirewallName(): string
{
return $this->firewallName;
}
public function __serialize(): array
{
return [$this->firewallName, parent::__serialize()];
}
public function __unserialize(array $data): void
{
[$this->firewallName, $parentData] = $data;
parent::__unserialize($parentData);
}
}