Skip to content
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@
"ext-tokenizer": "*",
"composer-runtime-api": "^2.2",
"aws/aws-sdk-php": "^3.322.9",
"bacon/bacon-qr-code": "^3.1",
"brick/math": "^0.17",
"chillerlan/php-qrcode": "^6.0",
"doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4",
"egulias/email-validator": "^4.0",
Expand All @@ -171,13 +171,14 @@
"nyholm/psr7": "^1.0",
"paragonie/constant_time_encoding": "^3.1",
"phpseclib/phpseclib": "^3.0",
"pragmarx/google2fa": "^9.0",
"psr/clock": "^1.0",
"psr/container": "^2.0.1",
"psr/http-message": "^2.0",
"psr/log": "^3.0",
"psr/simple-cache": "^3.0",
"psy/psysh": "^0.12.22",
"sentry/sentry": "^4.15",
"spomky-labs/otphp": "^11.0",
"symfony/console": "^8.0",
"symfony/error-handler": "^8.0",
"symfony/finder": "^8.0",
Expand Down
920 changes: 920 additions & 0 deletions docs/plans/2026-07-03-fortify-otphp-chillerlan-refactor.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/boost/docs/fortify.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ The built-in two-factor routes include:

Fortify stores recovery codes as one encrypted JSON value. When a recovery code is used, Hypervel Fortify replaces the exact decoded JSON array entry and re-encrypts the whole JSON value.

The two-factor provider defaults to 32-character secrets, matching `pragmarx/google2fa` v9. The optional `window` feature option is passed to Google2FA per verification call; Fortify does not mutate shared Google2FA state in a Swoole worker.
The two-factor provider defaults to 32-character TOTP secrets. The optional `window` feature option is step-based: a value of `1` accepts the previous, current, and next 30-second periods. Accepted TOTP codes are cached for the full accepted window to prevent replay for as long as Fortify still accepts the code. Hypervel Fortify uses fresh OTPHP TOTP objects with an injected clock, so verification does not mutate shared TOTP engine state in a Swoole worker.

During login, users with enabled two-factor authentication are redirected to the two-factor challenge route. JSON login requests receive a response containing a `two_factor` boolean. The challenge form should submit either a `code` field containing a TOTP code or a `recovery_code` field containing one of the user's recovery codes to `POST /two-factor-challenge`.

