Awesome Testing

Markdown document

2FA i TOTP w projekcie kursowym — przewodnik dla osób testujących

Lekcja 23: Automatyzacja two-factor authentication

Historical artifacts may name disposable training credentials and environments. Do not reuse credentials, target course systems, or execute archived prompts without authorization.

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:

  1. ostatnie 4 bity H wskazują offset;
  2. od tego miejsca pobierane są 4 bajty;
  3. najstarszy bit jest zerowany, aby otrzymać dodatnią liczbę 31-bitową;
  4. wynik modulo 10^6 daje sześciocyfrowy kod;
  5. 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

OperacjaUwierzytelnienie i daneNajważniejszy rezultat
GET /api/v1/users/2fa/statusJWTenabled i liczba niewykorzystanych kodów odzyskiwania
POST /api/v1/users/2fa/setupJWTsekret Base32, otpAuthUri, kod QR i czas wygaśnięcia setupu
POST /api/v1/users/2fa/confirmJWT + codeaktywne 2FA i osiem kodów odzyskiwania zwróconych jeden raz
POST /api/v1/users/signinlogin + hasłodla konta z 2FA: challenge, ale bez JWT i refresh tokena
POST /api/v1/users/signin/2fachallengeToken + TOTP lub kod odzyskiwaniazakończone logowanie oraz tokeny
POST /api/v1/users/2fa/recovery-codesJWT + hasło + świeży TOTPnowy zestaw kodów; stary zestaw przestaje działać
POST /api/v1/users/2fa/disableJWT + hasło + TOTP lub kod odzyskiwaniausunię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.ts opisuje requesty do sześciu operacji 2FA;
  • types/mfa.ts zawiera requesty, odpowiedzi i typy błędów walidacji;
  • support/totp.ts generuje świeże kody i celowo uszkadza dane do testów negatywnych;
  • support/assertions/mfa.ts sprawdza sekret, URI OTPAuth, PNG z kodem QR oraz format i unikalność kodów odzyskiwania;
  • fixtures/mfa-user.ts tworzy 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.sh pozwala 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;
  • 400 dla niekompletnego body, 401 dla braku autoryzacji lub błędnego składnika i 409 dla 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ń

ObjawCo sprawdzić najpierw?
401 Invalid authenticator or recovery codeCzy TOTP nie został już użyty? Czy parametry i czas są zgodne z serwerem?
401 Invalid or expired MFA challengeCzy challenge nie został już zużyty albo zastąpiony nowym logowaniem?
409 Start two-factor setup firstCzy przed confirm wykonano setup dla tego samego użytkownika?
409 Two-factor authentication is already enabledCzy fixture nie próbuje ponownie uruchomić setupu na aktywnym koncie?
429 Too Many RequestsCzy testy nie współdzielą konta lub nie wyczerpały limitu na IP/challenge?
Flaky test przy zmianie koduCzy 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.