সরাসরি পর্বে যান
পর্ব ৪৯ · ৯৩-এর মধ্যে · মডিউল ৭

Account-এর কাজ — signup, email যাচাই, password reset আর rate limit

Account Flows — Signup, Email Verification, Password Reset and Rate Limits

Taught in Bangla, this lesson builds account flows that do not reveal whether an email exists, in wording or timing, with a measured timing leak and fix, password rules from NIST SP 800-63B-4, single-use hashed tokens for email verification and password reset checked in one SQL statement, email and phone changes and phone OTP in Bangladesh, TOTP and recovery codes, login rate limits with backoff instead of lockout, account deletion and audit trails.

এই পর্বে যা শিখবেন

  • User enumeration ছাড়া signup আর password reset বানাতে, আর সময়ের ফাঁস মাপতে
  • NIST-এর বর্তমান password নিয়ম আর ফাঁস-তালিকা মেলানোর পদ্ধতি প্রয়োগ করতে
  • Email যাচাইয়ের token নিরাপদে তৈরি, জমা আর ব্যবহার করতে, local SMTP-তে আসল মেইল ধরে
  • Email ও ফোন বদল, phone OTP, TOTP আর recovery code নিরাপদে চালাতে
  • Redis দিয়ে login rate limit বসাতে, আর account মোছা ও audit trail নকশা করতে

১ · Signup: দরজা খোলা, তথ্য বন্ধ

পর্ব ৪৫ থেকে ৪৮-এ login আর permission দেখেছি। কিন্তু একটি দোকানের account শুধু login নয়। মানুষ নতুন account খোলেন, password ভোলেন, ফোন বদলান, চলেও যান। এই পর্বের বেশিরভাগ বাগ login-এর পাশের এই ছোট পথগুলোতে থাকে, কারণ এগুলো কম চলে আর কম পরীক্ষা হয়।

ব্যাংকের লকার শাখার কথা ভাবুন। লকার ঘরের দরজা খুব মজবুত। কিন্তু রিসেপশনে কেউ জিজ্ঞেস করলেন, “করিম সাহেবের কি এখানে লকার আছে?” রিসেপশনিস্ট “হ্যাঁ” বললেন। লকার ভাঙেনি, তবু একটি গোপন তথ্য বেরিয়ে গেল। হারানো চাবির ফর্মে পরিচয় না দেখে নতুন চাবি দিলে আরও খারাপ। Account-এর কাজ হলো এই রিসেপশন। দরজার মতোই এখানেও নিয়ম লাগে।

প্রথম নিয়মের নাম user enumeration ঠেকানো। পর্ব ২১-এ (বড় form) শব্দটি পেয়েছেন: উত্তর দেখে বোঝা যায় কোন email বা ফোন নম্বর এই সাইটে আছে। এই তালিকা দিয়ে প্রতারক বিশ্বাসযোগ্য phishing পাঠান, আর অন্য সাইটের ফাঁস হওয়া password মিলিয়ে দেখেন, যাকে বলে credential stuffing.

Signup-এ সবচেয়ে সাধারণ ভুল হলো এই উত্তর: 409 email already exists. এর সমাধান: email আগে থেকে থাকুক বা না থাকুক, উত্তর একই থাকবে, আর পরের খবরটি যাবে মেইলে। আসল মালিকই শুধু সেই মেইল পড়তে পারেন।

app.post('/api/signup', async (req, res) => {
  const email = norm(req.body.email), pw = req.body.password;
  const bad = await policy(pw);                     // অংশ ২
  if (bad) return res.status(422).json({ error: bad });
  const hash = await argon2.hash(pw, ARGON);        // দুই পথেই একই কাজ
  const r = await q(`INSERT INTO users(email,password_hash) VALUES($1,$2)
                     ON CONFLICT (email) DO NOTHING RETURNING id`, [email, hash]);
  if (r.rowCount) {
    const t = await createToken(r.rows[0].id, 'verify_email', 60 * 24);
    enqueueMail(verifyMail(email, t));
  } else {
    enqueueMail(alreadyHaveAccountMail(email));
  }
  res.status(202).json(ok202);
});

ON CONFLICT দুটি কাজ করে। আগে SELECT করে পরে INSERT করলে race থাকত, আর দুই পথে ডেটাবেসের কাজও আলাদা হতো। argon2.hash দুই পথেই চলে, কারণ এটি এই route-এর সবচেয়ে ধীর ধাপ। Password hash-এর তত্ত্ব আছে Password সংরক্ষণ পর্বে, আর আমরা OWASP-এর সেই ন্যূনতম মান নিয়েছি: Argon2id, m=19456, t=2, p=1.

সত্যিকারের run · Express 5.3.0, PostgreSQL 18.6, Node.js 24.15 · ~/bazarghar · সব ঠিকানা কল্পিত · কোনো মেইল বাইরে যায়নি · বাংলা বার্তা ছাঁটা

POST signup rahim (new)     202 {"message":"অনুরোধ পেয়েছি। …"}
POST signup rahim (again)   202 {"message":"অনুরোধ পেয়েছি। …"}
  MAIL to=rahim@example.com | বাজারঘর: আপনার email যাচাই করুন
  MAIL to=rahim@example.com | বাজারঘর: এই email-এ আগেই account আছে

দুটি উত্তর হুবহু এক। পার্থক্য শুধু মেইলের বাক্সে। এই পর্বের সব পথ একটি Express অ্যাপে চলেছে: ডেটা আসল PostgreSQL-এ, session আর limit আসল Redis 8.10.3-এ, আর মেইল ধরেছে নিজের কম্পিউটারের একটি নকল SMTP server. লেখার সময়ের অন্য version: argon2 0.45.1, redis client 6.3.1. পাতার কোড ল্যাবের কোড থেকে ছোট করা, তাই কিছু সাহায্যকারী function-এর নাম আলাদা।

একটি সৎ সীমা। সব পথে এই লুকানো সম্ভব হয় না। ফোন নম্বরে account হলে, অথবা username সবার সামনে দেখানো নিয়ম হলে, “এই নাম নেওয়া হয়েছে” না বলে উপায় নেই। সেখানে লক্ষ্য বদলায়: তালিকা বানানো ধীর আর দামি করা। Rate limit, আর পর্ব ২১-এর সেই সতর্কতা। কোথায় লুকাবেন আর কোথায় সুবিধা দেবেন, তা ব্যবসা আর ঝুঁকির সিদ্ধান্ত।

২ · Password-এর নিয়ম: NIST আসলে কী বলে

অনেক সাইট এখনো বলে “একটি বড় হাতের অক্ষর, একটি সংখ্যা, একটি চিহ্ন”, আর ৯০ দিন পরপর বদলাতে হবে। বর্তমান নির্দেশিকা NIST SP 800-63B-4 (২০২৫) এর উল্টো কথা বলে। নিচের কথাগুলো NIST-এর প্রকাশিত পাতা থেকে মিলিয়ে নেওয়া। এটি মার্কিন সরকারি সংস্থার জন্য লেখা, তবে সারা বিশ্ব একে মানদণ্ড ধরে।

বিষয়NIST SP 800-63B-4শক্তি
ন্যূনতম দৈর্ঘ্যএকা password হলে ১৫; শুধু MFA-র অংশ হলে ৮SHALL
সর্বোচ্চ দৈর্ঘ্যঅন্তত ৬৪ পর্যন্ত নিতে হবেSHOULD
মিশ্র অক্ষরের নিয়মআর কোনো গঠনের নিয়ম চাপানো যাবে নাSHALL NOT
নিয়মিত বদলসময় ধরে বদলাতে বলা যাবে না; ফাঁসের প্রমাণ পেলে বদলাতে হবেSHALL NOT / SHALL
Blocklistসাধারণ, অনুমেয় বা ফাঁস হওয়া password-এর তালিকা মেলাতে হবে, পুরো password ধরেSHALL
অক্ষর গোনাপ্রতিটি Unicode code point একটি অক্ষরSHALL
Password managerautofill চলতে দিতে হবে; paste চলতে দেওয়া উচিতSHALL / SHOULD
Hint, গোপন প্রশ্নচলবে নাSHALL NOT

কেন দৈর্ঘ্য? মিশ্র অক্ষরের নিয়ম মানুষকে Password1! এর মতো অনুমেয় ছাঁচে ঠেলে দেয়। প্রতিটি নতুন অক্ষর সম্ভাবনাকে গুণ করে বাড়ায়, তাই লম্বা বাক্যাংশ মনে রাখা সহজ, ভাঙা কঠিন। বাংলা বাক্যাংশও ভালো, কিন্তু একটি ফাঁদ আছে: pw.length UTF-16 ইউনিট গোনে, code point নয়। আর একই লেখা ভিন্ন ফোনে ভিন্ন Unicode ক্রমে আসতে পারে, তাই NIST hash করার আগে NFC normalization করা উচিত বলে। আমাদের [...pw.normalize('NFC')].length এই কাজই করে।

ফাঁস-তালিকা, password না পাঠিয়েই

তালিকা মিলাতে গিয়ে পুরো password বাইরে পাঠালে সেটিই নতুন ফাঁস। সমাধানের নাম k-anonymity ভিত্তিক খোঁজ। Password-এর SHA-1 ছাপের প্রথম ৫টি hex অক্ষর পাঠানো হয়। সেবা ওই শুরুর সব ছাপের বাকি অংশ ফেরায়। মিলটি আপনার server নিজে করে, তাই সেবা জানতেই পারে না কোনটি খোঁজা হলো।

হিসাব: ৫টি hex মানে ১৬৫ = ১০,৪৮,৫৭৬টি ঝুড়ি। ধরে নিই তালিকায় ১০০ কোটি ছাপ আছে। তাহলে এক ঝুড়িতে গড়ে ১০০ কোটি ÷ ১০,৪৮,৫৭৬ ≈ ৯৫৪টি। সেবা ৯৫৪টির মধ্যে আপনারটি আলাদা করতে পারে না। এখানে SHA-1 গোপনীয়তার জন্য নয়, শুধু তালিকার সূচি হিসেবে। Password জমা রাখা হচ্ছে Argon2id-তেই। ল্যাবে আমরা কোনো বাইরের সেবায় যাইনি। Local একটি নকল তালিকা-সেবা চালিয়েছি, আর সে কী দেখল তা ছেপেছি।

সত্যিকারের run · নকল তালিকা-সেবা, শুধু local · সব password বানানো

signup pw short-pass-12 (13)         422 {"error":"too_short"}
signup pw password1234567 (15, list) 422 {"error":"breached"}
signup pw AAAAaaaa1111!!!!           202 {"message":"অনুরোধ পেয়েছি। …"}
signup pw (বাংলা বাক্যাংশ)              202 {"message":"অনুরোধ পেয়েছি। …"}
 full sha1 of the sample: 648B54E01222585930982009E68112AC270DE1EE
 range requests seen by the list service: /range/5CF9B /range/336F6 /range/648B5 /range/2A64F /range/76DC8

