Email

The email method. The server derives the code and sends it by email, so the secret lives on the server only and the user has nothing to install.

The container gives you a single service, configured from dot_totp.options:

use Dot\Totp\Totp;

$totp = $container->get(Totp::class);
Method Purpose
generateSecretBase32(int $length = 16): string A new random Base32 secret.
getCode(string $secret, ?int $timestamp = null): string The code for a secret at a moment in time; defaults to now. This is what lets the server derive the code it sends.
verifyCode(string $secret, string $code, int $window = 1): bool Whether a submission matches, checking $window steps either side of now.
generateRecoveryCodes(int $count = 8, int $length = 10, string $separator = '-'): array Single-use fallback codes in plain text.
hashRecoveryCodes(array $codes): array The same codes hashed with password_hash(), for storage.
validateRecoveryCode(string $inputCode, array &$hashedCodes): bool Consumes a matching code, modifying the array by reference.
getPeriod(): int / getDigits(): int / getAlgorithm(): string The configured options.

getProvisioningUri() and generateInlineSvgQr() are not listed: a delivered code gives the user nothing to scan.

Entity trait

Add the following trait to the entity that authenticates:

<?php

declare(strict_types=1);

namespace YourApp\Entity;

use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;

use function max;

trait TotpTrait
{
    public const string TOTP_METHOD_APP   = 'app';
    public const string TOTP_METHOD_EMAIL = 'email';
    public const string TOTP_METHOD_SMS   = 'sms';

    #[ORM\Column(name: 'totp_secret', type: 'string', length: 32, nullable: true)]
    protected ?string $totpSecret = null;

    #[ORM\Column(name: 'totp_enabled', type: 'boolean', options: ['default' => false])]
    protected bool $totpEnabled = false;

    #[ORM\Column(name: 'totp_method', type: 'string', length: 16, options: ['default' => 'app'])]
    protected string $totpMethod = self::TOTP_METHOD_APP;

    #[ORM\Column(name: 'totp_code_sent_at', type: 'datetime_immutable', nullable: true)]
    protected ?DateTimeImmutable $totpCodeSentAt = null;

    #[ORM\Column(name: 'totp_attempts', type: 'smallint', options: ['default' => 0])]
    protected int $totpAttempts = 0;

    /** @var array<int, string>|null */
    #[ORM\Column(name: 'totp_recovery_codes', type: 'json', nullable: true)]
    protected ?array $totpRecoveryCodes = null;

    public function enableTotp(string $secret, string $method = self::TOTP_METHOD_APP): self
    {
        $this->totpSecret  = $secret;
        $this->totpEnabled = true;
        $this->totpMethod  = $method;

        return $this;
    }

    public function disableTotp(): self
    {
        $this->totpSecret        = null;
        $this->totpEnabled       = false;
        $this->totpMethod        = self::TOTP_METHOD_APP;
        $this->totpCodeSentAt    = null;
        $this->totpAttempts      = 0;
        $this->totpRecoveryCodes = null;

        return $this;
    }

    public function isTotpEnabled(): bool
    {
        return $this->totpEnabled;
    }

    public function getTotpSecret(): ?string
    {
        return $this->totpSecret;
    }

    public function rotateTotpSecret(string $secret): self
    {
        $this->totpSecret = $secret;

        return $this;
    }

    public function getTotpMethod(): string
    {
        return $this->totpMethod;
    }

    public function usesTotpDelivery(): bool
    {
        return $this->totpMethod !== self::TOTP_METHOD_APP;
    }

    public function markTotpCodeSent(): self
    {
        $this->totpCodeSentAt = new DateTimeImmutable();
        $this->totpAttempts   = 0;

        return $this;
    }

    public function getTotpResendRetryAfter(int $resendInterval): int
    {
        if ($this->totpCodeSentAt === null) {
            return 0;
        }

        $elapsed = (new DateTimeImmutable())->getTimestamp() - $this->totpCodeSentAt->getTimestamp();

        return max(0, $resendInterval - $elapsed);
    }

    public function canResendTotpCode(int $resendInterval): bool
    {
        return $this->getTotpResendRetryAfter($resendInterval) === 0;
    }

    public function registerFailedTotpAttempt(): self
    {
        $this->totpAttempts++;

        return $this;
    }

    public function hasTotpAttemptsLeft(int $maxAttempts): bool
    {
        return $this->totpAttempts < $maxAttempts;
    }

