02Architektura5 min

Dlaczego wymagam logowania w aplikacjach, które go nie potrzebują

Bez tożsamości nie ma limitu, nie ma pułapu wydatków, nie ma jak odciąć tego, kto nadużywa. Tarcie rejestracji jest tanie w porównaniu z tym, ile kosztuje jej brak.

Autor: Salva Castells

Zdjęcie okładkowe: Draceane, CC BY-SA 4.0.
W tym wpisie

Każde wyszukiwanie semantyczne w jednym z moich projektów kosztuje pieniądze: embedding, wywołanie rerankingu i synteza tekstu. Grosze na żądanie. Pomnożone przez bota z pętlą to już nie grosze. Pierwsza wersja nie prosiła o konto do niczego, bo proszenie o konto, żeby szukać anime, wydawało się absurdem. Nadal się wydaje. I tak to robię.

Powodem nie jest marketing ani lista mailingowa. Powodem jest to, że bez tożsamości nie ma czego liczyć.

Koszt wpuszczania bez identyfikacji

Zbudowałem harness do walidacji typu fake-door z pięcioma landingami naraz, żeby zmierzyć zainteresowanie, zanim cokolwiek zbuduję. Jedyne, co trzeba było chronić, to panel admina, więc wstawiłem banowanie IP po kilku nieudanych logowaniach. Wystarczy, pomyślałem.

Nie wystarczyło. IP klienta brało się z x-forwarded-for, a ten nagłówek to lista klient, proxy1, proxy2…, w której każde proxy dokłada z prawej, ale nikt nie czyści tego, co już było wpisane. Wzięcie pierwszej wartości to wzięcie tego, co klient sam zechce wpisać. Każdy wysyła x-forwarded-for: 1.2.3.4, omija własnego bana, a przy okazji może zbanować cudze IP, wpisując je zamiast swojego.

Naprawiłem to, licząc przeskoki zamiast ufać lewej stronie:

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 prawdziwym klientem jest ostatnia wartość: ta, którą wpisuje proxy i której klient nie może ruszyć. Działa. Ale zobacz, co właśnie zrobiłem: wprowadziłem zmienną środowiskową zależną od topologii sieciowej wdrożenia, po to żeby móc kogoś zidentyfikować w przybliżeniu. Jeśli jutro wstawię CDN z przodu, liczba się zmieni, a system banów zacznie się mylić po cichu. Nie wywala się: myli się.

Dlaczego IP nie nadaje się na tożsamość

Poza tym, że za niewłaściwym proxy da się je podrobić, IP jest złym kluczem z powodów, których nie da się naprawić. Cztery osoby z tego samego biura dzielą jedno. Człowiek z komórką zmienia IP, wychodząc z klatki. Sieci mobilne robią NAT na ogromnych blokach: zbanowanie IP operatora to zbanowanie całej dzielnicy.

Od identyfikatora chcesz, żeby był stabilny dla tej samej osoby i różny dla różnych osób. IP nie spełnia ani jednego, ani drugiego. user_id spełnia oba z definicji.

Co możesz zrobić dopiero wtedy, gdy wiesz, kto jest kim

W wyszukiwarce skończyłem z trzema poziomami i skończonym limitem na wszystkich trzech:

visitor:    { semanticSearchLimit: 10,  historyLimit: 0 }
registered: { semanticSearchLimit: 30,  historyLimit: 10 }
premium:    { semanticSearchLimit: 200, historyLimit: null }

Komentarz, który zostawiłem przy typie, to całe kryterium: zawsze skończony; żaden poziom nie jest zwolniony z limitu wyszukiwań. Premium ma najwyższą liczbę, nie nieskończoność. historyLimit owszem, przyjmuje null w znaczeniu "bez limitu". Ten od wyszukiwań nie, bo każde wyszukiwanie to faktura.

Odwiedzający nadal istnieje, z ciasteczkiem dającym mu anonimowy klucz. Ale okno odnowienia zależy od tego, kim jesteś: zarejestrowanemu odnawia się na miesiąc kalendarzowy w UTC, premium odnawia się w rocznicę założenia konta. To wymaga trzymania premiumAnchorAt, a trzymanie czegokolwiek wymaga wiersza z właścicielem. Bez konta nie ma rocznicy, do której można się przyczepić.

Przypadek aplikacji z kosztem za żądanie

Kiedy każda akcja kosztuje prawdziwe pieniądze, limit przestaje być obroną, a staje się księgowością. A księgowość zawodzi w nudny sposób.

W wyszukiwarce dwa identyczne żądania liczyły się dwa razy. Rozwiązaniem była rezerwacja z hashem zawartości żądania, a nie jego tekstu: znormalizowane zapytanie, limit, flaga treści dla dorosłych i posortowane filtry. Komentarz przy typie mówi, dlaczego kolejność ma znaczenie — ten sam zbiór wybrany w innej kolejności to to samo wyszukiwanie — i dlaczego filtry są w środku: ten sam tekst z innym gatunkiem to inne żądanie, a sklejenie ich zwróciłoby złe wyniki. Rezerwacja zwraca reserved, duplicate, in_progress albo exhausted. Wszystkie cztery stany istnieją, bo wszystkie cztery się zdarzyły.

W API do transkrypcji naliczanie za znaki zaczęło się od transkrypcji plus tekst przetworzony podzielony przez dziesięć. Po zmierzeniu prawdziwego kosztu linia zmieniła się w to:

transcriptionText.length + Math.floor(processedText.length * 0.25)

Z /10 na * 0.25: dwa i pół raza drożej. Cena była zła o współczynnik 2,5 i przez miesiące dopłacałem różnicę, nie wiedząc o tym, bo nie miałem pomiaru, który by mi to pokazał.

W SaaS do generowania obrazów bug był odwrotny: kredyty dzienne istniały, ale wstępne sprawdzenie patrzyło tylko na subCredits. Użytkownicy z dziennym kredytem dostawali 402, mając środki na koncie. Naprawiło to dailyCredits + subCredits. To wszystko są kolumny wiersza użytkownika. Bez wiersza nie ma buga do naprawienia, bo nie ma czego liczyć.

Kiedy sama tożsamość też nie wystarcza

Mieć konto to nie to samo co mieć ważną tożsamość. W API z kluczami wiersz klucza przeżywał stan konta właściciela: przy offboardingu czyszczono rekord rozliczeniowy, a nikt nie ruszał isActive klucza. Klucz dalej uwierzytelniał. Poprawką było ponowne sprawdzanie w samym authenticateApiKey, czy właściciel nadal ma rekord rozliczeniowy, zamiast ufania fladze:

if (!ownerBilling) {
  return { authenticated: false, error: "API key owner is no longer active" };
}

Idzie tam, a nie do każdego endpointu, właśnie po to, żeby żaden endpoint nie musiał o tym pamiętać.

Kiedy jednak wpuszczam bez konta

Kiedy akcja nic nie kosztuje i nie kumuluje stanu: przeczytanie karty, obejrzenie katalogu, wczytanie landinga. Plus poziom odwiedzającego z krótkim limitem, żeby ktoś mógł spróbować przed decyzją. Dziesięć wyszukiwań przed rejestracją to uczciwe tarcie; zero to otwarta faktura.

Pytanie nie brzmi, czy logowanie przeszkadza. Przeszkadza. Pytanie brzmi, co wpiszesz w WHERE, kiedy ktoś przesadzi.