← Blog

API SMS do 2FA i OTP. Implementacja i bezpieczeństwo

Zespół Przypominamy.com · 18 maja 2026 · 9 min czytania

Dwuskładnikowe uwierzytelnianie (2FA) i jednorazowe kody (OTP) wysyłane SMS-em to standard branżowy dla bankowości, e-commerce, logowań do paneli administracyjnych i potwierdzeń transakcji. W tym przewodniku pokazujemy, jak zaimplementować 2FA z REST API Przypominamy.com — od generowania bezpiecznego kodu, przez wysyłkę i weryfikację, po obronę przed brute force, ochronę RODO i monitorowanie doręczenia.

Co znajdziesz w tym poście: generowanie kodu OTP, wysyłka jako flash SMS, TTL i unieważnianie, rate-limit per user, webhooki DLR jako potwierdzenie, audit log, RODO i retencja, pełny kod Flask+Redis.

1. Czym jest 2FA przez SMS

Dwuskładnikowe uwierzytelnianie wymaga od użytkownika dwóch niezależnych dowodów tożsamości: czegoś, co wie (hasło) i czegoś, co ma (telefon, na który przyszedł kod). SMS jako drugi czynnik jest słabszy od TOTP (Google Authenticator, Authy, 1Password) z dwóch powodów:

Mimo to SMS jest znacznie lepszy niż brak 2FA i często jedynym wyborem dla użytkowników bez smartfona z aplikacją autentykacyjną. Dla operacji wysokiego ryzyka (transfer dużej kwoty, zmiana hasła) warto łączyć SMS z dodatkowym czynnikiem (push notification, biometria).

2. Generowanie bezpiecznego kodu

Pierwsza zasada: kod musi być losowy, generowany przez kryptograficzny PRNG. W Pythonie:

import secrets

def generate_otp(length: int = 6) -> str:
    """Generuje 6-cyfrowy kod OTP. NIE używaj random.randint()."""
    return "".join(str(secrets.randbelow(10)) for _ in range(length))

NIE używaj random.randint() — to PRNG nieprzeznaczony do kryptografii. Atakujący może odzyskać seed po obserwacji kilku kodów.

Długość 6 cyfr daje 1 000 000 kombinacji — w połączeniu z rate-limit (sekcja 4) i krótkim TTL (sekcja 3) odporne na brute force.

3. Wysyłka kodu jako flash SMS

Flash SMS (parametr flash: 1) wyświetla treść bezpośrednio na ekranie odbiorcy bez konieczności otwierania aplikacji wiadomości — UX dla 2FA jest znacznie lepszy. Kod widzisz natychmiast.

import os, requests

TOKEN = os.environ["PRZYPOMINAMY_API_KEY"]
BASE = "https://api.przypominamy.com"

def send_otp(phone: str, code: str, user_id: int) -> str:
    """Wysyła kod OTP i zwraca message_id."""
    resp = requests.post(
        f"{BASE}/v1/sms",
        headers={"Authorization": f"Bearer {TOKEN}"},
        json={
            "to": phone,
            "message": f"Twój kod logowania: {code}. Wygasa za 5 minut.",
            "from": "TwojaApp",
            "flash": True,
            "idx": f"otp-{user_id}-{int(time.time())}",
        },
        timeout=10,
    )
    resp.raise_for_status()
    data = resp.json()
    return data["data"]["messages"][0]["id"]

Pole idx łączy SMS z użytkownikiem i timestampem — przyda się w webhookach DLR (sekcja 5) i audit log (sekcja 7).

TTL i unieważnianie

Standard branżowy: 5 minut. Krótszy TTL = wyższe bezpieczeństwo, ale więcej frustracji (kod wygasa zanim użytkownik doczyta SMS). Dłuższy = więcej okazji do przechwycenia.

Trzymaj kod w Redisie z TTL — wygasa automatycznie:

import redis, time

