Voyti

Voyti

User management, authentication & authorization

Two-Factor Auth

Two-factor authentication is not part of the Voyti core - it lives in its own package family. The core distribution carries no 2FA code or data at all; it only exposes the seams that let a 2FA package hook into the login flow. To add 2FA you install the base package yiirocks/voyti-2fa together with at least one method package (email, TOTP, WebAuthn, ...). 2FA becomes active as soon as a method is registered - there is no separate on/off flag.

Installation

Install the base package plus one or more method packages. With yiisoft/config present (the standard way to run a Yii3 app) everything wires itself up - the routes, the login-confirmation step, the settings screen, and the enforcement middleware all register automatically, with no host code.

Each method is its own Composer package. Install one and it registers itself: its button appears on the settings screen's method switcher and it becomes selectable in the login confirmation step. Multiple method packages can be installed side by side; a user's active method is remembered per account.

Email

Mails a fresh six-digit code at the start of the confirmation step; needs only a configured mailer.

Create an issue →
Packagist Version PHP Version Downloads Last Commit GitHub Workflow Status
TOTP

Time-based One-Time Password (TOTP) using an authenticator app like Google Authenticator, Authy, or Microsoft Authenticator. Users scan a QR code during setup to register the account, then enter time-based codes generated by their app during login.

Create an issue →
Packagist Version PHP Version Downloads Last Commit GitHub Workflow Status
WebAuthn

Passwordless authentication using biometrics (fingerprint, face recognition) or hardware security keys. The credential is registered on the user's device and verified during login without requiring a typed code.

Create an issue →
Packagist Version PHP Version Downloads Last Commit GitHub Workflow Status

The base package ships a migration creating its user_two_factor and user_backup_code tables; some method packages ship their own tables too (WebAuthn adds user_webauthn_credential). Their migration paths are registered for yiisoft/db-migration automatically, so a host's migrate command picks them up. See Routes for the full route list these packages contribute.

How it works

A successful password login can be interrupted before the session is established by a login challenge. Voyti core collects every service tagged voyti.login-challenge and consults them in turn; the first one to return a response short-circuits login with that response. yiirocks/voyti-2fa contributes the two-factor challenge: when the user has an enabled, registered method it stashes the pending credentials, starts the method's step, and renders the confirmation screen (route voyti/session-confirm). Core references none of this - it only knows the tag.

A user's 2FA state (enabled flag, active method, method secret) lives in the base package's UserTwoFactor record, kept out of the core user table. On enabling any method, Voyti generates ten single-use backup codes (hashed via the base package's BackupCodeService); a user can regenerate them from settings/two-factor/backup-codes/regenerate after re-verifying their current method.

Enforcing 2FA for a permission

To require 2FA for the holders of a given permission, list those permissions under the base package's forcedPermissions param:

// config/params.php
return [
    'yiirocks/voyti-2fa' => [
        'forcedPermissions' => ['admin'],
    ],
];

The base package's TwoFactorAuthenticationEnforceMiddleware redirects such a user to the setup screen (with an explanatory flash) until they enable 2FA. It is tagged voyti.enforce-middleware, so it joins core's VoytiMiddleware chain automatically once the package is installed - a host using VoytiMiddleware needs no extra wiring. See Middleware for that chain.

Writing a method plugin

A method plugin is a small Composer package that depends on yiirocks/voyti-2fa and does two things: register a provider class implementing TwoFactorMethodInterface, and contribute its setup routes. Both happen through yiisoft/config, so a host that installs the package needs no extra wiring.

1. Register the method (DI tag). Tag the provider class with voyti.two-factor-method in the package's config/di.php. The base package's TwoFactorMethodRegistry collects every tagged provider, keyed by its getName():

// config/di.php
use YiiRocks\Voyti\TwoFactor\Totp\TotpTwoFactorMethod;

return [
    TotpTwoFactorMethod::class => [
        'class' => TotpTwoFactorMethod::class,
        'tags' => ['voyti.two-factor-method'],
    ],
];

2. Contribute setup routes (params splice). Append the method's routes to the twoFactorMethodRoutes key under yiirocks/voyti in the package's config/params.php. The base package splices them into its own settings/ group, so they inherit the login requirement and the session and CSRF middleware with no duplication:

// config/params.php
use YiiRocks\Voyti\TwoFactor\Totp\Controller\TotpController;
use Yiisoft\Router\Route;

return [
    'yiirocks/voyti' => [
        'twoFactorMethodRoutes' => [
            Route::get('two-factor/totp/')
                ->name('voyti/user-two-factor-totp')
                ->action([TotpController::class, 'settings']),
        ],
    ],
];

Because params is merged recursively and route lists are appended, any number of method packages can contribute at once - the lists concatenate rather than collide. Keep route names and paths unique per method (the voyti/user-two-factor-<name> convention avoids clashes, and is the name your method's getSettingsUrl() generates). A client-collected method that needs a guest-accessible confirmation fragment (like WebAuthn) registers that one route as its own top-level group, reusing VoytiRoutes::webMiddleware() for the session and CSRF stack.

The interface. TwoFactorMethodInterface (namespace YiiRocks\Voyti\TwoFactor, provided by the base package) is the whole contract:

Method Purpose
getName()The exclusive name the method is stored under in UserTwoFactor (e.g. 'totp'); also the registry key.
isAvailable()Whether the backing library is installed. Unavailable methods are hidden and never chosen as the default - the graceful self-disable hook.
isCodeBased()true for a user-typed code (TOTP, email), false for a client-collected payload (WebAuthn).
requiresCodeDelivery()Whether a code must be delivered before the user can enter it (email) versus available on demand (TOTP, WebAuthn). Drives the disable flow's "send a code first" pre-step generically.
verify($user, $data)Validate the attempt. $data is ['code' => ...] for code-based methods, ['payload' => ...] for client-collected ones.
getSettingsUrl($url)GET route for the method's settings screen; the settings page also fetches it to lazy-load the setup fragment.
getConfirmFragmentUrl($url)GET route for the login-confirmation fragment, or null for code-based methods (their code form is rendered inline).
getButtonLabel($translator)Short label for the method-switch button.
getEnabledWithMethodName($translator)Name shown in the "2FA is enabled with {method}" message.
getErrorMessage()Translated error from the last failed verify(); empty on success.
onAuthenticationStepStart($user)Runs when a login reaches this method's confirmation step (e.g. email a fresh code); a no-op for methods with nothing to send.
onDisable($user)Runs when 2FA is disabled entirely, to clear method-specific state.

Enrollment and storage. A code-based method's setup route persists its secret to the user's UserTwoFactor record (via UserTwoFactor::forUser($user)) and renders the setup fragment. Enrollment then finalizes through the base package's generic voyti/user-two-factor-enable route, which calls the method's verify() and, on success, records the active method and issues backup codes. Disable, re-verification, and method resolution are all handled generically by the base package's TwoFactorController - a plugin never touches them. A method that needs its own table (as WebAuthn does for stored credentials) ships its own model and migration, whose user_id foreign key is declared ON DELETE CASCADE so the rows are removed with the user.