তৃতীয় লাইনটি দেখুন। AAAAaaaa1111!!!! দুর্বল, কিন্তু আমাদের ছোট তালিকায় নেই বলে পাস করেছে। এটি নিয়মের সীমা: তালিকা শুধু জানা জিনিস ধরে। NIST তাই প্রত্যাখ্যানের কারণ জানাতে আর ভালো password বাছাইয়ের পরামর্শ দিতেও বলে। এই 422 উত্তরে email-এর কোনো খবর নেই, কারণ policy চলে ডেটাবেস দেখার আগে।

২০০৩ সালে NIST-এর একটি পরিশিষ্টে Bill Burr মিশ্র অক্ষর আর নিয়মিত বদলের পরামর্শ লিখেছিলেন। ২০১৭ সালে একটি পত্রিকার সাক্ষাৎকারে তিনি এ নিয়ে অনুশোচনার কথা বলেন। সে বছরই নতুন SP 800-63B ওই পরামর্শ বাদ দেয়। ২০১৮ সালে Troy Hunt-এর Pwned Passwords সেবা, Cloudflare-এর Junade Ali-র সহযোগিতায়, k-anonymity দিয়ে এই খোঁজ জনপ্রিয় করে। ২০২৫ সালের চতুর্থ সংস্করণে আগের “উচিত” অনেক জায়গায় “বাধ্যতামূলক” হয়েছে।

৩ · Email যাচাই: token, hash আর একবার ব্যবহার

কেউ অন্যের email দিয়ে account খুললে মালিকের বাক্সে আপনার মেইল জমবে, আর পরে সেই মানুষ password reset চাইলে গোলমাল বাধবে। তাই যাচাই লাগে: “এই ঠিকানা আপনার তো?” এর জন্য একটি token মেইলে পাঠানো হয়।

ডাকঘরের রেজিস্ট্রি চিঠির কথা ভাবুন। চিঠিতে একটি গোপন নম্বর, আর ডাকঘরের খাতায় ওই নম্বরের একটি ছাপ। খাতা চুরি হলেও ছাপ থেকে নম্বর পাওয়া যায় না। প্রাপ্ত নম্বরটি একবার দেখালে কাজ শেষ, পরে আর চলে না। Token-এর চারটি নিয়ম এখানেই:

এলোমেলো

randomBytes(32), অর্থাৎ ২৫৬ bit, CSPRNG থেকে। Math.random(), সময় বা user id থেকে নয়।

অনুমান অসম্ভব

Hash করে জমা

Database-এ শুধু SHA-256 ছাপ। টেবিল ফাঁস হলে হাতে ছাপ আসে, কাজের token নয়।

hashed at rest

একবার

used_at বসিয়ে দেওয়া। দ্বিতীয়বার একই token চলবে না।

single use

মেয়াদ

Email যাচাইয়ে এখানে ২৪ ঘণ্টা, reset-এ ৩০ মিনিট। NIST email-এ পাঠানো recovery code-এ সর্বোচ্চ ২৪ ঘণ্টা রাখে।

expiry

Password-এ Argon2id কিন্তু token-এ সাধারণ SHA-256 কেন? Password মানুষের বানানো, তাই অনুমান করা যায়, আর ধীর hash সেই অনুমানকে দামি করে। ২৫৬ bit এলোমেলো token অনুমানই করা যায় না, তাই ধীর হওয়ার দরকার নেই। দ্রুত hash এখানে ঠিক হাতিয়ার।

Nodemailer আর নিজের নকল SMTP

মেইল পাঠাতে Nodemailer ব্যবহার করব (লেখার সময়ে 10.3.1). আসল ঠিকানায় মেইল পাঠিয়ে শেখা ঠিক নয়। তাই smtp-server package (3.21.3) দিয়ে নিজের কম্পিউটারে একটি SMTP server চালানো হলো, যা মেইল নিয়ে শুধু জমায়। উন্নয়নের সময় Mailpit-এর মতো টুলও এই কাজ করে, তবে এই ল্যাবে সেটি চালানো হয়নি।

import { SMTPServer } from 'smtp-server';
import { simpleParser } from 'mailparser';
import nodemailer from 'nodemailer';

const box = [];
const smtp = new SMTPServer({
  authOptional: true, disabledCommands: ['STARTTLS'],
  onData(stream, session, cb) {
    simpleParser(stream).then((m) => { box.push(m); cb(); }, cb);
  },
});
smtp.listen(38566, '127.0.0.1');

const mailer = nodemailer.createTransport({
  host: '127.0.0.1', port: 38566, secure: false, ignoreTLS: true, // শুধু local ল্যাবে
});
const info = await mailer.sendMail({ from, to, subject, text });

সত্যিকারের run · Nodemailer 10.3.1 আর smtp-server 3.21.3 · মেইল এই কম্পিউটারের বাইরে যায়নি

{
  accepted: [ 'rahim@example.com' ],
  rejected: [],
  response: '250 OK: message queued',
  envelope: { from: 'no-reply@bazarghar.example', to: [ 'rahim@example.com' ] }
}
captured: "বাজারঘর" <no-reply@bazarghar.example> -> rahim@example.com
subject : বাজারঘর: আপনার email যাচাই করুন
text    : যাচাই করতে: https://bazarghar.example/verify-email?token=SAMPLE
headers : from to subject message-id content-transfer-encoding date mime-version content-type

250 OK মানে শুধু “পরের server নিল”, মালিকের হাতে পৌঁছানো নয়। Production-এ ignoreTLS থাকবে না, TLS আর লগইন লাগবে। SPF, DKIM, DMARC আর transactional mail-এর বিস্তার পর্ব ৬৩-তে আসবে, আর তত্ত্ব আছে DNS ও email নিরাপত্তা পর্বে। এবার পুরো পথটি দেখুন। মানগুলো আসল run-এর, ছাঁটা।

  1. SignupPOSTক্রেতা
  2. Databasehashশুধু ছাপ
  3. SMTPtokenআসল token
  4. যাচাই পাতাPOSTক্লিকের পরে
Expressঅনুরোধ
POST /api/signup
{ "email": "rahim@example.com", "password": "…" }
উত্তর: 202 {"message":"অনুরোধ পেয়েছি। …"}
account_tokensdatabase-এ যা গেল
purpose    verify_email
token_hash 1e624508a63e1307…
ttl        1 day
used_at    null
নকল SMTPমেইলে যা গেল
to: rahim@example.com
token: Wf-peFIc… (৪৩ অক্ষর)
sha256(token) মিলল db-র সাথে: true
Loginযাচাইয়ের আগে
POST /api/login (ঠিক password)
403 {"error":"email_not_verified"}
যাচাইএকই statement-এ
POST /api/verify-email { token }
UPDATE … SET used_at = now() … RETURNING user_id
200 {"verified":true}
আবারতিন রকম ব্যর্থতা, একই উত্তর
same token    400 {"error":"invalid_or_expired"}
wrong token   400 {"error":"invalid_or_expired"}
expired token 400 {"error":"invalid_or_expired"}
  1. Signup. রহিম form জমা দিলেন। উত্তরে 202, অর্থাৎ “অনুরোধ নিয়েছি”, “তৈরি করেছি” নয়। মেইল যাবে পেছনে।
  2. শুধু ছাপ জমা। Server একটি এলোমেলো token বানাল, কিন্তু টেবিলে গেল তার SHA-256 ছাপ 1e624508…, মেয়াদ ১ দিন, used_at খালি।
  3. আসল token শুধু মেইলে। ৪৩ অক্ষরের Wf-peFIc… মেইলের লিংকে আছে। এটি ছাড়া আর কোথাও নেই। Run-এ ছাপ মিলিয়ে দেখা হয়েছে: true.
  4. যাচাইয়ের আগে login. ঠিক password দিলেও 403 email_not_verified. এতে enumeration নেই, কারণ এই উত্তর পেতে ঠিক password জানতে হয়।
  5. লিংকের পাতা, তারপর POST. লিংক একটি পাতা খোলে, আর সেই পাতার বোতাম token পাঠায় POST-এ। সরাসরি GET-এ যাচাই করলে মেইলের নিরাপত্তা-স্ক্যানার লিংক খুলেই token খরচ করে ফেলতে পারে। সফল হলে 200.
  6. একবারই। একই token, ভুল token আর মেয়াদ শেষ, তিনটিতেই হুবহু 400 invalid_or_expired. কেন ব্যর্থ, তা বলার দরকার নেই।

নিচের মডেলে একটি array হলো টেবিল, আর ঘড়িটি নকল, তাই ৩০ মিনিট অপেক্ষা করতে হয় না। crypto আসল। শেষ দুটি লাইনে দেখুন ফাঁস হওয়া ছাপ token হিসেবে চলে কি না।

const { randomBytes, createHash } = require('node:crypto');
let now = 0;                                   // নকল ঘড়ি (মিনিট)
const rows = [];                               // নকল account_tokens টেবিল
const hash = (t) => createHash('sha256').update(t).digest('hex');

function issue(userId) {
  const token = randomBytes(32).toString('base64url');
  rows.push({ userId, tokenHash: hash(token), expiresAt: now + 30, usedAt: null });
  return token;                                // শুধু mail-এ যাবে
}
function consume(token) {
  const h = hash(token);
  const row = rows.find((r) => r.tokenHash === h);
  if (!row) return 'invalid';
  if (row.usedAt !== null) return 'invalid';   // তিনটি পথেই একই উত্তর
  if (row.expiresAt <= now) return 'invalid';
  row.usedAt = now;
  return 'ok';
}
const a = issue(42);
console.log('token length :', a.length);
console.log('stored       :', Object.keys(rows[0]).join(','));
console.log('raw in table :', JSON.stringify(rows).includes(a));
now = 5;
console.log('t=5  use     :', consume(a));
now = 6;
console.log('t=6  reuse   :', consume(a));
console.log('t=6  garbage :', consume('x'.repeat(43)));
const b = issue(42);
now = 40;                                      // ৩৪ মিনিট পরে
console.log('t=40 expired :', consume(b));
const c = issue(42);
const leaked = rows[2].tokenHash;              // ধরুন টেবিল ফাঁস হলো
console.log('t=40 use hash:', consume(leaked));
console.log('t=40 real one:', consume(c));

raw in table : false: টেবিলে আসল token নেই। t=6 reuse : invalid: একবার ব্যবহারের পরে শেষ। t=40 expired : invalid: মেয়াদের ৩০ মিনিট পার। আর t=40 use hash: invalid লাইনটি মূল কথা: ছাপটি token হিসেবে দিলে server তার আবার hash করে, আর সেটি কোনো সারির সাথে মেলে না। তাই টেবিল ফাঁস হলেও token চালু হয় না, যেখানে t=40 real one: ok আসল token-এ চলে।

