Ktoś chce pokazać projekt klientowi, który nie ma konta. Oczywistym rozwiązaniem jest krótki kod: sześć cyfr, wygasa po pięciu minutach, gość go wpisuje i wchodzi. Zaimplementowałem to tak w narzędziu do współdzielonych notatek i plików i działało od pierwszego dnia. Problemem nie było to, że nie działało. Problemem było to, że trzy miesiące później musiałem to przerabiać dwa razy.
Trzy iteracje, a trzecia jest tą, która powinna być pierwsza.
Co krótki kod robi, a czego nie
Pierwsza tabela wyglądała dosłownie tak:
CREATE TABLE guest_access_codes (
id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
user_id text NOT NULL,
code char(6) NOT NULL,
permission permission NOT NULL,
session_duration_minutes integer NOT NULL,
code_expires_at timestamptz NOT NULL,
is_used boolean DEFAULT false NOT NULL,
used_at timestamptz,
session_expires_at timestamptz
);
Dwa różne terminy wygaśnięcia — i to akurat było dobre od początku: code_expires_at to czas, po którym kod umiera, jeśli nikt go nie użyje (pięć minut), a session_duration_minutes to czas trwania wizyty po wejściu. To dwie różne rzeczy i mieszanie ich to klasyczny błąd. Kod, który żyje osiem godzin na WhatsAppie, to dziura; sesja, która urywa się po pięciu minutach, jest nie do użycia.
Masz tu już uprawnienia (read albo read_write), tabelę pośredniczącą decydującą, do których projektów kod daje dostęp, oraz wygasanie. Wygląda kompletnie. Nie jest: kod nie identyfikuje nikogo. Identyfikuje zaproszenie, a nie gościa.
Pierwsza próba: przekazywać id wiersza
Pierwsza poprawka polegała na tym, żeby przestać wysyłać sześciocyfrowy kod w każdym żądaniu i wysyłać id wiersza. Klient trzymał codeId, backend go rozwiązywał, a żądania szły z Authorization: Bearer <codeId>.
To naprawia jedną realną rzecz: sześciocyfrowy kod przestaje podróżować. Nie ma go już w logu dostępu ani w URL-u. Ale wprowadza coś gorszego. codeId to autoinkrementowana liczba całkowita. Da się ją zgadnąć. Da się ją wyliczyć po kolei. Każdy z jednym poprawnym id może spróbować sąsiedniego i jeśli tamta sesja wciąż żyje, wchodzi. Zamieniłem krótki sekret na identyfikator, który w ogóle nie jest sekretem.
I dalej nie rozwiązuje pierwotnego problemu, którym było to: jeśli dwie osoby użyją tego samego zaproszenia, dzielą ten sam codeId. Nie możesz wyrzucić jednej bez wyrzucenia drugiej. Nie wiesz, która z nich skasowała plik.
Co rozwiązuje token sesji
Trzecia wersja rozdziela dwie rzeczy, które przez trzy miesiące były razem. Sześciocyfrowy kod to realizacja: używa się go raz, w endpoincie łączenia, i nigdy więcej. To, co z niego wychodzi, to token sesji:
const suffix = randomBytes(4).toString("hex");
const sessionToken = `${code}-${suffix}`;
await db
.update(guestAccessCodes)
.set({ isUsed: true, usedAt: now, sessionExpiresAt, sessionToken })
.where(eq(guestAccessCodes.id, record.id));
Odpowiedź nie zwraca już codeId, tylko sessionToken. Od tego miejsca cały frontend go przenosi: gdzie było guestCodeId, jest guestSessionToken, a !!guestCodeId, które decydowało, czy ktoś jest gościem, zamieniło się w !!guestSessionToken.
Co się naprawdę zmienia:
- Odbieranie dostępu. Ustawienie
sessionTokenna null w jednym wierszu wyrzuca tę sesję i tylko tę. Przy kodzie nie było w co celować. - Audyt. Token jest w wierszu, więc każdą akcję da się przypisać do konkretnej realizacji i konkretnej godziny.
- Nie do zgadnięcia. To już nie jest kolejna liczba po sąsiedzku. Czterech losowych bajtów w hex nie przelicisz po kolei.
- Jedno miejsce, w którym wygasa.
sessionExpiresAtzapisuje się w tym samym update, który wydaje token. Nie ma dwóch zegarów, które mogłyby się rozjechać.
Co znalazł audyt, a czego nie znalazłem ja
Kilka miesięcy później przepuściłem kod przez audyt. Wyszło wszystko — trasa eksportu do PDF bez żadnego guardu uwierzytelnienia jest najgorsza na liście — ale rzecz, która dała mi najbardziej do myślenia, zapisałem sobie sam w TODO: nawet jeśli sześciocyfrowy kod się zużywa, to, co zwraca, to token sześć plus cztery, i to on uwierzytelnia.
Tu jest błąd. Krótki kod jest chroniony pięciominutowym życiem i tym, że da się go użyć tylko raz. Token sesji nie ma żadnej z tych dwóch rzeczy: żyje godzinami i jest używany w każdym żądaniu. Zmiana z kodu na token likwiduje jeden wektor i otwiera drugi, a zauważysz to tylko, jeśli spojrzysz na to później na chłodno. Cztery bajty wystarczą przeciwko przypadkowemu przelatywaniu po kolei, ale nie przeciwko komuś, kto ma osiem godzin i pętlę.
Druga rzecz, która wyszła i jest podręcznikowa: endpoint łączenia nie opakowywał niczego w transakcję. Między odczytaniem, że kod nie jest zużyty, a oznaczeniem go jako zużyty jest okno. Dwa równoczesne żądania z tym samym kodem przechodziły oba.
Blokowanie po IP jest bardziej kruche, niż się wydaje
Licznik nieudanych prób musi wiedzieć, z jakiego IP przychodzi żądanie, i tu prawie wszyscy mylą się tak samo. W innym projekcie miałem trwaną blokadę po IP i brałem pierwszą wartość z x-forwarded-for. Ten nagłówek to lista klient, proxy1, proxy2: każde proxy dopisuje z prawej, ale nikt nie czyści tego, co już było wpisane. Wzięcie pierwszej wartości to wzięcie czegoś, co klient może sobie wymyślić.
Zmieniłem to na liczenie od końca według tego, ile zaufanych proxy stoi z przodu:
const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map(s => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];
Przy jednym proxy z przodu ostatnia wartość jest tą, którą wpisuje proxy i której klient nie może ruszyć. Jeśli weźmiesz pierwszą, każdy ominie swoją blokadę, wysyłając śmieci, albo gorzej — zablokuje kogoś innego.
Sześciocyfrowy kod nie był trudną częścią. Trudną częścią było przyjęcie, że zaproszenie i sesja to dwa różne obiekty, a ich zmieszanie zostawia cię bez możliwości odebrania dostępu, bez możliwości audytu i z kolejnym numerkiem w roli poświadczenia.