Expand Down
4 changes: 3 additions & 1 deletion src/fortify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ See `src/boost/docs/fortify.md` for the canonical Fortify and Passkeys documenta
- Fortify supports boot-time request-aware redirect callbacks for dynamic post-login destinations, such as for custom domains, multi-guard apps, or multi-tenant apps.
- Fortify throttles two-factor challenge submissions by default.
- Fortify fixes Laravel's two-factor response contract mismatch.
- Fortify's two-factor provider contract accepts the configured secret length, and the default is `32` for `pragmarx/google2fa` v9.
- Fortify's two-factor provider uses OTPHP with mandatory PSR clock injection, fresh per-secret TOTP objects, and a default secret length of `32` characters.
- Fortify renders two-factor QR SVGs through a concrete internal chillerlan renderer.
- Fortify caches accepted TOTP codes for the full configured verification window to prevent replay for as long as the code remains acceptable.
- Recovery code replacement operates on decoded JSON entries.
- Fortify omits Laravel's deprecated `Rules\Password`.
- Fortify tightens loose upstream comparisons and application-model event docs where Hypervel can express the real contract.
6 changes: 4 additions & 2 deletions src/fortify/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"require": {
"php": "^8.4",
"ext-json": "*",
"bacon/bacon-qr-code": "^3.1",
"chillerlan/php-qrcode": "^6.0",
"hypervel/auth": "^0.4",
"hypervel/cache": "^0.4",
"hypervel/collections": "^0.4",
Expand All @@ -34,7 +34,9 @@
"hypervel/translation": "^0.4",
"hypervel/validation": "^0.4",
"hypervel/view": "^0.4",
"pragmarx/google2fa": "^9.0",
"nesbot/carbon": "^3.8.4",
"psr/clock": "^1.0",
"spomky-labs/otphp": "^11.0",
"symfony/console": "^8.0"
},
"autoload": {
Expand Down
4 changes: 2 additions & 2 deletions src/fortify/src/FortifyServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Hypervel\Fortify;

use Carbon\FactoryImmutable;
use Hypervel\Contracts\Cache\Repository;
use Hypervel\Contracts\Config\Repository as Config;
use Hypervel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
Expand Down Expand Up @@ -54,7 +55,6 @@
use Hypervel\Passkeys\Passkeys;
use Hypervel\Support\Facades\Route;
use Hypervel\Support\ServiceProvider;
use PragmaRX\Google2FA\Google2FA;

class FortifyServiceProvider extends ServiceProvider
{
Expand All @@ -70,7 +70,7 @@ public function register(): void

$this->app->singleton(TwoFactorAuthenticationProviderContract::class, function ($app): TwoFactorAuthenticationProvider {
return new TwoFactorAuthenticationProvider(
$app->make(Google2FA::class),
new FactoryImmutable,
$app->make(Repository::class),
);
});
Expand Down
17 changes: 3 additions & 14 deletions src/fortify/src/TwoFactorAuthenticatable.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,6 @@

namespace Hypervel\Fortify;

use BaconQrCode\Renderer\Color\Rgb;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\Fill;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use Hypervel\Container\Container;
use Hypervel\Contracts\Auth\Authenticatable;
use Hypervel\Contracts\Config\Repository as Config;
Expand Down Expand Up @@ -141,14 +135,9 @@ protected function dispatchRecoveryCodeReplacedEvent(string $code): void
*/
public function twoFactorQrCodeSvg(): string
{
$svg = (new Writer(
new ImageRenderer(
new RendererStyle(192, 0, null, null, Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(45, 55, 72))),
new SvgImageBackEnd
)
))->writeString($this->twoFactorQrCodeUrl());

return trim(substr($svg, strpos($svg, "\n") + 1));
return Container::getInstance()
->make(TwoFactorQrCodeRenderer::class)
->svg($this->twoFactorQrCodeUrl());
}

/**
Expand Down
111 changes: 91 additions & 20 deletions src/fortify/src/TwoFactorAuthenticationProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,27 @@

use Hypervel\Contracts\Cache\Repository;
use Hypervel\Fortify\Contracts\TwoFactorAuthenticationProvider as TwoFactorAuthenticationProviderContract;
use PragmaRX\Google2FA\Google2FA;
use InvalidArgumentException;
use OTPHP\TOTP;
use OTPHP\TOTPInterface;
use Psr\Clock\ClockInterface;

class TwoFactorAuthenticationProvider implements TwoFactorAuthenticationProviderContract
{
private const string ALGORITHM = 'SHA1';

private const int DEFAULT_WINDOW = 1;

private const int DIGITS = 6;

private const int PERIOD = TOTPInterface::DEFAULT_PERIOD;

/**
* Create a new two factor authentication provider instance.
*/
public function __construct(
private readonly Google2FA $engine,
private readonly ?Repository $cache = null,
private readonly ClockInterface $clock,
private readonly Repository $cache,
) {
}

Expand All @@ -24,43 +35,103 @@ public function __construct(
*/
public function generateSecretKey(int $secretLength = 32): string
{
return $this->engine->generateSecretKey($secretLength);
if ($secretLength < 1) {
throw new InvalidArgumentException('Two-factor authentication secret length must be greater than zero.');
}

$byteLength = (int) ceil($secretLength * 5 / 8);

return substr(TOTP::generate($this->clock, $byteLength)->getSecret(), 0, $secretLength);
}

/**
* Get the two factor authentication QR code URL.
*/
public function qrCodeUrl(string $companyName, string $companyEmail, string $secret): string
{
return $this->engine->getQRCodeUrl($companyName, $companyEmail, $secret);
return 'otpauth://totp/'
. rawurlencode($companyName)
. ':'
. rawurlencode($companyEmail)
. '?secret='
. rawurlencode($secret)
. '&issuer='
Comment thread
greptile-apps[bot] marked this conversation as resolved.
. rawurlencode($companyName)
. '&algorithm='
. self::ALGORITHM
. '&digits='
. self::DIGITS
. '&period='
. self::PERIOD;
}

/**
* Verify the given code.
*/
public function verify(string $secret, string $code): bool
{
$window = Features::option(Features::twoFactorAuthentication(), 'window');
$window = is_int($window) ? $window : null;
$key = 'fortify.2fa_codes.' . hash('xxh128', $secret . '|' . $code);

$timestamp = $this->engine->verifyKeyNewer(
$secret,
$code,
$this->cache?->get($key),
$window,
);
$window = $this->window();
$totp = TOTP::createFromSecret($secret, $this->clock);

if ($timestamp === false) {
$matchedTimecode = $this->matchingTimecode($totp, $code, $window);

if ($matchedTimecode === null) {
return false;
}

if ($timestamp === true) {
$timestamp = $this->engine->getTimestamp();
return $this->cache->add(
$this->replayCacheKey($secret, $code),
$matchedTimecode,
$this->replayTtl($window)
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Find the matching TOTP timecode.
*/
private function matchingTimecode(TOTP $totp, string $code, int $window): ?int
{
$currentTimecode = intdiv($this->clock->now()->getTimestamp(), self::PERIOD);
$firstTimecode = max(0, $currentTimecode - $window);
$lastTimecode = $currentTimecode + $window;

for ($timecode = $firstTimecode; $timecode <= $lastTimecode; ++$timecode) {
if (hash_equals($totp->at($timecode * self::PERIOD), $code)) {
return $timecode;
}
}

$this->cache?->put($key, $timestamp, ($this->engine->getWindow($window) ?: 1) * 60);
return null;
}

return true;
/**
* Get the configured verification window.
*/
private function window(): int
{
$window = Features::option(Features::twoFactorAuthentication(), 'window');
$window = is_int($window) ? $window : self::DEFAULT_WINDOW;

if ($window < 0) {
throw new InvalidArgumentException('Two-factor authentication window must be greater than or equal to zero.');
}

return $window;
}

/**
* Get the replay cache key.
*/
private function replayCacheKey(string $secret, string $code): string
{
return 'fortify.2fa_codes.' . hash('xxh128', $secret . '|' . $code);
}

/**
* Get the replay cache TTL.
*/
private function replayTtl(int $window): int
{
return (2 * $window + 1) * self::PERIOD;
}
}
56 changes: 56 additions & 0 deletions src/fortify/src/TwoFactorQrCodeRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace Hypervel\Fortify;

use chillerlan\QRCode\Common\EccLevel;
use chillerlan\QRCode\Output\QRMarkupSVG;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use RuntimeException;

class TwoFactorQrCodeRenderer
{
private const string DARK = '#2d3748';

private const string LIGHT = '#fff';

/**
* Render the QR code URL as SVG.
*/
public function svg(string $url): string
{
$svg = (new QRCode(new QROptions([
'addQuietzone' => false,
'drawLightModules' => true,
'eccLevel' => EccLevel::L,
'moduleValues' => $this->moduleValues(),
'outputBase64' => false,
'outputInterface' => TwoFactorQrCodeSvgOutput::class,
'svgUseFillAttributes' => true,
])))->render($url);

if (! is_string($svg)) {
throw new RuntimeException('Two-factor QR code renderer did not return SVG output.');
}

return trim($svg);
}

/**
* Get the QR module color values.
*
* @return array<int, string>
*/
private function moduleValues(): array
{
$values = [];

foreach (QRMarkupSVG::DEFAULT_MODULE_VALUES as $module => $isDark) {
$values[$module] = $isDark ? self::DARK : self::LIGHT;
}

return $values;
}
}
32 changes: 32 additions & 0 deletions src/fortify/src/TwoFactorQrCodeSvgOutput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Hypervel\Fortify;

use chillerlan\QRCode\Output\QRMarkupSVG;
use chillerlan\QRCode\QROptions;

use function sprintf;

class TwoFactorQrCodeSvgOutput extends QRMarkupSVG
{
private const int SIZE = 192;

/**
* Return the SVG header.
*/
protected function header(): string
{
/** @var QROptions $options */
$options = $this->options;

return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="%s">%s',
self::SIZE,
self::SIZE,
$this->getViewBox(),
$options->eol,
);
}
}
Loading