r = redis.Redis()

OTP_TTL = 5 * 60  # 5 minut
def store_otp(user_id: int, code: str):
    key = f"otp:{user_id}"
    # SHA-256 zamiast plain text — gdyby Redis wyciekł, kod nie wycieka
    import hashlib
    code_hash = hashlib.sha256(code.encode()).hexdigest()
    r.setex(key, OTP_TTL, code_hash)

4. Rate-limit i obrona przed brute force

Trzy warstwy ochrony:

  1. Limit wysyłki: max 3 kody w 10 minut per użytkownik. Zapobiega spamowi SMS-ami i wyczerpaniu budżetu API.
  2. Limit weryfikacji: max 5 prób w 15 minut per użytkownik. Zapobiega brute force na kod.
  3. Automatyczne unieważnienie: po 3 nieudanych próbach kod znika, użytkownik musi poprosić o nowy.
def can_send_otp(user_id: int) -> bool:
    """Max 3 wysyłki w 10 minut."""
    key = f"otp:send_count:{user_id}"
    count = r.incr(key)
    if count == 1:
        r.expire(key, 10 * 60)
    return count <= 3

def verify_otp(user_id: int, code_user: str) -> bool:
    """Weryfikacja z licznikiem prób."""
    fail_key = f"otp:fails:{user_id}"
    fails = int(r.get(fail_key) or 0)
    if fails >= 3:
        # 3 nieudane → unieważnij kod
        r.delete(f"otp:{user_id}")
        return False

    stored_hash = r.get(f"otp:{user_id}")
    if not stored_hash:
        return False

    import hashlib
    if hashlib.sha256(code_user.encode()).hexdigest() == stored_hash.decode():
        r.delete(f"otp:{user_id}")
        r.delete(fail_key)
        return True

    fails = r.incr(fail_key)
    r.expire(fail_key, 15 * 60)
    return False

5. Webhook DLR: czy SMS faktycznie dotarł

Wysłaliśmy kod, ale czy klient go dostał? Zanim wymusimy ponowne logowanie, sprawdź webhook DLR — to potwierdzi rzeczywiste doręczenie.

from flask import Flask, request, jsonify
import logging

app = Flask(__name__)

@app.route("/webhook/dlr", methods=["POST"])
def dlr_webhook():
    payload = request.get_json(silent=True) or {}

    if payload.get("event") != "delivery_report":
        return jsonify({"received": True}), 200

    idx = payload.get("idx", "")
    status = payload.get("status")
    msg_id = payload.get("message_id")

    if idx.startswith("otp-"):
        # idx ma format: otp-{user_id}-{timestamp}
        parts = idx.split("-")
        user_id = int(parts[1])

        if status == "delivered":
            mark_otp_delivered(user_id, msg_id)
        elif status in ("failed", "expired", "not_found", "undelivered"):
            # SMS nie dotarł — możesz pokazać użytkownikowi opcję "Spróbuj inny kanał"
            mark_otp_failed(user_id, msg_id, status)
            logging.warning("OTP nie dostarczony user=%s status=%s", user_id, status)

    return jsonify({"received": True}), 200

Webhook wzbogaca UX: gdy SMS faktycznie nie dotrze (np. numer wyłączony), zaproponujesz alternatywę (rozmowa głosowa /v1/vms, email, fallback do TOTP).

6. Pełny przepływ end-to-end

Łącząc wszystko w działający endpoint Flaska:

@app.route("/auth/2fa/request", methods=["POST"])
def request_2fa():
    user = get_authenticated_user(request)  # po pierwszym hasłem
    if not user:
        return jsonify({"error": "unauthorized"}), 401

    if not can_send_otp(user.id):
        return jsonify({"error": "too_many_requests"}), 429

    code = generate_otp(6)
    store_otp(user.id, code)
    audit_log(user.id, "otp_sent")

    try:
        msg_id = send_otp(user.phone, code, user.id)
    except requests.HTTPError as e:
        audit_log(user.id, "otp_send_failed", error=str(e))
        return jsonify({"error": "send_failed"}), 502

    return jsonify({"sent": True, "message_id": msg_id}), 200