৪ · Password reset: একই উত্তর, একই সময়, একবার

Reset হলো password ছাড়াই account-এ ঢোকার বৈধ পথ। তাই এটি login-এর মতোই গুরুত্বপূর্ণ। প্রথমে উত্তর। জানা আর অজানা email-এ একই লেখা, একই status. নিচের আসল node:http server-এ ভুল আর ঠিক, দুটি পথই আছে।

const http = require('node:http');
const users = new Set(['rahim@example.com']);
let mailsSent = 0, jobs = 0;
const body = JSON.stringify({ message: 'If that address is right, a mail is on its way.' });

function naive(email, res) {                    // ভুল নকশা
  if (!users.has(email)) { res.statusCode = 404; return res.end('{"error":"no such user"}'); }
  mailsSent++; res.end(body);
}
function uniform(email, res) {                  // একই উত্তর, কাজ পরে
  jobs++;
  setImmediate(() => { if (users.has(email)) mailsSent++; });
  res.statusCode = 202; res.end(body);
}
const server = http.createServer((req, res) => {
  let raw = ''; req.on('data', (c) => (raw += c));
  req.on('end', () => (req.url === '/naive' ? naive : uniform)(JSON.parse(raw).email, res));
});
server.listen(0, '127.0.0.1', async () => {
  const url = `http://127.0.0.1:${server.address().port}`;
  const ask = async (path, email) => {
    const r = await fetch(url + path, { method: 'POST', body: JSON.stringify({ email }) });
    return `${r.status} ${await r.text()}`;
  };
  const list = ['rahim@example.com', 'ghost@example.com'];
  for (const path of ['/naive', '/uniform']) {
    const [k, u] = [await ask(path, list[0]), await ask(path, list[1])];
    console.log(path.padEnd(9), 'same answer:', k === u);
    console.log('  known  :', k.slice(0, 44));
    console.log('  unknown:', u.slice(0, 44));
  }
  await new Promise((r) => setImmediate(r));
  console.log('mails sent:', mailsSent, ' jobs queued:', jobs);
  server.close();
});

/naive same answer: false: 200 আর 404 সোজা বলে দিল কে আছে। /uniform same answer: true: দুটিই 202, একই লেখা। শেষ লাইন mails sent: 2 jobs queued: 2: job দুটি, মেইল গেছে naive-এর একটি আর uniform-এর একটি মিলিয়ে, অর্থাৎ uniform-এ অজানা ghost-এর জন্য job চলেছে, মেইল যায়নি। Runner-এর সময় ওঠানামা করে, তাই সময় এখানে মাপা হয়নি। আসল মাপ নিচে।

লেখা এক হলেও সময় কথা বলে

উত্তর এক হলেই শেষ নয়। জানা email-এ যদি মেইল পাঠিয়ে তবে উত্তর দেন, সময় বেশি লাগবে। এই পার্থক্য দিয়ে তথ্য বের করাকে বলে timing leak. আমরা একই Express অ্যাপে তিনটি নকশা মাপলাম, প্রতিটিতে ২০০ জোড়া অনুরোধ, পালা করে।

সত্যিকারের run · একটি local কম্পিউটারে একবার · network নেই · এটি সাধারণ ফল নয় · limiter বন্ধ রেখে মাপা

naive login   known    n=200 p50   21.81 ms  p95   27.10 ms
naive login   unknown  n=200 p50    1.05 ms  p95    1.59 ms
              p50 gap 20.76 ms
fixed login   known    n=200 p50   21.48 ms  p95   25.21 ms
fixed login   unknown  n=200 p50   21.36 ms  p95   24.89 ms
              p50 gap 0.12 ms
naive forgot  known    n=200 p50  107.10 ms  p95  119.91 ms
naive forgot  unknown  n=200 p50    0.92 ms  p95    1.43 ms
              p50 gap 106.18 ms
mail later    known    n=200 p50    0.95 ms  p95    1.46 ms
mail later    unknown  n=200 p50    0.53 ms  p95    0.89 ms
              p50 gap 0.42 ms
all later     known    n=200 p50    0.37 ms  p95    0.63 ms
all later     unknown  n=200 p50    0.37 ms  p95    0.72 ms
              p50 gap 0.01 ms

তিনটি শিক্ষা। Login-এ অজানা email-এ password hash না করলে ২০ ms-এর ফাঁক হয়, যা জানা email-এর ঠিক argon2.verify-এর সময়। সমাধান: অজানা হলেও একটি নকল hash-এর সাথে verify চালানো। Reset-এ SMTP-র অপেক্ষাই ১০৬ ms বাড়িয়েছে। শুধু মেইল পরে পাঠালেও ০.৪২ ms রয়ে গেল, কারণ জানা email-এ দুটি বাড়তি ডেটাবেস লেখা আছে। ফাঁক ছোট, কিন্তু বহু নমুনায় গড় নিলে ছোট ফাঁকও ধরা পড়ে। সবচেয়ে পরিষ্কার সমাধান হলো উত্তরকে অস্তিত্বের ওপর নির্ভরই করতে না দেওয়া।

The response is sent before the code ever looks up the address

উত্তর আগে, খোঁজ পরে

Password reset: উত্তর আর পেছনের কাজ আলাদা POST /api/forgot Queue-তে কাজ উত্তর ফিরল Background job পাওয়া গেছে পাওয়া যায়নি শুধু email পড়া এখনো খোঁজা হয়নি 200, সবার জন্য একই এবার ডেটাবেস দেখা পুরোনো বাতিল নতুন token, মেইল কিছুই নয় মেইল নেই
ছবি ৪৯.১ — ভাঙা দাগের ওপরে যা আছে, তা দুই রকম email-এ হুবহু এক। তাই সময় মেপে কিছু বোঝা যায় না।
app.post('/api/forgot', (req, res) => {
  const email = norm(req.body.email);
  later(() => forgotJob(email, req));   // উত্তরের পরে, durable queue-তে (পর্ব ৬২)
  res.json(ok202);
});

async function forgotJob(email, req) {
  const u = await findActiveUser(email);
  const lim = await limiter.hit(`rl:forgot:${acctId(email)}`, 60 * M, 4); // মেইল-বোমা ঠেকায়
  if (!u || lim.over) return;
  const raw = newToken();
  await cancelOldResetTokens(u.id);
  await saveToken(u.id, 'reset', hashToken(raw), '30 min');
  enqueueMail(resetMail(email, `${APP_URL}/reset-password?token=${raw}`));
}

লিংকের ঠিকানা আসে config-এর APP_URL থেকে, অনুরোধের Host header থেকে নয়। Host থেকে বানালে কেউ ভুয়া header পাঠিয়ে আসল মালিকের মেইলে নিজের সাইটের লিংক ঢোকাতে পারতেন, যাকে বলে host header injection. আসল run-এ ভুয়া X-Forwarded-Host দিয়েও মেইলের লিংক ছিল https://bazarghar.example.

অনুরোধজানা আর অজানা
forgot known    200 {"message":"অনুরোধ পেয়েছি। …"}
forgot unknown  200 {"message":"অনুরোধ পেয়েছি। …"}
bodies identical: true  status equal: true
Background jobশুধু জানা email-এ
UPDATE account_tokens SET used_at = now() -- পুরোনো reset
INSERT … purpose = 'reset', expires ৩০ মিনিট
MAIL to=rahim@example.com | বাজারঘর: password নতুন করুন
Reset পাতাheader
GET /reset-password?token=…
Referrer-Policy: no-referrer
Cache-Control: no-store
নতুন passwordএকটি transaction
POST /api/reset { token, password }
BEGIN; UPDATE … used_at … RETURNING;
UPDATE users SET password_hash …; COMMIT;
উত্তর: 200 {"ok":true,"sessionsEnded":2}
একই token আবারএকবারই
reset, same token again  400 {"error":"invalid_or_expired"}
পুরোনো sessionসব শেষ
GET me, old phone session   401 {"error":"not_signed_in"}
GET me, old laptop session  401 {"error":"not_signed_in"}
শেষlogin আর নোটিশ
login with old password  401 {"error":"invalid_credentials"}
login with new password  200 {"ok":true}
MAIL to=rahim@example.com | বাজারঘর: আপনার password বদলেছে
  1. একই উত্তর। জানা আর অজানা email, দুটিতেই 200 আর হুবহু একই লেখা। Run-এ দুটি তুলনাই true.
  2. পেছনের কাজ। জানা email-এ আগের সব reset token বাতিল হলো, নতুন একটি তৈরি হলো, ৩০ মিনিট মেয়াদে, মেইল গেল। অজানা email-এ কিছুই হলো না।
  3. পাতা খোলা। Token URL-এ আছে, তাই পাতা থেকে বাইরের কোনো লিংকে গেলে Referer-এ ফাঁস হতে পারে। Referrer-Policy: no-referrer সেটি বন্ধ করে, আর no-store cache-এ জমতে দেয় না।
  4. নতুন password. Policy আবার যাচাই, তারপর token খরচ আর password বদল একই transaction-এ। উত্তরে sessionsEnded: 2.
  5. একবারই। একই token-এ 400. কারণ used_at ভরে গেছে।
  6. সব session শেষ। Password চুরি হয়েছিল বলে reset হলে দখলকারীর session-ও মরতে হবে। ফোন আর ল্যাপটপ, দুটিই এখন 401. Redis-এ usess:<id> set থেকে সব session key পাওয়া যায় বলে এটি সম্ভব। Session-এর গঠন পর্ব ৪৫-এ।
  7. নতুন করে login. পুরোনো password 401, নতুন 200. Reset-এর পর নিজে থেকে login করানো হয়নি, OWASP-ও স্বাভাবিক login-এ পাঠাতে বলে। শেষে মালিককে নোটিশ মেইল গেল।

“একবারই” কথাটি race-এও সত্য হতে হবে

অসাবধান কোড আগে SELECT করে দেখে token ঠিক আছে, তারপর ধীর password hash, শেষে used_at বসায়। মাঝের ২০ ms-এ বাকি অনুরোধও পাস করে যায়। এটি পর্ব ৪৩-এর (Application-এ transaction) সেই race condition. একই token-এ ১২টি অনুরোধ একসাথে পাঠিয়ে দেখলাম।

সত্যিকারের run · তিন দফা · local কম্পিউটারে একবার

check-then-act   round 1 200: 6 400: 6
check-then-act   round 2 200: 11 400: 1
check-then-act   round 3 200: 12 400: 0
atomic UPDATE    round 1 200: 1 400: 11
atomic UPDATE    round 2 200: 1 400: 11
atomic UPDATE    round 3 200: 1 400: 11
UPDATE account_tokens SET used_at = now()
 WHERE token_hash = $1 AND purpose = $2
   AND used_at IS NULL AND expires_at > now()
RETURNING user_id, new_value;

