IlmHamroh
JavaScript Full-stack/28-qism. Autentifikatsiya va backend xavfsizligi13/31-dars27 daqiqa
Mundarija (35)

OTP orqali login: SMS kod, HMAC bilan saqlash, urinishlar chegarasi va SMS pumping

Qisqacha: SMS kod (OTP) — atigi 10⁶ variant. Shuning uchun uning xavfsizligi kodning murakkabligida emas, cheklovlarda: 5 daqiqa muddat, bitta kodga 5 urinish, bir marta ishlatish, raqamga soatiga 5 va kuniga 10 SMS. Kod crypto.randomInt bilan yasaladi (Math.random emas) va bazada HMAC-SHA256(server siri) ko'rinishida turadi: kanon o'lchovida sizib chiqqan oddiy SHA-256 dan kod 564 ms da topildi, HMAC'dan esa sirsiz — topilmadi. SMS'ni faqat ruxsat etilgan mamlakat raqamlariga yuboramiz — bu SMS pumping firibgarligiga qarshi birinchi to'siq.

Bu darsda

  • 6 xonali kodni nega Math.random bilan emas, CSPRNG bilan va bias'siz yasash kerakligini ko'rasiz.
  • Kanon o'lchovi bilan oddiy hash va HMAC farqini tushuntirasiz.
  • Urinishlar chegarasi, muddat va yuborish chastotasini atomik qilib yozasiz.
  • Telefon raqamni E.164 ga normallash va SMS pumping himoyasini qo'shasiz.
  • vazifalar-api ga 115–116 migratsiyalarni, otp.ts ni, to'rt yo'lni va sakkizta testni qo'shasiz.

Oldin bilishingiz kerak: Email tasdiqlash va parol tiklash oqimlari, crypto 1: tasodif, hash va HMAC, String, TTL va kalit nomlash, Qulflar: row, table va advisory, SMS provayderlar: Eskiz va PlayMobile.

Oldingi darsda xat orqali kelgan uzun kalit bilan ishladik — 256 bit tasodif. Bugun xuddi shu g'oya, lekin kalit qisqa: odam uni SMS'dan ko'chirib, qo'lda yozadi. Uzunlik o'zgarishi bilan butun xavfsizlik hisobi o'zgaradi.

1. Nega bu kerak?

O'zbekistonda ko'p ilova telefon raqam bilan kiradi: taksi, bank, yetkazib berish, davlat xizmatlari. Foydalanuvchi parol o'ylab topmaydi — raqamini yozadi, SMS'da 6 ta raqam keladi, kiradi. Bu qulay va tanish.

Lekin 6 raqam — atigi bir million variant. Parol tiklash havolasini taxmin qilib bo'lmasdi (2²⁵⁶). SMS kodni esa — kerakli shart-sharoitda — topish mumkin. Ikkinchi xavf — pul: har SMS sizga pul turadi va firibgarlar shundan foydalanadi. Bugun ikkala xavfni ham yopamiz.

2. Oqim

vazifalar-api da hisob baribir email bilan ochiladi. Telefon — keyin, profil orqali ulanadi va shundan keyingina u bilan kirish mumkin:

sequenceDiagram
  participant B as Brauzer
  participant A as vazifalar-api
  participant S as SMS provayder
  B->>A: POST /auth/telefon (sessiya bilan)
  A->>S: kod (telefon_ulash)
  B->>A: POST /auth/telefon/tasdiq {telefon, kod}
  A-->>B: raqam ulandi
  B->>A: POST /auth/otp/yubor {telefon}
  A-->>B: 202 (har doim bir xil)
  A->>S: kod (kirish), fonda
  B->>A: POST /auth/otp/kirish {telefon, kod}
  A-->>B: 200 + sessiya cookie

Nimaga qarang: raqam faqat o'zi tasdiqlagan foydalanuvchiga ulanadi. Aks holda kimdir begona raqamni o'z hisobiga yozib qo'yardi. "Telefon bilan ro'yxatdan o'tish" (emailsiz) — boshqa variant; uning uchun sxemada email NULL bo'lishi kerak edi va kanon uni qo'shmadi.

3. Kodni yasash

3.1 Math.random emas

Math.random() — tez, lekin kriptografik emas. V8 uni xorshift128+ algoritmi bilan hisoblaydi: bir nechta chiqishni ko'rgan hujumchi ichki holatni tiklab, keyingi qiymatlarni oldindan aytishi mumkin. Kanon tajribasida 100 000 ta Math.random kodi taqsimot bo'yicha "tasodifiyga o'xshash" chiqdi — muammo sifatda emas, oldindan aytib bo'lishida. Shuning uchun Node'da crypto.randomInt, brauzerda crypto.getRandomValues (crypto 1: tasodif, hash va HMAC).

3.2 Modulo bias

crypto.getRandomValues baytlar beradi. Ularni raqamga aylantirishning sodda yo'li — qoldiq: bayt % 10. Unda yashirin tuzoq bor:

js
// Bayt (0–255) ni 10 ga bo'lib qoldiq olsak — qaysi raqam necha?
const sanoq = Array(10).fill(0);
for (let b = 0; b < 256; b++) sanoq[b % 10]++;
console.log(sanoq.join(" "));

// To'g'ri yo'l: chegaradan oshganini tashlab, qayta olish
const CHEGARA = 4_294_000_000; // 10^6 ga bo'linadigan eng katta son
function kodYasa() {
  const bir = new Uint32Array(1);
  do {
    crypto.getRandomValues(bir);
  } while (bir[0] >= CHEGARA);
  return String(bir[0] % 1_000_000).padStart(6, "0");
}

const kodlar = Array.from({ length: 1000 }, kodYasa);
console.log(kodlar.every((k) => /^\d{6}$/.test(k)));
console.log(CHEGARA % 1_000_000, 2 ** 32 - CHEGARA);

Konsolda:

text
26 26 26 26 26 26 25 25 25 25
true
0 967296

