IlmHamroh
JavaScript Full-stack/28-qism. Autentifikatsiya va backend xavfsizligi19/31-dars30 daqiqa
Mundarija (46)

Ikki faktorli autentifikatsiya (2FA) va passkeys: TOTP, zaxira kodlar va WebAuthn

Qisqacha: Parol sizib chiqishi mumkin, shuning uchun muhim hisoblarga ikkinchi omil qo'shiladi. Eng keng tarqalgani — TOTP: telefondagi ilova har 30 soniyada yangi 6 raqamli kod chiqaradi, server esa xuddi shu kodni umumiy sirdan hisoblaydi. TOTP sirini hash qilib bo'lmaydi, shuning uchun u bazada shifrlangan saqlanadi. Telefon yo'qolsa — bir martalik zaxira kodlar. Eng kuchli variant — passkey (WebAuthn): qurilmadagi yopiq kalit bilan imzo, u soxta saytda umuman ishlamaydi.

Bu darsda

  • Autentifikatsiya omillarini va SMS kodning zaif tomonini tushuntira olasiz.
  • TOTP algoritmini Web Crypto bilan o'zingiz yozasiz va RFC test vektorlari bilan tekshirasiz.
  • TOTP sirini AES-256-GCM bilan shifrlash, kodni qayta ishlatishdan himoya va zaxira kodlarni yozasiz.
  • Passkey (WebAuthn) qanday ishlashini va nega u fishingga chidamli ekanini tushuntira olasiz.
  • vazifalar-api ga 2FA va passkey yo'llarini, migratsiyani va testlarni qo'shasiz.

Oldin bilishingiz kerak: crypto 1: tasodif, hash va HMAC, crypto 2: parolni hash'lash, shifrlash va imzo, Session-based auth, OTP orqali login, Auth kutubxonalari va xizmatlari.

Oldingi darslarda vazifalar-api ga kirishning ko'p yo'lini qo'shdik: parol, SMS kod, Google, Telegram. Hammasida bitta ojiz joy bor: kimdir sizning parolingizni bilsa yoki sizni soxta saytga kiritsa, u siz bo'lib kiradi. Bugun bunga ikkinchi qulf qo'yamiz.

1. Nega bu kerak?

1.1 Parol yetmaydi

Parolni xavfsiz saqlash darsida bazamiz sizsa ham parollarni tiklash qimmat bo'lishini ta'minladik. Lekin parol boshqa yo'llar bilan ham o'g'irlanadi:

  • credential stuffing — boshqa saytdan sizgan parol bilan urinish;
  • fishing (phishing) — vazifalar-uz.example kabi soxta sahifa, foydalanuvchi o'zi parolni kiritadi;
  • yelka orqali qarash, klaviatura josusi, ochiq qolgan kompyuter.

Har birida hujumchi to'g'ri parolni biladi. Parol tekshiruvi qanchalik mukammal bo'lmasin, bu yerda yordam bermaydi.

1.2 Uch xil omil

Autentifikatsiya omili (factor) — "siz o'zingiz ekaningizni" qanday isbotlaysiz:

Omil Misol Qanday o'g'irlanadi
bilish parol, PIN sizish, fishing
egalik telefon, kalit qurilmasi qurilmani o'g'irlash
o'zlik barmoq izi, yuz qurilmadan tashqariga chiqmaydi

