new: usr: implement the 2FA authentication (TOTP and backup codes) #4
This commit is contained in:
@@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
src/Migrations/2026/04/Version20260412090000.php
Normal file
44
src/Migrations/2026/04/Version20260412090000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
54
src/Security/PasskeyToken.php
Normal file
54
src/Security/PasskeyToken.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user