M-Bus Gateway
← Tilbage til blog
· TOTP· 2FA· pyotp· Python· FastAPI· sikkerhed· autentifikation· QR-kode· Google Authenticator· backup-koder

TOTP 2FA i Python med pyotp — tidsbaseret engangskoder til SaaS

TOTP (Time-based One-Time Password) 2FA med pyotp: hemmeligt nøgle-generering, QR-kode til authenticator-apps, verifikation, backup-koder og FastAPI integration.

Af M-Bus Gateway

M-Bus Gateway platformen tilbyder TOTP 2FA til udlejer- og admin-konti. Her er den komplette implementering med pyotp, QR-kode og backup-koder.


TOTP: Tidsbaseret engangskode

TOTP (Time-based One-Time Password) — RFC 6238:
  → Genererer 6-cifret kode baseret på:
    - Delt hemmeligt nøgle (Base32-kodet)
    - Nuværende tid (30-sekunders vindue)
  → Bruger HMAC-SHA1 (standard)
  → Samme nøgle → same kode i 30 sek → ny kode

Eksempel:
  Nøgle: JBSWY3DPEHPK3PXP
  Tid:   2026-05-24 10:00:15 UTC
  Kode:  283746

  30 sek senere (10:00:45):
  Kode:  519284 (ny kode)

Sikkerhed:
  → Nøglen gemmes krypteret i databasen
  → Koden kan ikke genbruges (server markerer brugte koder)
  → Time-drift tolerance: ±30 sek (1 periode)
  → Brute force: 10^6 kombinationer / 30 sek = håbløst

pyotp: Implementering

# server/src/auth/totp.py
import pyotp
import qrcode
import io
import base64
import secrets
from server.src.core.config import settings


def generate_totp_secret() -> str:
    """
    Generér ny TOTP hemmelighed.
    32 bytes = 256 bits entropi (overkill men fremtidssikret).
    """
    return pyotp.random_base32()


def get_totp_uri(secret: str, email: str) -> str:
    """
    Generér otpauth:// URI til QR-kode.
    Format: otpauth://totp/ISSUER:EMAIL?secret=SECRET&issuer=ISSUER
    """
    totp = pyotp.TOTP(secret)
    return totp.provisioning_uri(
        name=email,
        issuer_name="M-Bus Gateway",
    )


def generate_qr_code_base64(uri: str) -> str:
    """
    Generér QR-kode som base64-kodet PNG.
    Sendes til frontend som data:image/png;base64,...
    """
    img = qrcode.make(uri)
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    buf.seek(0)
    return base64.b64encode(buf.read()).decode()


def verify_totp(secret: str, code: str) -> bool:
    """
    Verificér TOTP-kode.
    valid_window=1: Acceptér ±30 sekunder (1 ekstra vindue).
    """
    if not code or not code.strip().isdigit() or len(code.strip()) != 6:
        return False

    totp = pyotp.TOTP(secret)
    return totp.verify(code.strip(), valid_window=1)

Backup-koder: Nødadgang

# server/src/auth/backup_codes.py
import secrets
import hashlib

def generate_backup_codes(count: int = 10) -> tuple[list[str], list[str]]:
    """
    Generér backup-koder til 2FA nødadgang.
    Returnerer (plaintext_codes, hashed_codes).
    Gem KUN hashed i DB — send plaintext til bruger én gang.
    """
    codes = []
    hashes = []

    for _ in range(count):
        # Format: XXXX-XXXX-XXXX (12 hex chars + bindestreger)
        raw = secrets.token_hex(6)  # 6 bytes = 12 hex chars
        code = f"{raw[:4]}-{raw[4:8]}-{raw[8:]}".upper()
        codes.append(code)

        # Hash med SHA-256 (ingen salt nødvendigt — kode er allerede tilfældig):
        code_hash = hashlib.sha256(code.encode()).hexdigest()
        hashes.append(code_hash)

    return codes, hashes