Ikki faktorli autentifikatsiya (2FA, two-factor authentication) — ikki har xil turdagi omil. Parol va "maxfiy savol" — ikkalasi ham "bilish", bu 2FA emas. Parol va telefondagi kod — 2FA. Umumiy nomi — MFA (multi-factor authentication, ko'p faktorli).

Hujumchi endi ikki narsani birdaniga qo'lga kiritishi kerak: parolni va sizning telefoningizni. Bu ancha qiyin.

1.3 SMS nega zaif

OTP orqali login darsida SMS kod yozdik. U ham "egalik" omiliga o'xshaydi, lekin u telefonga emas, raqamga bog'langan. Raqam esa telefondan ajralishi mumkin:

  • SIM swap — hujumchi aloqa operatoriga "SIM kartam yo'qoldi" deb, sizning raqamingizni o'z kartasiga o'tkazadi; endi SMS unga keladi;
  • SMS shifrlanmagan holda operator tarmog'idan o'tadi;
  • fishing sahifasi SMS kodni ham so'rab, darhol haqiqiy saytga yuboradi.

Shuning uchun SMS — eng zaif ikkinchi omil. Bugun kuchliroq ikki usulni quramiz: TOTP (ilova kodi) va passkey.

2. TOTP qanday ishlaydi

2.1 Umumiy sir va soat

Google Authenticator, Microsoft Authenticator yoki 2FAS kabi ilovalar internetsiz ham kod chiqaradi. Sir oddiy:

  1. Sozlashda server tasodifiy siri (secret) yaratadi va uni QR kod orqali telefonga beradi.
  2. Endi ikkala tomonda bir xil siri va bir xil soat bor.
  3. Har 30 soniyada ikkalasi ham HMAC(siri, joriy_qadam) ni hisoblaydi va undan 6 raqam oladi.

Kod tarmoq orqali hech qayerga yuborilmaydi — telefon uni o'zi hisoblaydi. Server ham o'zi hisoblab, foydalanuvchi kiritgani bilan solishtiradi.

Bu algoritmning ikki qatlami bor:

  • HOTP (RFC 4226) — HMAC asosidagi bir martalik parol: HMAC-SHA1(siri, hisoblagich) → 6 raqam.
  • TOTP (RFC 6238) — vaqtga asoslangan variant: hisoblagich o'rniga floor(unix_vaqt / 30).

2.2 TOTP'ni o'zimiz yozamiz

crypto 1 darsida HMAC bilan tanishgansiz. Brauzerdagi Web Crypto'da u bor, demak TOTP'ni kutubxonasiz yoza olamiz. Kodni RFC 6238 ning rasmiy test vektorlari bilan tekshiramiz: siri 12345678901234567890, natija 8 raqamli.

js
async function hotp(siri, hisoblagich, raqam = 6) {
  const kalit = await crypto.subtle.importKey(
    "raw", siri, { name: "HMAC", hash: "SHA-1" }, false, ["sign"],
  );
  const h = new DataView(new ArrayBuffer(8));
  h.setBigUint64(0, BigInt(hisoblagich));
  const mac = new Uint8Array(
    await crypto.subtle.sign("HMAC", kalit, h.buffer),
  );
  const o = mac[mac.length - 1] & 0x0f;
  const son = new DataView(mac.buffer).getUint32(o) & 0x7fffffff;
  return String(son % 10 ** raqam).padStart(raqam, "0");
}

const siri = new TextEncoder().encode("12345678901234567890");
for (const t of [59, 1111111109, 1234567890, 2000000000]) {
  const qadam = Math.floor(t / 30);
  console.log(t, qadam, await hotp(siri, qadam, 8));
}

Konsolda:

text
59 1 94287082
1111111109 37037036 07081804
1234567890 41152263 89005924
2000000000 66666666 69279037

To'rtala qator RFC 6238 ilovasidagi (Appendix B) SHA-1 jadvali bilan bir xil. Bu — bizning kodimiz standartga mosligining eng ishonchli dalili. Kriptografiyada "ishlayapti shekilli" yetmaydi: rasmiy vektorlar bilan tekshirasiz.

Ikkinchi qatorga qarang: 07081804 — boshida nol. padStart bo'lmaganda 7081804 chiqardi va foydalanuvchi kodi hech qachon mos kelmasdi.

2.3 Dinamik qisqartirish

20 baytlik HMAC'dan qanday qilib 6 raqam chiqdi? Bu qadam dinamik qisqartirish (dynamic truncation) deyiladi. T=59 uchun uni ochib ko'ramiz:

js
const rfc = new TextEncoder().encode("12345678901234567890");
const kalit = await crypto.subtle.importKey(
  "raw", rfc, { name: "HMAC", hash: "SHA-1" }, false, ["sign"],
);
const h = new DataView(new ArrayBuffer(8));
h.setBigUint64(0, 1n);
const mac = new Uint8Array(
  await crypto.subtle.sign("HMAC", kalit, h.buffer),
);
const hex = Array.from(mac, (b) => b.toString(16).padStart(2, "0"))
  .join("");
console.log(hex);

const o = mac[19] & 0x0f;
console.log(o, hex.slice(o * 2, o * 2 + 8));

const son = new DataView(mac.buffer).getUint32(o) & 0x7fffffff;
console.log(son, son % 10 ** 8, son % 10 ** 6);

Konsolda:

text
75a48a19d4cbe100644e8ac1397eea747a2d33ab
11 c1397eea
1094287082 94287082 287082

Qadamma-qadam:

  1. Oxirgi baytning (ab) pastki 4 biti — 0xab & 0x0f = 11. Bu — siljish (offset).
  2. 11-baytdan boshlab 4 bayt olinadi: c1397eea.
  3. Eng katta bit o'chiriladi (& 0x7fffffff) — ishorali/ishorasiz son chalkashmasin.
  4. Hosil bo'lgan sonning oxirgi 6 (yoki 8) raqami — kod.

DataView, setBigUint64 va & 0x0f — Typed arrays va binar ma'lumot va Bitwise operatorlar darslaridan tanish. Hisoblagich 8 baytli big-endian son bo'lishi kerak — DataView sukut bo'yicha aynan shunday yozadi.

2.4 Siri qanday uzatiladi: base32 va QR kod

Siri — 20 tasodifiy bayt. Foydalanuvchi uni qo'lda terishi ham mumkin bo'lsin deb, u base32 (RFC 4648) da yoziladi: faqat katta harflar va 2–7 raqamlari. 0 va O, 1 va I chalkashmaydi.

js
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

function base32(baytlar) {
  let bits = 0;
  let qiymat = 0;
  let s = "";
  for (const x of baytlar) {
    qiymat = (qiymat << 8) | x;
    bits += 8;
    while (bits >= 5) {
      s += B32[(qiymat >>> (bits - 5)) & 31];
      bits -= 5;
    }
    qiymat &= (1 << bits) - 1;
  }
  if (bits > 0) s += B32[(qiymat << (5 - bits)) & 31];
  return s;
}

const enc = new TextEncoder();
for (const m of ["f", "fo", "foo", "foobar"]) {
  console.log(m, base32(enc.encode(m)));
}
console.log(base32(enc.encode("12345678901234567890")));

Konsolda:

text
f MY
fo MZXQ
foo MZXW6
foobar MZXW6YTBOI
GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ

Yana RFC 4648 dagi rasmiy vektorlar. Har 8 bit kelganda, 5 bitlik bo'laklar bo'yicha harf olinadi; qolgan bitlar keyingi baytga qo'shiladi. qiymat &= (1 << bits) - 1 — ishlatilgan bitlarni tashlaydi. Bu niqob kodni o'qishni osonlashtiradi; to'g'rilikning asosiy kafolati esa RFC vektorlari bilan test.

Telefon ilovasi sirni otpauth URI ichida oladi, QR kod faqat shu matnni rasmga aylantiradi:

text
otpauth://totp/vazifalar%3Aaziz%40example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=vazifalar&algorithm=SHA1&digits=6&period=30

Bu satr kanon testidagi namunadan olingan (sir — RFC'dagi test qiymati). issuer — ilovada ko'rinadigan nom, period=30 — qadam uzunligi. QR rasmni frontend chizadi; server faqat URI qaytaradi.

Diqqat: Siri bilan kod hosil qilish mumkin. Uni log'ga yozmang, xatoda ko'rsatmang, faqat sozlash javobida bir marta qaytaring.

3. Serverda tekshirish

3.1 Soat farqi va oyna

Telefon soati serverdan bir necha soniya farq qilishi mumkin. Foydalanuvchi kodni 29-soniyada ko'rib, 31-soniyada yuborishi ham mumkin. Shuning uchun server bitta emas, uchta qadamni tekshiradi: oldingi, joriy va keyingi. Bu oyna (window) ±1 deyiladi — jami 90 soniya.

3.2 Bitta kod — bir marta

Oyna 90 soniya bo'lgani uchun yangi xavf chiqadi: hujumchi kodni (masalan, foydalanuvchi yelkasi orqali) ko'rib, shu 90 soniya ichida o'zi ham ishlatishi mumkin. RFC 6238 buni aniq aytadi: tekshiruvchi bir kodni ikkinchi marta qabul qilmasligi kerak.

Kanon yechimi oddiy: bazada oxirgi qabul qilingan qadam saqlanadi va faqat undan katta qadam qabul qilinadi. Oldingi blokdagi hotp ga tayanib yozamiz:

js
async function totpTekshir(siri, kod, oxirgi, ms, oyna = 1) {
  if (!/^\d{6}$/.test(kod)) return undefined;
  const t = Math.floor(ms / 1000 / 30);
  for (let i = -oyna; i <= oyna; i++) {
    const q = t + i;
    if (q <= oxirgi) continue;
    if ((await hotp(siri, q)) === kod) return q;
  }
  return undefined;
}

const HOZIR = 1_800_000_000_000;
const q = Math.floor(HOZIR / 1000 / 30);
const tek = (kod, oxirgi = 0) =>
  totpTekshir(siri, kod, oxirgi, HOZIR);
console.log("joriy qadam:", q);
console.log(await tek(await hotp(siri, q)));
console.log(await tek(await hotp(siri, q - 1)));
console.log(await tek(await hotp(siri, q - 2)));
console.log(await tek(await hotp(siri, q), q));
console.log(await tek("12345"));

Konsolda:

text
joriy qadam: 60000000
60000000
59999999
undefined
undefined
undefined

Beshta holat: joriy kod — qabul; bitta oldingi (30 s kechikdi) — qabul; ikki qadam oldingi — rad; o'sha kod ikkinchi marta (oxirgi = q) — rad; besh raqam — format bo'yicha rad. Vaqtni parametr qilib berdik (ms) — shunda test har doim bir xil natija beradi.

Funksiya kod emas, qadamni qaytaradi. Server uni bazaga oxirgi_qadam qilib yozadi. Brauzer versiyasida === ishlatdik; serverda kodni timingSafeEqual bilan solishtiramiz — Parolni xavfsiz saqlash darsidagi qoida: sirdan chiqqan qiymatni solishtirish — doim vaqtga chidamli.

3.3 Poyga: ikki so'rov bir vaqtda

Hujumchi va foydalanuvchi bir kodni bir millisekundda yuborsa-chi? Ikkala so'rov ham bazadan oxirgi_qadam = 0 ni o'qiydi, ikkalasi ham "yangi" deb qaror qiladi. Bu — klassik "o'qidim, tekshirdim, yozdim" poygasi.

Kanon buni bitta atomik UPDATE bilan yopadi:

sql
UPDATE mfa_totp SET oxirgi_qadam = $2
WHERE foydalanuvchi_id = $1 AND oxirgi_qadam < $2;

WHERE ... oxirgi_qadam < $2 shartini Postgres qatorni qulflab tekshiradi. Ikki so'rovdan faqat bittasi rowCount = 1 oladi, ikkinchisi — 0, ya'ni rad. Bu naqsh Qulflar: row, table va advisory darsidan tanish.

4. Siri bazada: hash emas, shifr

4.1 Nega hash qilib bo'lmaydi

Parolni hash qildik: server parolning o'zini bilishi shart emas, faqat "mos keladimi" deb tekshiradi. TOTP'da bunday emas — server kodni hisoblashi uchun sirning o'zi kerak. Hash'dan sirni qaytarib bo'lmaydi, demak hash yaramaydi.

Qolgan yo'l — shifrlash (encryption): siri bazada shifrlangan, kalit esa bazada emas, ilova sozlamasida. Baza sizsa ham, kalitsiz sirlar yaroqsiz.

Tekshirib ko'ring: Zaxira kodlarni esa HMAC bilan saqlash mumkin (keyingi bo'lim). Nega ular uchun shifr kerak emas?

Javob

Zaxira kodni server hech qachon hisoblamaydi, faqat foydalanuvchi yuborgan kod "bazadagisiga mosmi" deb tekshiradi. Bu parol tekshiruviga o'xshaydi — bir tomonlama funksiya yetadi. TOTP'da esa server har 30 soniyada yangi kodni sirdan hosil qiladi, shuning uchun sirning o'zi kerak.

4.2 AES-256-GCM va qo'shimcha ma'lumot

crypto 2 darsida AES-GCM bilan tanishgansiz: u shifrlaydi va autentifikatsiya tegi (tag) qo'shadi — shifr bir bayt o'zgartirilsa ham deshifrlash xato beradi. Kanon yana bir imkoniyatdan foydalanadi: qo'shimcha ma'lumot (AAD, additional authenticated data). U shifrlanmaydi, lekin tegga kiradi.

AAD sifatida foydalanuvchi id beriladi. Nima uchun? Bazaga yozish imkoni bo'lgan hujumchini tasavvur qiling: u o'z hisobining shifrlangan sirini Azizning qatoriga ko'chiradi. Endi Azizning hisobiga o'z telefonidagi kod bilan kiradi. AAD bilan bu ishlamaydi:

js
const kalit = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"],
);
const siri = new TextEncoder().encode("12345678901234567890");
const iv = crypto.getRandomValues(new Uint8Array(12));
const aad = (id) => new TextEncoder().encode(String(id));

