neophp / twofactor-package
TOTP two-factor authentication toolkit for NeoPHP, usable with any auth system
Requires
- php: >=8.5
README
A TOTP (Time-based One-Time Password) two-factor authentication toolkit for NeoPHP — compatible with Google Authenticator, Authy, and any standard authenticator app. Fully autonomous: it does not modify or hook into any other package's authentication flow. You call its services explicitly, wherever you decide 2FA should apply in your own code.
Structure
twofactor-package/
├── composer.json
├── README.md
├── src/
│ ├── NeoTwoFactorPackage.php
│ ├── Service/
│ │ ├── TotpManager.php
│ │ └── TwoFactorManager.php
│ ├── Assets/
│ │ └── css/twofactor.css
│ └── Templates/
│ └── components/
│ ├── TwoFactorSetup.macro.html.twig
│ └── TwoFactorVerify.macro.html.twig
└── database/
├── Entity/
│ └── TwoFactorSecret.php
├── Repository/
│ └── TwoFactorSecretRepository.php
└── Migrations/
└── MigrationVersion_TwoFactor_1.php
Design principle: bring your own integration
This package ships no controllers and no routes. It provides:
TwoFactorManager— a service to set up, verify, and manage 2FA for any user, from any auth system- Two Twig macros for the two screens you'll typically need (setup, verification at login)
- One database table, storing a secret per
(user_type, user_id)pair — not tied to any specific user entity
You decide, in your own controllers, when to call isEnabledFor() and
verifyCode() — whether that's inside neo-admin-package's
AdminAuthManager, NeoPHP's core AuthManager, or a completely custom
auth system. No other package is ever modified to make this work.
Installation
php bin/neo package:require neophp/twofactor-package --project=MyProject
Register it in the project's Config/app.config.php:
return [ // ... 'packages' => [ \Vendor\NeoPHP\TwoFactorPackage\NeoTwoFactorPackage::class, ], ];
Run the migration to create its table:
php bin/neo database:migration:migrate --project=MyProject
The (userType, userId) pair
Every method takes a string $userType and an int $userId instead of
a user object — this is what makes the package usable with any user
entity, from any auth system, without a hard foreign key to a specific
table. Use the user entity's own class-string as $userType:
$twoFactor->isEnabledFor(\Vendor\NeoPHP\AdminPackage\Database\Entity\AdminUser::class, $user->getId());
Usage example: adding 2FA to a login flow
This example integrates with neo-admin-package's AdminAuthManager,
but the same pattern works with any auth system — nothing here is
specific to that package.
1. After password verification, check if 2FA is required
public function login(Request $request, AdminAuthManager $auth, TwoFactorManager $twoFactor): Response { if (!$auth->attempt($request->getPost('email', ''), $request->getPost('password', ''))) { return $this->render('@NeoAdmin/pages/login.html.twig', ['error' => 'Invalid credentials.']); } $user = $auth->user(); if ($twoFactor->isEnabledFor(AdminUser::class, $user->getId())) { $this->session()->set('pending_2fa_user_id', $user->getId()); return $this->redirectToRoute('twofactor.verify'); } return $this->redirectToRoute('admin.panel.index'); }
2. Verification page
#[Route(path: '/2fa/verify', name: 'twofactor.verify', methods: ['GET'])] public function verifyForm(): Response { return $this->render('pages/twofactor-verify.html.twig', [ 'verifyUrl' => $this->generateUrl('twofactor.verify.submit'), ]); } #[Route(path: '/2fa/verify', name: 'twofactor.verify.submit', methods: ['POST'])] public function verify(Request $request, TwoFactorManager $twoFactor): Response { $userId = $this->session()->get('pending_2fa_user_id'); $code = $request->getPost('code', ''); if (!$twoFactor->verifyCode(AdminUser::class, $userId, $code)) { return $this->render('pages/twofactor-verify.html.twig', [ 'verifyUrl' => $this->generateUrl('twofactor.verify.submit'), 'error' => 'Invalid code.', ]); } $this->session()->remove('pending_2fa_user_id'); // finalize the actual login session here (your own logic) return $this->redirectToRoute('admin.panel.index'); }
{# pages/twofactor-verify.html.twig #} {% import '@TwoFactor/components/TwoFactorVerify.macro.html.twig' as TwoFactor %} <link rel="stylesheet" href="/packages-assets/TwoFactor/css/twofactor.css"> {{ TwoFactor.verify(verifyUrl, 'tf', error ?? null) }}
3. Setup page (letting a user enable 2FA)
#[Route(path: '/2fa/setup', name: 'twofactor.setup', methods: ['GET'])] public function setupForm(TwoFactorManager $twoFactor, AdminAuthManager $auth): Response { $user = $auth->user(); $secret = $twoFactor->setupFor(AdminUser::class, $user->getId()); $qrCodeUrl = $twoFactor->getQrCodeUrlFor(AdminUser::class, $user->getId(), $user->getEmail()); return $this->render('pages/twofactor-setup.html.twig', [ 'qrCodeUrl' => $qrCodeUrl, 'secret' => $secret->getSecret(), 'confirmUrl' => $this->generateUrl('twofactor.setup.confirm'), ]); } #[Route(path: '/2fa/setup', name: 'twofactor.setup.confirm', methods: ['POST'])] public function confirmSetup(Request $request, TwoFactorManager $twoFactor, AdminAuthManager $auth): Response { $user = $auth->user(); $code = $request->getPost('code', ''); if (!$twoFactor->confirmAndEnable(AdminUser::class, $user->getId(), $code)) { return $this->redirectToRoute('twofactor.setup'); // add an error flash as needed } return $this->redirectToRoute('admin.panel.index'); }
{# pages/twofactor-setup.html.twig #} {% import '@TwoFactor/components/TwoFactorSetup.macro.html.twig' as TwoFactor %} <link rel="stylesheet" href="/packages-assets/TwoFactor/css/twofactor.css"> {{ TwoFactor.setup(qrCodeUrl, secret, confirmUrl) }}
TwoFactorManager API
| Method | Purpose |
|---|---|
setupFor(string $userType, int $userId): TwoFactorSecret |
Creates (or returns the existing) secret for a user — not yet enabled |
getQrCodeUrlFor(string $userType, int $userId, string $label, string $issuer = 'NeoPHP'): ?string |
QR code image URL for the setup screen |
confirmAndEnable(string $userType, int $userId, string $code): bool |
Verifies the first code and enables 2FA if correct |
isEnabledFor(string $userType, int $userId): bool |
Whether 2FA is active for this user |
verifyCode(string $userType, int $userId, string $code): bool |
Verifies a code at login time (only succeeds if 2FA is enabled) |
disableFor(string $userType, int $userId): void |
Removes the secret entirely |
Theming
Both macros use only CSS custom properties scoped to .tf-setup /
.tf-verify-form — override them on an ancestor element to match your
project's palette, exactly like neo-formbuilder-package's components:
.tf-setup, .tf-verify-form { --tf-accent: #6366f1; --tf-accent-hover: #4f46e5; --tf-border: #2d3342; --tf-bg: #161923; --tf-text: #e5e7eb; --tf-text-muted: #9ca3af; }
You are also free to skip loading twofactor.css entirely and write
your own stylesheet targeting the same class names
(.tf-qr-image, .tf-code-input, .tf-submit-btn, etc.) for full
control over markup styling.
QR code generation
QR codes are generated via a free external service
(api.qrserver.com) rather than a bundled PHP library, to avoid a heavy
Composer dependency. This means QR code generation requires the server
to have outbound internet access. If that's not acceptable for your
environment, replace TotpManager::getQrCodeUrl()'s implementation with
a local QR code library of your choice.
What this package does not do
- No SMS or email codes — TOTP only, generated locally by the user's authenticator app
- No recovery codes generated automatically yet — the
recovery_codescolumn exists on the entity but nothing populates or checks it; wire this up yourself if needed - No login flow of its own — you always integrate it into your existing authentication code
License
MIT