@app.route("/auth/2fa/verify", methods=["POST"])
def verify_2fa():
    user = get_authenticated_user(request)
    code = request.json.get("code", "")

    if not user or not code.isdigit() or len(code) != 6:
        return jsonify({"error": "invalid_input"}), 400

    if verify_otp(user.id, code):
        audit_log(user.id, "otp_verified")
        issue_session_token(user.id, second_factor=True)
        return jsonify({"verified": True}), 200

    audit_log(user.id, "otp_verify_failed")
    return jsonify({"verified": False}), 401

7. Audit log

Każda operacja związana z 2FA powinna być w audit log:

Audit log pozwala odpowiedzieć na pytania: "Czy mój kod faktycznie dotarł?", "Czy ktoś inny próbował się logować na moje konto?", "Dlaczego konto zostało zablokowane?". Jest też wymagany przez większość audytów bezpieczeństwa (ISO 27001, SOC 2).

8. RODO i retencja

Numer telefonu, kod OTP, timestamp wysyłki i treść SMS-a to dane osobowe. Trzy zasady:

  1. Zgoda — użytkownik musi świadomie wybrać 2FA SMS przy konfiguracji konta. Domyślnie nie włączaj.
  2. Minimalizacja retencji — kody usuwaj po użyciu, audit log trzymaj nie dłużej niż uzasadnione (zwykle 90-365 dni dla compliance).
  3. Nie loguj treści SMS-a w plain text — to obejmuje kod OTP. Loguj message_id i status, nie tekst.

Pamiętaj też o prawie do usunięcia — gdy użytkownik usuwa konto, usuń też wszystkie powiązane wpisy w audit log (lub anonimizuj user_id).

9. Cennik i koszty

Przy planie Business (0,10 zł/SMS) jeden SMS OTP kosztuje 10 groszy. Typowa aplikacja z 10 000 użytkowników, gdzie 30% loguje się raz w tygodniu z 2FA, daje:

3 000 SMS/tydzień × 0,10 zł = 300 zł/tydzień ≈ 1 300 zł/miesiąc

Dla większej skali (100 000+ aktywnych użytkowników) warto rozważyć indywidualną wycenę Enterprise.

FAQ

Czy SMS jest bezpiecznym kanałem dla 2FA?

SMS jest słabszy od TOTP (Google Authenticator) ze względu na SIM swap i SS7. Ale jest znacznie lepszy niż brak 2FA. Dla wysokoryzykowych operacji łącz SMS z dodatkowym czynnikiem.

Jakie powinno być TTL kodu OTP?

Standard branżowy: 5-10 minut. 5 minut to dobry kompromis bezpieczeństwa i UX dla większości aplikacji.

Jak chronić się przed brute force na kod OTP?

Trzy warstwy: kod 6+ cyfr (1M kombinacji), rate-limit weryfikacji per user (5 prób / 15 min), automatyczne unieważnienie po 3 nieudanych próbach.

Czy 2FA SMS jest zgodne z RODO?

Tak, jeśli zbierasz zgodę, traktujesz numer jako dane osobowe, minimalizujesz retencję i nie logujesz treści SMS-a w plain text.

Czy mogę używać flash SMS dla kodów OTP?

Tak, parametr flash: 1 wyświetla SMS na ekranie bez otwierania wiadomości. Wadą jest brak zapisu w historii — jeśli użytkownik zamknie SMS bez przepisania, nie odzyska go.

Wdrażasz 2FA SMS? Sprawdź API.

Załóż konto, w 24h zweryfikujemy zgłoszenie, generujesz token i wysyłasz pierwszy kod OTP.

Załóż konto