const shifr = await crypto.subtle.encrypt(
  { name: "AES-GCM", iv, additionalData: aad(1) },
  kalit,
  siri,
);
console.log(siri.length, shifr.byteLength);

const ochiq = await crypto.subtle.decrypt(
  { name: "AES-GCM", iv, additionalData: aad(1) }, kalit, shifr,
);
console.log(new TextDecoder().decode(ochiq));

try {
  await crypto.subtle.decrypt(
    { name: "AES-GCM", iv, additionalData: aad(2) }, kalit, shifr,
  );
} catch (e) {
  console.log(e.name);
}

Konsolda:

text
20 36
12345678901234567890
OperationError

20 baytlik siri 36 bayt bo'ldi: 16 bayti — teg. Foydalanuvchi 1 uchun deshifr ishladi; xuddi shu shifrni foydalanuvchi 2 nomidan ochishga urinish — OperationError. Kalit to'g'ri, shifr buzilmagan, lekin "kimniki" degan ma'lumot mos emas.

Bazada iv (12 bayt), teg (16 bayt) va shifr bitta bytea ustunida ketma-ket turadi. iv sir emas, lekin har shifrlashda yangi bo'lishi shart.

4.3 Kalit qayerdan

Kanonda bitta asosiy sir bor — ILOVA_SIRI (.env da, kamida 32 belgi). Undan HKDF bilan har maqsad uchun alohida kalit olinadi: totp, zaxira-kod, mfa-cookie, webauthn-cookie. Bu naqsh OTP orqali login va Google bilan kirish darslarida ishlatilgan. Bitta kalit sizsa, boshqa maqsaddagilar zarar ko'rmaydi.

Kalitni almashtirish (rotation) bu yerda murakkab: eski kalit bilan shifrlangan hamma sirni qayta shifrlash kerak. Kanon buni qilmadi — halol cheklov sifatida yozib qo'ying.

5. Zaxira kodlar

Telefon yo'qoldi, sindi, ilova o'chib ketdi. Endi foydalanuvchi o'z hisobiga hech qachon kira olmaydimi? Buning uchun 2FA yoqilganda bir martalik zaxira kodlar (backup codes) beriladi. Foydalanuvchi ularni chop etib yoki parol menejerida saqlaydi.

Kanon qoidalari:

  • 10 ta kod, ko'rinishi XXXXX-XXXXX (base32, chiziqcha o'qishni osonlashtiradi);
  • har kod ~50 bit tasodif — taxmin qilib topib bo'lmaydi;
  • bazada kodning o'zi emas, HMAC'i (kodlar tasodifiy va uzun — argon2 kerak emas);
  • har kod bir marta ishlaydi;
  • kodlar faqat yoqish javobida bir marta ko'rsatiladi.
js
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
function base32(b) {
  let bits = 0, q = 0, s = "";
  for (const x of b) {
    q = (q << 8) | x;
    bits += 8;
    while (bits >= 5) {
      s += B32[(q >>> (bits - 5)) & 31];
      bits -= 5;
    }
    q &= (1 << bits) - 1;
  }
  return bits > 0 ? s + B32[(q << (5 - bits)) & 31] : s;
}

