2FA i TOTP w projekcie kursowym — przewodnik dla osób testujących
Ten dokument porządkuje przepływ 2FA używany w kursowym API i pokazuje, jak
testować go bez telefonu oraz bez ręcznego przepisywania kodów z aplikacji
uwierzytelniającej. Kod z lekcji znajduje się w checkpointcie l18.
Najważniejszy model: 2FA to proces, a nie pojedynczy endpoint
W tej implementacji drugim składnikiem jest TOTP, czyli sześciocyfrowy kod wyliczany z sekretu i bieżącego czasu. Kod zmienia się co 30 sekund. Serwer akceptuje bieżące okno czasu oraz po jednym sąsiednim oknie, ale nie pozwala ponownie wykorzystać wcześniej przyjętego kroku TOTP.
Pełny przepływ składa się z kilku stanów:
brak 2FA
-> setup
oczekująca konfiguracja (maks. 15 minut)
-> confirm + pierwszy TOTP
aktywne 2FA + 8 kodów odzyskiwania
-> login hasłem
krótkotrwały challenge (maks. 5 minut, bez JWT)
-> signin/2fa + świeży TOTP albo kod odzyskiwania
zalogowany użytkownik + JWT i refresh token
To rozróżnienie jest kluczowe w testach. Poprawne hasło nie kończy logowania, gdy użytkownik ma aktywne 2FA. Serwer zwraca wtedy challenge, a tokeny aplikacji pojawiają się dopiero po poprawnej weryfikacji drugiego składnika.
Jak TOTP działa technicznie?
TOTP z RFC 6238 jest czasową odmianą HOTP z RFC 4226. HOTP używa rosnącego licznika zdarzeń. TOTP zastępuje ten licznik numerem okna czasu, dzięki czemu telefon i serwer mogą niezależnie wyliczyć ten sam kod bez komunikowania się ze sobą przy każdym logowaniu.
Obie strony muszą znać te same parametry:
K— losowy, współdzielony sekret użytkownika;T0— początek liczenia czasu, standardowo Unix epoch;X— długość kroku czasu, w projekcie 30 sekund;- algorytm HMAC, w projekcie HMAC-SHA-1;
- długość wyniku, w projekcie 6 cyfr.
Najpierw obliczany jest numer bieżącego kroku:
T = floor((UnixTime - T0) / X)
Dla X = 30 wszystkie urządzenia znajdujące się w tym samym 30-sekundowym
oknie otrzymują ten sam T. Następnie liczba T jest kodowana jako
ośmiobajtowa liczba całkowita w kolejności big-endian i przekazywana do HMAC
razem z sekretem:
H = HMAC-SHA-1(K, T)
HMAC zwraca 20 bajtów, a użytkownik potrzebuje krótkiego kodu. HOTP stosuje więc dynamiczne obcięcie wyniku:
- ostatnie 4 bity
Hwskazują offset; - od tego miejsca pobierane są 4 bajty;
- najstarszy bit jest zerowany, aby otrzymać dodatnią liczbę 31-bitową;
- wynik modulo
10^6daje sześciocyfrowy kod; - brakujące zera są dopisywane z lewej strony.
W skrócie:
TOTP = Truncate(HMAC-SHA-1(K, T)) mod 1_000_000
Kodowanie Base32 widoczne w odpowiedzi setup nie jest dodatkowym
szyfrowaniem. To tekstowy zapis bajtów sekretu, wygodny do ręcznego przepisania
i umieszczenia w URI otpauth://. Kod QR zawiera właśnie takie URI: typ totp,
nazwę konta, sekret i parametry potrzebne aplikacji uwierzytelniającej. Kto
pozna sekret, może generować poprawne kody, dlatego QR i URI należy chronić tak
samo jak hasło.
Dlaczego serwer sprawdza kilka okien czasu?
Zegary telefonu i serwera mogą różnić się o kilka sekund, a request może dotrzeć tuż po zmianie okna. Backend sprawdza więc krok bieżący oraz po jednym kroku w przeszłości i przyszłości. Poprawia to użyteczność, ale zwiększa liczbę kodów, które w danej chwili mogą zostać zaakceptowane. Z tego powodu krótki krok czasu musi być połączony z limitem prób.
Tolerancja zegara nie oznacza zgody na replay. Po poprawnej weryfikacji backend zapisuje numer zaakceptowanego kroku i odrzuca ten sam lub starszy krok. To realizuje właściwość „one time”, której nie zapewnia samo wygaśnięcie po 30 sekundach.
Co daje HMAC-SHA-1, a czego nie daje?
SHA-1 występuje tutaj jako część HMAC, a nie jako funkcja haszująca hasło.
O wyborze w projekcie decyduje zgodność z popularnymi aplikacjami
uwierzytelniającymi. Bez znajomości K nie da się wyliczyć prawidłowego HMAC,
ale sześciocyfrowy wynik ma małą przestrzeń możliwych wartości. Dlatego TOTP
nie zastępuje silnego hasła, TLS, limitowania prób ani bezpiecznego przechowywania
sekretu — jest dodatkowym składnikiem całego procesu uwierzytelniania.
Mapa endpointów
| Operacja | Uwierzytelnienie i dane | Najważniejszy rezultat |
|---|---|---|
GET /api/v1/users/2fa/status | JWT | enabled i liczba niewykorzystanych kodów odzyskiwania |
POST /api/v1/users/2fa/setup | JWT | sekret Base32, otpAuthUri, kod QR i czas wygaśnięcia setupu |
POST /api/v1/users/2fa/confirm | JWT + code | aktywne 2FA i osiem kodów odzyskiwania zwróconych jeden raz |
POST /api/v1/users/signin | login + hasło | dla konta z 2FA: challenge, ale bez JWT i refresh tokena |
POST /api/v1/users/signin/2fa | challengeToken + TOTP lub kod odzyskiwania | zakończone logowanie oraz tokeny |
POST /api/v1/users/2fa/recovery-codes | JWT + hasło + świeży TOTP | nowy zestaw kodów; stary zestaw przestaje działać |
POST /api/v1/users/2fa/disable | JWT + hasło + TOTP lub kod odzyskiwania | usunięty stan 2FA i unieważnione refresh tokeny |
Endpointy zarządzające 2FA dotyczą lokalnych kont hasłowych. Dla kont, których uwierzytelnianiem zarządza zewnętrzny dostawca tożsamości, odpowiedzialność za drugi składnik pozostaje po stronie tego dostawcy.
Jak test generuje TOTP bez telefonu?
POST /2fa/setup zwraca sekret Base32. Aplikacja uwierzytelniająca zapisałaby
ten sam sekret z kodu QR. Test może przekazać go do biblioteki otplib i
wyliczyć dokładnie ten sam kod:
const code = await generate({
secret,
algorithm: 'sha1',
digits: 6,
period: 30,
epoch: Math.floor(Date.now() / 1000),
});
Parametry nie są dowolne. Muszą odpowiadać konfiguracji serwera: w tym
projekcie są to SHA-1, sześć cyfr i okres 30 sekund. Gotowa implementacja
znajduje się w l18/support/totp.ts.
Telefon nadal jest przydatny w krótkim teście zgodności z prawdziwą aplikacją uwierzytelniającą, ale nie powinien być zależnością automatycznego suite.
Dlaczego nie można używać tego samego TOTP kilka razy?
Backend zapamiętuje ostatni zaakceptowany krok czasu. Kolejny kod jest poprawny
tylko wtedy, gdy pochodzi z późniejszego kroku. Chroni to przed replayem, ale
ma ważną konsekwencję dla automatyzacji: kod użyty do confirm nie może od razu
posłużyć do logowania albo rotacji kodów odzyskiwania.
Helper generateTotpAfter() sprawdza numer zaakceptowanego kroku i — tylko gdy
to konieczne — czeka do następnego okna. Dzięki temu test nie ma stałego
waitForTimeout(30_000), a jednocześnie nie staje się losowo czerwony w pobliżu
granicy czasu.
Jak jest zbudowana warstwa testowa?
W checkpointcie l18 odpowiedzialności są rozdzielone:
httpclients/mfa-client.tsopisuje requesty do sześciu operacji 2FA;types/mfa.tszawiera requesty, odpowiedzi i typy błędów walidacji;support/totp.tsgeneruje świeże kody i celowo uszkadza dane do testów negatywnych;support/assertions/mfa.tssprawdza sekret, URI OTPAuth, PNG z kodem QR oraz format i unikalność kodów odzyskiwania;fixtures/mfa-user.tstworzy użytkownika, wykonuje setup i confirm, a potem przekazuje testowi gotowy stan 2FA;tests/api/users/2fa/zawiera osobne specy dla każdej operacji;scripts/explore-2fa-poc.shpozwala przejść cały przepływ niezależnie od Playwrighta i redaguje dane wrażliwe.
Każdy test stanowy otrzymuje własnego użytkownika i własny sekret, dlatego scenariusze nie muszą korzystać ze wspólnego konta ani być uruchamiane sekwencyjnie. Izolacja danych jest bezpieczniejsza i szybsza niż wymuszanie jednego workera dla całego modułu.
Najważniejsze scenariusze regresji
Dobry zestaw nie kończy się na happy pathie. Powinien potwierdzać co najmniej:
- status przed konfiguracją i po jej potwierdzeniu;
- poprawność sekretu,
otpAuthUri, obrazu QR i czasu wygaśnięcia setupu; - odrzucenie confirm bez setupu, bez kodu oraz z błędnym kodem;
- brak JWT i refresh tokena po pierwszym kroku logowania;
- logowanie świeżym TOTP oraz jednorazowym kodem odzyskiwania;
- zmniejszenie licznika kodów odzyskiwania po użyciu kodu;
- odrzucenie zużytego challenge i wcześniej przyjętego TOTP;
- rotację kodów, pełną zmianę zestawu i unieważnienie starych kodów;
- wyłączenie 2FA oraz powrót do zwykłego logowania hasłem;
400dla niekompletnego body,401dla braku autoryzacji lub błędnego składnika i409dla operacji niedozwolonej w bieżącym stanie.
Ścieżki 410 po wygaśnięciu 15-minutowego setupu i 429 po przekroczeniu
limitów są istotne, ale nie powinny zatruwać współdzielonego środowiska ani
wydłużać zwykłego suite. Najlepiej testować je niżej, z kontrolowanym zegarem i
resetowalnym limiterem.
Dane wrażliwe i artefakty Playwrighta
Sekret TOTP, otpAuthUri, kod QR, challenge i kody odzyskiwania są danymi
uwierzytelniającymi. Nie należy ich logować, wklejać do raportów ani zapisywać w
repozytorium. Specy 2FA mają trace: 'off', ponieważ trace może zachować body
requestu lub odpowiedzi.
Backend również ogranicza ekspozycję danych:
- sekret TOTP jest szyfrowany przed zapisem;
- kody odzyskiwania są przechowywane jako selektor i hash weryfikatora;
- challenge jest zapisywany w postaci hasha;
- zaakceptowany challenge i kod odzyskiwania są jednorazowe;
- odpowiedzi 2FA mają nagłówki zabraniające cache'owania.
W prawdziwym projekcie warto dodatkowo sprawdzić konfigurację CI, retencję artefaktów, redakcję logów HTTP i dostęp do raportów testowych.
Jak uruchomić testy z lekcji?
Po przejściu do checkpointu l18:
npm ci
npm test -- --project=api tests/api/users/2fa
Pełny suite uruchamia się standardowo:
npm test
Testy klienckiego 2FA korzystają z projektu api i środowiska
awesome.byst.re. Nie potrzebują danych administratora ani środowiska
aitesters.byst.re.
Szybka diagnostyka niepowodzeń
| Objaw | Co sprawdzić najpierw? |
|---|---|
401 Invalid authenticator or recovery code | Czy TOTP nie został już użyty? Czy parametry i czas są zgodne z serwerem? |
401 Invalid or expired MFA challenge | Czy challenge nie został już zużyty albo zastąpiony nowym logowaniem? |
409 Start two-factor setup first | Czy przed confirm wykonano setup dla tego samego użytkownika? |
409 Two-factor authentication is already enabled | Czy fixture nie próbuje ponownie uruchomić setupu na aktywnym koncie? |
429 Too Many Requests | Czy testy nie współdzielą konta lub nie wyczerpały limitu na IP/challenge? |
| Flaky test przy zmianie kodu | Czy helper czeka na krok późniejszy niż ostatni zaakceptowany przez backend? |
Jeżeli runtime i Swagger opisują inne body błędu, najpierw zachowaj dowód i zgłoś defekt kontraktu. Nie dopasowuj testu na siłę do błędnego schematu.
Co warto rozbudować dalej?
Najbardziej wartościowym kolejnym krokiem jest uruchamialne środowisko testowe z
kontrolowanym zegarem i resetem limitera. Pozwoliłoby szybko oraz deterministycznie
sprawdzać wygaśnięcie setupu, wygaśnięcie challenge, tolerancję przesunięcia
zegara i odpowiedzi 429. Warto również dodać test, który potwierdza
unieważnienie wcześniejszych refresh tokenów po włączeniu i wyłączeniu 2FA.
