Skip to content

Commit

Permalink
TOTP: introduce new interface that prevents code reuse
Browse files Browse the repository at this point in the history
  • Loading branch information
glaubinix committed Sep 26, 2024
1 parent 5ccef72 commit 2c8f53e
Show file tree
Hide file tree
Showing 3 changed files with 76 additions and 5 deletions.
19 changes: 19 additions & 0 deletions src/OTPWithPreviousTimestampInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace OTPHP;

interface OTPWithPreviousTimestampInterface extends OTPInterface
{
/**
* Verify method which prevents previously used codes from being used again. The passed values are in seconds.
*
* @param non-empty-string $otp
* @param null|0|positive-int $timestamp
* @param null|0|positive-int $leeway
* @param null|0|positive-int $previousTimestamp
* @return int|false the timestamp matching the otp on success, and false on error
*/
public function verifyWithPreviousTimestamp(string $otp, null|int $timestamp = null, null|int $leeway = null, null|int $previousTimestamp = null): int|false;
}
50 changes: 45 additions & 5 deletions src/TOTP.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
/**
* @see \OTPHP\Test\TOTPTest
*/
final class TOTP extends OTP implements TOTPInterface
final class TOTP extends OTP implements TOTPInterface, OTPWithPreviousTimestampInterface
{
private readonly ClockInterface $clock;

Expand Down Expand Up @@ -117,13 +117,27 @@ public function now(): string
* @param null|0|positive-int $leeway
*/
public function verify(string $otp, null|int $timestamp = null, null|int $leeway = null): bool
{
return $this->verifyWithPreviousTimestamp($otp, $timestamp, $leeway, null) !== false;
}

/**
* Verify method which prevents previously used codes from being used again. The passed values are in seconds.
*
* @param non-empty-string $otp
* @param 0|positive-int $timestamp
* @param null|0|positive-int $leeway
* @param null|0|positive-int $previousTimestamp
* @return int|false the timestamp matching the otp on success, and false on error
*/
public function verifyWithPreviousTimestamp(string $otp, null|int $timestamp = null, null|int $leeway = null, null|int $previousTimestamp = null): int|false
{
$timestamp ??= $this->clock->now()
->getTimestamp();
$timestamp >= 0 || throw new InvalidArgumentException('Timestamp must be at least 0.');

if ($leeway === null) {
return $this->compareOTP($this->at($timestamp), $otp);
return $this->verifyOTPAtTimestamps($otp, [$timestamp], $previousTimestamp);
}

$leeway = abs($leeway);
Expand All @@ -135,9 +149,35 @@ public function verify(string $otp, null|int $timestamp = null, null|int $leeway
'The timestamp must be greater than or equal to the leeway.'
);

return $this->compareOTP($this->at($timestampMinusLeeway), $otp)
|| $this->compareOTP($this->at($timestamp), $otp)
|| $this->compareOTP($this->at($timestamp + $leeway), $otp);
return $this->verifyOTPAtTimestamps(
$otp,
[$timestampMinusLeeway, $timestamp, $timestamp + $leeway],
$previousTimestamp
);
}

/**
* @param non-empty-string $otp
* @param array<0|positive-int> $timestamps
*/
private function verifyOTPAtTimestamps(string $otp, array $timestamps, null|int $previousTimestamp): int|false
{
$previousTimeCode = null;
if ($previousTimestamp > 0) {
$previousTimeCode = $this->timecode($previousTimestamp);
}

foreach ($timestamps as $timestamp) {
if ($previousTimeCode !== null && $previousTimeCode >= $this->timecode($timestamp)) {
continue;
}

if ($this->compareOTP($this->at($timestamp), $otp)) {
return $timestamp;
}
}

return false;
}

public function getProvisioningUri(): string
Expand Down
12 changes: 12 additions & 0 deletions tests/TOTPTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,18 @@ public function verifyOtpWithEpoch(): void
static::assertFalse($otp->verify('139664', 1_301_012_297));
}

#[Test]
public function verifyOtpWithPreviousTimestamp(): void
{
$otp = self::createTOTP(6, 'sha1', 30);

static::assertSame(319_690_800, $otp->verifyWithPreviousTimestamp('762124', 319_690_800, null, 0));
static::assertSame(319_690_800, $otp->verifyWithPreviousTimestamp('762124', 319_690_800, null, 319_690_770), 'Can use new code');
static::assertFalse($otp->verifyWithPreviousTimestamp('762124', 319_690_800, null, 319_690_800), 'Cannot use same code again');
static::assertFalse($otp->verifyWithPreviousTimestamp('762124', 319_690_801, null, 319_690_800), 'Cannot use same code again at different timestamp');
static::assertFalse($otp->verifyWithPreviousTimestamp('762124', 319_690_800, null, 319_690_830), 'Cannot use previous code');
}

#[Test]
public function notCompatibleWithGoogleAuthenticator(): void
{
Expand Down

0 comments on commit 2c8f53e

Please sign in to comment.