যাচাই আর ব্যবহার একই statement-এ, তাই PostgreSQL-এর row lock একজনকেই জিততে দেয়। Hash-এর তুলনা ডেটাবেসের index-এ, আর purpose মেলানোয় email যাচাইয়ের token দিয়ে password reset করা যায় না। মেয়াদ ফুরানো সারি মুছতে একটি নির্ধারিত কাজ রাখুন।

৫ · Email, ফোন বদল আর বাংলাদেশে phone OTP

Account দখলের পর প্রথম কাজ হয় যোগাযোগের ঠিকানা বদলানো, যাতে আসল মালিক reset করতে না পারেন। তাই বদলের পথে চারটি নিয়ম:

  1. আবার পরিচয়: session থাকলেও বর্তমান password চাইতে হবে, যাকে বলে reauthentication. খোলা ল্যাপটপে কেউ বসলে এতে আটকাবে।
  2. নতুন ঠিকানায় প্রমাণ: নতুন ঠিকানা token দিয়ে নিশ্চিত হওয়ার আগে কিছু বদলাবে না।
  3. পুরোনো ঠিকানায় নোটিশ: মালিক দেখতে পাবেন, আর দাবি জানাতে পারবেন।
  4. বদলের পরে session শেষ: সব ডিভাইস আবার login করবে।

সত্যিকারের run · email বদল · শুধু local SMTP

change, wrong password  401 {"error":"reauth_failed"}
change, right password  202 {"pending":true}
  MAIL to=rahim.new@example.com | বাজারঘর: নতুন email নিশ্চিত করুন
  MAIL to=rahim@example.com | বাজারঘর: email বদলের অনুরোধ
 email now: rahim@example.com          <- এখনো পুরোনো
confirm                 200 {"ok":true,"sessionsEnded":1}
 email now: rahim.new@example.com

Phone OTP: বাস্তব প্রয়োজন, আর তার সীমা

বাংলাদেশে অনেক ক্রেতার কাছে email-এর চেয়ে ফোন নম্বরই প্রধান পরিচয়, তাই OTP প্রায়ই দরকার হয়। নিয়মগুলো আগে দেখুন। NIST ফোন নেটওয়ার্কের OTP-কে restricted বলে, মানে চলতে পারে, কিন্তু ঝুঁকি বিচার করতে হবে আর বিকল্প দিতে হবে। SIM swap এর একটি কারণ। SMS gateway-র দাম, sender ID আর BTRC-র নিয়ম সময়ে বদলাতে পারে, তাই নিজের চুক্তি আর বর্তমান নিয়ম দেখে নেবেন।

সিদ্ধান্তএই ল্যাবেকেন
দৈর্ঘ্য৬ অঙ্ক, crypto.randomIntNIST-এর ন্যূনতম ৬ দশমিক অঙ্ক
মেয়াদ৫ মিনিটNIST-এর ঊর্ধ্বসীমা ১০ মিনিট
ভুল চেষ্টা৫ বারনিজের পছন্দ; নতুন কোড পাঠালেও গণনা শূন্য হবে না
আবার পাঠানো৬০ সেকেন্ড বিরতি, দিনে ৫ বারখরচ আর SMS-বোমা ঠেকানো
জমা রাখাHMAC(pepper, ফোন:কোড), Redis-এ TTL সহকোড জমা থাকে না

শেষ সারিটিতে ফাঁদ আছে। ৬ অঙ্কের মাত্র ১০ লাখ মান। সাধারণ SHA-256 ছাপ ফাঁস হলে মুহূর্তেই সব মান চেষ্টা করে কোড বের করা যায়। তাই গোপন key-সহ HMAC, যে key Redis-এর বাইরে থাকে। আর লিংকের token-এর সাথে পার্থক্যটি মনে রাখুন: token-এ ২৫৬ bit, তাই সাধারণ hash যথেষ্ট; OTP-তে ২০ bit-এরও কম, তাই গোপন key আর চেষ্টার সীমা দুটোই লাগে।

সত্যিকারের run · দুটি run-এর লাইন একসাথে · নকল gateway, কোনো SMS যায়নি · নম্বর কল্পিত · কোড ঢেকে দেওয়া

start          202 {"sent":true,"ttl":300,"failsSoFar":0}
resend at once 429 {"error":"wait_before_resend"}
 gateway got: 01700000000 বাজারঘর কোড: ******. ৫ মিনিট বৈধ। কাউকে বলবেন না।
 redis hash: phone=01700000000 mac=7e5ccd2d2201d684… ttl 300
wrong 1        400 {"error":"invalid_or_expired","triesLeft":4}
right          200 {"ok":true}
 … অন্য কোডে পরপর ভুল …
wrong 5        400 {"error":"invalid_or_expired","triesLeft":0}
wrong 6        429 {"error":"too_many_wrong_codes"}
 after resend  {"sent":true,"ttl":300,"failsSoFar":3}   <- ভুলের গণনা থেকে গেল
 wrong again   {"error":"invalid_or_expired","triesLeft":1}

হিসাব: অনুমান আর খরচ

অনুমানের সম্ভাবনা। একটি কোডে ৫ বার চেষ্টা: ৫ ÷ ১০,০০,০০০ = ০.০০০৫%. দিনে ৫টি কোড ধরলে সর্বোচ্চ ২৫ চেষ্টা, অর্থাৎ ০.০০২৫%. সারা বছর একটি নম্বরে চালিয়ে গেলে ২৫ × ৩৬৫ = ৯,১২৫ চেষ্টা, সম্ভাবনা ১ − (১ − ১০−৬)৯১২৫ ≈ ০.৯১%. সীমা না থাকলে এই সংখ্যা ১০০%-এর দিকে যেত।

খরচ (অনুমান, কোনো gateway-র দাম নয়): ধরে নিই প্রতি SMS ৳০.৫০. প্রতিদিন ২০,০০০ signup, গড়ে ১.৩ বার পাঠানো: ২০,০০০ × ১.৩ × ০.৫০ = ৳১৩,০০০. এখন সীমা নেই এমন endpoint-এ একটি script সেকেন্ডে একটি অনুরোধ করে: ৮৬,৪০০ × ০.৫০ = ৳৪৩,২০০ দিনে, আসল ব্যবসার তিন গুণের বেশি। প্রতি IP-তে ঘণ্টায় ৫টি সীমা দিলে সেই IP থেকে ১২০ × ০.৫০ = ৳৬০. এই ধরনের অপব্যবহারের একটি বড় রূপের নাম SMS pumping. বাংলা SMS Unicode-এ যায়, তাই এক ভাগে ৭০ অক্ষর ধরে, ইংরেজির ১৬০ নয়। লম্বা বাংলা বার্তা দুই ভাগ হয়ে খরচ বাড়তে পারে। ফোনে নিজে কোড পূরণ করতে autocomplete="one-time-code" দিন।

৬ · TOTP আর recovery code

MFA-র তত্ত্ব আছে MFA ও passkey পর্বে, সেখানে দেখবেন কেন passkey সবচেয়ে phishing-প্রতিরোধী। এখানে আমরা সহজে চালু করা যায় এমন TOTP বানাব। Server আর ফোনের app একটি গোপন secret ভাগ করে। দুজনেই বর্তমান সময়কে ৩০ সেকেন্ডের ধাপে ভাগ করে, secret দিয়ে HMAC করে, আর ৬ অঙ্ক বের করে। কোনো network লাগে না। RFC 4226 (২০০৫) HOTP দেয়, আর RFC 6238 (২০১১) তার ওপর সময় বসায়।

const { createHmac } = require('node:crypto');

function hotp(secret, counter, digits, algo) {
  const c = Buffer.alloc(8);
  c.writeBigUInt64BE(BigInt(counter));
  const mac = createHmac(algo, secret).update(c).digest();
  const off = mac[mac.length - 1] & 0x0f;       // dynamic truncation
  const n = mac.readUInt32BE(off) & 0x7fffffff;
  return String(n % 10 ** digits).padStart(digits, '0');
}
const totp = (secret, t, digits = 8, algo = 'sha1', step = 30) =>
  hotp(secret, Math.floor(t / step), digits, algo);

// RFC 6238 Appendix B-এর test vector
const sha1 = Buffer.from('12345678901234567890');
const sha256 = Buffer.from('12345678901234567890123456789012');
const sha512 = Buffer.from('1234567890'.repeat(6) + '1234');
const v = [
  [59, '94287082', '46119246', '90693936'],
  [1111111109, '07081804', '68084774', '25091201'],
  [1234567890, '89005924', '91819424', '93441116'],
  [2000000000, '69279037', '90698825', '38618901'],
  [20000000000, '65353130', '77737706', '47863826'],
];
for (const [t, a, b, c] of v) {
  const got = [totp(sha1, t), totp(sha256, t, 8, 'sha256'), totp(sha512, t, 8, 'sha512')];
  const ok = got[0] === a && got[1] === b && got[2] === c;
  console.log(String(t).padEnd(12), got.join(' '), ok ? 'PASS' : 'FAIL');
}
console.log('six digits at t=59 :', totp(sha1, 59, 6));
console.log('t=60 is a new step :', totp(sha1, 60, 6));

পাঁচটি সারিতেই PASS: মাত্র কয়েক লাইনের হাতে লেখা কোড RFC-র মানের সাথে মিলে গেল, তিনটি hash-এই। শেষ দুই লাইন 287082 আর 359152 দেখায় t=৫৯ আর t=৬০ আলাদা ধাপে পড়ে, তাই কোড সম্পূর্ণ বদলে যায়। শেখার জন্য হাতে লিখলেন। Production-এ যাচাই করা library ব্যবহার করুন। আমরা otplib 13.6.0 ব্যবহার করেছি, যার ৮ অঙ্কের ফল এই একই RFC মানের সাথে মিলেছে।

এবার আসল অ্যাপে। পাঁচটি জিনিস খেয়াল করুন: secret database-এ AES-256-GCM দিয়ে এনক্রিপ্ট করা, কারণ server-কে আসল secret দরকার, তাই hash চলে না; প্রথম সঠিক কোড না দেওয়া পর্যন্ত TOTP চালু হয় না; recovery code-এর শুধু ছাপ জমে; একই কোড দ্বিতীয়বার চলে না; আর সময়ের ছাড় শুধু আগের এক ধাপ, যা RFC 6238-এর সুপারিশ।

সত্যিকারের run · otplib 13.6.0 · secret কেটে দেখানো · সব account কল্পিত

 secret: ZX27O2LK… length 32
 uri: otpauth://totp/Bazarghar:rahim.new%40example.com?secret=<secret>&issuer=Bazarghar
 in db: kbJOkmBJ7i/2Kjwc… enabled false contains plain secret: false
confirm wrong             400 {"error":"wrong_code"}
 confirm right 200 codes 10 sample XXXX-XXXX-XXXX-XXXX