function zaxiraKod() {
  const b = crypto.getRandomValues(new Uint8Array(7));
  return base32(b).slice(0, 10).replace(/(.{5})/, "$1-");
}
const kodlar = Array.from({ length: 10 }, zaxiraKod);
console.log(kodlar.length, new Set(kodlar).size);
console.log(kodlar.every((k) => /^[A-Z2-7]{5}-[A-Z2-7]{5}$/.test(k)));

const norm = (k) => k.toUpperCase().replace(/[^A-Z2-7]/g, "");
console.log(norm("abcde-fghij"), norm(" ABCDE FGHIJ "));
console.log(Math.log2(32 ** 10));

Konsolda:

text
10 10
true
ABCDEFGHIJ ABCDEFGHIJ
50

Kodlarning o'zi har safar boshqa, shuning uchun faqat ularning sonini va formatini chiqardik. norm — foydalanuvchi kodni kichik harf bilan, chiziqchasiz yoki bo'shliq bilan kiritsa ham bir xil HMAC chiqadi. Oxirgi qator — tasodif hajmi: 10 belgi × 5 bit = 50 bit.

Bir martalikni yana atomik UPDATE ta'minlaydi: SET ishlatilgan_vaqt = now() WHERE ... AND ishlatilgan_vaqt IS NULL. Ikki parallel so'rovdan biri yutadi.

6. Kirish oqimi ikki qadamda

6.1 Parol to'g'ri, lekin sessiya yo'q

2FA yoqilgan foydalanuvchi parol bilan kirganda server darhol sessiya bermaydi. Aks holda ikkinchi omilning ma'nosi qolmaydi. U o'rniga:

sequenceDiagram
  participant B as Brauzer
  participant S as Server
  B->>S: POST /kirish (email, parol)
  S-->>B: 200 {mfa: "kerak"} + __Host-mfa (5 daq)
  Note over B: foydalanuvchi ilovadan kodni oladi
  B->>S: POST /2fa/tekshir {kod} + __Host-mfa
  S->>S: cookie imzosi, muddat, TOTP, oxirgi_qadam
  S-->>B: 200 + __Host-sessiya

Oraliq holat — "parol to'g'ri, ikkinchi omil kutilmoqda" — qayerda saqlanadi? Kanon uni bazaga yozmaydi. U imzolangan cookie'da: ichida foydalanuvchi id va "eslab qol" bayrog'i, muddati 5 daqiqa. Bu cookie bilan /vazifalar 401 qaytaradi — u sessiya emas.

ts
// imzo-cookie.ts — qisqa muddatli, HMAC bilan imzolangan cookie
// (holat serverda saqlanmaydi): 2FA kutilayotgan login, WebAuthn
// challenge. Ichidagini mijoz O'QIY oladi (shifrlanmagan, base64url
// JSON) — sir saqlanmaydi; o'zgartira OLMAYDI (imzo), muddati ichida.
import { createHmac, timingSafeEqual } from "node:crypto";
import type { CookieOptions, Request, Response } from "express";
import { cookieOl } from "./sessiya.ts";

export class ImzoliCookie<T extends object> {
  readonly nom: string;
  readonly #kalit: Buffer;
  readonly #muddatMs: number;
  readonly #atr: CookieOptions;

