Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Classes/Controller/BackendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ public function newWebAuthnAction(): void
*/
public function createAction(string $secret, string $secondFactorFromApp, string $name = ''): void
{
$isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp);
$isValid = $this->tOTPService->checkIfOtpIsValid($secret, $secondFactorFromApp);

if (!$isValid) {
$this->addFlashMessage(
Expand Down
4 changes: 2 additions & 2 deletions Classes/Controller/LoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ public function setupWebAuthnAction(?string $username = null): void
*/
public function createSecondFactorAction(string $secret, string $secondFactorFromApp, string $name = ''): void
{
$isValid = TOTPService::checkIfOtpIsValid($secret, $secondFactorFromApp);
$isValid = $this->tOTPService->checkIfOtpIsValid($secret, $secondFactorFromApp);

if (!$isValid) {
$this->addFlashMessage(
Expand Down Expand Up @@ -441,7 +441,7 @@ private function enteredTotpMatchesAnyTotpFactor(string $enteredSecondFactor, Ac
{
$totpFactors = $this->secondFactorRepository->findByAccountAndType($account, SecondFactor::TYPE_TOTP);
foreach ($totpFactors as $secondFactor) {
if (TOTPService::checkIfOtpIsValid($secondFactor->getSecret(), $enteredSecondFactor)) {
if ($this->tOTPService->checkIfOtpIsValid($secondFactor->getSecret(), $enteredSecondFactor)) {
return true;
}
}
Expand Down
35 changes: 26 additions & 9 deletions Classes/Service/TOTPService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use Neos\Flow\Security\Account;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Security\Account;
use Neos\Neos\Domain\Repository\DomainRepository;
use Neos\Neos\Domain\Repository\SiteRepository;
use OTPHP\TOTP;
Expand All @@ -14,31 +14,48 @@ class TOTPService
{
/**
* @Flow\Inject
* @var DomainRepository
*/
protected $domainRepository;
protected DomainRepository $domainRepository;

/**
* @Flow\Inject
* @var SiteRepository
*/
protected $siteRepository;
protected SiteRepository $siteRepository;

/**
* @Flow\InjectConfiguration(path="issuerName")
* @var string | null
*/
protected $issuerName;
protected string|null $issuerName;

/**
* @Flow\InjectConfiguration(path="totpLeewayInSeconds")
*/
protected int $totpLeewayInSeconds;

public static function generateNewTotp(): TOTP
{
return TOTP::create();
}

public static function checkIfOtpIsValid(string $secret, string $submittedOtp): bool
public function checkIfOtpIsValid(string $secret, string $submittedOtp): bool
{
$otp = TOTP::create($secret);
return $otp->verify($submittedOtp);

$leeway = (int)$this->totpLeewayInSeconds;
$period = $otp->getPeriod();

if ($leeway <= 0) {
// No leeway configured: exact-match verification (single window).
return $otp->verify($submittedOtp);
}

if ($leeway >= $period) {
// The leeway MUST be lower than the TOTP period, otherwise verify() throws.
// Clamp to the maximum allowed value instead of failing at login time.
$leeway = $period - 1;
}

return $otp->verify($submittedOtp, null, $leeway);
}

public function generateQRCodeForTokenAndAccount(TOTP $otp, Account $account): string
Expand Down
4 changes: 4 additions & 0 deletions Configuration/Settings.2FA.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Sandstorm:
enforce2FAForRoles: []
# (optional) if set this will be used as a naming convention for the TOTP. If empty the Site name will be used
issuerName: ''
# Acceptable TOTP clock drift in seconds. verify() will accept codes from
# (now - leeway) through (now + leeway). 0 disables leeway (exact match only).
# MUST be lower than the 30s TOTP period; values >= 30 are clamped to 29.
totpLeewayInSeconds: 0

webAuthn:
# Human-readable relying party name shown in the browser's WebAuthn prompt.
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ Sandstorm:
issuerName: ""
```

### TOTP leeway

By default, TOTP codes are verified against the current 30-second window only, with no tolerance for
clock drift between the user's device and the server. If users occasionally hit "invalid code"
errors near the boundary of a code's lifetime, you can allow some drift via:

```yml
Sandstorm:
NeosTwoFactorAuthentication:
# Acceptable TOTP clock drift in seconds. Codes from (now - leeway) through (now + leeway)
# are accepted. 0 disables leeway (exact match only). MUST be lower than the 30s TOTP period;
# values >= 30 are clamped to 29.
totpLeewayInSeconds: 5
```

## Tested 2FA apps

Thx to @Sebobo @Benjamin-K for creating a list of supported and testet apps!
Expand Down