Mundarija (43)
- Bu darsda
- 1. Nega bu kerak?
- 1.1 Bitta tugma
- 1.2 Yangi xavf
- 2. Google tomoni: OAuth mijozi
- 2.1 Sozlash tartibi
- 2.2 .env
- 3. Bitta sir — ko'p kalit
- 3.1 Muammo
- 3.2 HKDF
- 3.3 src/umumiy/sir.ts
- 4. Oqim holati: imzolangan cookie
- 4.1 Uch qiymat qayerda turadi
- 4.2 Brauzerda sinab ko'ring
- 5. src/auth/oidc.ts
- 5.1 To'liq modul
- 5.2 Nimaga e'tibor berish kerak
- 6. Hisoblarni bog'lash
- 6.1 Nega email kalit emas
- 6.2 To'rt holat
- 6.3 Pre-hijacking
- 6.4 tashqiKirish
- 7. Bazada: tashqi_hisoblar
- 7.1 Migratsiya
- 7.2 O'z bazangizda sinang
- 7.3 Prisma sxemasi
- 8. Marshrut va ulash
- 8.1 Ikki yo'l
- 8.2 server.ts
- 8.3 arctic haqida
- 9. Testlar
- 9.1 Lokal provayder bitta jarayonda
- 9.2 Ishga tushirish
- 10. Tekshiruv va commit
- 11. Hujumchi nigohi
- 12. Ko'p uchraydigan xatolar
- 13. Mashqlar
- 1-mashq (oson): qaysi holat?
- 2-mashq (o'rta): kimlikni yig'ish
- 3-mashq (qiyin): bog'lash qarori
- 14. Real ishda
- Xulosa
- Manbalar
Google bilan kirish Node.js'da: openid-client, imzolangan holat cookie'si va hisoblarni bog'lash
Qisqacha: "Google bilan kirish" — OpenID Connect oqimi: API foydalanuvchini Google'ga yo'naltiradi, qaytgan
codeniopenid-clientbilan tokenga almashtiradi va id_token'dansubva emailni oladi. Oqim holati (state,nonce, PKCE verifier) — 10 daqiqalik HMAC bilan imzolangan cookie'da. Foydalanuvchi bazada email bo'yicha emas,(provayder, sub)bo'yicha bog'lanadi. Provayder emailni tasdiqlamagan bo'lsa — rad. Shu email bilan kimdir oldindan tasdiqlanmagan hisob ochgan bo'lsa — uning paroli o'chiriladi (pre-hijacking himoyasi).
Bu darsda
- Google Cloud'da OAuth mijozini sozlash tartibini bilasiz (hujjat bo'yicha).
- Bitta ilova siridan HKDF bilan har vazifaga alohida kalit hosil qilasiz.
- Oqim holatini bazasiz, imzolangan cookie'da saqlaysiz va tekshirasiz.
- Tashqi kimlikni mavjud hisobga xavfsiz bog'lash qoidalarini yozasiz.
vazifalar-apigaoidc.ts,tashqi_hisoblarjadvali va oltita testni qo'shasiz.
Oldin bilishingiz kerak: OAuth 2.0 va OpenID Connect nazariyasi, Session-based auth, Email tasdiqlash va parol tiklash oqimlari, crypto 1: tasodif, hash va HMAC.
Oldingi darsda oqimni lokal provayderda kuzatdik va yetti hujumning rad etilishini ko'rdik. Bugun shu oqim vazifalar-api ning bir qismiga aylanadi. Nazariyada yo'q yangi savollar chiqadi: holatni qayerda saqlash, sir qayerdan olinadi, Google'dan kelgan odamni bazadagi qaysi hisobga ulash.
1. Nega bu kerak?
1.1 Bitta tugma
Aziz vazifalar ga birinchi marta keldi. Ro'yxatdan o'tish formasi: email, ism, parol, keyin pochtaga kelgan havolani bosish. To'rt qadam. "Google bilan kirish" tugmasi esa bitta: Google uni allaqachon taniydi, emailni ham tasdiqlagan.
Biz uchun foyda ham bor: yana bitta parol saqlamaymiz, email tasdiqlash xatini yubormaymiz. Bu hisobning xavfsizligi endi Google'ning ikki faktorli himoyasiga tayanadi.
1.2 Yangi xavf
Lekin yangi eshik — yangi hujum yuzasi. Eng xavfli savol: Google "bu — aziz@example.com" desa, bazadagi shu emailli hisobga kiritib yuboraveramizmi? Javob — "har doim emas". Bu darsning yarmi aynan shu savolga bag'ishlangan.
2. Google tomoni: OAuth mijozi
2.1 Sozlash tartibi
Diqqat: Biz haqiqiy Google hisobi va kalitlari bilan sinamadik. Quyidagi qadamlar — Google'ning rasmiy hujjati bo'yicha (2026-10-28 da o'qildi). Testlar lokal provayder bilan ishlaydi — «Testlar» bo'limida.
- Google Cloud Console'da loyiha yarating (yoki mavjudini tanlang).
- Clients sahifasiga o'ting va Create Client ni bosing.
- Ilova turi — Web application.
- Authorized redirect URIs ga qaytish manzilini yozing: lokal sinov uchun
http://localhost:3000/v1/auth/oidc/google/qaytish. - Create dan keyin client ID va client secret chiqadi. Hujjatga ko'ra secret faqat yaratilgandan keyin ko'rsatiladi — darhol
.envga yozing.
Ikki qoida hujjatda alohida ta'kidlangan. Birinchisi: redirect URI ro'yxatdagi bilan aniq mos kelishi kerak — sxema, registr va oxiridagi / ham hisobga olinadi. Mos kelmasa, Google redirect_uri_mismatch xatosini beradi. Ikkinchisi: production'da faqat https://, http://localhost — faqat sinov uchun.
2.2 .env
# Ilova siri (>= 32 belgi). Yaratish:
# node -e "console.log(require('node:crypto')
# .randomBytes(32).toString('base64url'))"
# ILOVA_SIRI=
# OIDC qaytish manzili shu API manziliga quriladi
API_URL=http://localhost:3000
# Google (OIDC): ikkalasi berilsa — /v1/auth/oidc/google yoqiladi
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GOOGLE_ISSUER=https://accounts.google.com
OIDC_HTTP=falseQiymatlar bo'sh — .env.example ga haqiqiy kalit yozilmaydi. OIDC_HTTP=true faqat lokal soxta provayder uchun (u http:// da ishlaydi). Kanon .env.example faylida bu qatorlarni qism oxirida qo'shgan; siz bugunoq qo'shing — keyin unutiladi.
src/config.ts da Zod qoidasi bor: GOOGLE_CLIENT_ID va GOOGLE_CLIENT_SECRET faqat birga beriladi. Bittasi bo'lsa — server ishga tushmaydi. Ikkalasi bo'lmasa — Google yo'li shunchaki o'chiq (404).
3. Bitta sir — ko'p kalit
3.1 Muammo
Oldingi darslarda SMS kodlar OTP_SIRI bilan HMAC qilingan edi. Endi OIDC cookie'sini imzolash uchun ham kalit kerak. Keyin — kursor, Telegram, API kalitlar. Har biriga alohida .env o'zgaruvchisi — chalkash. Hammasiga bitta kalit — xavfli: bitta maqsaddagi xato boshqasini ham ochib qo'yadi.
3.2 HKDF
Yechim — HKDF (HMAC-based Key Derivation Function, RFC 5869): bitta asosiy sirdan "maqsad" nomi bo'yicha istalgancha mustaqil kalit hosil qilish. Bitta kalitdan boshqasini topib bo'lmaydi.
import { hkdfSync } from "node:crypto";
// faqat dars uchun namuna — haqiqiy siri .env da, 32+ tasodifiy bayt
const asos = Buffer.from("namuna-siri-faqat-dars-uchun-0123456789");
const MAQSADLAR = ["otp", "oidc-cookie", "kursor", "telegram"];
for (const maqsad of MAQSADLAR) {
const kalit = Buffer.from(
hkdfSync("sha256", asos, "vazifalar-api", maqsad, 32),
);
console.log(maqsad.padEnd(12), kalit.toString("hex").slice(0, 16));
}Konsolda:
otp cf841890d6a4f0bb
oidc-cookie 4816400e64fd71f0
kursor 40198e7b68dab2a7
telegram 9c42b7ab55c9f714Bitta asos, to'rtta butunlay boshqa kalit (har birining boshini ko'rsatdik). hkdfSync argumentlari: hash, asosiy sir, tuz (bu yerda ilova nomi), info (maqsad nomi) va uzunlik (32 bayt). Asos o'zgarmasa, natija ham o'zgarmaydi — server qayta ishga tushganda kalitlar o'sha-o'sha.
3.3 src/umumiy/sir.ts
Kanon moduli (telefon uchun qatorlar va izohlar bo'lindi, ma'no kanondagidek):
// sir.ts — bitta ilova siri (ILOVA_SIRI) dan har vazifa uchun ALOHIDA
// kalit — HKDF (RFC 5869, node:crypto hkdfSync).
// Nega alohida: OTP HMAC kaliti sizsa ham kursor yoki OIDC cookie
// imzosi soxtalashtirilmasin (bir kalit — bir maqsad).
// Yangi maqsad — yangi "info" satri.
import { hkdfSync, randomBytes } from "node:crypto";
export type KalitMaqsadi =
| "otp"
| "oidc-cookie"
| "kursor"
| "telegram"
| "api-kalit";
export class Sirlar {
readonly #asos: Buffer;
/** true — ILOVA_SIRI berilmagan, jarayon ichida tasodifiy
* (dev: qayta ishga tushsa kodlar, kursorlar, OIDC cookie'lar
* yaroqsiz) */
readonly vaqtinchalik: boolean;
/** @param siri ILOVA_SIRI (≥ 32 belgi) yoki undefined */
constructor(siri?: string) {
this.vaqtinchalik = siri === undefined;
this.#asos =
siri === undefined
? randomBytes(32)
: Buffer.from(siri, "utf8");
if (this.#asos.length < 32) {
throw new Error("ILOVA_SIRI kamida 32 bayt bo'lsin");
}
}
kalit(maqsad: KalitMaqsadi): Buffer {
return Buffer.from(
hkdfSync("sha256", this.#asos, "vazifalar-api", maqsad, 32),
);
}
}ILOVA_SIRI berilmasa — dev uchun tasodifiy sir va jurnalda ogohlantirish. Bu holda server qayta ishga tushsa, yarim yo'lda qolgan OIDC oqimlari va SMS kodlar yaroqsiz bo'ladi. Shu modul bilan oldingi OTP_SIRI o'zgaruvchisi olib tashlandi: SMS kod kaliti endi sirlar.kalit("otp").
4. Oqim holati: imzolangan cookie
4.1 Uch qiymat qayerda turadi
Oqim boshida server state, nonce va PKCE verifier'ni yaratadi. Qaytishda ular kerak — lekin bu boshqa HTTP so'rov. Uchta variant bor:
| Joy | Muammo |
|---|---|
server xotirasida (Map) |
ikkinchi server nusxasi ko'rmaydi, qayta ishga tushsa yo'qoladi |
| bazada | har kirishga yozish va o'chirish |
| brauzer cookie'sida | foydalanuvchi o'zgartirishi mumkin |
Kanon uchinchisini tanladi va muammoni imzo bilan hal qildi: cookie ichidagi JSON ga HMAC qo'shiladi. Foydalanuvchi cookie'ni o'qiy oladi (u HttpOnly, lekin baribir sir emas), ammo o'zgartirsa — imzo mos kelmaydi.
Muhimi: cookie aynan shu brauzerga tegishli. Hujumchi o'z oqimining code ini sizga yuborsa, sizning brauzeringizdagi cookie'da boshqa state turadi — login CSRF yopiladi.
4.2 Brauzerda sinab ko'ring
Mantiq Web Crypto bilan bir xil ishlaydi. Kalit qat'iy — natija takrorlansin:
const enc = new TextEncoder();
const b64url = (b) =>
btoa(String.fromCharCode(...b))
.replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
const baytga = (s) =>
Uint8Array.from(
atob(s.replaceAll("-", "+").replaceAll("_", "/")),
(c) => c.charCodeAt(0),
);
// faqat dars uchun qat'iy kalit — kanonda HKDF bilan hosil qilinadi
const kalit = await crypto.subtle.importKey(
"raw", enc.encode("oidc-cookie-kaliti-namuna-32-bayt"),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"],
);
async function imzola(holat) {
const t = b64url(enc.encode(JSON.stringify(holat)));
const imzo = await crypto.subtle.sign("HMAC", kalit, enc.encode(t));
return `${t}.${b64url(new Uint8Array(imzo))}`;
}
async function och(qiymat, provayder, hozir) {
const [t, imzo] = qiymat.split(".");
if (!t || !imzo) return null;
const togri = await crypto.subtle.verify(
"HMAC", kalit, baytga(imzo), enc.encode(t));
if (!togri) return null;
const h = JSON.parse(new TextDecoder().decode(baytga(t)));
return h.p === provayder && h.e > hozir ? h : null;
}
const hozir = 1_800_000_000_000;
const holat = {
p: "google", s: "state-1", n: "nonce-1", v: "verifier-1",
e: hozir + 600_000,
};
const cookie = await imzola(holat);
console.log(cookie.split(".").map((x) => x.length));
console.log((await och(cookie, "google", hozir))?.s);
// hujumchi state'ni o'zgartirdi, imzoni esa eski qoldirdi
const [, imzo] = cookie.split(".");
const json = JSON.stringify({ ...holat, s: "x" });
const soxta = b64url(enc.encode(json));
console.log(await och(`${soxta}.${imzo}`, "google", hozir));
console.log(await och(cookie, "telegram", hozir));
console.log(await och(cookie, "google", hozir + 600_001));Konsolda:
[ 103, 43 ]
state-1
null
null
null- Cookie ikki qismdan iborat: 103 belgili JSON (base64url) va 43 belgili imzo (32 bayt).
- To'g'ri cookie ochildi.
- O'zgartirilgan
state— imzo mos emas. - Boshqa provayder uchun — rad (Google oqimining cookie'si Telegram oqimiga yaramaydi).
- 10 daqiqadan bir millisekund keyin — muddati o'tgan.
Brauzer konsoli massivni (2) [103, 43] ko'rinishida chiqaradi — ma'nosi bir xil.
Tekshirib ko'ring: Cookie'da PKCE verifier ochiq (faqat base64) turibdi. Foydalanuvchi uni o'qiy oladi. Bu xavflimi?
Javob
Yo'q. Verifier'ning vazifasi — kodni boshqa odam ishlatolmasligi. Foydalanuvchining o'zi uni bilsa, hech narsa yo'qotilmaydi: kod ham, verifier ham uning o'z oqimiga tegishli. Hujumchi esa cookie'ni ko'ra olmaydi: u HttpOnly (JavaScript o'qiy olmaydi), __Host- va Secure. Muhimi — foydalanuvchi uni o'zgartira olmasligi; buni imzo ta'minlaydi.
5. src/auth/oidc.ts
5.1 To'liq modul
Ikki asosiy metod: boshla() — yo'naltirish URL'i va cookie, yakunla() — qaytishni tekshirish va kimlikni qaytarish. Telefon uchun formatladik (qatorlar va izohlar bo'lindi, ma'no kanondagidek). Bo'lingan nusxa tip, lint va testlardan o'tdi — biz tekshirdik:
// oidc.ts — "Google bilan kirish": OpenID Connect, Authorization
// Code + PKCE (openid-client 6). Provayder umumiy: issuer + client.
// Haqiqiy Google — faqat hujjat bo'yicha sozlash; testlar lokal
// oidc-provider bilan (Google hisobi ishlatilmagan — SINALMAGAN).
// Oqim holati (state, nonce, PKCE verifier) — qisqa muddatli
// IMZOLANGAN cookie'da (HMAC, ILOVA_SIRI dan HKDF kaliti):
// server xotirasi/baza kerak emas, state brauzerga bog'langan
// (login CSRF).
// SameSite=Lax: provayderdan qaytish — yuqori darajadagi GET, cookie
// yuboriladi.
import { createHmac, timingSafeEqual } from "node:crypto";
import * as client from "openid-client";
import type { Request, Response } from "express";
import { IlovaXatosi } from "../umumiy/xatolar.ts";
export type OidcSozlama = {
/** URL yo'lidagi nom: /v1/auth/oidc/<nom> */
nom: string;
issuer: string;
clientId: string;
clientSecret: string;
/** API'ning tashqi manzili + yo'l — provayderda ro'yxatdan
* o'tgan redirect URI bilan AYNAN bir xil */
qaytishUrl: string;
/** faqat test/dev: http issuer (lokal soxta provayder) */
httpRuxsat?: boolean;
};
export type TashqiKimlik = {
provayder: string;
subyekt: string;
email?: string | undefined;
emailTasdiqlangan: boolean;
ism?: string | undefined;
};
const MUDDAT_MS = 10 * 60_000;
type Holat = {
p: string;
s: string;
n: string;
v: string;
e: number;
};
export class OidcXatosi extends IlovaXatosi {
constructor(sabab: string) {
super(400, `Tashqi kirish muvaffaqiyatsiz: ${sabab}`, {
kod: "oidc-xato",
sarlavha: "Tashqi kirish muvaffaqiyatsiz",
});
}
}
export class Oidc {
readonly s: OidcSozlama;
readonly #kalit: Buffer;
readonly #secure: boolean;
#config: Promise<client.Configuration> | undefined;
constructor(s: OidcSozlama, kalit: Buffer, secure: boolean) {
this.s = s;
this.#kalit = kalit;
this.#secure = secure;
}
get cookieNomi(): string {
return this.#secure ? "__Host-oidc" : "oidc";
}
/** Discovery — birinchi kerak bo'lganda, keyin keshda;
* xatoda keyingi urinish qaytadan */
#sozlama(): Promise<client.Configuration> {
this.#config ??= client.discovery(
new URL(this.s.issuer),
this.s.clientId,
this.s.clientSecret,
undefined,
// openid-client buni ataylab "deprecated" belgilagan (ko'zga
// tashlansin) — faqat lokal http provayder uchun (test/dev)
this.s.httpRuxsat === true
? // eslint-disable-next-line @typescript-eslint/no-deprecated
{ execute: [client.allowInsecureRequests] }
: undefined,
).catch((x: unknown) => {
this.#config = undefined;
throw x;
});
return this.#config;
}
#imzola(h: Holat): string {
const t = Buffer.from(JSON.stringify(h)).toString("base64url");
const imzo = createHmac("sha256", this.#kalit)
.update(t)
.digest("base64url");
return `${t}.${imzo}`;
}
#och(qiymat: string | undefined): Holat | undefined {
if (qiymat === undefined || qiymat.length > 2000) {
return undefined;
}
const [t, imzo] = qiymat.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 json = Buffer.from(t, "base64url").toString("utf8");
const h = JSON.parse(json) as Holat;
return h.p === this.s.nom && h.e > Date.now() ? h : undefined;
}
/** Provayderga yo'naltirish URL'i + holat cookie'si */
async boshla(res: Response): Promise<URL> {
const config = await this.#sozlama();
const v = client.randomPKCECodeVerifier();
const h: Holat = {
p: this.s.nom,
s: client.randomState(),
n: client.randomNonce(),
v,
e: Date.now() + MUDDAT_MS,
};
res.cookie(this.cookieNomi, this.#imzola(h), {
httpOnly: true,
secure: this.#secure,
sameSite: "lax",
path: "/",
maxAge: MUDDAT_MS,
});
return client.buildAuthorizationUrl(config, {
redirect_uri: this.s.qaytishUrl,
scope: "openid email profile",
state: h.s,
nonce: h.n,
code_challenge: await client.calculatePKCECodeChallenge(v),
code_challenge_method: "S256",
});
}
/** Qaytish: cookie (imzo, muddat) → kod + PKCE bilan token →
* id_token tekshiruvi (iss, aud, exp, nonce — openid-client) */
async yakunla(
req: Request,
res: Response,
cookie: string | undefined,
): Promise<TashqiKimlik> {
const h = this.#och(cookie);
res.clearCookie(this.cookieNomi, {
httpOnly: true,
secure: this.#secure,
sameSite: "lax",
path: "/",
});
if (h === undefined) {
throw new OidcXatosi(
"holat cookie'si yo'q, buzilgan yoki eskirgan",
);
}
const config = await this.#sozlama();
// redirect_uri bilan bir xil asos (proxy orqasida ham)
const joriy = new URL(req.originalUrl, this.s.qaytishUrl);
let t: client.TokenEndpointResponse &
client.TokenEndpointResponseHelpers;
try {
t = await client.authorizationCodeGrant(config, joriy, {
pkceCodeVerifier: h.v,
expectedState: h.s,
expectedNonce: h.n,
idTokenExpected: true,
});
} catch (x) {
throw new OidcXatosi(
x instanceof client.ResponseBodyError
? x.error
: x instanceof Error
? x.message.slice(0, 100)
: "token",
);
}
const c = t.claims();
if (c === undefined) throw new OidcXatosi("id_token yo'q");
// ba'zi provayderlar email'ni id_token'ga qo'ymaydi (access token
// bilan userinfo'da beradi) — Google qo'yadi
const u =
c["email"] === undefined
? await client.fetchUserInfo(config, t.access_token, c.sub)
: c;
const email =
typeof u["email"] === "string"
? u["email"].toLowerCase()
: undefined;
return {
provayder: this.s.nom,
subyekt: c.sub,
email,
emailTasdiqlangan: u["email_verified"] === true,
ism: typeof u["name"] === "string" ? u["name"] : undefined,
};
}
}5.2 Nimaga e'tibor berish kerak
- Discovery keshi.
#config ??=— discovery hujjati faqat birinchi kirishda yuklanadi. Xato bo'lsa (Google vaqtincha javob bermadi),catchkeshni tozalaydi — keyingi urinish qaytadan so'raydi. Aks holda bitta tarmoq xatosi Google kirishini server qayta ishga tushguncha o'ldirardi. allowInsecureRequests.openid-clienthttp://provayder bilan ishlashni rad etadi. Lokal test uchun ruxsat kerak, lekin kutubxona muallifi bu funksiyani ataylab "deprecated" (eskirgan) deb belgilagan — har ishlatilgan joy ko'zga tashlansin. ESLint ham shuni ushlaydi, shuning uchun aniq bitta qatorgaeslint-disableizohi qo'yilgan.- Cookie oldin o'chiriladi.
yakunla()avval cookie'ni tozalaydi, keyin tekshiradi. Natija qanday bo'lmasin, holat bir martalik — qayta ishlatib bo'lmaydi. idTokenExpected: true. Provayder id_token bermasa — xato. Oddiy OAuth javobi kimlik isboti emas.qiymat.length > 2000. Cookie'ni ochishdan oldin uzunlik chegarasi: katta satrni HMAC va JSON tahliliga yubormaslik.- Email kichik harfga — bazadagi emaillar ham shunday saqlanadi (Ro'yxat va login oqimi hamda hisob xavfsizligi).
x.message.slice(0, 100)— xato matni foydalanuvchiga emas, jurnalga ketadi, lekin uzunligi cheklangan.
6. Hisoblarni bog'lash
6.1 Nega email kalit emas
Google hujjatidan ikki fakt (2026-10-28 da o'qildi):
sub— "barcha Google hisoblari orasida noyob va hech qachon qayta ishlatilmaydi"; ilovada foydalanuvchining kaliti sifatida aynan uni ishlatish tavsiya qilinadi;email— "bu hisob uchun noyob bo'lmasligi va vaqt o'tishi bilan o'zgarishi mumkin".
Aziz Google hisobidagi emailini almashtirsa ham, sub o'sha. Shuning uchun kanon jadvalida kalit — (provayder, subyekt), email esa faqat ma'lumot uchun.
Yana bir halol kuzatuv. Google hujjatidagi id_token namunasida "email_verified": "true" — satr ko'rinishida yozilgan. OpenID Connect standarti esa bu maydonni mantiqiy (boolean) qiymat deydi, hujjatdagi jadval ham "true yoki false" deydi. Kanon faqat true (boolean) ni qabul qiladi va haqiqiy Google bilan sinalmagan. Haqiqiy kalitlar bilan birinchi kirishda shuni tekshiring: satr kelsa, har yangi Google foydalanuvchisi 403 oladi.
6.2 To'rt holat
flowchart TD
K[Google: sub, email, email_verified] --> B{provayder + sub bog'langanmi?}
B -->|ha| KIR[O'sha hisobga kirish]
B -->|yo'q| V{email tasdiqlanganmi?}
V -->|yo'q| R[403: rad]
V -->|ha| E{shu email bilan hisob bormi?}
E -->|yo'q| Y[Yangi hisob, parolsiz]
E -->|ha, tasdiqlangan| BG[Bog'lash]
E -->|ha, tasdiqlanmagan| T[Parol va sessiyalarni o'chirib, bog'lash]Diagrammadagi to'rtta yakun:
- Bog'langan — oddiy qaytgan foydalanuvchi. Email bu yerda umuman tekshirilmaydi.
- Email tasdiqlanmagan — rad. Ba'zi provayderlarda foydalanuvchi istalgan emailni yozib qo'yishi mumkin. Tasdiqlanmagan email bilan hisobga ulash — boshqa odamning hisobiga kirish yo'li.
- Hisob yo'q — yangi hisob: parolsiz, email darhol tasdiqlangan (Google tasdiqlagan).
- Hisob bor — bog'lash. Lekin bir nozik holat bor.
6.3 Pre-hijacking
Pre-hijacking (hisobni oldindan egallash) — hujumchi qurbondan oldin harakat qiladi. Atama Sudhodanan va Paverd tadqiqotidan (USENIX Security 2022): ular parol bilan ro'yxat va tashqi kirish (SSO) yonma-yon turgan mashhur xizmatlarda shunday teshiklarni topgan. Ssenariy:
- Hujumchi Azizning emaili bilan
vazifalarda ro'yxatdan o'tadi va o'z parolini qo'yadi. Email tasdiqlanmaydi — tasdiq xati Azizga ketadi, Aziz unga e'tibor bermaydi. - Bir haftadan keyin Aziz "Google bilan kirish" ni bosadi. Google emailni tasdiqlagan — server hisobni bog'laydi.
- Aziz hisobdan foydalanadi: vazifalar, telefon raqami. Hujumchi esa o'z paroli bilan o'sha hisobga kiradi.
Himoya: shu email bilan hisob bor, lekin u tasdiqlanmagan bo'lsa — bog'lashdan oldin uning paroli o'chiriladi va barcha sessiyalari bekor qilinadi. Hisob endi Google tasdiqlagan email egasiniki. Hammasi bitta tranzaksiyada: yarim yo'lda to'xtasa, parol o'chgan-u, bog'lanmagan holat bo'lmasin.
6.4 tashqiKirish
src/auth/xizmat.ts dagi metod (telefon uchun bo'lindi; ikki joyda takrorlangan hisob?.emailTasdiqlangan === null ifodasini tozala o'zgaruvchisiga oldik, raqamli izohlar — kanon JSDoc izohidan):
async tashqiKirish(
k: TashqiKimlik,
mijoz: Mijoz,
): Promise<Foydalanuvchi> {
// (1) shu (provayder, sub) allaqachon bog'langan — o'sha hisob
const bor = await ombor.tashqiBilan(k.provayder, k.subyekt);
if (bor !== undefined) {
await ombor.hodisa({
tur: "tashqi_kirish", foydalanuvchiId: bor.id, ...mijoz,
tafsilot: { provayder: k.provayder },
});
return {
id: bor.id, email: bor.email, toliqIsm: bor.toliqIsm,
};
}
// (2) provayder emailni tasdiqlamagan — rad
if (k.email === undefined || !k.emailTasdiqlangan) {
await ombor.hodisa({
tur: "tashqi_rad", ...mijoz,
tafsilot: {
provayder: k.provayder, sabab: "email-tasdiqlanmagan",
},
});
throw new IlovaXatosi(403,
"Provayder emailingizni tasdiqlamagan — boshqa usul " +
"bilan kiring", {
kod: "tashqi-email-tasdiqlanmagan",
sarlavha: "Email tasdiqlanmagan",
});
}
// (3) shu email bilan hisob bor yoki (4) yangi hisob
const hisob = await ombor.emailBilan(k.email);
let f: Foydalanuvchi;
if (hisob === undefined) {
try {
const ism =
k.ism ?? k.email.split("@")[0] ?? "Foydalanuvchi";
f = await ombor.yaratTasdiqlangan(
k.email, ism.slice(0, 100));
} catch (x) {
if (x instanceof EmailBandXatosi) {
throw new KonfliktXatosi("Hisob shu paytda yaratildi — " +
"qayta urining",
{ kod: "qayta-urining", sarlavha: "Qayta urining" });
}
throw x;
}
} else {
f = {
id: hisob.id, email: hisob.email, toliqIsm: hisob.toliqIsm,
};
}
// tasdiqlanmagan hisob (pre-hijacking) — parol va sessiyalar
// tozalanadi (ombor ichida, bitta tranzaksiyada)
const tozala = hisob?.emailTasdiqlangan === null;
const boglandi = await ombor.tashqiBogla(
f.id, k.provayder, k.subyekt, k.email, tozala);
if (!boglandi) {
throw new KonfliktXatosi(
"Bu hisobga shu provayderning boshqa hisobi ulangan",
{ kod: "tashqi-band", sarlavha: "Boshqa hisob ulangan" });
}
await ombor.hodisa({
tur: "tashqi_boglandi", foydalanuvchiId: f.id, ...mijoz,
tafsilot: {
provayder: k.provayder,
yangi: hisob === undefined,
tozalandi: tozala,
},
});
return f;
},Uch tafsilot:
- Poyga. Ikki brauzer bir vaqtda "Google bilan kirish" ni bossa, ikkalasi ham "hisob yo'q" deb yaratishga urinadi. Ikkinchisi
EmailBandXatosioladi (email UNIQUE) va 409 "qayta urining" qaytadi. Qayta urinishda hisob allaqachon bor. - Jurnal. Har holat
auth_hodisalarga yoziladi (Ro'yxat va login oqimi hamda hisob xavfsizligi darsidagi jadval).tozalandi: true— pre-hijacking holati; uni kuzatib turish foydali. ombor.tashqiBoglabitta tranzaksiyada to'rt ish qiladi: (agartozala) parolniNULLqiladi va sessiyalarni bekor qiladi, emailni tasdiqlangan deb belgilaydi,tashqi_hisoblarga qator qo'shadi. Agar foydalanuvchida shu provayderdan boshqa hisob bo'lsa — UNIQUE buziladi vafalseqaytadi.
7. Bazada: tashqi_hisoblar
7.1 Migratsiya
Yangi fayl sql/117-tashqi-hisoblar.sql (va xuddi shu SQL — prisma/migrations/20261027160000_tashqi_hisoblar/migration.sql). Kanondagi uzun izohlarni qisqartirdik:
-- Tashqi kimliklar (Google OIDC, keyin Telegram): bitta
-- foydalanuvchiga bir nechta kirish usuli. Kalit — (provayder,
-- subyekt): OIDC "sub" o'zgarmaydi, email esa o'zgarishi mumkin
CREATE TABLE tashqi_hisoblar (
id bigint GENERATED ALWAYS AS IDENTITY,
foydalanuvchi_id bigint NOT NULL,
provayder text NOT NULL,
subyekt text NOT NULL,
email text, -- bog'langan paytdagi email
yaratilgan_vaqt timestamptz NOT NULL DEFAULT now(),
oxirgi_kirish timestamptz,
CONSTRAINT tashqi_hisoblar_pkey PRIMARY KEY (id),
CONSTRAINT tashqi_hisoblar_foydalanuvchi_id_fkey
FOREIGN KEY (foydalanuvchi_id)
REFERENCES foydalanuvchilar (id) ON DELETE CASCADE,
CONSTRAINT tashqi_hisoblar_provayder_subyekt_key
UNIQUE (provayder, subyekt),
-- bitta foydalanuvchiga har provayderdan bitta hisob
CONSTRAINT tashqi_hisoblar_foydalanuvchi_provayder_key
UNIQUE (foydalanuvchi_id, provayder),
CONSTRAINT tashqi_hisoblar_provayder_check
CHECK (provayder ~ '^[a-z][a-z0-9_-]{1,29}$'),
CONSTRAINT tashqi_hisoblar_subyekt_check
CHECK (char_length(subyekt) BETWEEN 1 AND 255)
);Ikkita UNIQUE — ikki xil qoida:
(provayder, subyekt)— bitta Google hisobi faqat bittavazifalarhisobiga ulanadi;(foydalanuvchi_id, provayder)— bittavazifalarhisobida ko'pi bilan bitta Google hisobi.
ON DELETE CASCADE — foydalanuvchi o'chirilsa, uning tashqi bog'lanishlari ham o'chadi.
7.2 O'z bazangizda sinang
Quyidagi so'rovlar kanon sxemasi (117 gacha, namuna foydalanuvchilar bilan) bo'lgan lokal bazada bajarildi (biz ishga tushirdik, PostgreSQL 17.6). Aziz (id = 1) Google'ni ulaydi:
INSERT INTO tashqi_hisoblar
(foydalanuvchi_id, provayder, subyekt, email)
VALUES (1, 'google', '1001', 'aziz@example.com')
RETURNING id, foydalanuvchi_id, provayder, subyekt;Natija:
id | foydalanuvchi_id | provayder | subyekt
----+------------------+-----------+---------
1 | 1 | google | 1001
(1 row)
INSERT 0 1Endi Dilnoza (id = 2) o'sha Google hisobini ulamoqchi:
\set VERBOSITY terse
INSERT INTO tashqi_hisoblar (foydalanuvchi_id, provayder, subyekt)
VALUES (2, 'google', '1001');Natija:
ERROR: duplicate key value violates unique constraint "tashqi_hisoblar_provayder_subyekt_key""Takror kalit qiymati tashqi_hisoblar_provayder_subyekt_key noyoblik cheklovini buzadi". Aziz ikkinchi Google hisobini ulamoqchi:
\set VERBOSITY terse
INSERT INTO tashqi_hisoblar (foydalanuvchi_id, provayder, subyekt)
VALUES (1, 'google', '2002');Natija:
ERROR: duplicate key value violates unique constraint "tashqi_hisoblar_foydalanuvchi_provayder_key"Provayder nomi katta harf bilan:
\set VERBOSITY terse
INSERT INTO tashqi_hisoblar (foydalanuvchi_id, provayder, subyekt)
VALUES (3, 'Google', '3003');Natija:
ERROR: new row for relation "tashqi_hisoblar" violates check constraint "tashqi_hisoblar_provayder_check"CHECK faqat kichik harfli nomlarni qabul qiladi: google va Google ikki xil provayder bo'lib qolmasin. Va kirishdagi qidiruv (ombor.tashqiBilan shu so'rovga oxirgi_kirish yangilanishini qo'shadi):
SELECT f.id, f.email
FROM tashqi_hisoblar t
JOIN foydalanuvchilar f ON f.id = t.foydalanuvchi_id
WHERE t.provayder = 'google' AND t.subyekt = '1001';Natija:
id | email
----+------------------
1 | aziz@example.com
(1 row)Email emas, sub bo'yicha topildi. Aziz Google'da emailini almashtirsa ham, shu so'rov uni topadi.
7.3 Prisma sxemasi
schema.prisma ga TashqiHisob modeli qo'shiladi (@@map("tashqi_hisoblar"), ikkita @@unique — cheklov nomlari bilan) va Foydalanuvchi ga tashqiHisoblar TashqiHisob[] maydoni. Kanon bu yerda o'z xatosini topdi: oldingi darslarda teskari bog'lanish maydonlarini Prisma o'zi nomlagan edi — authHodisas, sessiyas, birMartalikTokens, otpKods (inglizcha ko'plik s). Shu qadamda ular authHodisalar, sessiyalar, birMartalikTokenlar, otpKodlar ga tuzatildi. Bu faqat nomlar — sxema farqi (drift) yo'q, migratsiya kerak emas. Sizda ham shunday bo'lsa — bugun tuzating.
8. Marshrut va ulash
8.1 Ikki yo'l
src/auth/marshrut.ts ga (telefon uchun bo'lindi; ikki joyda takrorlangan 404 xatosini provayderYoq funksiyasiga oldik):
// tashqi kirish (OIDC). Brauzer navigatsiyasi — JSON emas:
// muvaffaqiyat/xato bilan frontend'ga 303 (xato kodi query'da,
// tafsilot — jurnalda)
const provayderYoq = () =>
new TopilmadiXatosi("Bunday provayder sozlanmagan",
"provayder-yoq", "Provayder yo'q");
r.get("/oidc/:provayder", async (req, res) => {
const p = d.oidc.get(req.params["provayder"]);
if (p === undefined) throw provayderYoq();
res.redirect(303, (await p.boshla(res)).href);
});
r.get("/oidc/:provayder/qaytish", async (req, res) => {
const p = d.oidc.get(req.params["provayder"]);
if (p === undefined) throw provayderYoq();
try {
const cookie = cookieOl(req.headers.cookie, p.cookieNomi);
const k = await p.yakunla(req, res, cookie);
const f = await xizmat.tashqiKirish(k, mijoz(req));
await sessiyaBer(req, res, f, true);
res.redirect(303, `${d.ilovaUrl}/`);
} catch (x) {
if (!(x instanceof IlovaXatosi)) throw x;
const kod = encodeURIComponent(x.kod ?? "xato");
res.redirect(303, `${d.ilovaUrl}/kirish?xato=${kod}`);
}
});Bu yo'llar JSON qaytarmaydi. Foydalanuvchi ularga brauzer manzili orqali keladi (havola yoki tugma), shuning uchun javob — yo'naltirish. Muvaffaqiyat: sessiya cookie'si (Session-based auth) va ilova bosh sahifasi. Xato: kirish sahifasi va ?xato= dagi qisqa kod (oidc-xato, tashqi-email-tasdiqlanmagan). Xato tafsiloti URL'ga chiqmaydi — u jurnalda.
sessiyaBer(…, true) — tashqi kirish doim "meni eslab qol" bilan (Session vs JWT qarori va uzoq sessiyalar). Kutilmagan xato (IlovaXatosi emas) yuqoriga uzatiladi — umumiy xato ishlovchisi 500 qaytaradi va jurnalga yozadi.
Tekshirib ko'ring: Nega xatoda frontend'ga 303 bilan qaytamiz, oddiy 400 JSON emas?
Javob
Bu so'rovni fetch emas, brauzerning o'zi yuborgan — Google uni bizning manzilimizga yo'naltirgan. 400 JSON qaytarsak, foydalanuvchi ekranda xom JSON matnini ko'radi. 303 esa uni ilovaning kirish sahifasiga qaytaradi, u yerda frontend ?xato= kodiga qarab tushunarli xabar ko'rsatadi.
8.2 server.ts
Google sozlangan bo'lsagina provayder ro'yxatga qo'shiladi (telefon uchun bo'lindi):
oidc: new Map(
config.GOOGLE_CLIENT_ID === undefined ||
config.GOOGLE_CLIENT_SECRET === undefined
? []
: [["google", new Oidc({
nom: "google",
issuer: config.GOOGLE_ISSUER,
clientId: config.GOOGLE_CLIENT_ID,
clientSecret: config.GOOGLE_CLIENT_SECRET,
qaytishUrl:
`${config.API_URL}/v1/auth/oidc/google/qaytish`,
httpRuxsat: config.OIDC_HTTP,
}, sirlar.kalit("oidc-cookie"), config.COOKIE_SECURE)]],
),Map — keyingi provayderlar uchun tayyor joy: yangi OIDC provayder — yangi qator, oidc.ts o'zgarmaydi. qaytishUrl API_URL dan quriladi, so'rovning Host sarlavhasidan emas: hujumchi Host ni o'zgartirib, qaytish manzilini o'z saytiga burib yuborolmasin.
8.3 arctic haqida
Xaritada yana bir kutubxona bor — arctic: mashhur provayderlar uchun tayyor, yengil OAuth mijozlari to'plami (Lucia muallifi yozgan). Uning rasmiy sahifasida endi "Arctic 2026-yil iyulida eskirgan (deprecated)" deb yozilgan va o'rniga repozitoriydagi namuna kod taklif qilingan (2026-10-28 da o'qildi; npm'dagi oxirgi versiya — 3.7.0). Kanon uni sinamadi. Yangi loyihada eskirgan kutubxonani olmang.
openid-client tanlovi sababi — u OpenID sertifikatlash dasturidan o'tgan (README'da Basic, FAPI 1.0 va FAPI 2.0 Relying Party profillari) va har standart OIDC provayderda bir xil ishlaydi. Shu sababli test uchun Google o'rniga lokal provayder qo'yish oson.
9. Testlar
9.1 Lokal provayder bitta jarayonda
test/oidc.test.ts oldingi darsdagi tajriba naqshini takrorlaydi. Bitta test jarayonida ikki server ko'tariladi (listen(0)): vazifalar-api va oidc-provider. Oidc sozlamasida issuer — lokal provayder, httpRuxsat: true. "Google hisobi" — o'zgaruvchi obyekt, har test uni almashtiradi. Brauzer taqlidi fetch bilan yo'naltirishlarni qo'lda kuzatadi va oxirida API'ning qaytish manziliga cookie bilan boradi.
Eng muhim test — pre-hijacking (telefon uchun bo'lindi):
test("PRE-HIJACKING: hujumchi oldindan ochgan TASDIQLANMAGAN " +
"hisob — Google bilan kirgach hujumchi paroli o'chadi, " +
"sessiyalari bekor", async () => {
await request(app).post("/v1/auth/royxat").send({
email: "qurbon@example.com", toliqIsm: "Soxta Ism",
parol: "Hujumchining-maxfiy-paroli",
}).expect(202);
hisob = { sub: "google-3003", email: "qurbon@example.com",
email_verified: true, name: "Qurbon" };
const n = await oqim();
assert.equal((await men(n.sessiya))?.email, "qurbon@example.com");
// email endi tasdiqlangan — lekin hujumchi paroli bilan
// kira olmaydi
await request(app).post("/v1/auth/kirish").send({
email: "qurbon@example.com",
parol: "Hujumchining-maxfiy-paroli",
}).expect(401);
const { rows } = await pool.query<{ t: string }>(
"SELECT tafsilot->>'tozalandi' AS t FROM auth_hodisalar " +
"WHERE tur = 'tashqi_boglandi'");
assert.equal(rows[0]?.t, "true");
});Uch qadam — hujum ssenariysi: hujumchi ro'yxatdan o'tadi, "qurbon" Google bilan kiradi, hujumchi o'z paroli bilan kirishga urinadi — 401. Oxirida jurnalda tozalandi: true borligi tekshiriladi.
9.2 Ishga tushirish
Faqat shu fayl (biz ishga tushirdik: kanon nusxasi va darsdagi bo'lingan fayllar bilan, Node 24.21.0, PostgreSQL 17.6; provayder ogohlantirishlarini olib tashladik):
node --test test/oidc.test.tsTest nomlari uzun — har biri bitta ssenariyni to'liq aytadi. Belgilarga va oxirgi pass / fail ga qarang:
✔ yangi foydalanuvchi: hisob yaratiladi (parolsiz, email tasdiqlangan), sessiya, ILOVA_URL ga 303; keyingi kirish — o'sha hisob (email o'zgarsa ham, sub bo'yicha) (545.5377ms)
✔ mavjud TASDIQLANGAN hisob (shu email) — bog'lanadi, paroli saqlanadi (456.424ms)
✔ PRE-HIJACKING: hujumchi oldindan ochgan TASDIQLANMAGAN hisob — Google bilan kirgach hujumchi paroli o'chadi, sessiyalari bekor (474.0505ms)
✔ email_verified=false — rad (xato kodi bilan frontend'ga), hisob yaratilmaydi, mavjudga ham bog'lanmaydi (378.8489ms)
✔ holat cookie'si: yo'q, imzosi buzilgan yoki BOSHQA brauzerniki (login CSRF) — oidc-xato, sessiya yo'q (250.5547ms)
✔ noma'lum provayder — 404; holat cookie: __Host-, HttpOnly, SameSite=Lax, 10 daqiqa (226.9907ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6783.7513Nima bo'ldi? Oltita test ham o'tdi. Ular «To'rt holat» diagrammasidagi hamma yakunni qamraydi: yangi hisob, bog'lash, pre-hijacking, rad. Qolgan ikkitasi — holat cookie'si va noma'lum provayder.
Beshinchi test login CSRF'ni uch xil sinaydi: cookie yo'q, imzo buzilgan (oxirgi uch belgi almashtirilgan) va boshqa brauzer oqimining haqiqiy cookie'si. Uchala holatda ham ?xato=oidc-xato qaytadi va sessiya berilmaydi.
Bir halol eslatma: OIDC oqimi haqiqiy brauzerda (Playwright) sinalmadi — fetch taqlidi bilan. Cookie va yo'naltirish semantikasi brauzerda CSRF e2e testlarida sinalgan (Tokenni qayerda saqlash va CSRF).
10. Tekshiruv va commit
Butun loyiha tekshiruvi (kanon, 2026-10-27, PostgreSQL 18.6): tip va lint xatosiz, testlar 171 (170 + 1 o'tkazilgan) — oltita yangi OIDC testi qo'shildi. Testcontainers 10/10, e2e 4/4, Prisma migratsiyalari sql/ bilan bir xil, schema.prisma va migratsiyalar orasida farq yo'q. Production bog'liqliklari bittaga ko'paydi — openid-client 6.8.8. Dev bog'liqliklar: oidc-provider 9.12.2 va @types/oidc-provider 9.12.1.
M="feat(auth): Google (OIDC) bilan kirish — Authorization Code"
M+=" + PKCE, imzolangan holat cookie'si, hisoblarni bog'lash va"
M+=" pre-hijacking himoyasi; ILOVA_SIRI + HKDF; db: 117"
git add src test sql prisma package.json package-lock.json
git commit -m "$M"11. Hujumchi nigohi
| Hujum | Nima uchun ishlaydi | Himoya (kanonda) | Qanday tekshirilgan |
|---|---|---|---|
| pre-hijacking | tasdiqlanmagan hisobning paroli qoladi | bog'lashda parol va sessiyalar o'chadi | 3-test: hujumchi paroli 401 |
| tasdiqlanmagan email bilan kirish | email = kimlik deb olinadi | email_verified !== true → 403 |
4-test |
login CSRF (begona code) |
state brauzerga bog'lanmagan |
imzolangan __Host-oidc cookie |
5-test: boshqa brauzer cookie'si |
| holat cookie'sini o'zgartirish | imzosiz JSON | HMAC + timingSafeEqual |
5-test; brauzer misoli |
| eski cookie bilan qaytish | muddatsiz holat | 10 daqiqa, bir martalik | brauzer misoli; 6-test (Max-Age) |
Host ni almashtirib qaytish manzilini burish |
URL so'rovdan quriladi | API_URL dan, aniq ro'yxat |
kod ko'rigi (alohida test yo'q) |
| bitta Google hisobini ikki hisobga ulash | cheklov yo'q | ikkita UNIQUE | «O'z bazangizda sinang» SQL |
| Google javob bermay qoladi | discovery xatosi keshda qoladi | catch keshni tozalaydi |
kod ko'rigi (alohida test yo'q) |
Hujumchi uchun eng qiziq joy — "bog'lash" qarori. Yuqoridagi uchta qoida (sub, tasdiqlangan email, pre-hijacking tozalash) birgalikda ishlaydi. Bittasini olib tashlasangiz, boshqa odamning hisobiga kirish yo'li ochiladi.
12. Ko'p uchraydigan xatolar
| Xato | Nega xavfli | To'g'risi |
|---|---|---|
| foydalanuvchini email bo'yicha topish | email o'zgaradi, takrorlanadi | (provayder, sub) |
email_verified ni tekshirmaslik |
birovning emaili bilan kirish | faqat === true |
| mavjud hisobga ko'r-ko'rona ulash | pre-hijacking | tasdiqlanmagan — tozalash |
state ni server xotirasida saqlash |
ikki nusxada ishlamaydi | imzolangan cookie |
client_secret ni frontend'ga berish |
hamma ko'radi | faqat serverda |
redirect URI'ni Host dan yasash |
Host injection | API_URL |
| xato tafsilotini URL'ga yozish | jurnal emas, brauzer tarixi | qisqa kod, tafsilot — jurnalda |
| hamma narsaga bitta kalit | bitta sizish — hammasi ochiq | HKDF, maqsad bo'yicha |
13. Mashqlar
Mashqlar kurs/mashqlar/28/15-google/ da.
1-mashq (oson): qaysi holat?
Har vaziyatda server nima qiladi (kirish, rad, yangi hisob, bog'lash, tozalab bog'lash)?
- Malika
vazifalarga parol bilan ro'yxatdan o'tgan va emailini tasdiqlagan. Birinchi marta Google bilan kiryapti. - Sardor bir oy oldin Google bilan kirgan. Keyin Google'dagi emailini almashtirgan.
- Provayder
email_verified: falseqaytardi. - Kimdir Jasurning emaili bilan ro'yxatdan o'tgan, tasdiqlamagan. Jasur Google bilan kiryapti.
Yechim
- Bog'lash — hisob bor va tasdiqlangan; parol saqlanadi, endi ikki usul bilan kira oladi.
- Kirish —
(google, sub)bog'langan; yangi email ahamiyatsiz. - Rad (403,
tashqi-email-tasdiqlanmagan). - Tozalab bog'lash — tasdiqlanmagan hisobning paroli o'chadi, sessiyalari bekor bo'ladi. Bu pre-hijacking'dan himoya.
2-mashq (o'rta): kimlikni yig'ish
kimlikYasa(provayder, claims, userinfo) funksiyasini yozing. id_token claim'larida email bo'lsa — email va email_verified ni ulardan oling, bo'lmasa — userinfo dan. Email kichik harfga o'tsin, emailTasdiqlangan faqat email_verified === true bo'lsa true. Uch holatda sinang: Google (email claim'larda, "Aziz@Example.com"), lokal provayder (email faqat userinfo'da) va email_verified: "true" (satr!). Ishora: typeof x === "string", toLowerCase() — Matnni o'zgartirish va tozalash.
Yechim
function kimlikYasa(provayder, claims, userinfo) {
const u = claims.email === undefined ? userinfo : claims;
return {
provayder,
subyekt: claims.sub,
email: typeof u.email === "string"
? u.email.toLowerCase()
: undefined,
emailTasdiqlangan: u.email_verified === true,
};
}
const google = {
sub: "1001", email: "Aziz@Example.com", email_verified: true,
};
const a = kimlikYasa("google", google, {});
console.log(a.subyekt, a.email, a.emailTasdiqlangan);
const lokal = { sub: "google-sub-1001" };
const userinfo = {
sub: "google-sub-1001",
email: "aziz.google@example.com",
email_verified: true,
};
const b = kimlikYasa("lokal", lokal, userinfo);
console.log(b.subyekt, b.email, b.emailTasdiqlangan);
const shubhali = {
sub: "77", email: "x@example.com", email_verified: "true",
};
console.log(kimlikYasa("boshqa", shubhali, {}).emailTasdiqlangan);Konsolda:
1001 aziz@example.com true
google-sub-1001 aziz.google@example.com true
falseUchinchi holat — nega === true. "true" satri JavaScript'da "truthy" (if ("true") ishlaydi). if (u.email_verified) deb yozsangiz, satr ham o'tib ketardi. Standart bo'yicha bu qiymat mantiqiy (boolean) — boshqa har narsa "tasdiqlanmagan" deb olinadi. Google hujjatidagi satrli namuna haqida — «Nega email kalit emas» bo'limida. Shubhali joyda qat'iy solishtirish — xavfsiz sukut.
3-mashq (qiyin): bog'lash qarori
qaror(kimlik, boglangan, hisob) funksiyasini yozing. kimlik = { email, emailTasdiqlangan }, boglangan — (provayder, sub) bazada bormi (true/false), hisob — shu email bilan hisob (null yoki { tasdiqlangan }). Natija — satr: "kirish", "rad", "yangi", "bogla" yoki "bogla-tozala". Tartib «To'rt holat» diagrammasidagidek. 1-mashqdagi to'rt vaziyatni va "birinchi marta, hisob yo'q" holatini sinang.
Yechim
function qaror(k, boglangan, hisob) {
if (boglangan) return "kirish";
if (!k.email || !k.emailTasdiqlangan) return "rad";
if (!hisob) return "yangi";
return hisob.tasdiqlangan ? "bogla" : "bogla-tozala";
}
const tasdiq = { email: "aziz@example.com", emailTasdiqlangan: true };
const HOLATLAR = [
["qaytgan foydalanuvchi", tasdiq, true, null],
["email tasdiqlanmagan",
{ ...tasdiq, emailTasdiqlangan: false }, false, null],
["birinchi marta, hisob yo'q", tasdiq, false, null],
["parol bilan ochilgan, tasdiqlangan", tasdiq, false,
{ tasdiqlangan: true }],
["kimdir oldindan ochgan", tasdiq, false, { tasdiqlangan: false }],
];
for (const [nom, k, b, h] of HOLATLAR) {
console.log(`${nom}: ${qaror(k, b, h)}`);
}Konsolda:
qaytgan foydalanuvchi: kirish
email tasdiqlanmagan: rad
birinchi marta, hisob yo'q: yangi
parol bilan ochilgan, tasdiqlangan: bogla
kimdir oldindan ochgan: bogla-tozalaTartib muhim. "Bog'langanmi" tekshiruvi birinchi turadi: qaytgan foydalanuvchida email tasdiqlanmagan bo'lib qolgan bo'lsa ham (masalan, Google'da yangi email kiritib, hali tasdiqlamagan), u o'z hisobiga kiraveradi. "Email tasdiqlanmagan" tekshiruvi esa yangi bog'lanishdan oldin — aks holda tasdiqlanmagan email bilan birovning hisobiga ulanib olish mumkin edi. Kanon testlari aynan shu ketma-ketlikni tekshiradi.
14. Real ishda
- Hisoblarni bog'lash — tayyor auth xizmatlarida (Auth0, Clerk, Supabase Auth) alohida sozlama. Xizmat tanlaganingizda birinchi savol: "email bo'yicha avtomatik bog'laydimi va tasdiqlanmagan email bilan nima qiladi?" — javobni o'sha xizmat hujjatidan tekshiring.
- Google Workspace (korporativ hisob) —
hdclaim'i tashkilot domenini beradi. Google hujjatiga ko'ra, kirishni faqat bitta domen xodimlariga cheklasangiz, aynan shu claim'ni serverda tekshirish shart. - Bir nechta provayder — Apple, Microsoft, GitLab: kanondagi
Mapga yangi qator. GitHub kabi faqat OAuth beradiganlar uchun — alohida adapter. - Intervyuda: "Google bilan kirgan foydalanuvchini bazada qanday saqlaysiz?", "Email bo'yicha bog'lashning xavfi?", "Account pre-hijacking nima?", "state qayerda saqlanadi?".
Xulosa
- Google tomoni: Clients → Create Client → Web application, redirect URI aniq mos (sinovda
localhost); secret — faqat.envda. - Bitta
ILOVA_SIRIdan HKDF bilan har maqsadga alohida kalit — bitta sizish hammasini ochmaydi. - Oqim holati (
state,nonce, verifier) — 10 daqiqalik, HMAC bilan imzolangan, bir martalik__Host-oidccookie'da. - Foydalanuvchi
(provayder, sub)bo'yicha topiladi; yangi bog'lanish faqatemail_verified === truebilan. - Tasdiqlanmagan mavjud hisob bog'lanayotganda paroli va sessiyalari o'chiriladi — pre-hijacking himoyasi.
- Testlar Google o'rniga lokal
oidc-providerbilan: 6 test, login CSRF va pre-hijacking ham.
Keyingi dars: Telegram Login va Mini App initData tekshiruvi — Telegram email bermaydi. Eski (legacy) Login Widget usulida kirish ma'lumotining imzosini bot tokeni bilan tekshiramiz va Telegram'ni mavjud hisobga ulaymiz.
Manbalar
- Google "OpenID Connect" hujjati (Clients sahifasi,
subvaemailhaqida,email_verified,hd): https://developers.google.com/identity/openid-connect/openid-connect (2026-10-28 da o'qildi) - Google "Using OAuth 2.0 for Web Server Applications" (Create Client, redirect URI qoidalari): https://developers.google.com/identity/protocols/oauth2/web-server (2026-10-28 da o'qildi)
- RFC 5869 "HMAC-based Extract-and-Expand Key Derivation Function (HKDF)": https://www.rfc-editor.org/rfc/rfc5869
- openid-client: https://github.com/panva/openid-client; oidc-provider: https://github.com/panva/node-oidc-provider
- arctic (eskirgan, 2026-yil iyul): https://arctic.pilcrowonpaper.com (2026-10-28 da o'qildi)
- Sudhodanan A., Paverd A. "Pre-hijacked accounts: An Empirical Study of Security Failures in User Account Creation on the Web", USENIX Security 2022: https://www.usenix.org/conference/usenixsecurity22/presentation/sudhodanan (2026-10-28 da o'qildi)
- OpenID Connect Core 1.0, Standard Claims (
email_verified— boolean): https://openid.net/specs/openid-connect-core-1_0.html - Kanon o'lchovi (IlmHamroh, 2026-10-27): loyiha tekshiruvi (PostgreSQL 18.6, Node 24.21.0). Darsdagi brauzer va Node misollari, SQL so'rovlar (PostgreSQL 17.6), bo'lingan kod nusxasining tip/lint tekshiruvi va
oidc.test.ts— biz ishga tushirdik (2026-10-28).
Izohlar (0)
Izoh yozish uchun kiring.
- Hozircha izoh yo'q. Birinchi bo'ling!