Birinchi qator: 256 ta bayt 10 ga teng bo'linmaydi, shuning uchun 0–5 raqamlari 6–9 dan ko'proq chiqadi (26 va 25). Bu modulo bias (qoldiq og'ishi). Hujumchi uchun "ehtimolroq" kodlar paydo bo'ladi. Yechim — rad etish (rejection sampling): 32 bitli son 10⁶ ga teng bo'linadigan chegaradan oshsa, uni tashlab, yangisini olamiz. 4.29 milliarddan atigi 967 296 tasi tashlanadi — kamdan-kam takrorlash.

padStart(6, "0") ham muhim: 4217 emas, 004217. Nol bilan boshlanadigan kodlar ham to'liq 6 xonali bo'lsin. Node'ning crypto.randomInt(0, 10 ** 6) bu ishni ichida o'zi qiladi — kanon shuni ishlatadi.

3.3 Uzunlik va muddat

Kanon: 6 raqam, 5 daqiqa. Nega 4 emas? 4 raqam — 10 000 variant; 5 urinish bilan bitta kodni topish ehtimoli 1/2000. Nega 8 emas? SMS'dan ko'chirish qiyinlashadi, xatolar ko'payadi. 6 — sanoatdagi odatiy muvozanat. 5 daqiqa — SMS kechikib kelsa ham yetadi, lekin hujumchiga uzoq oyna bermaydi.

4. Kodni saqlash: hash yetmaydi

4.1 Kanon o'lchovi

Oldingi darsda tokenni SHA-256 bilan saqlash yetarli edi — 2²⁵⁶ variant. Bu yerda variantlar 10⁶. Kanon tekshirdi (Node 24.21.0, bitta yadro):

text
1) sizib chiqqan sha256(telefon:kod) dan kod topildi: true — 564 ms (bitta yadro, eng ko'pi 10^6 urinish)
2) HMAC(OTP_SIRI) — sirsiz 10^6 urinish: topildi false (67105 ms) — sir bazada emas, config'da

Birinchi qator: baza sizsa, oddiy hash'dan kod yarim soniyada topiladi — u hali 5 daqiqa amal qilib turgan bo'ladi. Ikkinchi qator: HMAC'da kalit sifatida server siri (OTP_SIRI) ishlatiladi. U bazada emas, server sozlamasida. Sirsiz hujumchi hamma 10⁶ variantni sinab chiqdi (67 soniya) — birortasi ham mos kelmadi, chunki u to'g'ri hisoblay olmaydi.

Bu — oldingi darslardagi pepper g'oyasining ishlatilishi (Parolni xavfsiz saqlash). Parol uchun kanon pepper'ni qo'shmagan edi — aylantirish qimmat. OTP uchun esa qo'shdi: kod 5 daqiqa yashaydi, sir almashsa eng ko'pi bilan faol kodlar o'ladi va foydalanuvchi yangi SMS so'raydi.

4.2 HMAC ichida nima bor

Kanon HMAC'ga faqat kodni emas, maqsad:telefon:kod ni beradi. Brauzerda ko'ramiz (namuna sir bilan):

js
const enc = new TextEncoder();
const hex = (b) => Array.from(new Uint8Array(b), (x) =>
  x.toString(16).padStart(2, "0")).join("");