login no code             401 {"error":"totp_required"}
login same code (replay)  401 {"error":"invalid_credentials"}
 waiting 12 s for the next 30 s step
login fresh code          200 {"ok":true}
same fresh code again     401 {"error":"invalid_credentials"}
 recovery first use 200 ok true replacement issued true
recovery second use       401 {"error":"invalid_credentials"}
 recovery_codes: { live: 10, total: 11 }

totp_last_step কলামে শেষ ব্যবহৃত ধাপ থাকে, আর নতুন login-এ UPDATE … WHERE totp_last_step < $2 শর্ত থাকে। NIST আর RFC 6238 দুটোই বলে একই OTP একবারের বেশি নেওয়া যাবে না। ফোন হারালে recovery code কাজে আসে। আমাদের প্রতিটি ৮০ bit, ১৬ অক্ষর, চার-চার ভাগে। NIST-এর শর্ত: অন্তত ৬৪ bit, hash করে জমা, ব্যবহারের পরে বাতিল, আর একটি নতুন code দেওয়া। Run-এর live: 10, total: 11 সেটিই দেখাচ্ছে। Code-গুলো একবারই দেখান, আর ব্যবহার হলে মেইলে জানান।

৭ · Login rate limit: IP, account আর lockout-এর ফাঁদ

পর্ব ২৬-এ (Middleware) rate limit-এর ধারণা, আর পর্ব ৪৪-এ (Cache আর Redis) Redis counter দেখেছেন। Login-এ একটি counter যথেষ্ট নয়, কারণ দুই রকম অপব্যবহার দুই দিক থেকে আসে:

  • এক IP, বহু account: একটি সাধারণ password অনেক account-এ চেষ্টা, যাকে বলে password spraying. প্রতি account-এ চেষ্টা কম, তাই account-এর গণনায় ধরা পড়ে না।
  • বহু IP, এক account: প্রতি IP-র গণনা কম, তাই IP limit ধরে না। এখানে account-এর গণনা লাগে।

Counter-টি sliding window-এ চলে। সাধারণ fixed window মিনিট বদলের মুহূর্তে শূন্য হয়, তাই সীমানার দুই পাশে দ্বিগুণ চেষ্টা ঢুকে যায়। নিচের মডেল সেটি দেখায়। ঘড়ি নকল, আর Redis-এর বদলে array.

// নকল ঘড়ি: সময় সেকেন্ডে। Redis-এর sorted set-এর বদলে একটি array
function slidingWindow(limit, windowSec) {
  const hits = new Map();
  return (key, now) => {
    const list = (hits.get(key) ?? []).filter((t) => t > now - windowSec);
    hits.set(key, list);
    if (list.length >= limit) return { ok: false, retry: list[0] + windowSec - now };
    list.push(now);
    return { ok: true, used: list.length };
  };
}
function fixedWindow(limit, windowSec) {
  const counts = new Map();
  return (key, now) => {
    const k = `${key}:${Math.floor(now / windowSec)}`;
    const n = (counts.get(k) ?? 0) + 1;
    counts.set(k, n);
    return { ok: n <= limit };
  };
}
const times = [58, 58, 59, 59, 59, 60, 60, 60, 61, 61];   // মিনিটের সীমানায় ১০টি চেষ্টা
const sw = slidingWindow(5, 60), fw = fixedWindow(5, 60);
const count = (fn) => times.filter((t) => fn('acct:42', t).ok).length;
console.log('attempts in 4 seconds :', times.length);
console.log('fixed window allowed  :', count(fw));
console.log('sliding window allowed:', count(sw));

const s2 = slidingWindow(3, 60);
for (const t of [0, 10, 20, 30, 59, 61, 70, 71, 82]) {
  const r = s2('ip:203.0.113.9', t);
  console.log(`t=${String(t).padStart(2)}`, r.ok ? `allowed (${r.used}/3)` : `blocked, wait ${r.retry}s`);
}

fixed window allowed : 10: সীমা ৫, কিন্তু ৪ সেকেন্ডে ১০টি ঢুকল, কারণ t=৫৯ আর t=৬০ দুটি আলাদা মিনিট। sliding window allowed: 5: সীমা সত্যিই রইল। নিচের অংশে t=59 blocked, wait 1s: t=০-এর চেষ্টাটি ঠিক t=৬০-এ জানালা থেকে বেরোবে। তাই t=61-এ আবার allowed. প্রতিটি পুরোনো চেষ্টা বের হলে একটি করে জায়গা খোলে, যেমন t=71 blocked, wait 9s আর t=82 allowed.

আসল অ্যাপে একই যুক্তি Redis-এর sorted set-এ, সময়কে score ধরে। পুরো ধাপটি একটি Lua script, তাই দুটি server-এর মাঝে race হয় না, আর সময় আসে Redis-এর TIME থেকে, যাতে সব server একই ঘড়ি দেখে।

local key, window, limit = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2])
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local n = redis.call('ZCARD', key)
if ARGV[3] == 'hit' then
  redis.call('ZADD', key, now, now .. ':' .. ARGV[4])
  redis.call('PEXPIRE', key, window)
  n = n + 1
end
return n

একটি সতর্কতা: req.ip তখনই ঠিক, যখন trust proxy আপনার নিজের proxy পর্যন্তই সেট করা। নইলে যে কেউ X-Forwarded-For বদলে প্রতি অনুরোধে নতুন IP সাজতে পারেন। বাংলাদেশে মোবাইল নেটওয়ার্কে বহু গ্রাহক একই সার্বজনীন IP-তে থাকতে পারেন, তাই খুব কড়া IP limit সৎ ক্রেতাকেও আটকাতে পারে।

Lockout নাকি backoff

পুরোনো সমাধান account lockout: ৫ বার ভুল হলে account ১৫ মিনিট বন্ধ। সমস্যা হলো, চাবি থাকে হামলাকারীর হাতে। যে কেউ আপনার email জানলে ইচ্ছামতো আপনাকে বাইরে রাখতে পারেন। এটি একটি denial of service. তাই account-এ নয়, account আর IP-র জোড়ায় অপেক্ষা বাড়ানো ভালো: ভুলকারী নিজেই ধীর হন, মালিক নন। অপেক্ষা ৩০ সেকেন্ড থেকে দ্বিগুণ হতে হতে সর্বোচ্চ ১ ঘণ্টা, যা NIST-এর দেওয়া উদাহরণের সীমার মধ্যেই।

শুরুদুজন মানুষ
ভুলকারী  IP 203.0.113.9   (password জানেন না)
মালিক    IP 198.51.100.7  (ঠিক password জানেন)
account  rahim@example.com
চেষ্টা ১–৫দুই নকশাতেই
401 401 401 401 401
চেষ্টা ৬–৮দুই নকশার তফাত
lockout : 429(wait 900s) 429(wait 900s) 429(wait 900s)
backoff : 429(wait 30s)  429(wait 30s)  429(wait 30s)
মালিক, lockoutঠিক password
429 {"error":"too_many_attempts","why":"account_lock"}
Retry-After 900
মালিক, backoffঠিক password
200 {"ok":true}
৫৫টি IPপ্রতিটি থেকে একবার
401:invalid_credentials  50
403:step_up_required      5  প্রথমটি ৫১তম চেষ্টায়
মালিক: 403 {"error":"step_up_required"}
  1. দুজন। একজন 203.0.113.9 থেকে রহিমের account-এ অনুমান করছেন। রহিম নিজে অন্য IP থেকে ঠিক password নিয়ে আসবেন। IP-গুলো উদাহরণের জন্য সংরক্ষিত ঠিকানা।
  2. প্রথম ৫টি। সব 401. এখনো দুই নকশায় কোনো পার্থক্য নেই।
  3. ৬ নম্বর থেকে। Lockout-এ পুরো account ৯০০ সেকেন্ড বন্ধ। Backoff-এ শুধু এই IP আর account-এর জোড়া ৩০ সেকেন্ড আটকে গেল। ভুলকারীর দেখা উত্তর প্রায় একই।
  4. Lockout-এ মালিক। ঠিক password দিয়েও 429, ১৫ মিনিট অপেক্ষা। ভুলকারী ৫টি অনুরোধেই মালিককে বাইরে রাখলেন। প্রতি ১৫ মিনিটে আবার করলে মালিক কখনোই ঢুকতে পারবেন না। why field শুধু এই ল্যাবের জন্য, আসল উত্তরে এটি থাকবে না।
  5. Backoff-এ মালিক। তাঁর জোড়ায় কোনো ভুল নেই, তাই 200. সফল login-এ ওই জোড়ার গণনাও মুছে যায়, NIST-ও পুরোনো ব্যর্থতা বাদ দিতে বলে।
  6. বহু IP. ৫৫টি IP-র প্রতিটি থেকে একবার। জোড়ার গণনা কাজে আসে না, তাই account-এর গণনা কাজ করে: ঘণ্টায় ৫০-এর পরে বন্ধ নয়, step-up. মালিকও এই উত্তর পান, তবে মেইলের লিংকের মতো বাড়তি ধাপ পেরিয়ে ঢুকতে পারবেন। এই ল্যাব উত্তর পর্যন্তই বানিয়েছে, ওই লিংকটি নয়।

সত্যিকারের run · Redis 8.10.3 · limiter চালু · সব email ও IP কল্পিত

--- one IP, many accounts (35)
 status counts { '401': 30, '429': 5 } first 429 at try 31
--- unknown account behaves the same
  rahim@example.com    401 401 401 401 401 429 429
  ghost@example.com    401 401 401 401 401 429 429
--- redis keys after 3 failures
  rl:acct:c9cf28d93acf3625 zset 3 pttl 3599997
  rl:ip:203.0.113.60 zset 3 pttl 599996
  rl:pair:c9cf28d93acf3625:203.0.113.60 string 3 pttl 3599995

মাঝের দুটি লাইন খুব জরুরি। অজানা ghost-ও ঠিক একই সময়ে 429 পায়। Limit যদি শুধু আসল account-এ বসত, তবে limiter নিজেই enumeration-এর পথ হতো। Key-তে email-এর বদলে keyed hash, তাই Redis-এ ঠিকানা জমে না। NIST-এর ঊর্ধ্বসীমা হলো পরপর ১০০ ব্যর্থতা, এর চেয়ে কম রাখতে বাধা নেই। Login-এর এই limit Redis বন্ধ হলে কী করবে, তা আগে ঠিক করুন। পর্ব ৪৪-এর fail open cache-এ চলে, কিন্তু login-এ পুরো খোলা ছেড়ে দিলে ঝুঁকি। প্রতি process-এর একটি ছোট in-memory সীমা মাঝামাঝি পথ, যা এই ল্যাবে চালানো হয়নি।

