Skip to content

Improve image error msg #181

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/pyotp/contrib/steam.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,24 @@ class Steam(TOTP):
"""

def __init__(
self, s: str, name: Optional[str] = None, issuer: Optional[str] = None, interval: int = 30, digits: int = 5
self, s: str, name: Optional[str] = None, issuer: Optional[str] = None, interval: int = 30, digits: int = 10
) -> None:
"""
:param s: secret in base32 format
:param s: secret in base32 format.
:param interval: the time interval in seconds for OTP. This defaults to 30.
:param name: account name
:param issuer: issuer
:param name: account name.
:param issuer: issuer.
:param digits: This parameter is ignored. Steam requires OTPs to be exactly 10 digits long,
so this value is hardcoded to 10 internally. It is only retained for
compatibility with the `pyotp.totp.TOTP` interface.
"""
self.interval = interval
super().__init__(s=s, digits=10, digest=hashlib.sha1, name=name, issuer=issuer)

def generate_otp(self, input: int) -> str:
"""
:param input: the HMAC counter value to use as the OTP input.
Usually either the counter, or the computed integer based on the Unix timestamp
Usually either the counter, or the computed integer based on the Unix timestamp.
"""
str_code = super().generate_otp(input)
int_code = int(str_code)
Expand Down
43 changes: 23 additions & 20 deletions src/pyotp/hotp.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ class HOTP(OTP):
"""

def __init__(
self,
s: str,
digits: int = 6,
digest: Any = None,
name: Optional[str] = None,
issuer: Optional[str] = None,
initial_count: int = 0,
self,
s: str,
digits: int = 6,
digest: Any = None,
name: Optional[str] = None,
issuer: Optional[str] = None,
initial_count: int = 0,
) -> None:
"""
:param s: secret in base32 format
Expand All @@ -39,26 +39,27 @@ def at(self, count: int) -> str:
"""
Generates the OTP for the given count.

:param count: the OTP HMAC counter
:returns: OTP
:param count: the OTP HMAC counter.
:returns: OTP instance.
"""
return self.generate_otp(self.initial_count + count)

def verify(self, otp: str, counter: int) -> bool:
"""
Verifies the OTP passed in against the current counter OTP.

