Skip to content

Commit 7553de0

Browse files
committed
implement a method for TOTP that returns the matching timecode
1 parent 81ed54a commit 7553de0

2 files changed

Lines changed: 40 additions & 2 deletions

File tree

src/pyotp/totp.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import datetime
33
import hashlib
44
import time
5-
from typing import Any, Optional, Union
5+
from typing import Any, Literal, Optional, Union
66

77
from . import utils
88
from .otp import OTP
@@ -40,7 +40,7 @@ def __init__(
4040

4141
def at(self, for_time: Union[int, datetime.datetime], counter_offset: int = 0) -> str:
4242
"""
43-
Accepts either a Unix timestamp integer or a datetime object.
43+
Accepts either a Unix timestamp or a datetime object.
4444
4545
To get the time until the next timecode change (seconds until the current OTP expires), use this instead:
4646
@@ -85,6 +85,35 @@ def verify(self, otp: str, for_time: Optional[datetime.datetime] = None, valid_w
8585

8686
return utils.strings_equal(str(otp), str(self.at(for_time)))
8787

88+
def verify_and_get_timecode(
89+
self, otp: str, for_time: Optional[Union[int, datetime.datetime]] = None, valid_window: int = 0
90+
) -> int | Literal[False]:
91+
"""
92+
Verifies the OTP passed in against the current time OTP and returns the matching timecode.
93+
94+
This is useful when callers need to prevent TOTP reuse. An
95+
application can store the timecode from the last successful
96+
authentication and reject future OTPs whose matching timecode
97+
is not greater than the stored one.
98+
99+
:param otp: the OTP to check against
100+
:param for_time: Time to check OTP at (defaults to now); accepts datetime or Unix timestamp
101+
:param valid_window: extends the validity to this counter ticks before and after the current one
102+
:returns: matching timecode if verification succeeded, False otherwise
103+
"""
104+
if for_time is None:
105+
for_time = datetime.datetime.now()
106+
elif isinstance(for_time, (float, int)):
107+
for_time = datetime.datetime.fromtimestamp(int(for_time))
108+
109+
if valid_window < 0:
110+
raise ValueError("valid_window cannot be negative")
111+
112+
for i in range(-valid_window, valid_window + 1):
113+
if utils.strings_equal(str(otp), str(self.at(for_time, i))):
114+
return self.timecode(for_time) + i
115+
return False
116+
88117
def provisioning_uri(self, name: Optional[str] = None, issuer_name: Optional[str] = None, **kwargs) -> str:
89118
"""
90119
Returns the provisioning URI for the OTP. This can then be

test.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,15 @@ def test_valid_window(self):
335335
self.assertFalse(totp.verify("195979", 200, 1))
336336

337337

338+
class GetTimeCodeTest(unittest.TestCase):
339+
def test_timecode(self):
340+
totp = pyotp.TOTP("ABCDEFGH")
341+
self.assertEqual(totp.verify_and_get_timecode("451564", 200, 1), 5)
342+
self.assertEqual(totp.verify_and_get_timecode("028307", 200, 1), 6)
343+
self.assertEqual(totp.verify_and_get_timecode("681610", 200, 1), 7)
344+
self.assertFalse(totp.verify_and_get_timecode("195979", 200, 1))
345+
346+
338347
class DigestFunctionTest(unittest.TestCase):
339348
def test_md5(self):
340349
with self.assertRaises(ValueError) as cm:

0 commit comments

Comments
 (0)