[ BLOG // 2026-07-28 // 2 MIN ]

Login eventy nepatří do audit logu

Proč má historie přihlášení v admin panelu J+P Autodíly vlastní tabulku místo sdíleného audit logu — a proč se záznam o přihlášení nesmí dotknout kritické cesty.

PROJEKTY autodilyadminbezpecnostdatovy-modelnextjs

Audit log v admin panelu J+P Autodíly zaznamenává business události: vytvoření objednávky, změna stavu dokladu, úprava zákazníka. Každá událost má typ, entitu, timestamp a kontext. Je to přehledné — dokud do té samé tabulky nezačnete sypat přihlášení.

Přihlášení je vysokofrekvenční událost. Zákazník se přihlásí, odhlásí, přihlásí z telefonu, přihlásí z tabletu. Každá úspěšná autentizace je jeden řádek. V tabulce s business událostmi by login eventy během týdne převálcovaly všechno ostatní — audit log by se stal logem přihlášení s občasnou objednávkou navrch.

Řešení je jednoduché: LoginEvent je vlastní model, vlastní tabulka, vlastní index.

model LoginEvent {
  id         String   @id @default(cuid())
  customerId String
  ip         String
  userAgent  String?
  createdAt  DateTime @default(now())

  @@index([customerId, createdAt])
}

Žádné polymorfní entityType / entityId, žádné sdílené schéma. Login event má přesně ty pole, které potřebuje — nic navíc, nic míň.

Mimo kritickou cestu

Záznam o přihlášení je telemetrie. Nesmí ovlivnit samotné přihlášení. Pokud insert do databáze spadne, zákazník se pořád musí přihlásit. Pokud insert trvá 200 ms, nesmí to zákazník cítit.

Proto záznam běží přes afterResponse() — mimo request/response cyklus. NextAuth authorize() vrátí výsledek, HTTP odpověď odejde, a teprve pak se na pozadí zapíše řádek.

authorize() → ověří heslo → vrátí uživatele → HTTP 200
                                              ↓ (po odpovědi)
                                         loginEvent.create()

Když insert selže, chytí se to prázdným catchem. Ztracený řádek je lepší než ztracené přihlášení.

Rate limit na správném místě

Rate limit běží uvnitř authorize(), ne v server action. Důvod: authorize() je jediné místo, kudy projde každé přihlášení — server action i přímý POST na /api/auth/callback/credentials skončí tam. Rate limit v server action by HTTP endpoint obešel.

5 pokusů / 60 s / IP

A když účet neexistuje, proběhne stejně drahý bcrypt hash jako u reálného ověření. Časová odezva je identická — útočník podle latence nepozná, jestli e-mail v systému je.

Viditelnost v adminu

Historie přihlášení je vidět na dvou místech:

  • Karta zákazníka — záložka Přihlášení s tabulkou IP, user agentu a času, stránkování přes URL parametr ?ln=.
  • Správa pracovníků — stejná tabulka pro všechny admin účty, stejný design, stejné stránkování.

Stav záložky žije v URL (?tab=logins), takže F5 drží kontext a odkaz na konkrétní pohled funguje. Stejný pattern jako zbytek admin panelu — URL je stav.

Proč ne jeden audit log

Alternativa by byla: jeden audit log, action = "login", filtrování v UI. Funguje to, ale:

  • Business události se ztratí v záplavě loginů
  • Indexy musí obsloužit dva úplně jiné přístupy (časové okno vs. entita)
  • Retence se liší — login eventy chceš mazat po 90 dnech, business audit ne
  • Dotaz na historii zákazníka táhne zbytečné joiny

Vlastní tabulka je nuda. Ale je to ta správná nuda.

// ALL_POSTS
ZPĚT NA BLOG