:param otp: the OTP to check against
:param counter: the OTP HMAC counter
:param otp: the OTP to check against.
:param counter: the OTP HMAC counter.
"""
return utils.strings_equal(str(otp), str(self.at(counter)))

def provisioning_uri(
self,
name: Optional[str] = None,
initial_count: Optional[int] = None,
issuer_name: Optional[str] = None,
**kwargs,
self,
name: Optional[str] = None,
initial_count: Optional[int] = None,
issuer_name: Optional[str] = None,
image: Optional[str] = None,
**kwargs,
) -> str:
"""
Returns the provisioning URI for the OTP. This can then be
Expand All @@ -68,11 +69,12 @@ def provisioning_uri(
See also:
https://github.com/google/google-authenticator/wiki/Key-Uri-Format

:param name: name of the user account
:param initial_count: starting HMAC counter value, defaults to 0
:param name: name of the user account.
:param initial_count: starting HMAC counter value, defaults to 0.
:param issuer_name: the name of the OTP issuer; this will be the
organization title of the OTP entry in Authenticator
:returns: provisioning URI
organization title of the OTP entry in Authenticator.
:param image: the URL of the image to be displayed in the OTP.
:returns: provisioning URI.
"""
return utils.build_uri(
self.secret,
Expand All @@ -81,5 +83,6 @@ def provisioning_uri(
issuer=issuer_name if issuer_name else self.issuer,
algorithm=self.digest().name,
digits=self.digits,
image=image,
**kwargs,
)
31 changes: 17 additions & 14 deletions src/pyotp/otp.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ class OTP(object):
"""

def __init__(
self,
s: str,
digits: int = 6,
digest: Any = hashlib.sha1,
name: Optional[str] = None,
issuer: Optional[str] = None,
self,
s: str,
digits: int = 6,
digest: Any = hashlib.sha1,
name: Optional[str] = None,
issuer: Optional[str] = None,
) -> None:
self.digits = digits
if digits > 10:
Expand All @@ -30,7 +30,7 @@ def __init__(
def generate_otp(self, input: int) -> str:
"""
:param input: the HMAC counter value to use as the OTP input.
Usually either the counter, or the computed integer based on the Unix timestamp
Usually either the counter, or the computed integer based on the Unix timestamp.
"""
if input < 0:
raise ValueError("input must be positive integer")
Expand All @@ -40,27 +40,30 @@ def generate_otp(self, input: int) -> str:
hmac_hash = bytearray(hasher.digest())
offset = hmac_hash[-1] & 0xF
code = (
(hmac_hash[offset] & 0x7F) << 24
| (hmac_hash[offset + 1] & 0xFF) << 16
| (hmac_hash[offset + 2] & 0xFF) << 8
| (hmac_hash[offset + 3] & 0xFF)
(hmac_hash[offset] & 0x7F) << 24
| (hmac_hash[offset + 1] & 0xFF) << 16
| (hmac_hash[offset + 2] & 0xFF) << 8
| (hmac_hash[offset + 3] & 0xFF)
)
str_code = str(10_000_000_000 + (code % 10**self.digits))
return str_code[-self.digits :]
str_code = str(10_000_000_000 + (code % 10 ** self.digits))
return str_code[-self.digits:]

def byte_secret(self) -> bytes:
"""Decode a base32-encoded secret into its raw byte representation."""
secret = self.secret
missing_padding = len(secret) % 8
if missing_padding != 0:
secret += "=" * (8 - missing_padding)

# `casefold=True`, which allows the decoder to process both uppercase and lowercase characters.
return base64.b32decode(secret, casefold=True)

@staticmethod
def int_to_bytestring(i: int, padding: int = 8) -> bytes:
"""
Turns an integer to the OATH specified
bytestring, which is fed to the HMAC
along with the secret
along with the secret.
"""
result = bytearray()
while i != 0:
Expand Down
52 changes: 29 additions & 23 deletions src/pyotp/totp.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,20 @@ class TOTP(OTP):
"""

def __init__(
self,
s: str,
digits: int = 6,
digest: Any = None,
name: Optional[str] = None,
issuer: Optional[str] = None,
interval: int = 30,
self,
s: str,
digits: int = 6,
digest: Any = None,
name: Optional[str] = None,
issuer: Optional[str] = None,
interval: int = 30,
) -> None:
"""
:param s: secret in base32 format
:param s: secret in base32 format.
:param digits: number of integers in the OTP. Some apps expect this to be 6 digits, others support more.
:param digest: digest function to use in the HMAC (expected to be SHA1)
:param name: account name
:param issuer: issuer
:param digest: digest function to use in the HMAC (expected to be SHA1).
:param name: account name.
:param issuer: issuer.
:param interval: the time interval in seconds for OTP. This defaults to 30.
"""
if digest is None:
Expand All @@ -49,30 +49,30 @@ def at(self, for_time: Union[int, datetime.datetime], counter_offset: int = 0) -
totp = pyotp.TOTP(...)
time_remaining = totp.interval - datetime.datetime.now().timestamp() % totp.interval

:param for_time: the time to generate an OTP for
:param counter_offset: the amount of ticks to add to the time counter
:returns: OTP value
:param for_time: the time to generate an OTP for.
:param counter_offset: the amount of ticks to add to the time counter.
:returns: OTP value.
"""
if not isinstance(for_time, datetime.datetime):
for_time = datetime.datetime.fromtimestamp(int(for_time))
return self.generate_otp(self.timecode(for_time) + counter_offset)

def now(self) -> str:
"""
Generate the current time OTP
Generate the current time OTP.

:returns: OTP value
:returns: OTP value.
"""
return self.generate_otp(self.timecode(datetime.datetime.now()))

def verify(self, otp: str, for_time: Optional[datetime.datetime] = None, valid_window: int = 0) -> bool:
"""
Verifies the OTP passed in against the current time OTP.

:param otp: the OTP to check against
:param for_time: Time to check OTP at (defaults to now)
:param valid_window: extends the validity to this many counter ticks before and after the current one
:returns: True if verification succeeded, False otherwise
:param otp: the OTP to check against.
:param for_time: Time to check OTP at (defaults to now).
:param valid_window: extends the validity to this many counter ticks before and after the current one.
:returns: True if verification succeeded, False otherwise.
"""
if for_time is None:
for_time = datetime.datetime.now()
Expand All @@ -85,15 +85,21 @@ def verify(self, otp: str, for_time: Optional[datetime.datetime] = None, valid_w

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

def provisioning_uri(self, name: Optional[str] = None, issuer_name: Optional[str] = None, **kwargs) -> str:
def provisioning_uri(self, name: Optional[str] = None, issuer_name: Optional[str] = None,
image: Optional[str] = None, **kwargs) -> str:
"""
Returns the provisioning URI for the OTP. This can then be
encoded in a QR Code and used to provision an OTP app like
Google Authenticator.

See also:
https://github.com/google/google-authenticator/wiki/Key-Uri-Format

:param name: name of the user account.
:param issuer_name: the name of the OTP issuer; this will be the
organization title of the OTP entry in Authenticator.
:param image: the URL of the image to be displayed in the OTP.
:param kwargs: other query string parameters to include in the URI.
:returns: provisioning URI.
"""
return utils.build_uri(
self.secret,
Expand All @@ -102,6 +108,7 @@ def provisioning_uri(self, name: Optional[str] = None, issuer_name: Optional[str
algorithm=self.digest().name,
digits=self.digits,
period=self.interval,
image=image,
**kwargs,
)

Expand All @@ -110,7 +117,6 @@ def timecode(self, for_time: datetime.datetime) -> int:
Accepts either a timezone naive (`for_time.tzinfo is None`) or
a timezone aware datetime as argument and returns the
corresponding counter value (timecode).

"""
if for_time.tzinfo:
return int(calendar.timegm(for_time.utctimetuple()) / self.interval)
Expand Down
56 changes: 38 additions & 18 deletions src/pyotp/utils.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import unicodedata
from hmac import compare_digest
from typing import Dict, Optional, Union
from urllib.parse import quote, urlencode, urlparse

import unicodedata


def build_uri(
secret: str,
name: str,
initial_count: Optional[int] = None,
issuer: Optional[str] = None,
algorithm: Optional[str] = None,
digits: Optional[int] = None,
period: Optional[int] = None,
**kwargs,
secret: str,
name: str,
initial_count: Optional[int] = None,
issuer: Optional[str] = None,
algorithm: Optional[str] = None,
digits: Optional[int] = None,
period: Optional[int] = None,
image: Optional[str] = None,
**kwargs,
) -> str:
"""
Returns the provisioning URI for the OTP; works for either TOTP or HOTP.
Expand All @@ -25,18 +27,19 @@ def build_uri(
See also:
https://github.com/google/google-authenticator/wiki/Key-Uri-Format

:param secret: the hotp/totp secret used to generate the URI
:param name: name of the account
:param secret: the hotp/totp secret used to generate the URI.
:param name: name of the account.
:param initial_count: starting counter value, defaults to None.
If none, the OTP type will be assumed as TOTP.
:param issuer: the name of the OTP issuer; this will be the
organization title of the OTP entry in Authenticator
organization title of the OTP entry in Authenticator.
:param algorithm: the algorithm used in the OTP generation.
:param digits: the length of the OTP generated code.
:param period: the number of seconds the OTP generator is set to
expire every code.
:param kwargs: other query string parameters to include in the URI
:returns: provisioning uri
:param image: the URL of the image to be displayed in the OTP.
:param kwargs: other query string parameters to include in the URI.
:returns: provisioning uri.
"""
# initial_count may be 0 as a valid param
is_initial_count_present = initial_count is not None
Expand Down Expand Up @@ -64,13 +67,30 @@ def build_uri(
url_args["digits"] = digits
if is_period_set:
url_args["period"] = period

# Parse the image URL to validate it
if image is not None:
image_uri = urlparse(image)

# Get corresponding error message
mgs_error: str | None
if image_uri.scheme != "https":
mgs_error = "image URL must be HTTPS"
elif not image_uri.netloc:
mgs_error = "image URL must have a netloc"
elif not image_uri.path:
mgs_error = "image URL must have a path"
else:
mgs_error = None

if mgs_error is not None:
raise ValueError("{} is not a valid url: {}".format(image_uri, mgs_error))

url_args["image"] = image

for k, v in kwargs.items():
if not isinstance(v, str):
raise ValueError("All otpauth uri parameters must be strings")
if k == "image":
image_uri = urlparse(v)
if image_uri.scheme != "https" or not image_uri.netloc or not image_uri.path:
raise ValueError("{} is not a valid url".format(image_uri))
url_args[k] = v

uri = base_uri.format(otp_type, label, urlencode(url_args).replace("+", "%20"))
Expand Down