def verify_backup_code(code: str, stored_hashes: list[str]) -> tuple[bool, str | None]:
    """
    Verificér backup-kode og returnér den brugte hash (til sletning).
    Backup-koder kan kun bruges én gang.
    """
    code = code.strip().upper().replace(" ", "")
    code_hash = hashlib.sha256(code.encode()).hexdigest()

    if code_hash in stored_hashes:
        return True, code_hash
    return False, None

FastAPI endpoints: 2FA setup flow

# server/src/auth/router.py

@router.post("/2fa/setup")
async def setup_2fa(
    session: AsyncSession = Depends(get_session),
    user: TokenPayload = Depends(get_current_user),
):
    """
    Trin 1: Generér TOTP hemmelighed og QR-kode.
    Bruger scanner QR → trin 2 verificerer.
    """
    db_user = await session.get(User, user.sub)

    if db_user.totp_enabled:
        raise HTTPException(400, "2FA er allerede aktiveret")

    # Generér nyt secret (gemmes IKKE i DB endnu — først ved verifikation):
    secret = generate_totp_secret()

    # Gem midlertidigt i Redis (5 min timeout):
    await redis.setex(f"2fa_setup:{user.sub}", 300, secret)

    qr_uri = get_totp_uri(secret, db_user.email)
    qr_base64 = generate_qr_code_base64(qr_uri)

    return {
        "qr_code": f"data:image/png;base64,{qr_base64}",
        "manual_key": secret,  # Til manuel indtastning
    }


@router.post("/2fa/verify-setup")
async def verify_2fa_setup(
    body: TOTPVerifyIn,
    session: AsyncSession = Depends(get_session),
    user: TokenPayload = Depends(get_current_user),
):
    """
    Trin 2: Verificér TOTP-kode fra authenticator-app.
    Aktiverer 2FA og returnerer backup-koder.
    """
    # Hent det midlertidigt gemte secret:
    secret = await redis.get(f"2fa_setup:{user.sub}")
    if not secret:
        raise HTTPException(400, "Setup-session udløbet — start forfra")
    secret = secret.decode()

    if not verify_totp(secret, body.code):
        raise HTTPException(400, "Forkert kode — prøv igen")

    # Generér backup-koder:
    plaintext_codes, hashed_codes = generate_backup_codes(10)

    # Gem i DB:
    db_user = await session.get(User, user.sub)
    db_user.totp_secret = encrypt(secret)  # Krypteret lagring
    db_user.totp_backup_codes = hashed_codes
    db_user.totp_enabled = True
    session.add(db_user)
    await session.commit()

    # Slet Redis-nøgle:
    await redis.delete(f"2fa_setup:{user.sub}")

    return {
        "status": "enabled",
        "backup_codes": plaintext_codes,  # Vis KUN én gang!
        "message": "Gem backup-koderne på et sikkert sted",
    }


@router.post("/2fa/verify")
async def verify_2fa_login(
    body: TOTPVerifyIn,
    session: AsyncSession = Depends(get_session),
    user_id: str = Depends(get_pending_2fa_user),  # Fra 2FA-session cookie
):
    """Verificér TOTP-kode ved login — fuldfør autentifikation."""
    db_user = await session.get(User, user_id)

    if not db_user.totp_enabled:
        raise HTTPException(400, "2FA ikke aktiveret")

    # Prøv TOTP-kode:
    secret = decrypt(db_user.totp_secret)
    if verify_totp(secret, body.code):
        return await _issue_full_tokens(db_user)

    # Prøv backup-kode:
    valid, used_hash = verify_backup_code(body.code, db_user.totp_backup_codes)
    if valid:
        # Slet brugt backup-kode:
        db_user.totp_backup_codes = [h for h in db_user.totp_backup_codes if h != used_hash]
        session.add(db_user)
        await session.commit()
        return await _issue_full_tokens(db_user)

    raise HTTPException(400, "Forkert kode")

Konklusion

TOTP 2FA med pyotp giver stærk second-factor autentifikation uden ekstern afhængighed. QR-kode til Google Authenticator/Authy. Backup-koder med SHA-256-hashing og single-use sikrer nødadgang. FastAPI integration: 2-trins setup (generér + verificér), 2FA-login som sekundær check efter primær adgangskode-login.

Se JWT auth guide eller bcrypt password hashing guide.