summaryrefslogtreecommitdiff
path: root/passlib
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2016-11-08 17:29:24 -0500
committerEli Collins <elic@assurancetechnologies.com>2016-11-08 17:29:24 -0500
commita060b0645af97ba5a3559b0d93c1ea0f766932dd (patch)
tree6eda6684298b4916503bbf5add751d58a6194b41 /passlib
parentb3532829a38351dcd1c15143132436ce8ff3199f (diff)
downloadpasslib-a060b0645af97ba5a3559b0d93c1ea0f766932dd.tar.gz
totp: added TOTP.using() for constructing TOTP factories with custom configuration options
(such as secrets) already bound to the class. * added TOTP.using() helper * removed 'now' keyword from TOTP() constructor, can now only be specified via TOTP.using() -- adjusted UTs accordingly *
Diffstat (limited to 'passlib')
-rw-r--r--passlib/tests/test_totp.py61
-rw-r--r--passlib/totp.py140
2 files changed, 163 insertions, 38 deletions
diff --git a/passlib/tests/test_totp.py b/passlib/tests/test_totp.py
index d75890f..18e41c3 100644
--- a/passlib/tests/test_totp.py
+++ b/passlib/tests/test_totp.py
@@ -427,7 +427,7 @@ class _BaseOTPTest(TestCase):
#=============================================================================
# subclass utils
#=============================================================================
- def randotp(self, **kwds):
+ def randotp(self, cls=None, **kwds):
"""
helper which generates a random OtpType instance.
"""
@@ -435,7 +435,7 @@ class _BaseOTPTest(TestCase):
kwds['new'] = True
kwds.setdefault("digits", random.randint(6, 10))
kwds.setdefault("alg", random.choice(["sha1", "sha256", "sha512"]))
- return self.OtpType(**kwds)
+ return (cls or self.OtpType)(**kwds)
def test_randotp(self):
"""
@@ -562,6 +562,12 @@ class _BaseOTPTest(TestCase):
self.assertRaises(ValueError, OTP, KEY1, issuer="foo.com:bar")
#=============================================================================
+ # using()
+ #=============================================================================
+
+ # TODO: test using() w/ 'digits', 'alg', 'issue', 'context'
+
+ #=============================================================================
# internal helpers
#=============================================================================
@@ -773,8 +779,29 @@ class TotpTest(_BaseOTPTest):
self.assertRaises(ValueError, OTP, KEY1, period=0)
self.assertRaises(ValueError, OTP, KEY1, period=-1)
- def test_ctor_w_now(self):
- """constructor -- 'now' parameter"""
+ #=============================================================================
+ # using()
+ #=============================================================================
+
+ def test_using_w_period(self):
+ """using() -- 'period' parameter"""
+
+ # default
+ self.assertEqual(TOTP(KEY1).period, 30)
+
+ # explicit value
+ self.assertEqual(TOTP.using(period=63)(KEY1).period, 63)
+
+ # reject wrong type
+ self.assertRaises(TypeError, TOTP.using, period=1.5)
+ self.assertRaises(TypeError, TOTP.using, period='abc')
+
+ # reject non-positive values
+ self.assertRaises(ValueError, TOTP.using, period=0)
+ self.assertRaises(ValueError, TOTP.using, period=-1)
+
+ def test_using_w_now(self):
+ """using -- 'now' parameter"""
# NOTE: reading time w/ normalize_time() to make sure custom .now actually has effect.
@@ -788,20 +815,20 @@ class TotpTest(_BaseOTPTest):
def now():
counter[0] += 1
return counter[0]
- otp = self.randotp(now=now)
- # NOTE: TOTP() constructor currently invokes this twice, using up counter values 124 & 125
+ otp = self.randotp(cls=TOTP.using(now=now))
+ # NOTE: TOTP() constructor invokes this as part of test, using up counter values 124 & 125
self.assertEqual(otp.normalize_time(None), 126)
self.assertEqual(otp.normalize_time(None), 127)
# require callable
- self.assertRaises(TypeError, self.randotp, now=123)
+ self.assertRaises(TypeError, TOTP.using, now=123)
# require returns int/float
msg_re = r"now\(\) function must return non-negative"
- self.assertRaisesRegex(AssertionError, msg_re, self.randotp, now=lambda : 'abc')
+ self.assertRaisesRegex(AssertionError, msg_re, TOTP.using, now=lambda: 'abc')
# require returns non-negative value
- self.assertRaisesRegex(AssertionError, msg_re, self.randotp, now=lambda : -1)
+ self.assertRaisesRegex(AssertionError, msg_re, TOTP.using, now=lambda: -1)
#=============================================================================
# internal helpers
@@ -906,8 +933,8 @@ class TotpTest(_BaseOTPTest):
self.assertEqual(otp.generate(dt).token, token)
# omitting value should use current time
- otp.now = lambda : time
- self.assertEqual(otp.generate().token, token)
+ otp2 = TOTP.using(now=lambda: time)(key=otp.base32_key)
+ self.assertEqual(otp2.generate().token, token)
# reject invalid time
self.assertRaises(ValueError, otp.generate, -1)
@@ -963,7 +990,7 @@ class TotpTest(_BaseOTPTest):
"""verify() -- valid TotpMatch object"""
time = 141230981
token = '781501'
- otp = TOTP(KEY3, now=lambda : time + 24 * 3600)
+ otp = TOTP.using(now=lambda: time + 24 * 3600)(KEY3)
result = otp.verify(token, time)
self.assertTotpMatch(result, time=time, skipped=0)
@@ -973,27 +1000,23 @@ class TotpTest(_BaseOTPTest):
time = 141230981
token = '781501'
- otp = TOTP(KEY3, now=lambda: time + 24 * 3600)
+ otp = TOTP.using(now=lambda: time + 24 * 3600)(KEY3)
result = otp.verify(token, time - 30)
self.assertTotpMatch(result, time=time - 30, skipped=1)
def test_totp_match_w_new_token(self):
"""verify() -- valid TotpMatch object with past token"""
- from passlib.totp import TotpMatch
-
time = 141230981
token = '781501'
- otp = TOTP(KEY3, now=lambda : time + 24 * 3600)
+ otp = TOTP.using(now=lambda: time + 24 * 3600)(KEY3)
result = otp.verify(token, time + 30)
self.assertTotpMatch(result, time=time + 30, skipped=-1)
def test_totp_match_w_invalid_token(self):
"""verify() -- invalid TotpMatch object"""
- from passlib.totp import TotpMatch
-
time = 141230981
token = '781501'
- otp = TOTP(KEY3, now=lambda : time + 24 * 3600)
+ otp = TOTP.using(now=lambda: time + 24 * 3600)(KEY3)
self.assertRaises(exc.InvalidTokenError, otp.verify, token, time + 60)
#=============================================================================
diff --git a/passlib/totp.py b/passlib/totp.py
index b9f6eaf..9907525 100644
--- a/passlib/totp.py
+++ b/passlib/totp.py
@@ -558,6 +558,8 @@ class OTPContext(object):
AUTO = "auto"
+#: dummy bytes used as temp key for .using() method
+_DUMMY_KEY = b"\x00" * 16
class TOTP(object):
"""
@@ -632,11 +634,6 @@ class TOTP(object):
If set, and application secrets are present, they will be used to encrypt the OTP key
when :meth:`to_json` is invoked.
- :param now:
- Optional callable that should return current time for generator to use.
- Default to :func:`time.time`. This optional is generally not needed,
- and is mainly present for examples & unit-testing.
-
..
See the passlib documentation for list of attributes & methods.
"""
@@ -655,6 +652,10 @@ class TOTP(object):
# instance attrs
#=============================================================================
+ #---------------------------------------------------------------------------
+ # configuration
+ #---------------------------------------------------------------------------
+
#: OTPContext object used to handle encryption/decryption
context = None
@@ -694,24 +695,130 @@ class TOTP(object):
changed = False
#=============================================================================
+ # prototype construction
+ #=============================================================================
+ @classmethod
+ def using(cls, digits=None, alg=None, period=None,
+ issuer=None, context=None, now=None, **kwds):
+ """
+ Dynamically create subtype of :class:`!TOTP` class
+ which has the specified defaults set.
+
+ All keyword arguments function the same as for the TOTP constructor,
+ with the addition of two extra parameters:
+
+ :parameters: **digits, alg, period, issuer**:
+
+ All these options are the same as in the :class:`TOTP` constructor,
+ and the resulting class will use any values you specify here
+ as the default.
+
+ :param context:
+ Optional OTPContext that will be used for encrypting/decrypting keys.
+
+ :param secrets, secret_path, encrypt_cost:
+
+ If specified, these options will be passed to the :class:`OTPContext` constructor,
+ allowing you to directly specify the secret keys that should be used
+ to encrypt & decrypt stored keys.
+
+ :returns:
+ subclass of :class:`!TOTP`.
+
+ This method is useful for creating a TOTP class configured
+ to use your application's secrets for encrypting & decrypting
+ keys, as well as create new keys using it's desired configuration defaults.
+
+ As an example::
+
+ >>> # your application can create a custom class when it initializes
+ >>> from passlib.totp import TOTP, generate_secret
+ >>> TotpFactory = TOTP.using(secrets={"1": generate_secret()})
+
+ >>> # subsequent TOTP objects created from this factory
+ >>> # will use the specified secrets to encrypt their keys...
+ >>> totp = TotpFactory.new()
+ >>> totp.to_dict()
+ {'enckey': {'c': 14,
+ 'k': 'H77SYXWORDPGVOQTFRR2HFUB3C45XXI7',
+ 's': 'G5DOQPIHIBUM2OOHHADQ',
+ 't': '1',
+ 'v': 1},
+ 'type': 'totp',
+ 'v': 1}
+ """
+
+ # :param now:
+ # Optional callable that should return current time for generator to use.
+ # Default to :func:`time.time`. This optional is generally not needed,
+ # and is mainly present for examples & unit-testing.
+
+ subcls = type("TOTPFactory", (cls,), {})
+
+ def norm_param(attr, value):
+ """
+ helper which uses constructor to validate parameter value.
+ it returns corresponding attribute, so we use normalized value.
+ """
+ # NOTE: this creates *subclass* instance,
+ # so normalization takes into account any custom params
+ # already stored.
+ kwds = dict(key=_DUMMY_KEY, format="raw")
+ kwds[attr] = value
+ obj = subcls(**kwds)
+ return getattr(obj, attr)
+
+ if digits is not None:
+ subcls.digits = norm_param("digits", digits)
+
+ if alg is not None:
+ subcls.alg = norm_param("alg", alg)
+
+ if period is not None:
+ subcls.period = norm_param("period", period)
+
+ # XXX: add default size as configurable parameter?
+
+ if issuer is not None:
+ subcls.issuer = norm_param("issuer", issuer)
+
+ if kwds:
+ subcls.context = OTPContext(**kwds)
+ if context:
+ raise TypeError("'context' and 'secrets' keywords are mutually exclusive")
+ elif context is not None:
+ if not isinstance(context, OTPContext):
+ raise exc.ExpectedTypeError(context, OTPContext, "context")
+ subcls.context = context
+
+ if now is not None:
+ assert isinstance(now(), num_types) and now() >= 0, \
+ "now() function must return non-negative int/float"
+ subcls.now = staticmethod(now)
+
+ return subcls
+
+ #=============================================================================
# init
#=============================================================================
@classmethod
def new(cls, **kwds):
- """convenience alias for creating new totp key"""
+ """
+ convenience alias for creating new TOTP key, same as ``TOTP(new=True)``
+ """
return cls(new=True, **kwds)
def __init__(self, key=None, format="base32",
# keyword only...
new=False, digits=None, alg=None, size=None, period=None,
label=None, issuer=None, context=None, changed=False,
- now=None, # NOTE: mainly used for unittesting
**kwds):
super(TOTP, self).__init__(**kwds)
if changed:
self.changed = changed
- self.context = context
+ if context is not None:
+ self.context = context
# validate & normalize alg
info = lookup_hash(alg or self.alg)
@@ -784,18 +891,12 @@ class TOTP(object):
self._check_serial(period, "period", minval=1)
self.period = period
- # use custom timer --
- # intended for examples & unittests, not real-world use.
- if now:
- assert isinstance(now(), num_types) and now() >= 0, \
- "now() function must return non-negative int/float"
- self.now = now
-
#=============================================================================
# helpers to verify value types & ranges
#=============================================================================
- def _check_serial(self, value, param, minval=0):
+ @staticmethod
+ def _check_serial(value, param, minval=0):
"""
check that serial value (e.g. 'counter') is non-negative integer
"""
@@ -804,14 +905,16 @@ class TOTP(object):
if value < minval:
raise ValueError("%s must be >= %d" % (param, minval))
- def _check_label(self, label):
+ @staticmethod
+ def _check_label(label):
"""
check that label doesn't contain chars forbidden by KeyURI spec
"""
if label and ":" in label:
raise ValueError("label may not contain ':'")
- def _check_issuer(self, issuer):
+ @staticmethod
+ def _check_issuer(issuer):
"""
check that issuer doesn't contain chars forbidden by KeyURI spec
"""
@@ -873,7 +976,6 @@ class TOTP(object):
# time & token parsing
#=============================================================================
- # XXX: could be class/static method if not for the '.now' attribute
def normalize_time(self, time):
"""
Normalize time value to unix epoch seconds.