cfd79190ee
Adds TOTP (Time-based One-Time Password) two-factor authentication support including: - New TOTP service with secret generation, QR code provisioning, and code verification - New auth endpoints for enrollment, verification, status, and backup code management - New TOTP authentication method type and user methods for TOTP management - Backup codes generation and verification for account recovery - Updated OIDC endpoints with timezone-aware datetime handling and RFC-compliant responses - Added "roles" scope support for OIDC userinfo and ID tokens - New pyotp dependency for TOTP operations - Comprehensive unit tests for TOTP service
189 lines
6.4 KiB
Python
189 lines
6.4 KiB
Python
"""TOTP (Time-based One-Time Password) service."""
|
|
import base64
|
|
import io
|
|
import logging
|
|
import secrets
|
|
from typing import Tuple
|
|
|
|
import pyotp
|
|
from app.extensions import bcrypt
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TOTPService:
|
|
"""Service for TOTP operations."""
|
|
|
|
@staticmethod
|
|
def generate_secret() -> str:
|
|
"""
|
|
Generate a new TOTP secret.
|
|
|
|
Returns:
|
|
Base32 encoded secret (32 characters)
|
|
|
|
Note:
|
|
The secret is generated using cryptographically secure random bytes
|
|
and encoded in base32 format for compatibility with authenticator apps.
|
|
"""
|
|
# Generate 20 random bytes (160 bits) and encode as base32
|
|
random_bytes = secrets.token_bytes(20)
|
|
secret = base64.b32encode(random_bytes).decode("utf-8")
|
|
logger.debug(f"Generated new TOTP secret: {secret[:8]}...")
|
|
return secret
|
|
|
|
@staticmethod
|
|
def generate_provisioning_uri(user_email: str, secret: str, issuer: str = "Gatehouse") -> str:
|
|
"""
|
|
Generate provisioning URI for QR code.
|
|
|
|
Args:
|
|
user_email: User's email address
|
|
secret: TOTP secret (base32 encoded)
|
|
issuer: Issuer name (default: "Gatehouse")
|
|
|
|
Returns:
|
|
otpauth:// URI for QR code generation
|
|
|
|
Example:
|
|
>>> uri = TOTPService.generate_provisioning_uri("user@example.com", "JBSWY3DPEHPK3PXP")
|
|
>>> print(uri)
|
|
otpauth://totp/Gatehouse:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Gatehouse
|
|
"""
|
|
totp = pyotp.TOTP(secret)
|
|
uri = totp.provisioning_uri(name=user_email, issuer_name=issuer)
|
|
logger.debug(f"Generated provisioning URI for user: {user_email}")
|
|
return uri
|
|
|
|
@staticmethod
|
|
def verify_code(secret: str, code: str, window: int = 1) -> bool:
|
|
"""
|
|
Verify a TOTP code against the secret.
|
|
|
|
Args:
|
|
secret: TOTP secret (base32 encoded)
|
|
code: 6-digit TOTP code to verify
|
|
window: Time window for code validation (default: 1, allows codes from previous/next time steps)
|
|
|
|
Returns:
|
|
True if code is valid, False otherwise
|
|
|
|
Note:
|
|
The window parameter allows for clock skew between the server
|
|
and the authenticator app. A window of 1 allows codes from
|
|
the previous, current, and next 30-second intervals.
|
|
"""
|
|
totp = pyotp.TOTP(secret)
|
|
is_valid = totp.verify(code, valid_window=window)
|
|
logger.debug(f"TOTP code verification: valid={is_valid}, window={window}")
|
|
return is_valid
|
|
|
|
@staticmethod
|
|
def generate_backup_codes(count: int = 10) -> Tuple[list[str], list[str]]:
|
|
"""
|
|
Generate backup codes for TOTP recovery.
|
|
|
|
Args:
|
|
count: Number of backup codes to generate (default: 10)
|
|
|
|
Returns:
|
|
Tuple of (plain_codes, hashed_codes)
|
|
- plain_codes: List of plain text backup codes (for display to user)
|
|
- hashed_codes: List of bcrypt hashed backup codes (for storage)
|
|
|
|
Note:
|
|
Backup codes are 16-character alphanumeric codes that can be used
|
|
to recover access if the TOTP device is lost. Each code can only
|
|
be used once.
|
|
"""
|
|
plain_codes = []
|
|
hashed_codes = []
|
|
|
|
for _ in range(count):
|
|
# Generate a 16-character alphanumeric code
|
|
code = secrets.token_hex(8).upper()
|
|
plain_codes.append(code)
|
|
|
|
# Hash the code using bcrypt
|
|
hashed_code = bcrypt.generate_password_hash(code).decode("utf-8")
|
|
hashed_codes.append(hashed_code)
|
|
|
|
logger.debug(f"Generated {count} backup codes")
|
|
return plain_codes, hashed_codes
|
|
|
|
@staticmethod
|
|
def verify_backup_code(hashed_codes: list[str], code: str) -> Tuple[bool, list[str]]:
|
|
"""
|
|
Verify and consume a backup code.
|
|
|
|
Args:
|
|
hashed_codes: List of bcrypt hashed backup codes
|
|
code: Plain text backup code to verify
|
|
|
|
Returns:
|
|
Tuple of (is_valid, remaining_codes)
|
|
- is_valid: True if code was valid and consumed, False otherwise
|
|
- remaining_codes: List of remaining hashed codes (with consumed code removed)
|
|
|
|
Note:
|
|
Once a backup code is used, it is removed from the list and cannot
|
|
be used again. This ensures each code is single-use.
|
|
"""
|
|
remaining_codes = []
|
|
|
|
for hashed_code in hashed_codes:
|
|
if bcrypt.check_password_hash(hashed_code, code):
|
|
# Code found and valid - don't add to remaining codes (consumed)
|
|
logger.debug("Backup code verified and consumed")
|
|
return True, remaining_codes
|
|
else:
|
|
# Code doesn't match - keep it in remaining codes
|
|
remaining_codes.append(hashed_code)
|
|
|
|
logger.debug("Backup code verification failed")
|
|
return False, remaining_codes
|
|
|
|
@staticmethod
|
|
def generate_qr_code_data_uri(provisioning_uri: str) -> str:
|
|
"""
|
|
Generate QR code as data URI for frontend display.
|
|
|
|
Args:
|
|
provisioning_uri: otpauth:// URI to encode in QR code
|
|
|
|
Returns:
|
|
Base64 encoded PNG image as data URI (data:image/png;base64,...)
|
|
|
|
Note:
|
|
If the qrcode library is not installed, returns a placeholder message.
|
|
Install with: pip install qrcode[pil]
|
|
"""
|
|
try:
|
|
import qrcode
|
|
|
|
# Create QR code
|
|
qr = qrcode.QRCode(
|
|
version=1,
|
|
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
|
box_size=10,
|
|
border=4,
|
|
)
|
|
qr.add_data(provisioning_uri)
|
|
qr.make(fit=True)
|
|
|
|
# Generate image
|
|
img = qr.make_image(fill_color="black", back_color="white")
|
|
|
|
# Convert to base64
|
|
buffer = io.BytesIO()
|
|
img.save(buffer, format="PNG")
|
|
img_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
|
|
|
data_uri = f"data:image/png;base64,{img_base64}"
|
|
logger.debug("Generated QR code data URI")
|
|
return data_uri
|
|
|
|
except ImportError:
|
|
logger.warning("qrcode library not installed, returning placeholder")
|
|
return "QR code generation requires the qrcode library. Install with: pip install qrcode[pil]"
|