    /**
     * @return array<int, string>
     */
    public function getTotpRecoveryCodes(): array
    {
        return $this->totpRecoveryCodes ?? [];
    }

    /**
     * @param array<int, string> $hashedCodes
     */
    public function setTotpRecoveryCodes(array $hashedCodes): self
    {
        $this->totpRecoveryCodes = $hashedCodes;

        return $this;
    }
}
Column Purpose
totp_secret The shared Base32 secret.
totp_enabled Whether the second factor is on.
totp_method app, email or sms, so the application knows how to deliver a code.
totp_code_sent_at When the last code went out, driving the resend cooldown.
totp_attempts Wrong guesses made against the code in flight.
totp_recovery_codes Hashes of the codes not yet used.
#[ORM\Entity]
class User
{
    use TotpTrait;
}

The totp_* columns carry defaults so the ALTER TABLE succeeds on a populated table. Then generate a migration:

php bin/doctrine migrations:diff

The method constants live on the trait, and PHP requires reading them through the class that uses it - User::TOTP_METHOD_EMAIL, not TotpTrait::TOTP_METHOD_EMAIL.

Enrolling a user

Email has nothing to scan, so enrolment is one call with a secret the user never sees:

$user->enableTotp($totp->generateSecretBase32(), User::TOTP_METHOD_EMAIL);
$entityManager->flush();

Send only to an address the user has already verified.

Sending a code

Check the cooldown, derive the code with getCode() and stamp the send:

$resendInterval = 60;

if (! $user->canResendTotpCode($resendInterval)) {
    // 429, with Retry-After: $user->getTotpResendRetryAfter($resendInterval)
    return $this->tooManyRequests($user->getTotpResendRetryAfter($resendInterval));
}

$code = $totp->getCode((string) $user->getTotpSecret());

$user->markTotpCodeSent();
$entityManager->flush();

markTotpCodeSent() also resets the attempt counter, so guesses spent on the previous code do not count against the new one.

Then deliver the plain code:

$this->mailer->send($user->getEmail(), sprintf('Your code is %s', $code));

Email delivery pairs with dotkernel/dot-mail.

At sign-in, usesTotpDelivery() tells the two flows apart: an app user is asked for a code straight away, a delivery user needs one sent first.

if ($user->isTotpEnabled() && $user->usesTotpDelivery()) {
    // send a code, then ask for it
}

Verifying a code

A delivered code has to survive a mail queue or an SMS carrier, so it needs a wider window, an attempt limit, and a rotation once accepted:

$maxAttempts = 3;

if (! $user->hasTotpAttemptsLeft($maxAttempts)) {
    return $this->error('Too many attempts. Request a new code.');
}

// 30s period, window 5: 11 steps of 30s, so the code lives about five and a half minutes
if (! $totp->verifyCode((string) $user->getTotpSecret(), $submittedCode, window: 5)) {
    $user->registerFailedTotpAttempt();
    $entityManager->flush();

    return $this->error('That code is not correct.');
}

// Accepted: rotate the secret so this code cannot be used again.
$user->rotateTotpSecret($totp->generateSecretBase32());
$entityManager->flush();

verifyCode() accepts $window steps either side of now, so a code stays valid for (2 * $window + 1) * period seconds - half of that before the moment it was derived, half after. Pick $window from how long you are prepared to let a code live, (lifetime / period - 1) / 2, not from how long delivery usually takes. A number that only just covers a fast send will reject legitimate users the first time delivery backs up.

Why the wider window needs the other two lines is explained in Methods.

Recovery codes

A user who loses access to the mailbox needs another way in.

Generate the codes once, show them once, and store only their hashes:

$codes = $totp->generateRecoveryCodes();
// ['A2C4E-6GH8J', 'K3M5P-7QR9S', ...]

$user->setTotpRecoveryCodes($totp->hashRecoveryCodes($codes));
$entityManager->flush();

// $codes is the only chance the user has to write them down.

validateRecoveryCode() takes the hashes by reference and removes the one it consumes, so the mutated array has to be persisted or the code stays usable:

$hashedCodes = $user->getTotpRecoveryCodes();

if (! $totp->validateRecoveryCode($submittedCode, $hashedCodes)) {
    return $this->error('That recovery code is not valid.');
}

$user->setTotpRecoveryCodes($hashedCodes);
$entityManager->flush();

Other methods

App and SMS cover the other two methods. Methods compares all three.