  /** @param asosiyNom masalan "mfa" → secure bo'lsa "__Host-mfa" */
  constructor(
    asosiyNom: string,
    kalit: Buffer,
    muddatMs: number,
    secure: boolean,
  ) {
    this.nom = secure ? `__Host-${asosiyNom}` : asosiyNom;
    this.#kalit = kalit;
    this.#muddatMs = muddatMs;
    this.#atr = {
      httpOnly: true, secure, sameSite: "lax", path: "/",
    };
  }

  qoy(res: Response, qiymat: T): void {
    const ichki = { q: qiymat, e: Date.now() + this.#muddatMs };
    const t = Buffer.from(JSON.stringify(ichki))
      .toString("base64url");
    const imzo = createHmac("sha256", this.#kalit)
      .update(t)
      .digest("base64url");
    res.cookie(this.nom, `${t}.${imzo}`, {
      ...this.#atr,
      maxAge: this.#muddatMs,
    });
  }

  ol(req: Request): T | undefined {
    const c = cookieOl(req.headers.cookie, this.nom);
    if (c === undefined || c.length > 4000) return undefined;
    const [t, imzo] = c.split(".");
    if (t === undefined || imzo === undefined) return undefined;
    const kutilgan = createHmac("sha256", this.#kalit)
      .update(t)
      .digest();
    const berilgan = Buffer.from(imzo, "base64url");
    if (
      berilgan.length !== kutilgan.length ||
      !timingSafeEqual(berilgan, kutilgan)
    ) {
      return undefined;
    }
    const x = JSON.parse(
      Buffer.from(t, "base64url").toString("utf8"),
    ) as { q: T; e: number };
    return x.e > Date.now() ? x.q : undefined;
  }

  ochir(res: Response): void {
    res.clearCookie(this.nom, this.#atr);
  }
}

Bu — JWT anatomiyasi darsidagi g'oyaning kichik qo'lda yozilgan ko'rinishi: ma'lumot + imzo + muddat. Farqi — faqat 5 daqiqa yashaydi va faqat bitta vazifaga xizmat qiladi. __Host- prefiksi va HttpOnly — Cookie chuqur darsidan.

6.3 Halol cheklov

5 daqiqa ichida hujumchi (parolni bilgan holda) /2fa/tekshir ga million marta urinsa-chi? 6 raqam — atigi bir million variant. Kanon bu yo'lda hali urinishlar sonini cheklamagan. Bu teshik Rate limiting darsida yopiladi: bitta hisobga 15 daqiqada 5 urinish. Ikkinchi omil ham tezlik cheklovisiz to'liq emas.

7. Passkeys: parolsiz kelajak

7.1 Nega TOTP ham fishingdan himoya qilmaydi

Soxta sayt parolni so'raydi, keyin TOTP kodni ham so'raydi va ikkalasini darhol haqiqiy saytga uzatadi. 30 soniya yetarli. Foydalanuvchi uchun hammasi bir xil ko'rinadi.

Passkey (WebAuthn standarti, W3C) boshqacha ishlaydi:

  • Ro'yxatda qurilma (telefon, noutbuk, kalit qurilmasi) kalit juftini yaratadi: yopiq kalit qurilmada qoladi, ochiq kalit serverga beriladi.
  • Kirishda server tasodifiy challenge yuboradi, qurilma uni yopiq kalit bilan imzolaydi.
  • Brauzer imzoga saytning origin'ini qo'shadi. Soxta saytda origin boshqa — imzo haqiqiy serverda tekshiruvdan o'tmaydi.

Foydalanuvchi faqat barmoq izi, yuz yoki qurilma PIN'ini tasdiqlaydi. Parol yo'q, kod terish yo'q. Passkey o'zi ikki omil: qurilma (egalik) va biometrika yoki PIN (o'zlik/bilish).

Tekshirib ko'ring: Fishing sahifasi passkey javobini olib, haqiqiy serverga yuborsa nima bo'ladi?

Javob

Javob ichida brauzer qo'ygan origin — soxta saytniki. Server faqat o'z origin'ini kutadi (expectedOrigin), imzo tekshiruvi o'tmaydi — rad. Bundan tashqari, brauzer soxta domen uchun haqiqiy saytning passkey'ini umuman taklif qilmaydi: kalit rpId (domen) ga bog'langan.

sequenceDiagram
  participant B as Brauzer va qurilma
  participant S as Server
  B->>S: POST /passkey/kirish/boshla
  S-->>B: challenge + __Host-webauthn cookie
  Note over B: barmoq izi yoki PIN
  B->>S: POST /passkey/kirish/yakun (imzo, origin)
  S->>S: ochiq kalit bilan imzo, origin, hisoblagich
  S-->>B: 200 + __Host-sessiya

7.2 Bazada faqat ochiq kalit

Passkey jadvalida sir yo'q. Faqat:

  • credential_id — kalit identifikatori;
  • ochiq_kalit — ochiq kalit (COSE formatida);
  • hisoblagich (signCount) — qurilma har imzoda oshiradi; kamayib qolsa — kalit nusxalangan bo'lishi mumkin;
  • transports va nom ("Ish noutbuki").

Baza sizsa ham, ochiq kalit bilan kirib bo'lmaydi. Parol hash'i va TOTP shifridan farqli, bu yerda himoya qiladigan hech narsa yo'q.

7.3 Kutubxona — o'zimiz yozmaymiz

TOTP — 40 qatorlik algoritm, uni o'zimiz yozdik va RFC bilan tekshirdik. WebAuthn esa butunlay boshqa: CBOR formati, COSE kalitlari, attestation, bir nechta imzo algoritmi. Bu yerda o'z kodimiz — xato manbai. Kanon @simplewebauthn/server 14.0.3 ni oldi (prod bog'liqlik +1). Kanon o'lchovida uning node_modules hajmi 5.9 MB (33 paket).

Kanon sozlamalari:

Sozlama Qiymat Ma'nosi
residentKey required discoverable kalit — kirishda email so'ralmaydi
attestationType none qurilma modelini tekshirmaymiz
excludeCredentials mavjud kalitlar bir qurilma ikki marta qo'shilmaydi
expectedOrigin ruxsat ro'yxati boshqa origin — rad

Challenge serverda saqlanmaydi — u yana imzolangan cookie'da (__Host-webauthn, 5 daqiqa) va bir marta ishlatiladi: yakun so'rovi kelishi bilan cookie o'chiriladi.

7.4 Haqiqiy brauzerda sinash

Passkey'ni oddiy node:test bilan sinab bo'lmaydi — imzoni qurilma qo'yadi. Kanon Playwright Chromium'ida virtual autentifikator ishlatdi (Chrome DevTools Protocol: WebAuthn.addVirtualAuthenticator). Bu brauzer ichida soxta "qurilma": barmoq izini avtomatik tasdiqlaydi.

Kanon e2e testi (Playwright I: e2e testlar asoslari darsidagi vosita) ikki stsenariyni tekshirdi:

  1. Parol bilan kirish → passkey ro'yxati 201 → chiqish → /men 401 → passkey bilan kirish 200; o'sha javobni qayta yuborish — 401 (challenge bir marta); hisoblagich ≥ 1.
  2. Server boshqa origin'ni kutsa (fishing sahifasi yoki noto'g'ri sozlama) — ro'yxat 400.

Natija: e2e 6/6 (passkey 2 + oldingi CSRF testlari 4). Sinalmagan: haqiqiy qurilmalar (Windows Hello, Touch ID, Android), Firefox va Safari, attestation.

8. Tiklash va step-up

8.1 Hamma omil yo'qolsa

Telefon ham, zaxira kodlar ham yo'q. Bu — MFA tiklash (recovery) muammosi va u xavfsizlikning eng nozik joyi. Hujumchilar aynan shu eshikdan kirishga urinadi: "telefonimni yo'qotdim, 2FA'ni o'chirib bering".

Kanon vazifalar-api da alohida tiklash oqimi yo'q: 2FA'ni o'chirish uchun joriy TOTP kodi yoki zaxira kod kerak. Katta tizimlarda variantlar:

  • bir nechta omil (ikki passkey, TOTP va passkey) — biri yo'qolsa, ikkinchisi;
  • kutish muddati bilan tiklash (masalan, bir necha kun, har kuni email ogohlantirish bilan);
  • qo'llab-quvvatlash xizmati orqali hujjat bilan tekshirish.

Qaysi biri bo'lmasin, qoida bitta: tiklash yo'li ikkinchi omildan kuchsiz bo'lmasligi kerak. Aks holda 2FA bezak bo'lib qoladi.

8.2 Step-up auth

Sessiya bor, lekin foydalanuvchi xavfli amal qilmoqchi: parolni o'zgartirish, 2FA'ni o'chirish, API kalit yaratish. Kompyuter ochiq qolgan bo'lishi mumkin. Step-up auth — shunday amal oldidan omilni qayta so'rash.

Kanonda bu qisman bor: /2fa/ochirish sessiyadan tashqari joriy kod yoki zaxira kodni talab qiladi. Umumiy step-up mexanizmi (masalan, "oxirgi 5 daqiqada omil tasdiqlangan" belgisi sessiyada) — kanonda yo'q, darsda tushuncha sifatida.

9. Hujumchi nigohi

Har qatorda: hujum, uni yopadigan himoya va himoyani qotiradigan test.

Hujum Himoya Test
sizgan parol bilan kirish TOTP yoki passkey parol bilan — {mfa: "kerak"}, sessiya yo'q
SIM swap orqali SMS kod TOTP, passkey — (tanlov)
kodni 90 s ichida qayta ishlatish oxirgi_qadam, atomik UPDATE o'sha kod qayta — 401
bazadan TOTP sirlarini o'qish AES-256-GCM, kalit .env da siri bazada ochiq emas
boshqa hisob shifrini ko'chirish AAD = foydalanuvchi id boshqa AAD — xato
fishing sahifasi kodni uzatadi passkey (origin imzoda) e2e: boshqa origin — 400
challenge'ni qayta yuborish cookie bir marta e2e: o'sha javob qayta — 401
5 daqiqada million kod tezlik cheklovi Rate limiting darsida

10. Ko'p uchraydigan xatolar

Xato Nega xavfli To'g'risi
parol to'g'ri bo'lsa sessiya berish 2FA chetlab o'tiladi oraliq cookie, keyin sessiya
sirni hash qilish server kod hisoblay olmaydi AES-GCM + AAD
padStart siz kod 07081804 → 7081804 padStart(6, "0")
WebAuthn'ni o'zingiz yozish CBOR, COSE, attestation tayyor kutubxona

11. Mashqlar

Mashqlar kurs/mashqlar/28/19-2fa/ da.

1-mashq (oson): to'g'ri yoki noto'g'ri

  1. Parol va maxfiy savol — ikki faktorli autentifikatsiya.
  2. TOTP sirini argon2id bilan hash qilib saqlash mumkin.
  3. Oyna ±1 bo'lsa, server uchta qadamni tekshiradi.
  4. Passkey bazasida sir yo'q, faqat ochiq kalit.
  5. TOTP fishing sahifasidan to'liq himoya qiladi.
Yechim
  1. Noto'g'ri — ikkalasi ham "bilish" omili.
  2. Noto'g'ri — server kodni hisoblashi uchun sirning o'zi kerak; shuning uchun shifrlanadi.
  3. To'g'ri — oldingi, joriy va keyingi.
  4. To'g'ri — yopiq kalit qurilmadan chiqmaydi.
  5. Noto'g'ri — soxta sayt kodni so'rab, 30 soniya ichida haqiqiy saytga uzata oladi. Fishingdan passkey himoya qiladi.

2-mashq (o'rta): base32 dan baytga

Telefon ilovasi otpauth URI'dagi secret ni base32 dan baytlarga qaytaradi. base32dan(matn) funksiyasini yozing (Uint8Array qaytarsin) va RFC'dagi ikkita satrda sinang: MZXW6YTBOI → foobar, GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ → 12345678901234567890. Har biri uchun bayt sonini va TextDecoder bilan matnni chiqaring. Ishora: kodlash teskarisi — har harfdan 5 bit qo'shib boring, 8 bit yig'ilganda bitta bayt chiqaring.

Yechim
js
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

function base32dan(matn) {
  let bits = 0;
  let qiymat = 0;
  const chiqish = [];
  for (const ch of matn.replace(/=+$/, "")) {
    const i = B32.indexOf(ch);
    if (i === -1) throw new Error(`base32 emas: ${ch}`);
    qiymat = (qiymat << 5) | i;
    bits += 5;
    if (bits >= 8) {
      chiqish.push((qiymat >>> (bits - 8)) & 255);
      bits -= 8;
      qiymat &= (1 << bits) - 1;
    }
  }
  return Uint8Array.from(chiqish);
}

const dek = new TextDecoder();
for (const s of ["MZXW6YTBOI", "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"]) {
  const b = base32dan(s);
  console.log(b.length, dek.decode(b));
}

Konsolda:

text
6 foobar
20 12345678901234567890

Ortiqcha bitlar (oxirgi harfning to'ldiruvchi nollari) bayt hosil qilmaydi va tashlanadi. Noto'g'ri harf (0, 1, 8) — xato, jim o'tkazilmaydi.

3-mashq (qiyin): yangi telefonga o'tish

Foydalanuvchining telefon soati 75 soniya orqada. totpOyna(siri, ms) funksiyasini yozing: joriy qadam atrofida −3 dan +3 gacha har qadam uchun 6 raqamli kodni qaytarsin. So'ng telefon ko'rsatgan kod (u 75 s oldingi vaqt bo'yicha hisoblaydi) server oynasida (±1) qabul qilinadimi — tekshiring. Siri — RFC'dagi 12345678901234567890, server vaqti — 1_800_000_000_000. Ishora: HOTP funksiyasini darsdagidek Web Crypto bilan yozing; siljish = telefon qadami − server qadami.

Yechim
js
async function hotp(siri, hisoblagich) {
  const kalit = await crypto.subtle.importKey(
    "raw", siri, { name: "HMAC", hash: "SHA-1" }, false, ["sign"],
  );
  const h = new DataView(new ArrayBuffer(8));
  h.setBigUint64(0, BigInt(hisoblagich));
  const mac = new Uint8Array(
    await crypto.subtle.sign("HMAC", kalit, h.buffer),
  );
  const o = mac[19] & 0x0f;
  const son = new DataView(mac.buffer).getUint32(o) & 0x7fffffff;
  return String(son % 1e6).padStart(6, "0");
}

async function totpOyna(siri, ms) {
  const t = Math.floor(ms / 1000 / 30);
  const natija = new Map();
  for (let i = -3; i <= 3; i++) {
    natija.set(i, await hotp(siri, t + i));
  }
  return natija;
}

const siri = new TextEncoder().encode("12345678901234567890");
const SERVER = 1_800_000_000_000;
const TELEFON = SERVER - 75_000;

const oyna = await totpOyna(siri, SERVER);
const telefonKodi = await hotp(siri, Math.floor(TELEFON / 30_000));
for (const [siljish, kod] of oyna) {
  const belgi = kod === telefonKodi ? " <- telefon" : "";
  console.log(siljish, kod + belgi);
}

const topildi = [...oyna].find(([, k]) => k === telefonKodi);
const siljish = topildi?.[0];
console.log("siljish:", siljish, "qabul:", Math.abs(siljish) <= 1);

Konsolda:

text
-3 374225 <- telefon
-2 168521
-1 385088
0 768147
1 050219
2 687638
3 945226
siljish: -3 qabul: false

75 soniya — 2.5 qadam; telefon floor bilan uch qadam orqaga tushdi va ±1 oynadan tashqarida. Kod rad etiladi. Oynani kengaytirish — yechim emas (har qadam qayta ishlatish xavfini oshiradi). To'g'ri yo'l — foydalanuvchiga "telefon soatini avtomatik vaqtga qo'ying" deyish. RFC 6238 ham katta oyna o'rniga soat farqini kuzatib, uni hisobga olishni taklif qiladi.

12. Loyiha qadami: 2FA va passkeys

12.1 Migratsiya 118-mfa-passkey.sql

Yangi fayl sql/118-mfa-passkey.sql, Prisma'da prisma/migrations/20261028100000_mfa_passkey/migration.sql, schema.prisma ga MfaTotp, ZaxiraKod, Passkey modellari. Asosiy jadval (izohlar qisqartirilgan):

sql
CREATE TABLE mfa_totp (
  foydalanuvchi_id bigint      NOT NULL,
  -- iv(12) + teg(16) + shifrlangan siri
  siri_shifr       bytea       NOT NULL,
  -- NULL — sozlanmoqda (birinchi kod hali tasdiqlanmagan)
  yoqilgan_vaqt    timestamptz,
  -- oxirgi qabul qilingan 30 s qadami
  oxirgi_qadam     bigint      NOT NULL DEFAULT 0,
  yaratilgan_vaqt  timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT mfa_totp_pkey PRIMARY KEY (foydalanuvchi_id),
  CONSTRAINT mfa_totp_foydalanuvchi_id_fkey
    FOREIGN KEY (foydalanuvchi_id)
    REFERENCES foydalanuvchilar (id) ON DELETE CASCADE
);

Qolgan ikki jadval: zaxira_kodlar (kod_hmac bytea UNIQUE, ishlatilgan_vaqt) va passkeylar (credential_id text UNIQUE, ochiq_kalit bytea, hisoblagich bigint, transports text[], nom 1–60 belgi). Hammasida ON DELETE CASCADE — hisob o'chsa, omillar ham o'chadi.

E'tibor bering: yoqilgan_vaqt IS NULL — "sozlanmoqda". Foydalanuvchi QR kodni skanerlab, birinchi to'g'ri kodni kiritmaguncha 2FA yoqilmaydi. Aks holda noto'g'ri skanerlangan siri bilan u o'z hisobidan chiqib qolardi.

12.2 src/auth/totp.ts

To'liq modul. Telefon uchun uzun qatorlar bo'lingan, ma'nosi kanondagidek (bo'lingan nusxa tsc, eslint va totp.test.ts dan o'tdi — biz tekshirdik):

ts
// totp.ts — TOTP (RFC 6238) va HOTP (RFC 4226), node:crypto bilan.
// RFC 6238 test vektorlari bilan sinalgan (test/totp.test.ts).
// Google Authenticator va boshqalar: SHA-1, 6 raqam, 30 s —
// otpauth:// URI (QR kod ichida shu).
import {
  createCipheriv,
  createDecipheriv,
  createHmac,
  randomBytes,
  timingSafeEqual,
} from "node:crypto";

const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

export function base32(b: Buffer): string {
  let bits = 0;
  let qiymat = 0;
  let s = "";
  for (const x of b) {
    qiymat = (qiymat << 8) | x;
    bits += 8;
    while (bits >= 5) {
      s += B32.charAt((qiymat >>> (bits - 5)) & 31);
      bits -= 5;
    }
    // ishlatilgan bitlarni tashlaymiz — << 32 bitdan oshmasin
    qiymat &= (1 << bits) - 1;
  }
  if (bits > 0) s += B32.charAt((qiymat << (5 - bits)) & 31);
  return s;
}

/** HOTP (RFC 4226): HMAC(siri, 8 baytlik hisoblagich) →
 * dinamik qisqartirish → `raqam` ta raqam */
export function hotp(
  siri: Buffer,
  hisoblagich: bigint,
  raqam = 6,
  alg: "sha1" | "sha256" | "sha512" = "sha1",
): string {
  const h = Buffer.alloc(8);
  h.writeBigUInt64BE(hisoblagich);
  const mac = createHmac(alg, siri).update(h).digest();
  const o = (mac[mac.length - 1] ?? 0) & 0x0f;
  const kod = (
    (mac.readUInt32BE(o) & 0x7fffffff) % 10 ** raqam
  ).toString();
  return kod.padStart(raqam, "0");
}

export const QADAM_S = 30;
export const qadam = (ms = Date.now()): bigint =>
  BigInt(Math.floor(ms / 1000 / QADAM_S));

/**
 * Kod tekshiruvi: joriy qadam ± `oyna` (soat farqi). `oxirgi` dan
 * katta qadamgina qabul qilinadi — bir kodni (yoki eski kodni) qayta
 * ishlatib bo'lmaydi.
 * @returns qabul qilingan qadam yoki undefined
 */
export function totpTekshir(
  siri: Buffer,
  kod: string,
  oxirgi: bigint,
  ms = Date.now(),
  oyna = 1,
): bigint | undefined {
  if (!/^\d{6}$/.test(kod)) return undefined;
  const t = qadam(ms);
  for (let i = -oyna; i <= oyna; i++) {
    const q = t + BigInt(i);
    if (q <= oxirgi) continue;
    const mos = timingSafeEqual(
      Buffer.from(hotp(siri, q)),
      Buffer.from(kod),
    );
    if (mos) return q;
  }
  return undefined;
}

export function otpauthUri(
  siri: Buffer,
  hisob: string,
  nashriyot = "vazifalar",
): string {
  const p = new URLSearchParams({
    secret: base32(siri),
    issuer: nashriyot,
    algorithm: "SHA1",
    digits: "6",
    period: String(QADAM_S),
  });
  const yorliq = encodeURIComponent(`${nashriyot}:${hisob}`);
  return `otpauth://totp/${yorliq}?${p.toString()}`;
}

/** AES-256-GCM: iv(12) + teg(16) + shifrlangan; aad — foydalanuvchi
 * id (bir hisobning shifrini boshqasiga ko'chirib bo'lmaydi) */
export function shifrla(
  kalit: Buffer,
  ochiq: Buffer,
  aad: string,
): Buffer {
  const iv = randomBytes(12);
  const c = createCipheriv("aes-256-gcm", kalit, iv)
    .setAAD(Buffer.from(aad));
  const sh = Buffer.concat([c.update(ochiq), c.final()]);
  return Buffer.concat([iv, c.getAuthTag(), sh]);
}

export function deshifrla(
  kalit: Buffer,
  b: Buffer,
  aad: string,
): Buffer {
  const iv = b.subarray(0, 12);
  const d = createDecipheriv("aes-256-gcm", kalit, iv)
    .setAAD(Buffer.from(aad));
  d.setAuthTag(b.subarray(12, 28));
  return Buffer.concat([d.update(b.subarray(28)), d.final()]);
}

timingSafeEqual uzunlik teng bo'lmasa xato tashlaydi — bu yerda xavf yo'q: kod oldin ^\d{6}$ bilan tekshirilgan, hotp ham 6 raqam qaytaradi.

Nega otplib emas? Kanon uni ko'rib chiqdi: algoritm kichik, RFC vektorlari bilan to'liq tekshiriladi, otplib 13.5.0 esa node_modules da 2.7 MB. Passkey'da esa aksincha — kutubxona.

12.3 MfaOmbori va yo'llar

src/auth/mfa.ts — MfaOmbori sinfi. Uning asosiy usullari:

  • sozla(fid) — yangi 20 baytlik siri; ON CONFLICT ... WHERE yoqilgan_vaqt IS NULL — 2FA allaqachon yoqilgan bo'lsa, ustidan yozmaydi (undefined → 409);
  • totp(fid, kod, yoqilgan) — deshifr, totpTekshir, atomik oxirgi_qadam yangilash;
  • yoq(fid, kod) — birinchi to'g'ri kod: yoqilgan_vaqt = now() va 10 ta zaxira kod bitta tranzaksiyada;
  • zaxira(fid, kod) — bir martalik atomik UPDATE;
  • passkey: passkeylar, passkeyTop, passkeyQosh (23505 — bu kalit boshqa joyda bor), passkeyIshlatildi.

src/auth/mfa-marshrut.ts — yo'llar (/v1/auth ichida):

Yo'l Nima qiladi
POST /2fa/sozlash otpauth URI va siri (faqat shu javobda); yoqilgan → 409
POST /2fa/yoqish {kod} 10 ta zaxira kod
POST /2fa/ochirish {kod} joriy kod yoki zaxira kod → 204
POST /2fa/tekshir {kod} yoki {zaxiraKod} + __Host-mfa → sessiya
POST /passkey/royxat/boshla, /yakun sessiya bilan; 201
POST /passkey/kirish/boshla, /yakun email'siz, parolsiz; sessiya
GET /passkeylar faqat id va nom

/kirish ga bitta shart qo'shiladi (kanon kodi):

ts
// TOTP yoqilgan — sessiya hali YO'Q; 5 daqiqalik imzolangan cookie
// va ikkinchi qadam (/2fa/tekshir)
if (await d.mfa.ombor.yoqilganmi(f.id)) {
  d.mfa.kutish.qoy(res, { f: f.id, e: body.eslabQol });
  await xizmat.hodisa({
    tur: "kirish_mfa_kutilmoqda",
    foydalanuvchiId: f.id,
    ...mijoz(req),
  });
  res.json({ mfa: "kerak" });
  return;
}
await sessiyaBer(req, res, f, body.eslabQol);

MFA faqat parol bilan kirishga qo'yilgan. SMS, Google, Telegram va passkey yo'llari o'z omili bilan kiradi — bu kanon tanlovi, uni bilib qo'ying.

Sozlama (src/config.ts, Zod bilan): WEBAUTHN_RP_ID (sukut localhost) va ixtiyoriy WEBAUTHN_ORIGINLAR (berilmasa — ILOVA_URL ning origin'i). Kanon bu ikki qatorni .env.example ga qism oxirida, hujjatlar bilan birga yozgan; siz hozirdan yozib qo'ying. rpId — domen nomi, passkey shu domenga bog'lanadi; prodakshnda u haqiqiy domeningiz bo'ladi.

12.4 Testlar

test/totp.test.ts (4 test): RFC 6238 Appendix B ning 6 ta vektori, RFC 4648 base32 vektorlari, oyna va qayta ishlatish, otpauth URI va AES-GCM (boshqa AAD va buzilgan teg → xato). test/mfa.test.ts (4 test) — API orqali butun oqim.

Biz ishga tushirdik (kanon nusxasi, Node 24.21.0, o'z PostgreSQL 17.6 bazamiz):

bash
node --test test/totp.test.ts test/mfa.test.ts

Chiqishda har ✔ qatorining matniga qarang — u qaysi hujum yopilganini aytadi:

text
✔ yoqish: 10 zaxira kod; siri va kodlar bazada ochiq emas; qayta sozlash — 409 (835.4463ms)
✔ parol bilan kirish → {mfa: 'kerak'}, sessiya YO'Q; TOTP bilan → sessiya; o'sha kod qayta — 401 (780.43ms)
✔ zaxira kod — bir marta; mfa cookie'siz yoki buzilgan — 401 mfa-kutilmagan; noto'g'ri kod — jurnalga (693.9856ms)
✔ o'chirish — joriy kod yoki zaxira kod bilan; keyin parol bilan to'g'ridan sessiya (654.6494ms)
✔ RFC 6238 Appendix B (SHA-1, 8 raqam, seed '12345678901234567890') (4.309ms)
✔ RFC 4648 base32 vektorlari (0.714ms)
✔ totpTekshir: ±1 qadam oyna; 2 qadam — rad; oxirgi qadamdan eski/teng — rad (qayta ishlatish); format (3.1972ms)
✔ otpauth URI va AES-GCM: boshqa foydalanuvchi (aad) yoki buzilgan shifr — xato (2.0817ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 22257.8951

Nima bo'ldi? 8 test o'tdi: to'rttasi API orqali butun 2FA oqimini (sessiyasiz oraliq qadam, kodni qayta ishlatish — 401, zaxira kod bir marta), to'rttasi algoritmni RFC vektorlari bilan tekshirdi. Fayllar parallel yuradi, shuning uchun tartib va millisekundlar har safar boshqacha. Passkey testlari — e2e/passkey.e2e.ts, ular Playwright brauzerini talab qiladi (npm run e2e).

12.5 Tekshiruv va commit

Butun loyiha tekshiruvi (kanon, 2026-10-28, PostgreSQL 18.6): tip va lint xatosiz, testlar 184 (183 + 1 o'tkazilgan), mfa 4, Testcontainers 10, e2e 6/6. Prisma migratsiyalari sql/ bilan bir xil, schema.prisma va migratsiyalar orasida farq yo'q. Yangi prod bog'liqlik: @simplewebauthn/server 14.0.3; dev: @simplewebauthn/browser 14.0.0 (e2e sahifasi uchun).

bash
M="feat(auth): 2FA — TOTP (RFC 6238, shifrlangan siri, zaxira"
M+=" kodlar) va passkeys (WebAuthn); e2e: virtual autentifikator;"
M+=" db: 118"
git add src test e2e sql prisma package.json package-lock.json
git commit -m "$M"

13. Real ishda

  • GitHub, Google, Microsoft — hammasi TOTP, passkey va zaxira kodlarni beradi; ko'pchiligi foydalanuvchini passkey'ga undamoqda.
  • Banklar ko'pincha hali SMS ishlatadi — qamrov uchun murosa (trade-off), xavfsizlik uchun emas.
  • Auth xizmatlari (Auth0, Clerk, Better Auth) 2FA'ni tayyor beradi — Auth kutubxonalari va xizmatlari. Ular ichida ham aynan shu bloklar bor: RFC algoritmi, shifrlangan siri, zaxira kodlar, WebAuthn kutubxonasi.
  • Intervyuda: "TOTP qanday ishlaydi?", "Nega TOTP siri hash qilinmaydi?", "Passkey nega fishingdan himoya qiladi?", "2FA'ni tiklash oqimini qanday qurasiz?".

Xulosa

  • 2FA — ikki har xil omil; SMS eng zaifi (SIM swap, fishing).
  • TOTP = HMAC-SHA1(siri, floor(vaqt/30)) + dinamik qisqartirish; o'z kodingizni RFC 6238 va RFC 4648 vektorlari bilan tekshiring.
  • Oyna ±1 qadam, oxirgi_qadam va atomik UPDATE — bitta kod faqat bir marta.
  • TOTP siri hash qilinmaydi — AES-256-GCM bilan shifrlanadi, AAD = foydalanuvchi id.
  • Zaxira kodlar — tasodifiy, HMAC bilan saqlanadi, bir martalik.
  • Passkey — yopiq kalit qurilmada, bazada faqat ochiq kalit; origin imzoga kiradi — fishingga chidamli. WebAuthn — tayyor kutubxona bilan.

Keyingi dars: RBAC va resurs egaligi — kim kirganini bildik; endi u nima qila olishini belgilaymiz: rollar, admin yo'llari, "faqat o'z vazifasi" qoidasi va API'ni RLS ostida ishlatish.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Ikki faktorli autentifikatsiya (2FA) va passkeys: TOTP, zaxira kodlar va WebAuthn — IlmHamroh