Skip to content

Commit

Permalink
Support new parameters in provisioning_uri (#33)
Browse files Browse the repository at this point in the history
* Correct provisioning_uri method's output

Added to the provisioning_uri method in HOTP and TOTP classes the
ability to call the util's function build_uri with all the necessary
arguments.

Added to the function build_uri the ability to handle values different
than the defaults for algorithm, digits and period when generating URI.

* Make PEP-008 and lint errors corrections

Made some corrections to pass the standard PEP-008 in all files.
Corrected lint errors in docstring in utils' function build_uri. Also
corrected an import error for python 2.x and indentation error in utils
module.

* Add test cases for new URI parameters

Added the corresponding tests for the new URI generated parameters for
both, HOTP and TOTP algorithms. Some earlier tests had to be reformed
because now the URI parameters are encoded using urlencode instead of
concatenating strings. The string generated by urlencode comes from a
dictionary and dictionary items do not have a predefined order.

Also corrected some PEP-008 issues.
  • Loading branch information
baco authored and kislyuk committed Aug 16, 2016
1 parent 60f11fc commit 6cedd88
Show file tree
Hide file tree
Showing 6 changed files with 170 additions and 49 deletions.
4 changes: 3 additions & 1 deletion src/pyotp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from __future__ import print_function, unicode_literals, division, absolute_import
from __future__ import (absolute_import, division,
print_function, unicode_literals)

import random as _random

Expand All @@ -7,6 +8,7 @@
from pyotp.totp import TOTP # noqa
from . import utils # noqa


def random_base32(length=16, random=_random.SystemRandom(),
chars=list('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567')):
return ''.join(
Expand Down
6 changes: 5 additions & 1 deletion src/pyotp/hotp.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from __future__ import print_function, unicode_literals, division, absolute_import
from __future__ import (absolute_import, division,
print_function, unicode_literals)

from pyotp.otp import OTP
from pyotp import utils
from future.builtins import str


class HOTP(OTP):
def at(self, count):
"""
Expand Down Expand Up @@ -37,4 +39,6 @@ def provisioning_uri(self, name, initial_count=0, issuer_name=None):
name,
initial_count=initial_count,
issuer_name=issuer_name,
algorithm=self.digest().name,
digits=self.digits
)
9 changes: 6 additions & 3 deletions src/pyotp/otp.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import print_function, unicode_literals, division, absolute_import
from __future__ import (absolute_import, division,
print_function, unicode_literals)

import base64
import hashlib
import hmac
from future.builtins import str


class OTP(object):
def __init__(self, s, digits=6, digest=hashlib.sha1):
"""
Expand Down Expand Up @@ -62,6 +64,7 @@ def int_to_bytestring(i, padding=8):
while i != 0:
result.append(i & 0xFF)
i >>= 8
# It's necessary to convert the final result from bytearray to bytes because
# the hmac functions in python 2.6 and 3.3 don't work with bytearray
# It's necessary to convert the final result from bytearray to bytes
# because the hmac functions in python 2.6 and 3.3 don't work with
# bytearray
return bytes(bytearray(reversed(result)).rjust(padding, b'\0'))
14 changes: 10 additions & 4 deletions src/pyotp/totp.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from __future__ import print_function, unicode_literals, division, absolute_import
from __future__ import (absolute_import, division,
print_function, unicode_literals)

import datetime
import time
Expand All @@ -7,6 +8,7 @@
from pyotp.otp import OTP
from future.builtins import str


class TOTP(OTP):
def __init__(self, *args, **kwargs):
"""
Expand All @@ -21,7 +23,8 @@ def at(self, for_time, counter_offset=0):
Accepts either a Unix timestamp integer or a Time object.
Time objects will be adjusted to UTC automatically
@param [Time/Integer] time the time to generate an OTP for
@param [Integer] counter_offset an amount of ticks to add to the time counter
@param [Integer] counter_offset an amount of ticks to add to the time
counter
"""
if not isinstance(for_time, datetime.datetime):
for_time = datetime.datetime.fromtimestamp(int(for_time))
Expand All @@ -38,7 +41,8 @@ def verify(self, otp, for_time=None, valid_window=0):
"""
Verifies the OTP passed in against the current time OTP
@param [String/Integer] otp the OTP to check against
@param [Integer] valid_window extends the validity to this many counter ticks before and after the current one
@param [Integer] valid_window extends the validity to this many counter
ticks before and after the current one
"""
if for_time is None:
for_time = datetime.datetime.now()
Expand All @@ -59,7 +63,9 @@ def provisioning_uri(self, name, issuer_name=None):
@param [String] name of the account
@return [String] provisioning uri
"""
return utils.build_uri(self.secret, name, issuer_name=issuer_name)
return utils.build_uri(self.secret, name, issuer_name=issuer_name,
algorithm=self.digest().name,
digits=self.digits, period=self.interval)

def timecode(self, for_time):
i = time.mktime(for_time.timetuple())
Expand Down
51 changes: 32 additions & 19 deletions src/pyotp/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from __future__ import print_function, unicode_literals, division, absolute_import
from __future__ import (absolute_import, division,
print_function, unicode_literals)

import unicodedata
try:
Expand All @@ -7,11 +8,13 @@
from itertools import zip_longest as izip_longest

try:
from urllib.parse import quote
from urllib.parse import quote, urlencode
except ImportError:
from urllib import quote
from urllib import quote, urlencode

def build_uri(secret, name, initial_count=None, issuer_name=None):

def build_uri(secret, name, initial_count=None, issuer_name=None,
algorithm=None, digits=None, period=None):
"""
Returns the provisioning URI for the OTP; works for either TOTP or HOTP.
Expand All @@ -21,38 +24,48 @@ def build_uri(secret, name, initial_count=None, issuer_name=None):
For module-internal use.
See also:
http://code.google.com/p/google-authenticator/wiki/KeyUriFormat
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
@param [String] the hotp/totp secret used to generate the URI
@param [String] name of the account
@param [Integer] initial_count starting counter value, defaults to None.
If none, the OTP type will be assumed as TOTP.
@param [String] the name of the OTP issuer; this will be the
organization title of the OTP entry in Authenticator
@param [String] the algorithm used in the OTP generation.
@param [Integer] the length of the OTP generated code.
@param [Integer] the number of seconds the OTP generator is set to
expire every code.
@return [String] provisioning uri
"""
# initial_count may be 0 as a valid param
is_initial_count_present = (initial_count is not None)

# Handling values different from defaults
is_algorithm_set = (algorithm is not None and algorithm != 'sha1')
is_digits_set = (digits is not None and digits != 6)
is_period_set = (period is not None and period != 30)

otp_type = 'hotp' if is_initial_count_present else 'totp'
base = 'otpauth://%s/' % otp_type
base_uri = 'otpauth://{}/{}?{}'

if issuer_name:
issuer_name = quote(issuer_name)
base += '%s:' % issuer_name
url_args = {'secret': secret}

uri = '%(base)s%(name)s?secret=%(secret)s' % {
'name': quote(name, safe='@'),
'secret': secret,
'base': base,
}
label = quote(name)
if issuer_name is not None:
label = quote(issuer_name) + ':' + label
url_args['issuer'] = issuer_name

if is_initial_count_present:
uri += '&counter=%s' % initial_count

if issuer_name:
uri += '&issuer=%s' % issuer_name

url_args['counter'] = initial_count
if is_algorithm_set:
url_args['algorithm'] = algorithm.upper()
if is_digits_set:
url_args['digits'] = digits
if is_period_set:
url_args['period'] = period

uri = base_uri.format(otp_type, label, urlencode(url_args))
return uri


Expand Down
135 changes: 114 additions & 21 deletions test.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
#!/usr/bin/env python
# coding: utf-8

from __future__ import print_function, unicode_literals, division, absolute_import
from __future__ import (absolute_import, division,
print_function, unicode_literals)

import base64
import datetime
import hashlib
import os
import sys
import unittest
try:
from urllib.parse import urlparse, parse_qsl
except ImportError:
from urlparse import urlparse, parse_qsl

sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
import pyotp


class HOTPExampleValuesFromTheRFC(unittest.TestCase):
def test_match_rfc(self):
# 12345678901234567890 in Bas32
Expand All @@ -38,26 +44,65 @@ def test_verify_otp_reuse(self):
def test_provisioning_uri(self):
hotp = pyotp.HOTP('wrn3pqx5uqxqvnqr')

self.assertEqual(
hotp.provisioning_uri('mark@percival'),
'otpauth://hotp/mark@percival?secret=wrn3pqx5uqxqvnqr&counter=0')

self.assertEqual(
hotp.provisioning_uri('mark@percival', initial_count=12),
'otpauth://hotp/mark@percival?secret=wrn3pqx5uqxqvnqr&counter=12')

self.assertEqual(
hotp.provisioning_uri('mark@percival', issuer_name='FooCorp!'),
'otpauth://hotp/FooCorp%21:mark@percival?secret=wrn3pqx5uqxqvnqr&counter=0&issuer=FooCorp%21')
url = urlparse(
hotp.provisioning_uri('mark@percival'))
self.assertEqual(url.scheme, 'otpauth')
self.assertEqual(url.netloc, 'hotp')
self.assertEqual(url.path, '/mark%40percival')
self.assertEqual(dict(parse_qsl(url.query)),
{'secret': 'wrn3pqx5uqxqvnqr', 'counter': '0'})

url = urlparse(
hotp.provisioning_uri('mark@percival', initial_count=12))
self.assertEqual(url.scheme, 'otpauth')
self.assertEqual(url.netloc, 'hotp')
self.assertEqual(url.path, '/mark%40percival')
self.assertEqual(dict(parse_qsl(url.query)),
{'secret': 'wrn3pqx5uqxqvnqr', 'counter': '12'})

url = urlparse(
hotp.provisioning_uri('mark@percival', issuer_name='FooCorp!'))
self.assertEqual(url.scheme, 'otpauth')
self.assertEqual(url.netloc, 'hotp')
self.assertEqual(url.path, '/FooCorp%21:mark%40percival')
self.assertEqual(dict(parse_qsl(url.query)),
{'secret': 'wrn3pqx5uqxqvnqr', 'counter': '0',
'issuer': 'FooCorp!'})

key = 'c7uxuqhgflpw7oruedmglbrk7u6242vb'
hotp = pyotp.HOTP(key, digits=8, digest=hashlib.sha256)
url = urlparse(
hotp.provisioning_uri('baco@peperina', issuer_name='FooCorp'))
self.assertEqual(url.scheme, 'otpauth')
self.assertEqual(url.netloc, 'hotp')
self.assertEqual(url.path, '/FooCorp:baco%40peperina')
self.assertEqual(dict(parse_qsl(url.query)),
{'secret': 'c7uxuqhgflpw7oruedmglbrk7u6242vb',
'counter': '0', 'issuer': 'FooCorp',
'digits': '8', 'algorithm': 'SHA256'})

hotp = pyotp.HOTP(key, digits=8)
url = urlparse(
hotp.provisioning_uri('baco@peperina', issuer_name='FooCorp',
initial_count=10))
self.assertEqual(url.scheme, 'otpauth')
self.assertEqual(url.netloc, 'hotp')
self.assertEqual(url.path, '/FooCorp:baco%40peperina')
self.assertEqual(dict(parse_qsl(url.query)),
{'secret': 'c7uxuqhgflpw7oruedmglbrk7u6242vb',
'counter': '10', 'issuer': 'FooCorp',
'digits': '8'})

def test_other_secret(self):
hotp = pyotp.HOTP('N3OVNIBRERIO5OHGVCMDGS4V4RJ3AUZOUN34J6FRM4P6JIFCG3ZA')
hotp = pyotp.HOTP(
'N3OVNIBRERIO5OHGVCMDGS4V4RJ3AUZOUN34J6FRM4P6JIFCG3ZA')
self.assertEqual(hotp.at(0), '737863')
self.assertEqual(hotp.at(1), '390601')
self.assertEqual(hotp.at(2), '363354')
self.assertEqual(hotp.at(3), '936780')
self.assertEqual(hotp.at(4), '654019')


class TOTPExampleValuesFromTheRFC(unittest.TestCase):
RFC_VALUES = {
(hashlib.sha1, b'12345678901234567890'): (
Expand All @@ -78,7 +123,9 @@ class TOTPExampleValuesFromTheRFC(unittest.TestCase):
(20000000000, '77737706'),
),

(hashlib.sha512, b'1234567890123456789012345678901234567890123456789012345678901234'): (
(hashlib.sha512,
b'1234567890123456789012345678901234567890123456789012345678901234'):
(
(59, 90693936),
(1111111109, '25091201'),
(1111111111, '99943326'),
Expand Down Expand Up @@ -125,13 +172,57 @@ def test_validate_totp_with_digit_length(self):

def test_provisioning_uri(self):
totp = pyotp.TOTP('wrn3pqx5uqxqvnqr')
self.assertEqual(
totp.provisioning_uri('mark@percival'),
'otpauth://totp/mark@percival?secret=wrn3pqx5uqxqvnqr')

self.assertEqual(
totp.provisioning_uri('mark@percival', issuer_name='FooCorp!'),
'otpauth://totp/FooCorp%21:mark@percival?secret=wrn3pqx5uqxqvnqr&issuer=FooCorp%21')
url = urlparse(
totp.provisioning_uri('mark@percival'))
self.assertEqual(url.scheme, 'otpauth')
self.assertEqual(url.netloc, 'totp')
self.assertEqual(url.path, '/mark%40percival')
self.assertEqual(dict(parse_qsl(url.query)),
{'secret': 'wrn3pqx5uqxqvnqr'})

url = urlparse(
totp.provisioning_uri('mark@percival', issuer_name='FooCorp!'))
self.assertEqual(url.scheme, 'otpauth')
self.assertEqual(url.netloc, 'totp')
self.assertEqual(url.path, '/FooCorp%21:mark%40percival')
self.assertEqual(dict(parse_qsl(url.query)),
{'secret': 'wrn3pqx5uqxqvnqr',
'issuer': 'FooCorp!'})

key = 'c7uxuqhgflpw7oruedmglbrk7u6242vb'
totp = pyotp.TOTP(key, digits=8, interval=60, digest=hashlib.sha256)
url = urlparse(
totp.provisioning_uri('baco@peperina', issuer_name='FooCorp'))
self.assertEqual(url.scheme, 'otpauth')
self.assertEqual(url.netloc, 'totp')
self.assertEqual(url.path, '/FooCorp:baco%40peperina')
self.assertEqual(dict(parse_qsl(url.query)),
{'secret': 'c7uxuqhgflpw7oruedmglbrk7u6242vb',
'issuer': 'FooCorp',
'digits': '8', 'period': '60',
'algorithm': 'SHA256'})

totp = pyotp.TOTP(key, digits=8, interval=60)
url = urlparse(
totp.provisioning_uri('baco@peperina', issuer_name='FooCorp'))
self.assertEqual(url.scheme, 'otpauth')
self.assertEqual(url.netloc, 'totp')
self.assertEqual(url.path, '/FooCorp:baco%40peperina')
self.assertEqual(dict(parse_qsl(url.query)),
{'secret': 'c7uxuqhgflpw7oruedmglbrk7u6242vb',
'issuer': 'FooCorp',
'digits': '8', 'period': '60'})

totp = pyotp.TOTP(key, digits=8)
url = urlparse(
totp.provisioning_uri('baco@peperina', issuer_name='FooCorp'))
self.assertEqual(url.scheme, 'otpauth')
self.assertEqual(url.netloc, 'totp')
self.assertEqual(url.path, '/FooCorp:baco%40peperina')
self.assertEqual(dict(parse_qsl(url.query)),
{'secret': 'c7uxuqhgflpw7oruedmglbrk7u6242vb',
'issuer': 'FooCorp',
'digits': '8'})

def test_random_key_generation(self):
self.assertEqual(len(pyotp.random_base32()), 16)
Expand Down Expand Up @@ -168,6 +259,7 @@ def test_counter_offset(self):
self.assertEqual(totp.at(200), "028307")
self.assertTrue(totp.at(200, 1), "681610")


class ValidWindowTest(unittest.TestCase):
def test_valid_window(self):
totp = pyotp.TOTP("ABCDEFGH")
Expand All @@ -176,6 +268,7 @@ def test_valid_window(self):
self.assertTrue(totp.verify("681610", 200, 1))
self.assertFalse(totp.verify("195979", 200, 1))


class Timecop(object):
"""
Half-assed clone of timecop.rb, just enough to pass our tests.
Expand Down

0 comments on commit 6cedd88

Please sign in to comment.