API SMS do 2FA i OTP. Implementacja i bezpieczeństwo
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:
- SIM swap — atakujący przekonuje operatora do przepisania numeru na inną kartę SIM.
- Przechwycenie w sieci — luki w protokole SS7 (rzadko, ale możliwe).
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:
- Limit wysyłki: max 3 kody w 10 minut per użytkownik. Zapobiega spamowi SMS-ami i wyczerpaniu budżetu API.
- Limit weryfikacji: max 5 prób w 15 minut per użytkownik. Zapobiega brute force na kod.
- 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:
- Każda wysyłka kodu: user_id, timestamp, message_id, numer (zamaskowany: +48***123456).
- Każda próba weryfikacji: user_id, timestamp, sukces/porażka.
- Każde unieważnienie (3 nieudane próby): user_id, timestamp.
- Każde potwierdzenie doręczenia z webhooka: message_id, status, done_at.
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:
- Zgoda — użytkownik musi świadomie wybrać 2FA SMS przy konfiguracji konta. Domyślnie nie włączaj.
- Minimalizacja retencji — kody usuwaj po użyciu, audit log trzymaj nie dłużej niż uzasadnione (zwykle 90-365 dni dla compliance).
- 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