from base64 import b32encode
from binascii import unhexlify
import time
from urllib.parse import quote, urlencode
from django.conf import settings
from django.db import models
from django_otp.models import Device, ThrottlingMixin, TimestampMixin
from django_otp.oath import TOTP
from django_otp.util import hex_validator, random_hex
def default_key():
return random_hex(20)
def key_validator(value):
return hex_validator()(value)
[docs]
class TOTPDevice(TimestampMixin, ThrottlingMixin, Device):
"""
A generic TOTP :class:`~django_otp.models.Device`. The model fields mostly
correspond to the arguments to :func:`django_otp.oath.totp`. They all have
sensible defaults, including the key, which is randomly generated.
.. attribute:: key
*CharField*: A hex-encoded secret key of up to 40 bytes. (Default: 20
random bytes)
.. attribute:: step
*PositiveSmallIntegerField*: The time step in seconds. (Default: 30)
.. attribute:: t0
*BigIntegerField*: The Unix time at which to begin counting steps.
(Default: 0)
.. attribute:: digits
*PositiveSmallIntegerField*: The number of digits to expect in a token
(6 or 8). (Default: 6)
.. attribute:: tolerance
*PositiveSmallIntegerField*: The number of time steps in the past or
future to allow. For example, if this is 1, we'll accept any of three
tokens: the current one, the previous one, and the next one. (Default:
1)
.. attribute:: drift
*SmallIntegerField*: The number of time steps the prover is known to
deviate from our clock. If :setting:`OTP_TOTP_SYNC` is ``True``, we'll
update this any time we match a token that is not the current one.
(Default: 0)
.. attribute:: last_t
*BigIntegerField*: The time step of the last verified token. To avoid
verifying the same token twice, this will be updated on each successful
verification. Only tokens at a higher time step will be verified
subsequently. (Default: -1)
"""
key = models.CharField(
max_length=80,
validators=[key_validator],
default=default_key,
help_text="A hex-encoded secret key of up to 40 bytes.",
)
step = models.PositiveSmallIntegerField(
default=30, help_text="The time step in seconds."
)
t0 = models.BigIntegerField(
default=0, help_text="The Unix time at which to begin counting steps."
)
digits = models.PositiveSmallIntegerField(
choices=[(6, 6), (8, 8)],
default=6,
help_text="The number of digits to expect in a token.",
)
tolerance = models.PositiveSmallIntegerField(
default=1, help_text="The number of time steps in the past or future to allow."
)
drift = models.SmallIntegerField(
default=0,
help_text="The number of time steps the prover is known to deviate from our clock.",
)
last_t = models.BigIntegerField(
default=-1,
help_text="The t value of the latest verified token. The next token must be at a higher time step.",
)
class Meta(Device.Meta):
verbose_name = "TOTP device"
@property
def bin_key(self):
"""
The secret key as a binary string.
"""
return unhexlify(self.key.encode())
[docs]
def verify_token(self, token):
OTP_TOTP_SYNC = getattr(settings, 'OTP_TOTP_SYNC', True)
verify_allowed, _ = self.verify_is_allowed()
if not verify_allowed:
return False
try:
token = int(token)
except Exception:
verified = False
else:
key = self.bin_key
totp = TOTP(key, self.step, self.t0, self.digits, self.drift)
totp.time = time.time()
verified = totp.verify(token, self.tolerance, self.last_t + 1)
if verified:
self.last_t = totp.t()
if OTP_TOTP_SYNC:
self.drift = totp.drift
self.throttle_reset(commit=False)
self.set_last_used_timestamp(commit=False)
self.save()
if not verified:
self.throttle_increment(commit=True)
return verified
[docs]
def get_throttle_factor(self):
return getattr(settings, 'OTP_TOTP_THROTTLE_FACTOR', 1)
@property
def config_url(self):
"""
A URL for configuring Google Authenticator or similar.
See https://github.com/google/google-authenticator/wiki/Key-Uri-Format.
The issuer is taken from :setting:`OTP_TOTP_ISSUER`, if available.
The image (for e.g. FreeOTP) is taken from :setting:`OTP_TOTP_IMAGE`, if available.
"""
label = str(self.user.get_username())
params = {
'secret': b32encode(self.bin_key),
'algorithm': 'SHA1',
'digits': self.digits,
'period': self.step,
}
urlencoded_params = urlencode(params)
issuer = self._read_str_from_settings('OTP_TOTP_ISSUER')
if issuer:
issuer = issuer.replace(':', '')
label = '{}:{}'.format(issuer, label)
urlencoded_params += '&issuer={}'.format(
quote(issuer)
) # encode issuer as per RFC 3986, not quote_plus
image = self._read_str_from_settings('OTP_TOTP_IMAGE')
if image:
urlencoded_params += "&image={}".format(quote(image, safe=':/'))
url = 'otpauth://totp/{}?{}'.format(quote(label), urlencoded_params)
return url
def _read_str_from_settings(self, key):
val = getattr(settings, key, None)
if callable(val):
val = val(self)
if isinstance(val, str) and (val != ''):
return val
return None