You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
166 lines
5.3 KiB
166 lines
5.3 KiB
import base64
|
|
import hashlib
|
|
import hmac
|
|
import io
|
|
import secrets
|
|
import struct
|
|
import time
|
|
from typing import Optional
|
|
from urllib.parse import quote
|
|
|
|
try:
|
|
import pyotp # type: ignore
|
|
except ImportError:
|
|
pyotp = None
|
|
|
|
try:
|
|
import qrcode # type: ignore
|
|
import qrcode.image.svg # type: ignore
|
|
except ImportError:
|
|
qrcode = None
|
|
|
|
|
|
BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
|
|
|
|
|
def generate_totp_secret(length: int = 32) -> str:
|
|
"""Generate a cryptographically secure RFC 4648 Base32 secret for TOTP."""
|
|
if pyotp is not None:
|
|
return pyotp.random_base32(length=length)
|
|
return "".join(secrets.choice(BASE32_ALPHABET) for _ in range(length))
|
|
|
|
|
|
def _decode_base32_secret(secret: str) -> bytes:
|
|
cleaned = "".join(secret.strip().split()).upper()
|
|
padding = (8 - len(cleaned) % 8) % 8
|
|
cleaned += "=" * padding
|
|
return base64.b32decode(cleaned, casefold=True)
|
|
|
|
|
|
def get_totp_code(
|
|
secret: str,
|
|
for_time: Optional[int] = None,
|
|
interval: int = 30,
|
|
digits: int = 6,
|
|
) -> str:
|
|
"""Compute the 6-digit RFC 6238 TOTP code for a given timestamp."""
|
|
if not secret:
|
|
return ""
|
|
ts = int(for_time if for_time is not None else time.time())
|
|
if pyotp is not None:
|
|
try:
|
|
totp = pyotp.TOTP(secret, digits=digits, interval=interval)
|
|
return totp.at(ts)
|
|
except Exception:
|
|
pass
|
|
|
|
key = _decode_base32_secret(secret)
|
|
counter = int(ts // interval)
|
|
msg = struct.pack(">Q", counter)
|
|
digest = hmac.new(key, msg, hashlib.sha1).digest()
|
|
offset = digest[-1] & 0x0F
|
|
binary = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF
|
|
otp = binary % (10 ** digits)
|
|
return f"{otp:0{digits}d}"
|
|
|
|
|
|
def verify_totp_code(
|
|
secret: str,
|
|
code: str,
|
|
valid_window: int = 1,
|
|
interval: int = 30,
|
|
digits: int = 6,
|
|
) -> bool:
|
|
"""Verify a TOTP code within the allowed time-step window (RFC 6238)."""
|
|
if not secret or not code:
|
|
return False
|
|
normalized = "".join(str(code).strip().split()).replace("-", "")
|
|
if not normalized.isdigit() or len(normalized) != digits:
|
|
return False
|
|
|
|
if pyotp is not None:
|
|
try:
|
|
totp = pyotp.TOTP(secret, digits=digits, interval=interval)
|
|
return bool(totp.verify(normalized, valid_window=valid_window))
|
|
except Exception:
|
|
pass
|
|
|
|
now = int(time.time())
|
|
for offset in range(-valid_window, valid_window + 1):
|
|
candidate = get_totp_code(
|
|
secret=secret,
|
|
for_time=now + (offset * interval),
|
|
interval=interval,
|
|
digits=digits,
|
|
)
|
|
if candidate and hmac.compare_digest(candidate, normalized):
|
|
return True
|
|
return False
|
|
|
|
|
|
def build_otpauth_uri(
|
|
secret: str,
|
|
account_name: str,
|
|
issuer: str = "Muslim Connect Hub",
|
|
) -> str:
|
|
"""Build a standard otpauth:// URI for Google/Microsoft Authenticator."""
|
|
if pyotp is not None:
|
|
try:
|
|
return pyotp.TOTP(secret).provisioning_uri(
|
|
name=account_name,
|
|
issuer_name=issuer,
|
|
)
|
|
except Exception:
|
|
pass
|
|
label = f"{quote(issuer)}:{quote(account_name)}"
|
|
issuer_param = quote(issuer)
|
|
return (
|
|
f"otpauth://totp/{label}"
|
|
f"?secret={secret}&issuer={issuer_param}&algorithm=SHA1&digits=6&period=30"
|
|
)
|
|
|
|
|
|
def generate_qr_data_url(otpauth_uri: str) -> str:
|
|
"""
|
|
Generate a base64 Data URL (PNG or SVG) for the given otpauth:// URI.
|
|
"""
|
|
if qrcode is not None:
|
|
try:
|
|
qr = qrcode.QRCode(
|
|
version=1,
|
|
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
|
box_size=8,
|
|
border=2,
|
|
)
|
|
qr.add_data(otpauth_uri)
|
|
qr.make(fit=True)
|
|
img = qr.make_image(fill_color="black", back_color="white")
|
|
buf = io.BytesIO()
|
|
img.save(buf, format="PNG")
|
|
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
|
return f"data:image/png;base64,{b64}"
|
|
except Exception:
|
|
try:
|
|
img = qrcode.make(otpauth_uri, image_factory=qrcode.image.svg.SvgImage)
|
|
buf = io.BytesIO()
|
|
img.save(buf)
|
|
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
|
return f"data:image/svg+xml;base64,{b64}"
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback SVG placeholder if qrcode package is unavailable
|
|
svg = (
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">'
|
|
'<rect width="200" height="200" fill="#ffffff" stroke="#e2e8f0" stroke-width="4"/>'
|
|
'<rect x="20" y="20" width="50" height="50" fill="none" stroke="#0f172a" stroke-width="8"/>'
|
|
'<rect x="35" y="35" width="20" height="20" fill="#0f172a"/>'
|
|
'<rect x="130" y="20" width="50" height="50" fill="none" stroke="#0f172a" stroke-width="8"/>'
|
|
'<rect x="145" y="35" width="20" height="20" fill="#0f172a"/>'
|
|
'<rect x="20" y="130" width="50" height="50" fill="none" stroke="#0f172a" stroke-width="8"/>'
|
|
'<rect x="35" y="145" width="20" height="20" fill="#0f172a"/>'
|
|
'<rect x="85" y="85" width="30" height="30" fill="#0f172a"/>'
|
|
'</svg>'
|
|
)
|
|
b64 = base64.b64encode(svg.encode("utf-8")).decode("ascii")
|
|
return f"data:image/svg+xml;base64,{b64}"
|