CAPTCHA: CAPTCHA একা নিরাপত্তা নয়, শুধু বাড়তি ঘর্ষণ। সবার ওপর না চাপিয়ে সন্দেহ হলে দেখানোই ভালো, যেমন step-up-এর সময়। দৃষ্টি বা শ্রবণ-প্রতিবন্ধী ব্যবহারকারীর সমস্যা, ধীর network আর তৃতীয় পক্ষের script-এর privacy, এই খরচগুলোও হিসাবে রাখুন। NIST বট শনাক্তকরণকে একটি ঐচ্ছিক কৌশল হিসেবে উল্লেখ করে।

৮ · Account মোছা, ডেটা রাখা আর audit trail

“আমার account মুছে দিন” শুনতে একটি DELETE মনে হয়। কিন্তু বাজারঘরে ওই ক্রেতার order আছে, হিসাবের দরকারে যা রাখতে হতে পারে। তাই মোছা দুই ধাপে। প্রথম ধাপে account বন্ধ, session শেষ, আর ফেরার একটি সময় (এই ল্যাবে ৩০ দিন, যা আমাদের নিজের ধরে নেওয়া মান)। দ্বিতীয় ধাপে একটি নির্ধারিত কাজ ব্যক্তিগত তথ্য মুছে দেয়, আর order একটি নামহীন সারির সাথে থাকে। পর্ব ৪১-এর (সম্পর্ক) soft delete এখানে শুধু প্রথম ধাপ। সেটিকেই মোছা বলে দাবি করা ঠিক নয়।

সত্যিকারের run · সময় বদলাতে deleted_at ৩১ দিন পিছিয়ে দেওয়া হয়েছে · সারি ছাঁটা

delete, wrong password  401 {"error":"reauth_failed"}
delete, right password  202 {"scheduled":true}
GET me after delete     401 {"error":"not_signed_in"}
login after delete      401 {"error":"invalid_credentials"}
purge, 30 day grace     200 {"purged":0}
purge, 31 days later    200 {"purged":1}

 id | email             | password_hash | phone | totp_enabled
 1  | deleted+1@invalid | null          | null  | false
 orders: id 1 | user_id 1 | total_paisa 125000

বাংলাদেশের আইন (আইনি পরামর্শ নয়): ABCL TECH-এর Privacy engineering পর্বের ৩১ অক্টোবর ২০২৬-এর যাচাই অনুযায়ী ব্যক্তিগত উপাত্ত সুরক্ষা আইন, ২০২৬ (৬৩ নং) গেজেটে আছে, এর কিছু ধারা পরে কার্যকর হবে, আর ধারা ১৪-তে মোছাকে backup আর test কপিতে ছড়ানোর কথা আছে। আইন বদলায়, আর এই পর্ব প্রতিটি শর্ত ব্যাখ্যা করে না। হিসাব বা করের নথি কত দিন রাখতে হবে, তা অন্য আইনেও ঠিক হতে পারে। আসল অ্যাপে মেয়াদ ঠিক করার আগে বর্তমান লেখা পড়ুন, আর আইনজীবী ও হিসাবরক্ষকের সাথে কথা বলুন। Backup-এর কথাও ভুলবেন না: সেখান থেকে এক সারি মোছা কঠিন, তাই backup-এর মেয়াদ বেঁধে রাখুন।

Audit trail আর ব্যবহারকারীকে জানানো

“কে, কখন, কোথা থেকে, কী করলেন”, এই হিসাবই audit trail. চায়ের দোকানের বাকির খাতার মতো: পাতা ছেঁড়া যায় না, শুধু নতুন লাইন বসে। Log-এর গঠন পর্ব ৩১-এ (Log আর config) দেখেছেন, কিন্তু audit আলাদা: ব্যবসা আর নিরাপত্তার প্রমাণ, তাই আলাদা টেবিল, শুধু যোগ করার অনুমতি। এতে token, password বা কোড কখনো নয়। আমরা user id-র বদলে keyed ছদ্মনাম রেখেছি, যাতে মুছে ফেলার পরেও ঘটনার হিসাব থাকে, কিন্তু সরাসরি মানুষ চেনা না যায়। ছদ্মনামও সাধারণত ব্যক্তিগত তথ্যের মধ্যেই ধরা হয়, তাই এরও মেয়াদ লাগে।

সত্যিকারের run · একই account-এর পুরো জীবন, ঘটনার সংখ্যাসহ · নোটিশের শেষ দুটি লাইন আলাদা run থেকে

signup                   1
email_verified           1
login_ok                 5
reset_requested          2
password_reset           1
login_failed             1
email_change_requested   1
email_changed            1
phone_changed            1
2fa_enabled              1
recovery_code_used       2
delete_requested         1
purged                   1
secrets in audit_log: 0
--- sign-in notice
first time on Laptop/1: [ 'বাজারঘর: নতুন জায়গা থেকে login' ]
second time on Laptop/1: []

নোটিশ পাঠান password, email, ফোন বা MFA বদলে, recovery code ব্যবহারে, আর নতুন জায়গা থেকে login-এ। NIST recovery-র পরে নোটিশ বাধ্যতামূলক করেছে, আর নোটিশে দাবি জানানোর পরিষ্কার পথ দিতে বলে। নতুন ডিভাইস চিনতে এই ল্যাব browser আর IP-র একটি hash রেখেছে, যা সহজ কিন্তু মোবাইল IP বদলালে বাড়তি মেইল দেবে। মেইলে এমন লিংক দেবেন না যা ক্লিক করলেই কিছু ঘটে।

আরও গভীরে: OWASP-এর Forgot Password Cheat Sheet, Authentication Cheat Sheet আর Credential Stuffing Prevention Cheat Sheet এই পর্বের নকশার সাথে মিলিয়ে পড়ুন। NIST-এর ভাষায় reset আসলে “account recovery”, আর Sec. 4.2 recovery code, নোটিশ আর AAL অনুযায়ী শর্ত ঠিক করে। আরও শক্ত পথ হলো password একেবারে বাদ দেওয়া, যেখানে passkey (WebAuthn) ডোমেইনের সাথে বাঁধা থাকে। সময়ের ফাঁসের কোনো নিখুঁত সমাধান নেই, তাই লক্ষ্য হলো ফাঁককে noise-এর নিচে রাখা।

পরীক্ষা ও ইন্টারভিউতে:

  • Reset token database-এ hash করা কেন? টেবিল ফাঁস হলেও ছাপ দিয়ে reset করা যায় না।
  • Password reset-এর পরে কী করবেন? সব session আর বাকি token বাতিল, আর মালিককে নোটিশ।
  • User enumeration কীভাবে ঠেকাবেন? একই লেখা, একই status, একই সময়, আর খবর মেইলে।
  • Lockout-এর সমস্যা কী? অন্য কেউ ইচ্ছা করে মালিককে আটকাতে পারেন, যা denial of service.
  • Sliding window কেন? Fixed window-র সীমানায় দ্বিগুণ চেষ্টা ঢুকতে পারে।

৯ · AI-এর যুগে account flow

“Forgot password বানিয়ে দাও” বললে AI কয়েক সেকেন্ডে এমন কোড দেয় যা চলে। এখানেই বিপদ: এই পথের ভুলগুলোতে কোনো error আসে না, আর সাধারণ ব্যবহারে সব ঠিক দেখায়।

AI প্রায়ই যা লেখেসমস্যাকীভাবে ধরবেন
404 user not foundUser enumerationদুই email-এ উত্তর মিলিয়ে দেখা
await sendMail() উত্তরের আগেসময়ের ফাঁস২০০ জোড়া অনুরোধ মাপা
Token সরাসরি টেবিলেফাঁসেই চালু tokenটেবিলের সারি পড়া
Math.random() দিয়ে token বা OTPঅনুমেয় মানDiff-এ খোঁজা
JWT reset token, বাতিলের উপায় ছাড়াএকবার ব্যবহার নিশ্চিত নয়একই লিংক দুবার চালানো
req.headers.host দিয়ে লিংকHost header injectionভুয়া header-এ মেইল দেখা
Reset-এর পরে session অক্ষতদখলকারী থেকে যানপুরোনো cookie-তে অনুরোধ
বানানো package বা পুরোনো otplib APIv13-এ authenticator নেই, verify object ফেরায়নিজের version-এর docs আর আসল run

নিচের প্রম্পটে নিয়মগুলো আগেই বলে দেওয়া আছে, আর শেষে test চাওয়া হয়েছে। আসল secret কখনো দেবেন না।

আমি Express 5, PostgreSQL আর Redis দিয়ে password reset বানাচ্ছি। শর্তগুলো মেনে কোড দাও: ১. জানা ও অজানা email-এ একই status, একই লেখা, আর সময়েও পার্থক্য না থাকা। লুকআপ উত্তরের পরে হবে। ২. token randomBytes(32), database-এ শুধু SHA-256, ৩০ মিনিট মেয়াদ। ৩. token যাচাই আর ব্যবহার একই UPDATE ... RETURNING-এ। ৪. reset-এর পরে সব session ও বাকি token বাতিল, আর মালিককে নোটিশ মেইল। ৫. লিংক APP_URL config থেকে, Host header থেকে নয়। ৬. কোনো package নাম বানিও না; npm-এ যা আছে তাই ব্যবহার করো, আর version বলো। শেষে Supertest-এ test লেখো: একই token দুবার, মেয়াদ শেষ token, ১২টি সমান্তরাল অনুরোধ। কোন শর্ত তুমি নিশ্চিত করতে পারোনি, তা আলাদা করে বলো।

AI-এর লেখা account কোড হাতে পেলে

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১“বড় হাতের অক্ষর, সংখ্যা, চিহ্ন” নিয়ম দুই দশক চলল, অথচ এখন NIST তা নিষেধ করছে। মাঝখানে কী বদলাল?

বদলেছে প্রমাণ। ২০০৩ সালের পরামর্শ লেখা হয়েছিল তখনকার তাত্ত্বিক হিসাবের ওপর, ধরে নেওয়া হয়েছিল মানুষ অক্ষর এলোমেলোভাবে বেছে নেন। তখন বাস্তবের বড় password তালিকা গবেষকদের হাতে ছিল না। পরে একের পর এক ফাঁসে কোটি কোটি আসল password সামনে আসে, আর গবেষকরা দেখেন মানুষ নিয়ম মানেন অনুমেয় ছাঁচে: শুরুতে বড় হাতের অক্ষর, শেষে একটি সংখ্যা আর একটি !. ৯০ দিনের বদলে Summer2024! হয়ে যায় Autumn2024!. হামলাকারীর টুলগুলো এই ছাঁচ আগে থেকেই জানে।

নিয়মের খরচও ছিল। মানুষ লিখে রাখতেন, এক password সব জায়গায় চালাতেন, সাপোর্টে reset বাড়ত। অর্থাৎ নিরাপত্তা তেমন বাড়েনি, কিন্তু ব্যবহারের কষ্ট বেড়েছে। ২০১৭ সালে NIST এই পরামর্শ বাদ দেয়, আর লেখক Bill Burr নিজেই অনুশোচনার কথা বলেন। ২০২৫ সালের SP 800-63B-4 আরও এগিয়ে গঠনের নিয়ম আর সময় ধরে বদল নিষেধ করেছে, আর একক factor-এ ১৫ অক্ষর বাধ্যতামূলক করেছে।

