neophp/twofactor-package

TOTP two-factor authentication toolkit for NeoPHP, usable with any auth system

Maintainers

Package info

github.com/NeoPHP-Dev/neo-twofactor-package

pkg:composer/neophp/twofactor-package

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.1 2026-08-08 03:07 UTC

This package is auto-updated.

Last update: 2026-08-08 03:07:22 UTC


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_codes column 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