// namuna sir — haqiqiysi .env da (OTP_SIRI, kamida 32 belgi)
const kalit = await crypto.subtle.importKey(
  "raw", enc.encode("namuna-otp-siri-faqat-dars-uchun-0001"),
  { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
async function kodHmac(maqsad, telefon, kod) {
  const b = await crypto.subtle.sign(
    "HMAC", kalit, enc.encode(`${maqsad}:${telefon}:${kod}`));
  return hex(b);
}

const TEL = "+998900000000";
const kirish = await kodHmac("kirish", TEL, "004217");
const ulash = await kodHmac("telefon_ulash", TEL, "004217");
console.log(kirish.length, kirish.slice(0, 16));
console.log(ulash.slice(0, 16));
console.log("bir xilmi:", kirish === ulash);

Konsolda:

text
64 0b7f788946ca76d7
1f896e3301bada50
bir xilmi: false

Bir xil kod, bir xil raqam — lekin maqsad boshqa, HMAC boshqa. Natija: "raqamni ulash" uchun kelgan SMS kodi bilan kirib bo'lmaydi. Telefon ham ichida — bir raqamning kodi boshqa raqamga yaramaydi. Bazaga 64 hex belgi, ya'ni 32 bayt tushadi.

Tekshirib ko'ring: Nega OTP uchun argon2 kabi sekin hash ishlatmaymiz, axir variantlar kam?

Javob

Sekin hash ham 10⁶ variantni qutqarmaydi: argon2 bilan bitta urinish 30 ms bo'lsa, million urinish — 8 soatdan kam, bir nechta yadroda — undan ham tez. Muammo — variantlar soni, sekinlik uni yetarlicha qoplamaydi. HMAC'da esa hujumchi bitta ham to'g'ri hisob qila olmaydi — siri yo'q. Bundan tashqari argon2 har SMS so'rovida serverni 30 ms band qilardi.

5. Urinishlar chegarasi

5.1 Onlayn taxmin

Baza sizmasa ham, hujumchi POST /otp/kirish ga ketma-ket taxmin yuborishi mumkin. Kanon hisobi:

js
const VARIANT = 10 ** 6;
const foiz = (x) => `${(x * 100).toPrecision(2)} %`;

// bitta kodga 5 urinish
console.log("5 urinish:", foiz(5 / VARIANT));
// kuniga 10 SMS × 5 urinish
console.log("kuniga 50:", foiz(50 / VARIANT));
// cheklovsiz: 1000 so'rov/s × 300 s, hammasi har xil taxmin
console.log("cheklovsiz:", foiz((1000 * 300) / VARIANT));

Konsolda:

text
5 urinish: 0.00050 %
kuniga 50: 0.0050 %
cheklovsiz: 30 %

Uchinchi qator — darsning eng muhim raqami. Urinishlar cheklanmasa, sekundiga 1000 so'rov yuborgan hujumchi 5 daqiqada har uchinchi hisobni oladi. Cheklov bilan — bir kunda 0.005 %. 6 raqam kuchsiz emas, cheksiz urinish kuchsiz.

5.2 "5 dan keyin to'g'ri kod ham o'ladi"

Kanon qoidasi qattiq: beshinchi noto'g'ri urinishdan keyin kod o'ladi — hatto to'g'ri kod kiritilsa ham. Aks holda hujumchi 5 ta taxmin qiladi, kutadi va yana davom etadi. Foydalanuvchi uchun natija oddiy: "kod noto'g'ri — yangisini so'rang".

Urinishni hisoblash ham atomik bo'lishi kerak. Agar server avval urinishlar ni o'qib, keyin oshirsa, 50 ta parallel so'rov hammasi "4 urinish" ni ko'radi va hammasi tekshiriladi. Kanon bitta UPDATE … SET urinishlar = urinishlar + 1 … AND urinishlar < 5 RETURNING bilan hisoblaydi: hisoblagich avval oshadi, keyin kod solishtiriladi. 5 tadan keyin UPDATE hech qator qaytarmaydi.

Taqqoslash — timingSafeEqual bilan: ikkala HMAC ham 32 bayt, uzunlik farqi bo'lmaydi (Parolni xavfsiz saqlash).

5.3 NIST bilan solishtirish

NIST SP 800-63B-4 (AQSh standarti, 2025) SMS kod uchun aniq talablar qo'yadi. Kanon ularning ko'pchiligiga mos, bittasida farq bor:

NIST talabi Kanon
kamida 6 raqam, kriptografik tasodif 6 raqam, crypto.randomInt
10 daqiqadan ko'p amal qilmasin 5 daqiqa
bir kod — bir marta ishlatilgan_vaqt
ketma-ket xato urinishlar hisobga ko'pi bilan 100 ta; yangi kod hisoblagichni nolga tushirmaydi hisoblagich har kodda alohida (5 ta); yangi SMS bilan yangi 5 urinish

Oxirgi qator — halol farq. Kanonda bir kunda ko'pi bilan 10 SMS × 5 = 50 urinish bor. Bir kunlik ehtimol kichik (0.005 %), lekin kunlar ko'paygan sari u yig'iladi. NIST esa hisob bo'yicha umumiy hisoblagich talab qiladi: 100 ta ketma-ket xatodan keyin bu usul o'chadi. Buni kanonda kunlik chegara qisman qoplaydi; to'liq yechim — hisob bo'yicha xato urinishlar sanog'i (Rate limiting darsida ko'riladigan cheklovlar bilan birga).

6. Yuborish chastotasi

Har "kodni yuborish" bosilishi — bitta SMS va bitta to'lov. Kanon raqam bo'yicha uch qoida qo'yadi:

Qoida Qiymat Nimadan
oraliq 60 s tugmani qayta-qayta bosish
soatiga 5 SMS qo'lda yoki bot bilan to'ldirish
kuniga 10 SMS uzoq davom etadigan suiiste'mol

Har yangi kod shu raqamning eski faol kodlarini bekor qiladi — faqat oxirgi SMS'dagi kod ishlaydi.

Tekshiruv yana poygaga uchraydi: ikki parallel so'rov ikkalasi ham "oxirgi SMS 60 s dan eski" ni ko'radi. Oldingi darsda foydalanuvchi qatorini FOR UPDATE bilan qulflagandik. Bu yerda raqam bazada bo'lmasligi ham mumkin, shuning uchun kanon advisory lock oladi — raqamning o'zidan hisoblangan son bo'yicha qulf: pg_advisory_xact_lock(hashtextextended(telefon, 28)) (Qulflar: row, table va advisory). Bir raqamga kelgan so'rovlar navbat bilan o'tadi, boshqa raqamlar bir-birini kutmaydi. Qulf tranzaksiya tugashi bilan o'zi bo'shaydi.

Bu cheklovlar raqam bo'yicha. IP va umumiy so'rovlar bo'yicha cheklov — Rate limiting darsida.

7. Telefon raqamni normallash

Bitta raqamni foydalanuvchi o'n xil yozadi: +998 90 000-00-00, 998900000000, 90 000 00 00. Bazada esa bitta shakl kerak — aks holda chastota cheklovi va "bitta raqam — bitta hisob" qoidasi aylanib o'tiladi. Xalqaro standart shakl — E.164: +, mamlakat kodi va raqam, bo'sh joysiz, jami 15 raqamgacha.

js
function telefonNormallash(x) {
  const r = x.replace(/[\s\-()]/g, "");
  const t = /^\d{9}$/.test(r) ? `+998${r}`
    : /^998\d{9}$/.test(r) ? `+${r}`
    : r;
  return /^\+[1-9]\d{7,14}$/.test(t) ? t : undefined;
}

const KIRISH = [
  "+998 90 000-00-00",
  "998900000000",
  "90 000 00 00",
  "(90) 000-00-00",
  "+7 912 000 00 00",
  "8 90 000 00 00",
  "abc",
];
for (const x of KIRISH) {
  console.log(x.padEnd(18), "→", telefonNormallash(x));
}

Konsolda:

text
+998 90 000-00-00  → +998900000000
998900000000       → +998900000000
90 000 00 00       → +998900000000
(90) 000-00-00     → +998900000000
+7 912 000 00 00   → +79120000000
8 90 000 00 00     → undefined
abc                → undefined

9 raqam — O'zbekiston raqami deb olinadi va oldiga +998 qo'shiladi. Eski "8 …" shakli tanilmadi — kanon uni ataylab qo'llab-quvvatlamaydi. +7 … — to'g'ri E.164, lekin u boshqa mamlakat: normallash uni qabul qiladi, mamlakat tekshiruvi esa keyingi bo'limda rad etadi. Raqamlar namunaviy (+998 90 000 00 00 — kursdagi standart namuna).

Murakkab holatlar uchun (har mamlakat qoidalari, raqam turi) libphonenumber-js kabi kutubxona bor. Kanonga bitta mamlakat uchun 5 qatorli funksiya yetdi.

8. SMS pumping

8.1 Firibgarlik qanday ishlaydi

SMS pumping (SMS'ni "haydash", toll fraud) — eng qimmat OTP xatosi. Firibgar qimmat yo'nalishdagi raqamlar egasi (yoki operator bilan til biriktirgan) bo'ladi. U sizning "kodni yuborish" formangizga shu raqamlarni minglab yuboradi. Siz har SMS uchun to'laysiz, operator esa daromadning bir qismini firibgarga qaytaradi. Hech kim tizimga kirmaydi — maqsad kirish emas, sizning hisobingizdan pul.

8.2 Himoya qatlamlari

Qatlam Kanonda
faqat ruxsat etilgan mamlakatlar OTP_MAMLAKATLAR (sukut +998)
raqam bo'yicha chastota 60 s / soatiga 5 / kuniga 10
noma'lum raqamga SMS yo'q kirishda faqat ulangan raqamga
kunlik umumiy SMS byudjeti, CAPTCHA keyinroq: CAPTCHA tokenini serverda tekshirish, honeypot va SMS pumping

Birinchi qatlam — eng arzon va eng kuchli: ruxsat ro'yxati (allowlist). Ilovangiz faqat O'zbekistonda ishlasa, +998 dan boshqa raqamga SMS yuborishga hech qanday sabab yo'q. Ro'yxatdan tashqari raqam — 422. Bu raqam haqida hech narsa oshkor qilmaydi: hisobda bor-yo'qligidan qat'i nazar, bir xil xato.

Uchinchi qatlam ham muhim: kirish uchun SMS faqat hisobga ulangan raqamga ketadi. Noma'lum raqamga javob bir xil (202), lekin SMS yuborilmaydi — firibgar o'z raqamlarini ishlata olmaydi.

9. SMS'ning o'zi qanchalik ishonchli

SMS — eng qulay, lekin eng kuchli usul emas:

  • SIM swap — firibgar operatorni aldab, sizning raqamingizga yangi SIM karta oladi va SMS'laringiz unga keladi.
  • SS7 — operatorlar orasidagi eski signal protokoli; uning zaifliklari orqali SMS'ni tutib olish holatlari ma'lum.
  • Ijtimoiy muhandislik — "kodni ayting, bankdan qo'ng'iroq qilyapmiz". Shuning uchun SMS matnida: "Kodni hech kimga aytmang".

NIST SP 800-63B-4 telefon tarmog'i (SMS yoki ovozli qo'ng'iroq) orqali kodni cheklangan (restricted) autentifikator deb belgilaydi. Hujjat chiqqan paytda bu — yagona cheklangan autentifikator. Uni ishlatish mumkin, lekin shartlari bor:

  • tashkilot xavfni baholaydi va qabul qiladi; xavf qabul qilinmasa — boshqa usul;
  • foydalanuvchiga kamida bitta cheklanmagan muqobil beriladi;
  • foydalanuvchi SMS xavfi va muqobillar haqida ogohlantiriladi;
  • kelajakda SMS taqiqlansa — ko'chish rejasi tayyor turadi.

Hujjat yana tavsiya qiladi: SMS yuborishdan oldin xavf belgilariga qarang — SIM karta almashgani, raqam boshqa operatorga ko'chgani. Muqobillar — TOTP ilovasi va passkey — Ikki faktorli autentifikatsiya va passkeys darsida.

Kanon haqiqiy SMS provayderini sinamadi — hisob ochilmadi, pul sarflanmadi. SmsYuboruvchi interfeysi bor, testlarda XotiraSms, dev'da LogSms (kod log'ga yoziladi). Eskiz va PlayMobile bilan ulanish — SMS provayderlar: Eskiz va PlayMobile darsida.

10. Hujumchi nigohi

Har qatorda: hujum, himoya va uni tekshiradigan kanon testi (yoki o'lchovi).

Hujum Nima uchun ishlaydi Himoya Qanday tekshirilgan
Math.random chiqishidan keyingi kodni topish PRNG oldindan aytiladi crypto.randomInt yangiKod testi (10 000 kod)
sizgan bazadan sha256(kod) ni sanab chiqish 10⁶ variant, 564 ms HMAC(server siri) kanon o'lchovi; test: bazada kod ochiq emas
ketma-ket 300 000 taxmin urinish cheklanmagan 5 urinish, keyin kod o'ladi "5 ta noto'g'ridan keyin to'g'ri kod ham 401"
50 ta parallel taxmin o'qish va oshirish alohida atomik UPDATE … RETURNING 3 parallel to'g'ri so'rov — bittasi 200
ulash kodi bilan kirish maqsad ajratilmagan maqsad HMAC ichida "maqsad ajratilgan" testi
+998 90… va 90… bilan chastotani aylanib o'tish raqam normallanmagan E.164 telefonNormallash testi, baza CHECK
qimmat xalqaro raqamlarga SMS (pumping) mamlakat cheklanmagan OTP_MAMLAKATLAR, chastota boshqa mamlakat — 422; chastota testi
"bu raqam ro'yxatda bormi?" javob farqli bir xil 202, ish fonda noma'lum raqam — o'sha 202 tanasi
SIM swap SMS — raqamga, odamga emas TOTP / passkey muqobili keyingi darslarda

11. Ko'p uchraydigan xatolar

Xato Nega xavfli To'g'risi
Math.floor(Math.random() * 1e6) oldindan aytib bo'ladi crypto.randomInt
bayt % 10 modulo bias rad etish yoki randomInt
String(kod) — 4217 6 xonali emas padStart(6, "0")
kodni ochiq yoki SHA-256 bilan saqlash sizsa — darhol topiladi HMAC + sir config'da
urinishni hisoblamaslik 5 daqiqada 30 % 5 urinish
xato urinishda urinishni oshirmaslik cheksiz taxmin har urinish hisoblanadi
hamma mamlakatga SMS SMS pumping ruxsat ro'yxati
"bunday raqam yo'q" javobi enumeration bir xil 202

12. Mashqlar

Mashqlar kurs/mashqlar/28/13-otp/ da.

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

  1. 6 xonali SMS kodni SHA-256 bilan saqlash xavfsiz — u bir tomonlama funksiya.
  2. crypto.randomInt(0, 1_000_000) modulo bias'dan xoli.
  3. Beshta noto'g'ri urinishdan keyin to'g'ri kod qabul qilinishi kerak — foydalanuvchi uni kiritdi-ku.
  4. SMS pumping'da firibgar boshqa odamning hisobiga kirishga harakat qiladi.
  5. +998 90 000 00 00 va 998900000000 bazada bitta qiymat bo'lishi kerak.
Yechim
  1. Noto'g'ri — 10⁶ variantni sanab chiqish uchun yarim soniya yetadi (kanon: 564 ms). HMAC va server siri kerak.
  2. To'g'ri — Node uni ichida rad etish usuli bilan yasaydi.
  3. Noto'g'ri — aks holda hujumchi 5 tadan bo'lib cheksiz taxmin qiladi. Kod o'ladi, yangisi so'raladi.
  4. Noto'g'ri — maqsad kirish emas, sizning SMS hisobingizdan pul.
  5. To'g'ri — E.164 +998900000000; aks holda chastota cheklovi aylanib o'tiladi.

2-mashq (o'rta): beshinchi urinishdan keyin

Bitta kod uchun xotiradagi model yozing: { qiymat: "004217", urinishlar: 0, ishlatilgan: false } va tekshir(kiritilgan). Qoidalar: kod ishlatilgan yoki urinishlar 5 ga yetgan bo'lsa — "o'lik"; aks holda avval urinish oshadi, keyin solishtiriladi: mos kelmasa — "xato", mos kelsa — "kirdi" va kod ishlatilgan. Besh xato kod va ikki marta to'g'ri kodni sinang, har qadamda natija va urinishlar sonini chiqaring.

Yechim
js
const MAKS = 5;

// Bazadagi qatorning soddalashtirilgan modeli
const kod = { qiymat: "004217", urinishlar: 0, ishlatilgan: false };

function tekshir(kiritilgan) {
  if (kod.ishlatilgan || kod.urinishlar >= MAKS) return "o'lik";
  kod.urinishlar++; // avval hisoblanadi — to'g'ri bo'lsa ham
  if (kiritilgan !== kod.qiymat) return "xato";
  kod.ishlatilgan = true;
  return "kirdi";
}

const urinishlar = ["111111", "222222", "333333", "444444",
  "555555", "004217", "004217"];
for (const k of urinishlar) {
  console.log(k, tekshir(k), kod.urinishlar);
}

Konsolda:

text
111111 xato 1
222222 xato 2
333333 xato 3
444444 xato 4
555555 xato 5
004217 o'lik 5
004217 o'lik 5

Oltinchi qator — qoidaning mohiyati: to'g'ri kod keldi, lekin kech. Kanonda bu hol 401 otp-xato bo'ladi — "noto'g'ri" va "o'lik" foydalanuvchiga bir xil ko'rinadi, hujumchiga ham. Kanon testi xuddi shu ketma-ketlikni HTTP orqali tekshiradi.

3-mashq (qiyin): chastota cheklovi

cheklovSababi(yuborilgan, hozir) funksiyasini yozing: yuborilgan — shu raqamga oldin yuborilgan SMS vaqtlari (ms, o'sib boradi). Natija kanon tartibida: oxirgi SMS 60 s ichida bo'lsa — "oraliq"; oxirgi soatda 5 ta bo'lsa — "soatiga"; oxirgi kunda 10 ta bo'lsa — "kuniga"; aks holda "yuborilsin". Olti holatni sinang. Birinchi uchtasi: hech narsa yuborilmagan; 30 s oldin bitta; har 2 daqiqada 5 ta, 10-daqiqada. Keyingilari: o'sha 5 ta, 2 soatdan keyin; ikki to'lqin (0 va 3-soatda 5 tadan), 6-soatda; o'sha, 25-soatda. Ishora: filter, at(-1), spread bilan massivlarni birlashtirish (Spread va rest operatori).

Yechim
js
const S = 1000;
const SOAT = 3600 * S;
const QOIDA = { oraliq: 60 * S, soatiga: 5, kuniga: 10 };

function cheklovSababi(yuborilgan, hozir) {
  const kun = yuborilgan.filter((t) => t > hozir - 24 * SOAT);
  const soat = kun.filter((t) => t > hozir - SOAT);
  const oxirgi = kun.at(-1);
  if (oxirgi !== undefined && hozir - oxirgi < QOIDA.oraliq) {
    return "oraliq";
  }
  if (soat.length >= QOIDA.soatiga) return "soatiga";
  if (kun.length >= QOIDA.kuniga) return "kuniga";
  return "yuborilsin";
}

const har2daq = (n, boshi) =>
  Array.from({ length: n }, (_, i) => boshi + i * 120 * S);

console.log(cheklovSababi([], 0));
console.log(cheklovSababi([0], 30 * S));
console.log(cheklovSababi(har2daq(5, 0), 10 * 60 * S));
console.log(cheklovSababi(har2daq(5, 0), 2 * SOAT));
const kunBoyi = [...har2daq(5, 0), ...har2daq(5, 3 * SOAT)];
console.log(cheklovSababi(kunBoyi, 6 * SOAT));
console.log(cheklovSababi(kunBoyi, 25 * SOAT));

Konsolda:

text
yuborilsin
oraliq
soatiga
yuborilsin
kuniga
yuborilsin

Beshinchi qator: oxirgi soatda SMS yo'q, lekin kun bo'yi 10 ta — kunlik chegara ishladi. Oltinchi: 25 soatdan keyin eski SMS'lar "kun" oynasidan chiqdi. Kanonda xuddi shu hisobni Postgres bitta so'rovda qiladi (count(*) FILTER (WHERE …)), advisory lock ostida.

13. Loyiha qadami: telefon va SMS kod

13.1 Migratsiyalar

sql/115-telefon-otp.sql (izohlar qisqartirilgan, SQL kanondagidek):

sql
ALTER TABLE foydalanuvchilar ADD COLUMN telefon text;
ALTER TABLE foydalanuvchilar
  ADD CONSTRAINT foydalanuvchilar_telefon_check
  CHECK (telefon ~ '^\+[1-9][0-9]{7,14}$');
-- Bitta raqam — bitta hisob (NULL lar cheklanmaydi)
CREATE UNIQUE INDEX foydalanuvchilar_telefon_key
  ON foydalanuvchilar (telefon);

CREATE TABLE otp_kodlar (
  id               bigint      GENERATED ALWAYS AS IDENTITY,
  foydalanuvchi_id bigint      NOT NULL,
  maqsad           text        NOT NULL,
  telefon          text        NOT NULL,
  kod_hmac         bytea       NOT NULL,
  yaratilgan_vaqt  timestamptz NOT NULL DEFAULT now(),
  tugash_vaqti     timestamptz NOT NULL,
  urinishlar       int         NOT NULL DEFAULT 0,
  ishlatilgan_vaqt timestamptz,
  ip               inet,
  CONSTRAINT otp_kodlar_pkey PRIMARY KEY (id),
  CONSTRAINT otp_kodlar_foydalanuvchi_id_fkey
    FOREIGN KEY (foydalanuvchi_id)
    REFERENCES foydalanuvchilar (id) ON DELETE CASCADE,
  CONSTRAINT otp_kodlar_maqsad_check
    CHECK (maqsad IN ('kirish', 'telefon_ulash')),
  CONSTRAINT otp_kodlar_telefon_check
    CHECK (telefon ~ '^\+[1-9][0-9]{7,14}$'),
  CONSTRAINT otp_kodlar_kod_hmac_check
    CHECK (octet_length(kod_hmac) = 32),
  CONSTRAINT otp_kodlar_urinishlar_check
    CHECK (urinishlar BETWEEN 0 AND 100)
);
-- oxirgi faol kod va chastota: raqam + vaqt
CREATE INDEX otp_kodlar_telefon_idx
  ON otp_kodlar (telefon, yaratilgan_vaqt DESC);

sql/116-namuna-telefon.sql — Azizga namuna raqam (faqat lokal kurs bazasi uchun):

sql
UPDATE foydalanuvchilar SET telefon = '+998900000000'
WHERE email = 'aziz@example.com';

Prisma tomoni: prisma/migrations/20261027150000_telefon_otp/migration.sql, schema.prisma da Foydalanuvchi.telefon (@unique) va OtpKod modeli.

Lokal bazada (115–116 bilan) tekshirdik — biz ishga tushirdik, PostgreSQL 17.6:

sql
SELECT id, email, telefon
FROM foydalanuvchilar
WHERE telefon IS NOT NULL;

Natija:

text
 id |      email       |    telefon
----+------------------+---------------
  1 | aziz@example.com | +998900000000
(1 row)

Normallashtirilmagan raqamni baza qabul qilmaydi:

sql
\set VERBOSITY terse
UPDATE foydalanuvchilar SET telefon = '90 000 00 00'
WHERE email = 'dilnoza@example.com';

Natija:

text
ERROR:  new row for relation "foydalanuvchilar" violates check constraint "foydalanuvchilar_telefon_check"

Band raqamni boshqa hisobga yozish:

sql
\set VERBOSITY terse
UPDATE foydalanuvchilar SET telefon = '+998900000000'
WHERE email = 'dilnoza@example.com';

Natija:

text
ERROR:  duplicate key value violates unique constraint "foydalanuvchilar_telefon_key"

"Takroriy kalit qiymati foydalanuvchilar_telefon_key noyoblik cheklovini buzadi". Ilova bu holatni 409 telefon-band ga aylantiradi. Va bitta halol ogohlantirish — baza nimani tekshira olmasligi:

sql
\set VERBOSITY terse
INSERT INTO otp_kodlar (foydalanuvchi_id, maqsad, telefon, kod_hmac,
                        tugash_vaqti)
VALUES (1, 'kirish', '+998900000000', sha256('004217'),
        now() + interval '5 minutes');

Natija:

text
INSERT 0 1

Baza faqat "32 bayt" ni tekshiradi. U kod_hmac ustuniga HMAC emas, oddiy sha256('004217') yozilganini bilmaydi — bu xatoni faqat kod va test ushlaydi (kanon testi bazadagi qatorda kod ochiq turmasligini tekshiradi).

13.2 src/auth/otp.ts

Telefon uchun bo'lingan; uzun tiplarni alohida nom bilan chiqardik, izohlar qisqartirilgan, kod ma'nosi kanondagidek (tip, lint va 8 test o'tdi — biz tekshirdik). Yuboruvchilar (XotiraSms, LogSms) va tiplarning bir qismini tushirib qoldirdik:

ts
import { createHmac, randomInt, timingSafeEqual } from "node:crypto";

export type OtpMaqsadi = "kirish" | "telefon_ulash";

export const OTP = Object.freeze({
  uzunlik: 6,
  muddatMs: 5 * 60_000,
  maksUrinish: 5,
  oraliqMs: 60_000,
  soatiga: 5,
  kuniga: 10,
});

/** SMS yuborish interfeysi (Eskiz, PlayMobile …) */
export interface SmsYuboruvchi {
  yubor(telefon: string, matn: string): Promise<void>;
}

/** Kod: CSPRNG, oldidagi nollar bilan ("004217") */
export const yangiKod = (): string =>
  String(randomInt(0, 10 ** OTP.uzunlik)).padStart(OTP.uzunlik, "0");

export class OtpOmbori {
  readonly #pool: Pool;
  readonly #siri: Buffer;

  /** @param siri OTP_SIRI (≥ 32 bayt) — bazada emas, config'da */
  constructor(pool: Pool, siri: Buffer) {
    if (siri.length < 32) {
      throw new Error("OTP siri kamida 32 bayt bo'lsin");
    }
    this.#pool = pool;
    this.#siri = siri;
  }

  /** maqsad va telefon ham HMAC ichida — bir maqsadning kodi
   * boshqasiga yaramaydi */
  #hmac(maqsad: OtpMaqsadi, telefon: string, kod: string): Buffer {
    return createHmac("sha256", this.#siri)
      .update(`${maqsad}:${telefon}:${kod}`)
      .digest();
  }

  /** Yangi kod (chastota cheklovlari bilan); eski faol kodlar bekor.
   * Telefon bo'yicha advisory lock — parallel so'rovlar ikki SMS
   * yubormasin */
  async yarat(
    foydalanuvchiId: number,
    maqsad: OtpMaqsadi,
    telefon: string,
    ip?: string,
  ): Promise<YuborishNatijasi> {
    const c = await this.#pool.connect();
    try {
      await c.query("BEGIN");
      await c.query(
        "SELECT pg_advisory_xact_lock(hashtextextended($1, 28))",
        [telefon],
      );
      const { rows: [q] } = await c.query<Chastota>(
        `SELECT coalesce(max(yaratilgan_vaqt)
                  > now() - $2 * interval '1 millisecond', false)
                  AS oraliq,
                count(*) FILTER (WHERE yaratilgan_vaqt
                  > now() - interval '1 hour')::int AS soat,
                count(*) FILTER (WHERE yaratilgan_vaqt
                  > now() - interval '1 day')::int AS kun
         FROM otp_kodlar
         WHERE telefon = $1
           AND yaratilgan_vaqt > now() - interval '1 day'`,
        [telefon, OTP.oraliqMs],
      );
      const sabab = q?.oraliq === true ? "oraliq"
        : (q?.soat ?? 0) >= OTP.soatiga ? "soatiga"
        : (q?.kun ?? 0) >= OTP.kuniga ? "kuniga"
        : undefined;
      if (sabab !== undefined) {
        await c.query("ROLLBACK");
        return { tur: "cheklov", sabab };
      }
      await c.query(
        `UPDATE otp_kodlar SET ishlatilgan_vaqt = now()
         WHERE telefon = $1 AND ishlatilgan_vaqt IS NULL`,
        [telefon],
      );
      const kod = yangiKod();
      await c.query(
        `INSERT INTO otp_kodlar (foydalanuvchi_id, maqsad, telefon,
           kod_hmac, tugash_vaqti, ip)
         VALUES ($1, $2, $3, $4,
           now() + $5 * interval '1 millisecond', $6)`,
        [
          foydalanuvchiId,
          maqsad,
          telefon,
          this.#hmac(maqsad, telefon, kod),
          OTP.muddatMs,
          ip ?? null,
        ],
      );
      await c.query("COMMIT");
      return { tur: "yuborildi", kod };
    } catch (x) {
      await c.query("ROLLBACK");
      throw x;
    } finally {
      c.release();
    }
  }

  /** Har urinish hisoblanadi (atomik); 5 dan keyin kod o'ladi,
   * hatto to'g'ri kod ham. Solishtirish — timingSafeEqual */
  async tekshir(
    maqsad: OtpMaqsadi,
    telefon: string,
    kod: string,
    foydalanuvchiId?: number,
  ): Promise<TekshiruvNatijasi> {
    const [q] = await this.#sorov<KodQatori>(
      `UPDATE otp_kodlar SET urinishlar = urinishlar + 1
       WHERE id = (
           SELECT id FROM otp_kodlar
           WHERE telefon = $1 AND maqsad = $2
             AND ishlatilgan_vaqt IS NULL AND tugash_vaqti > now()
             AND ($3::bigint IS NULL OR foydalanuvchi_id = $3)
           ORDER BY yaratilgan_vaqt DESC LIMIT 1)
         AND urinishlar < $4
       RETURNING id, foydalanuvchi_id AS fid, kod_hmac AS hmac,
         urinishlar`,
      [telefon, maqsad, foydalanuvchiId ?? null, OTP.maksUrinish],
    );
    if (q === undefined || !/^\d{6}$/.test(kod)) {
      return { tur: "xato" };
    }
    const kutilgan = this.#hmac(maqsad, telefon, kod);
    if (!timingSafeEqual(q.hmac, kutilgan)) return { tur: "xato" };
    const r = await this.#pool.query(
      `UPDATE otp_kodlar SET ishlatilgan_vaqt = now()
       WHERE id = $1 AND ishlatilgan_vaqt IS NULL`,
      [q.id],
    );
    return r.rowCount === 1
      ? { tur: "ok", foydalanuvchiId: q.fid }
      : { tur: "xato" };
  }
}

Uch tafsilot:

  • hashtextextended($1, 28) — matndan 64 bitli son yasaydigan Postgres funksiyasi; 28 — "urug'" (seed), bu qulflar boshqa modullarning advisory qulflari bilan to'qnashmasligi uchun.
  • tekshir da urinish kod formatini tekshirishdan oldin hisoblanadi: abc yuborish ham bitta urinish. Aks holda hujumchi noto'g'ri formatli so'rovlar bilan tizimni bepul sinab ko'rardi.
  • Oxirgi UPDATE … AND ishlatilgan_vaqt IS NULL — ikkita to'g'ri parallel so'rovdan faqat bittasi rowCount === 1 oladi. Kanon testi: 3 parallel to'g'ri so'rovdan bittasi 200.

telefonNormallash — «Telefon raqamni normallash» bo'limidagi funksiyaning TypeScript varianti.

13.3 xizmat.ts va yo'llar

Kod so'rash — javob bir xil, ish fonda (oldingi darsdagi FonIshlar):

ts
    async kodSora(
      maqsad: OtpMaqsadi,
      xomTelefon: string,
      mijoz: Mijoz,
      foydalanuvchiId?: number,
    ): Promise<void> {
      const telefon = raqam(xomTelefon);
      let fid = foydalanuvchiId;
      if (maqsad === "kirish") {
        fid = (await ombor.telefonBilan(telefon))?.id;
      } else if ((await ombor.telefonBilan(telefon)) !== undefined) {
        // raqam band — SMS yubormaymiz (javob baribir bir xil)
        fid = undefined;
      }
      await ombor.hodisa({
        tur: "otp_sorov", foydalanuvchiId: fid ?? foydalanuvchiId,
        ...mijoz, tafsilot: { maqsad, topildi: fid !== undefined },
      });
      if (fid === undefined) return;
      const egasi = fid;
      d.fon.qosh("sms-kod", async () => {
        const n = await d.otp.yarat(egasi, maqsad, telefon, mijoz.ip);
        if (n.tur === "cheklov") {
          await ombor.hodisa({
            tur: "otp_sorov", foydalanuvchiId: egasi, ...mijoz,
            tafsilot: { maqsad, cheklov: n.sabab },
          });
          return;
        }
        const daqiqa = String(OTP.muddatMs / 60_000);
        await d.sms.yubor(telefon, `vazifalar: tasdiqlash kodi ` +
          `${n.kod}. ${daqiqa} daqiqa amal qiladi. ` +
          "Kodni hech kimga aytmang.");
      });
    },

raqam(x) — normallash va mamlakat ruxsat ro'yxati; ikkalasida ham xato — 422 (ValidatsiyaXatosi). Cheklov sababi (oraliq, soatiga) foydalanuvchiga aytilmaydi, faqat jurnalga yoziladi.

Yo'l Tana Javob
POST /v1/auth/otp/yubor { telefon } 202 har doim; 422
POST /v1/auth/otp/kirish { telefon, kod, eslabQol? } 200 + sessiya; 401 otp-xato
POST /v1/auth/telefon { telefon } (sessiya kerak) 202; 401
POST /v1/auth/telefon/tasdiq { telefon, kod } (sessiya) 200; 401; 409 telefon-band

Sxemalar (Zod): telefon — ko'pi bilan 30 belgi, kod — 10 belgi (formatni otp.ts tekshiradi). Config'ga ikki o'zgaruvchi:

  • OTP_SIRI — kamida 32 belgi. Berilmasa, har ishga tushishda tasodifiy yasaladi (dev uchun qulay: server qayta yongach eski kodlar yaroqsiz). Haqiqiy qiymatni .env ga yozasiz, kodga va git'ga — hech qachon.
  • OTP_MAMLAKATLAR — vergul bilan prefikslar, sukut +998.

13.4 Testlar

test/otp.test.ts — 8 ta test. Urinishlar testining yadrosi (bo'lingan):

ts
await request(app).post("/v1/auth/otp/yubor")
  .send({ telefon: TEL }).expect(202);
const k = await kod();
const notogri = k === "000000" ? "000001" : "000000";
for (let i = 0; i < 5; i++) {
  await request(app).post("/v1/auth/otp/kirish")
    .send({ telefon: TEL, kod: notogri }).expect(401);
}
// beshtadan keyin to'g'ri kod ham — 401
await request(app).post("/v1/auth/otp/kirish")
  .send({ telefon: TEL, kod: k }).expect(401);

notogri — tasodifan to'g'ri kodga teng bo'lib qolmasligi uchun (kod 000000 bo'lishi ham mumkin!). Biz ishga tushirdik — lokal PostgreSQL 17.6, kanon nusxasi va yuqoridagi bo'lingan fayllar bilan, Node 24.21.0:

bash
node --test test/otp.test.ts

Chiqishda har qatorning boshidagi belgiga va oxirgi pass / fail soniga qarang:

text
✔ telefonNormallash — E.164; 9 raqam → +998; yaroqsizlar (1.9991ms)
✔ yangiKod — har doim 6 raqam, oldidagi nollar bilan, takrorlar kam (10 000 ta) (5.6756ms)
✔ raqamni ulash → SMS kod bilan kirish → sessiya; kod bazada ochiq emas (HMAC) (248.8826ms)
✔ noma'lum raqam — o'sha 202 tanasi, SMS yuborilmaydi; boshqa mamlakat va noto'g'ri raqam — 422 (238.7198ms)
✔ 5 ta noto'g'ri urinishdan keyin TO'G'RI kod ham ishlamaydi; muddati o'tgan kod — 401 (227.8304ms)
✔ chastota: 60 s ichida ikkinchi SMS yo'q; soatiga 5 tadan ortiq yo'q (jurnalda cheklov) (215.0049ms)
✔ maqsad ajratilgan: telefon ulash kodi bilan kirib bo'lmaydi; parallel 3 ta to'g'ri so'rov — bittasi 200 (214.9031ms)
✔ band raqam: boshqa hisob ulamoqchi bo'lsa — 202, lekin SMS yuborilmaydi; sessiyasiz /telefon — 401 (217.4993ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3922.5924

Nima bo'ldi? Sakkizta test ham o'tdi (pass 8, fail 0). Har test nomi darsdagi bitta qoidaga mos: normallash, kod formati, HMAC, bir xil 202, 5 urinish, chastota, maqsad va band raqam. Qavs ichidagi son — test vaqti (millisekund).

13.5 Tekshiruv va commit

Kanon tekshiruvi (2026-10-27, PostgreSQL 18.6): tip va lint xatosiz, testlar 165 (164 + 1 o'tkazilgan) — sakkizta yangi; e2e 4/4; Prisma migratsiyalari va sql/ bir xil.

bash
M="feat(auth): telefon raqamni ulash va SMS kod bilan kirish —"
M+=" HMAC, 5 urinish, 5 daqiqa, chastota cheklovi, mamlakat"
M+=" ro'yxati; db: 115 (+116 namuna)"
git add src test sql prisma
git commit -m "$M"

14. Real ishda

  • O'zbek ilovalarida telefon + SMS kod — eng keng tarqalgan kirish usuli. Ko'pchilik SMS provayderi (Eskiz, PlayMobile) shablon matnini oldindan tasdiqlashni talab qiladi — kod matni ham shablon bo'ladi (SMS provayderlar: Eskiz va PlayMobile).
  • Telegram orqali kod — SMS'dan arzon va SIM swap'ga chidamliroq; kursda Telegram bilan kirish alohida darsda: Telegram Login va Mini App initData tekshiruvi.
  • Xarajat monitoringi — production'da SMS soni va xarajati uchun ogohlantirish qo'ying: SMS pumping'ni ko'pincha birinchi bo'lib hisob-faktura ko'rsatadi.
  • Intervyuda: "OTP'ni qanday saqlaysiz?", "Nega 6 raqam yetadi?", "SMS pumping nima?", "SMS 2FA nega zaif hisoblanadi?".

Xulosa

  • 6 xonali kod — 10⁶ variant; xavfsizlikni muddat (5 daqiqa), urinishlar (5) va chastota (60 s, soatiga 5, kuniga 10) beradi.
  • Kod — crypto.randomInt (modulo bias'siz), padStart(6, "0"); Math.random emas.
  • Bazada HMAC-SHA256(server siri, maqsad:telefon:kod); oddiy SHA-256 dan kanon kodni 564 ms da topdi.
  • Urinish atomik UPDATE … RETURNING bilan hisoblanadi va kodni solishtirishdan oldin; 5 dan keyin to'g'ri kod ham o'ladi.
  • Raqam E.164 ga normallanadi, faqat ruxsat etilgan mamlakatlarga va faqat ulangan raqamga SMS ketadi — SMS pumping'ga qarshi.
  • SMS — "cheklangan" autentifikator (SIM swap); kuchliroq muqobillar — TOTP va passkey.

Keyingi dars: OAuth 2.0 va OpenID Connect nazariyasi — "Google bilan kirish" ortidagi protokol: authorization code, PKCE, state va nonce.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
OTP orqali login: SMS kod, HMAC bilan saqlash, urinishlar chegarasi va SMS pumping — IlmHamroh