এখন প্রশ্ন, আপনার প্রতিষ্ঠানের নিরাপত্তা-নীতি বা গ্রাহকের চুক্তি যদি পুরোনো নিয়ম চায়? তখন NIST-এর লেখা দেখিয়ে আলোচনা করুন, তবে চুক্তি ভাঙবেন না। আর শুধু নিয়ম বদলালেই শেষ নয়: ফাঁস-তালিকা মেলানো, ধীর hash, rate limit আর MFA মিলে এই নতুন নীতি কাজ করে। একটি বাদ দিলে অন্যটি দুর্বল হয়।

মূল উপলব্ধি: নিরাপত্তার নিয়মও একটি অনুমান, আর আসল মানুষের আচরণের প্রমাণ এলে তা বদলাতে হয়। যে নিয়ম মানুষকে খারাপ অভ্যাসে ঠেলে দেয়, তা কাগজে শক্ত হলেও বাস্তবে দুর্বল।

প্র ০২Token তো এলোমেলো আর ৩০ মিনিটেই মরে যায়। তাহলে database-এ hash করার ঝামেলা কেন? আর JWT দিয়ে reset করলে তো টেবিলই লাগত না।

Hash না করলে টেবিল পড়ার ক্ষমতা মানেই account দখলের ক্ষমতা। ধরুন একটি SQL injection, একটি ভুল করে ছেড়ে দেওয়া backup, অথবা শুধু পড়ার অধিকার পাওয়া একজন কর্মী। তিনি তখনই চালু reset token পড়ে যেকোনো account নিতে পারেন, password hash ভাঙার কষ্ট ছাড়াই। ৩০ মিনিট মেয়াদ এখানে কম সান্ত্বনা, কারণ হাতে সুযোগ থাকলে তিনি নিজেই reset চেয়ে টেবিল থেকে token পড়ে নিতে পারেন। Hash থাকলে এই আক্রমণ বন্ধ, কারণ ছাপ থেকে token ফেরানো যায় না। খরচ প্রায় শূন্য: একটি SHA-256.

JWT-র আকর্ষণ হলো কোনো সারি লাগে না। কিন্তু তখন “একবার ব্যবহার” কঠিন হয়ে যায়। Token নিজেই বৈধ, তাই ব্যবহার হয়েছে কি না জানতে কোথাও লিখে রাখতেই হবে, অর্থাৎ টেবিল ফিরে আসে। একটি পরিচিত কৌশল আছে: password hash-এর একটি অংশ দিয়ে key বানানো, যাতে password বদলালে token নিজে থেকে মরে যায়। এটি চলে, তবে নতুন reset চাইলে পুরোনোটি বাতিল হয় না, আর signing key ফাঁস হলে সব account-এর token বানানো যায়। Audit-এর হিসাবও থাকে না। টেবিলের নকশায় key ফাঁসের এই ভয় নেই।

এখানে প্রকৌশলীরা একমত নন, আর একটি stateless নকশা সঠিকভাবে করা সম্ভব। তবে শিক্ষানবিশের জন্য একটি opaque token আর একটি সারি প্রায় সব প্রশ্নের সোজা উত্তর দেয়: কে চেয়েছিল, কখন, ব্যবহার হলো কি না, বাতিল করা যায় কি না। পর্ব ৪৬-এর (JWT login) আলোচনাতেও বাতিল করার এই একই সমস্যা দেখেছেন।

মূল উপলব্ধি: নিজেকে জিজ্ঞেস করুন, “এই টেবিলটি একদিন ফাঁস হবে ধরে নিলে কী ঘটবে?” যে মান দিয়ে সরাসরি কিছু করা যায়, তা কখনো সাদা অবস্থায় রাখবেন না।

প্র ০৩User enumeration ঠেকাতে গিয়ে ব্যবহারকারীর অভিজ্ঞতা খারাপ হয়। “এই email আগেই আছে” না বললে মানুষ বিভ্রান্ত হন না?

হন, আর এটি একটি সত্যিকারের বিনিময়। সাধারণ দোকানে একজন ক্রেতা ভুলে আবার signup করলে পাতায় সরাসরি বলে দিলে তাঁর দুই মিনিট বাঁচে। আমাদের নকশায় তাঁকে মেইল খুলতে হয়। ধীর network-এ বা email কম ব্যবহার করেন এমন মানুষের কাছে এই ধাপটি বড়। তাহলে প্রশ্ন হলো, ফাঁসের দাম কত?

দাম নির্ভর করে সাইটের ধরনের ওপর। স্বাস্থ্য, ঋণ, ডেটিং বা রাজনৈতিক সংগঠনের সাইটে “এই মানুষ এখানে আছেন” তথ্যটিই স্পর্শকাতর। সেখানে লুকানো আবশ্যক। বাজারঘরের মতো সাধারণ দোকানে তথ্যটি নিজে তত গোপন নয়, কিন্তু এটি দিয়ে তালিকা বানিয়ে credential stuffing আর phishing চালানো যায়। অনেক বড় সাইট বিভিন্ন সময়ে বিভিন্ন সিদ্ধান্ত নিয়েছে, আর নিরাপত্তা গবেষকরাও এক কথা বলেন না।

একটি কার্যকর মাঝামাঝি পথ আছে। Reset আর login-এ সবসময় লুকান, কারণ সেখানে খরচ প্রায় শূন্য। Signup-এ মেইলের নকশা ব্যবহার করুন, অথবা সুবিধা বেশি দরকার হলে সরাসরি জানান কিন্তু কড়া rate limit আর bot শনাক্তকরণ বসান। ফোন নম্বরে account হলে OTP পাঠিয়ে পরে জানানো যায়। যা-ই বেছে নিন, লিখে রাখুন কেন নিয়েছেন। আর সব পথ একসাথে দেখুন: login-এ লুকিয়ে signup-এ বলে দিলে login-এর লুকানো অর্থহীন হয়ে যায়। হামলাকারী দুর্বলতম পথটিই ব্যবহার করবেন। তাই একটি পথের উত্তর বদলানোর আগে বাকি সব পথের উত্তরও পাশাপাশি রেখে মিলিয়ে দেখুন।

মূল উপলব্ধি: নিরাপত্তার সিদ্ধান্ত হ্যাঁ বা না নয়, দাম আর লাভের হিসাব। কিন্তু হিসাবটি পুরো সিস্টেম ধরে করতে হয়, একটি পাতা ধরে নয়।

প্র ০৪NIST যদি ফোনের OTP-কে restricted বলে, তবু বাংলাদেশের সাইটগুলোতে কেন এটি চলে? আপনার সাইটে কী করবেন?

ইতিহাসটি শিক্ষণীয়। ২০১৬ সালে NIST-এর খসড়ায় SMS-কে deprecated বলা হয়, আর শিল্পে আলোড়ন ওঠে। ২০১৭ সালের চূড়ান্ত লেখায় শব্দটি নরম হয়ে “restricted” হয়। ২০২৫ সালের সংস্করণেও PSTN পথটি restricted, শর্ত হলো ঝুঁকি বিচার, একটি বিকল্প authenticator, আর SIM বদল বা নম্বর সরানোর মতো সংকেত দেখা। অর্থাৎ নিষেধ নয়, দুর্বল বলে চিহ্নিত।

দুর্বলতার কারণ তিনটি। SMS ফোন নেটওয়ার্ক দিয়ে যায়, যা এই কাজের জন্য বানানো হয়নি। SIM swap বা সামাজিক প্রকৌশলে নম্বর অন্যের হাতে যেতে পারে। আর phishing পাতায় মানুষ নিজেই কোড লিখে দিতে পারেন, যা TOTP-তেও সম্ভব। তবে শুধু passkey আর নিজের ডোমেইনের সাথে বাঁধা পদ্ধতি এই শেষটি ঠেকায়।

তাহলে কেন চলে? কারণ এটি প্রায় সবার কাছে পৌঁছায়। একটি সাধারণ ফোনেও SMS আসে, কোনো app লাগে না, আর বাংলাদেশে অনেক মানুষের জন্য নম্বরই প্রধান পরিচয়। সবার হাতে smartphone আর অভ্যাস থাকবে, এই ধারণা বাস্তবসম্মত নয়। কিছুই না থাকার চেয়ে OTP অনেক ভালো। আবার খরচ, পৌঁছাতে দেরি আর অপব্যবহারের ঝুঁকিও এর সঙ্গে আসে।

বাজারঘরের জন্য একটি যুক্তিসংগত পরিকল্পনা: শুরুতে OTP ঠিকমতো (হিসাব, সীমা, HMAC), সাথে TOTP আর passkey ঐচ্ছিক। ঝুঁকিপূর্ণ কাজে, যেমন ফোন বদল বা টাকা তোলায়, মেইলে নোটিশ আর বাড়তি ধাপ। বার্তায় স্পষ্ট লিখুন কোডটি কাউকে বলা যাবে না। Bank বা payment-এর জন্য নিজের নিয়ন্ত্রকের নিয়ম আলাদা হতে পারে, সেগুলো দেখে নিন।

মূল উপলব্ধি: মানদণ্ড একটি দিক দেখায়, কিন্তু আপনার ব্যবহারকারী কে, তা-ও নকশার অংশ। দুর্বল পদ্ধতি বেছে নিতে হলে দুর্বলতা জেনে, শক্ত বিকল্পসহ বেছে নিন।

প্র ০৫ব্যবহারকারী “সব মুছে দিন” বললেন, আবার নিরাপত্তা বলে audit log অপরিবর্তনীয় রাখতে হবে। দুটি কীভাবে মেলাবেন?

দুটি দাবিই ন্যায্য, তাই একটি জিতবে না, নকশা দিয়ে মেলাতে হবে। প্রথম ধাপ হলো ডেটাকে উদ্দেশ্য ধরে ভাগ করা। Profile, ঠিকানা, ফোন: সেবা শেষ হলে এগুলোর উদ্দেশ্যও শেষ, তাই মুছতে হবে। Order আর টাকার হিসাব: হিসাব বা কর-সংক্রান্ত প্রয়োজনে রাখতে হতে পারে, তবে নাম আর ফোন ছাড়া রাখা সম্ভব কি না দেখুন। Audit: নিরাপত্তা আর বিবাদের প্রমাণ, যা একটি নির্দিষ্ট সময় রাখা যায়।

দ্বিতীয় ধাপ হলো শুরু থেকেই audit-এ ব্যক্তিগত তথ্য কম রাখা। আমাদের ল্যাবে user_ref একটি keyed ছদ্মনাম, email নয়। আরও শক্ত নকশায় প্রতি ব্যবহারকারীর আলাদা key থাকে, আর সেটি মুছে দিলে সংযোগ কেটে যায়, যাকে বলে crypto-shredding. এতে অপরিবর্তনীয় log না ছুঁয়েই মানুষটির সাথে তার সম্পর্ক ভাঙা যায়। তবে এটি নিখুঁত নয়: IP, সময় আর ঘটনার ধরন মিলিয়ে কখনো মানুষ চেনা যেতে পারে, আর ছদ্মনামকে আইনে সাধারণত ব্যক্তিগত তথ্যই ধরা হয়। ৬৩ নং আইনের ধারা ১৪-তে একদিকে মোছাকে backup-এ ছড়ানোর কথা, অন্যদিকে পরিবর্তনের হিসাব অপরিবর্তনীয় লেজারে রাখার কথাও আছে বলে আমাদের Privacy engineering পর্ব জানায়। এই জোড়া ঠিক এই প্রশ্নটিই তোলে।

তৃতীয় ধাপ হলো মেয়াদ। প্রতিটি শ্রেণির জন্য একটি লেখা নিয়ম, আর একটি কাজ যা সত্যিই মোছে। ভুলে যাওয়া সহজ জায়গাগুলো: backup, search index, analytics, মেইল-সেবার তালিকা, cache, আর log. ব্যবহারকারীকে সৎভাবে বলুন কী মোছা হলো আর কী কেন রইল। একটি ডেটা তালিকা ছাড়া এই কাজ সম্ভব নয়।

মূল উপলব্ধি: মোছা একটি বোতাম নয়, ডেটার পুরো জীবনচক্রের নকশা। শুরুতে কম ডেটা নিলে শেষে কম সমস্যা থাকে। এ ধরনের সিদ্ধান্তে আইনি পরামর্শ নিন।

অনুশীলন

প্রথমটি হিসাব, দ্বিতীয়টি নকশা-সমালোচনা, তৃতীয়টি ফল অনুমান, চতুর্থটি হাতে-কলমে।

  1. Numerical: OTP ৪ অঙ্কের, প্রতি কোডে ৩ চেষ্টা, দিনে ৪টি কোড। (ক) একটি কোডে অনুমানের সম্ভাবনা কত? (খ) ৩০ দিনে মোট চেষ্টা কত, আর আনুমানিক সম্ভাবনা কত? (গ) ৬ অঙ্কে করলে (খ) কত হয়? (ঘ) প্রতিটি Argon2id hash-এ ২০ ms লাগলে ১টি CPU core সেকেন্ডে কতটি login সামলাবে?

    (ক) ৩ ÷ ১০,০০০ = ০.০৩%. (খ) ৩ × ৪ × ৩০ = ৩৬০ চেষ্টা। সম্ভাবনা ১ − (১ − ১০−৪)৩৬০ ≈ ৩.৫৪%. (গ) ৩৬০ ÷ ১০,০০,০০০ = ০.০৩৬%. প্রায় ১০০ গুণ কম। (ঘ) ১০০০ ÷ ২০ = ৫০টি। আট core-এ আদর্শভাবে ৪০০টি। তাই login endpoint-এ limit শুধু অনুমান নয়, CPU-কেও বাঁচায়: সেকেন্ডে কয়েকশো নকল অনুরোধই server-কে ব্যস্ত রাখতে পারে।

  2. নকশা-সমালোচনা: একজন সহকর্মী লিখেছেন: const u = await User.findOne({email}); if (!u) return res.status(404).send('No user'); const t = Date.now().toString(36); u.resetToken = t; await u.save(); await mail(email, `${req.headers.origin}/reset?t=${t}`); res.send('sent');. কতগুলো সমস্যা পান? প্রতিটির সমাধান বলুন।
    • 404: enumeration. একই উত্তর দিন।
    • Date.now(): অনুমেয়। randomBytes(32) নিন।
    • Token সাদা অবস্থায় জমা: SHA-256 ছাপ রাখুন।
    • মেয়াদ নেই: expires_at যোগ করুন।
    • await mail: সময়ের ফাঁস। Queue-তে পাঠান।
    • origin header থেকে লিংক: APP_URL config নিন।
    • সীমা নেই: মেইল-বোমা। প্রতি email-এ ও IP-তে limit বসান।
    • একবার ব্যবহারের ব্যবস্থা নেই: used_at আর একক UPDATE.
  3. ফল অনুমান: Backoff নকশায় একটি IP আর account-এর জোড়ায় ৫টি ভুলের পর ৩০ সেকেন্ড, ৬টির পর ৬০, ৭টির পর ১২০ সেকেন্ড অপেক্ষা (30 × 2^(n−5), সর্বোচ্চ ৩,৬০০)। একই IP থেকে ক্রম: t=০ থেকে t=৪-এ ৫টি ভুল; t=১০-এ ঠিক password; t=৪০-এ ভুল; t=৫০-এ ঠিক password. প্রতিটির ফল কী? t=৫০-এর পরের গণনা কত?

    t=০–৪: পাঁচটি 401, n=৫, শেষ ভুল t=৪-এ। t=১০: অপেক্ষা t=৩৪ পর্যন্ত, তাই ঠিক password হলেও 429; password দেখাই হয় না। t=৪০: অপেক্ষা শেষ, ভুল, 401, n=৬, এবার অপেক্ষা ৬০ সেকেন্ড, t=১০০ পর্যন্ত। t=৫০: আবার 429. গণনা এখনো ৬. মালিক এই IP-তেই আটকে আছেন, অন্য IP-তে আটকাতেন না। শিক্ষা: Retry-After দেখিয়ে অপেক্ষা করতে বলুন, আর ভুলের পর ভুল করলে সময় বাড়ে।

  4. হাতে-কলমে: নিজের কম্পিউটারে smtp-server আর Nodemailer দিয়ে অংশ ৩-এর মতো মেইল ধরুন। তারপর একটি reset route বানিয়ে জানা ও অজানা email-এ ২০০ জোড়া অনুরোধের p50 মাপুন। আগে await sendMail রেখে, পরে সরিয়ে। আপনার ফাঁক কত?

    আমাদের একটি run-এ await সহ ফাঁক ১০৬.১৮ ms, আর সব কাজ উত্তরের পরে নিলে ০.০১ ms. আপনার সংখ্যা আলাদা হবে, কারণ CPU, SMTP আর load আলাদা। অনুরোধ পালা করে পাঠান, একই ধরনের সব পরপর নয়, নইলে ডেটাবেস cache একটি পক্ষকে সুবিধা দেবে। কোনো আসল ঠিকানায় মেইল পাঠাবেন না, host অবশ্যই 127.0.0.1 রাখুন, আর পরীক্ষা শুধু নিজের অ্যাপে করুন।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি নিরাপদ password reset-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. সবার জন্য একই উত্তর পাঠানো
  2. পেছনের কাজে email খোঁজা, পুরোনো token বাতিল
  3. নতুন token-এর ছাপ জমা, আসল token মেইলে
  4. একই statement-এ token যাচাই ও ব্যবহার, password বদল
  5. সব session শেষ করে মালিককে নোটিশ

NIST SP 800-63B-4 অনুযায়ী একক factor হিসেবে ব্যবহৃত password নিয়ে কোন কথাটি ঠিক?

  • অন্তত ৮ অক্ষর, বড় হাতের অক্ষর ও চিহ্ন বাধ্যতামূলক
  • অন্তত ১৫ অক্ষর, গঠনের নিয়ম নয়, blocklist মেলানো বাধ্যতামূলক
  • ৯০ দিন পরপর বদলাতে হবে
  • ভুলে গেলে সাহায্যের জন্য hint রাখতে হবে

৩২ byte CSPRNG-এর reset token database-এ রাখতে কোনটি সবচেয়ে উপযুক্ত?

  • সাদা অবস্থায়, কারণ মেয়াদ কম
  • Argon2id দিয়ে, যাতে যত ধীর হয় তত ভালো
  • SHA-256 ছাপ, কারণ এলোমেলো মান অনুমান করা যায় না
  • Base64 করে

একটি reset route-এ জানা email-এ p50 ১০৭ ms, অজানা email-এ ১ ms, যদিও উত্তরের লেখা এক। সবচেয়ে ভালো সমাধান কোনটি?

  • কাজটি queue-তে দিয়ে আগে উত্তর ফেরানো, খোঁজ পরে
  • অজানা email-এ এলোমেলো ঘুম যোগ করা
  • Network-এর noise-এ এমনিতেই ঢেকে যাবে, কিছু লাগবে না
  • অজানা email-এ 404 দেওয়া

একই reset token-এ ১২টি অনুরোধ একসাথে এলে ঠিক একটিই সফল হওয়া নিশ্চিত করে কোনটি?

  • SELECT-এ দেখে নিয়ে পরে used_at বসানো
  • Node.js single-threaded, তাই আলাদা কিছু লাগে না
  • Frontend-এ বোতাম বন্ধ করা
  • WHERE used_at IS NULL সহ একটি UPDATE … RETURNING

৬ অঙ্কের OTP Redis-এ রাখার সবচেয়ে ভালো উপায় কোনটি?

  • সাদা অবস্থায়, TTL সহ
  • গোপন key-সহ HMAC, TTL সহ
  • সাধারণ SHA-256, salt ছাড়া
  • Base64 করে, TTL ছাড়া

প্রতি account-এ ৫টি ভুলে পুরো account ১৫ মিনিট বন্ধ, এই নিয়মের প্রধান দুর্বলতা কী?

  • Redis-এ বানানো যায় না
  • অনুমান একটুও ধীর করে না
  • অন্য কেউ ইচ্ছা করে আসল মালিককে বাইরে রাখতে পারেন
  • NIST কোনো rate limit চায় না

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Signup, login আর reset-এ লেখা, status আর সময়, তিনটিই একই রাখুন। খবর যাবে মেইলে।
  2. Password-এ দৈর্ঘ্য আর ফাঁস-তালিকা, মিশ্র অক্ষরের নিয়ম নয়, সময় ধরে বদল নয়।
  3. Token: এলোমেলো, hash করে জমা, একবার, মেয়াদসহ। যাচাই আর ব্যবহার একই statement-এ।
  4. Reset বা ঠিকানা বদলের পরে সব session শেষ, মালিককে নোটিশ। OTP-তে HMAC আর চেষ্টার সীমা, TOTP-তে replay বন্ধ।
  5. Lockout নয়, IP আর account-এর জোড়ায় backoff; মোছা দুই ধাপে, audit-এ কোনো secret নয়।

পরের পর্বে: Account সুরক্ষিত হলো, কিন্তু পুরো অ্যাপ? পরের পর্বে CORS, CSRF, XSS, নিরাপত্তা header আর dependency নিয়ে একটি বাস্তব তালিকা ধরে বাজারঘর যাচাই করব।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান