অধ্যায় 3 · প্রোডাকশন FastAPI
অথেন্টিকেশন ও অথরাইজেশন
- পৃষ্ঠা 11 / 22
- 29 মিনিট পড়া
প্রতিটা AI প্রোডাক্টকে একসময় জানতে হয় কে কল করছে। আপনার প্রম্পট লাইব্রেরি অচেনা কাউকে প্রাইভেট চ্যাট পড়তে দিতে পারে না, আপনার মডেল এন্ডপয়েন্ট প্রতিটা কলে টাকা খরচ করে, আর অ্যাডমিন স্ক্রিন শুধু অ্যাডমিনের জন্যই থাকতে হবে। বাস্তবের বেশিরভাগ API ব্রিচ চালাক ক্রিপ্টোগ্রাফির আক্রমণ নয়; বরং একটা বাদ পড়া চেক, ফাঁস হওয়া কি, বা এমন টোকেন যার মেয়াদ কখনো শেষ হয় না। এই পাতা FastAPI দিয়ে একদম গোড়া থেকে ছোট একটা লগইন সিস্টেম বানায়, যাতে আপনি প্রতিটা চলমান অংশ দেখতে পান: পাসওয়ার্ড হ্যাশিং, JWT অ্যাক্সেস ও রিফ্রেশ টোকেন, রিভোকেশন, রোল, অবজেক্ট-পর্যায়ের পারমিশন, সার্ভিসের জন্য API কি, কুকি ও লগইন রেট লিমিটিং, তারপর সবকিছুর টেস্ট।
এই পাতা ধরে নেয় আপনি HTTP গভীরে: ইডেমপোটেন্সি, ক্যাশিং ও অথ (401 বনাম 403, API কি বনাম বিয়ারার টোকেন, OAuth ফ্লো) এবং FastAPI আর্কিটেকচার: রাউটার, স্কিমা ও এরর হ্যান্ডলিং পড়েছেন। কোডে নিজস্ব ইন-মেমরি স্টোর আছে, তাই এটা একা চলে; নিচে যে আসল ডেটাবেস বসবে তা SQLAlchemy ও Alembic দিয়ে ডেটাবেস পাতায় আছে।
যা শিখবেন
- অথেন্টিকেশন বনাম অথরাইজেশন, আর যে চার ধরনের ব্যর্থতা একটা অথ সিস্টেমকে আলাদা করতে হয়।
pwdlibও Argon2 দিয়ে পাসওয়ার্ড হ্যাশ করা, এবং এমন লগইন যা কোন ইউজারনেম আছে তা ফাঁস করে না।- PyJWT দিয়ে JWT: কাঠামো, ক্লেইম, সাইনিং, যেসব আক্রমণ ঠেকাতে হবে (টেম্পারিং,
alg: none, মেয়াদ), আর রোটেশন ও রিভোকেশনসহ অ্যাক্সেস ও রিফ্রেশ টোকেন। - বিয়ারার-টোকেন ডিপেন্ডেন্সি, সুরক্ষিত রুট, রোল (RBAC) এবং BOLA ঠেকানো অবজেক্ট-পর্যায়ের চেক।
- সার্ভিসের জন্য API কি, কুকি বনাম বিয়ারার টোকেন ও CSRF, বাইরের প্রোভাইডারের OAuth2/OIDC লগইন, লগইন রেট লিমিটিং, এবং এসবের টেস্ট।
দুটো প্রশ্ন, চার ধরনের ফল
| প্রশ্ন | নাম | ব্যর্থতা দেখতে যেমন | এই পাতায় কোথায় |
|---|---|---|---|
| আপনি কে? | অথেন্টিকেশন | 401 এবং WWW-Authenticate: Bearer | লগইন, টোকেন, current_user |
| এই কাজটা করার অনুমতি আছে? | অথরাইজেশন (রোল) | 403 | require_role("admin") |
| এই অবজেক্টে হাত দেওয়ার অনুমতি আছে? | অথরাইজেশন (রিসোর্স) | 404 (অস্তিত্ব লুকানো) বা 403 | owned_chat |
| অনেক বেশি অনুমান পাঠাচ্ছেন? | অপব্যবহার নিয়ন্ত্রণ | 429 এবং Retry-After | লগইন লিমিটার |
একটা রিকোয়েস্ট এই ক্রমেই এগুলো পার হয়। তৃতীয় সারিটা বাদ দেওয়াই ক্লাসিক বাগ: ব্যবহারকারী লগইন করা আছে, তাই এন্ডপয়েন্ট তাকে বিশ্বাস করে বসে, সে যে id-ই টাইপ করুক।
প্রজেক্ট সেট আপ
অ্যাপ টোকেন সাইন করে এমন একটা সিক্রেট দিয়ে, যা কখনো কোডে থাকা চলবে না। সেটা আসে এনভায়রনমেন্ট থেকে (সাধারণত যে সেটিংস ক্লাস এটা পড়ে, তা ডিপেন্ডেন্সি, লাইফস্প্যান, মিডলওয়্যার ও সেটিংস পাতায় আছে)। HS256-এর জন্য কমপক্ষে ৩২ বাইট লাগে; আসল সিক্রেট বানাতে চালান python -c "import secrets; print(secrets.token_urlsafe(48))"। নিচের মানটা শুধু এই টিউটোরিয়ালের জন্য।
mkdir -p ~/projects/auth-api
cd ~/projects/auth-api
uv init --bare -q
uv add -q "fastapi[standard]" pyjwt "pwdlib[argon2]"
uv add -q --dev pytest httpx2
# with pip: python -m venv .venv && source .venv/bin/activate && pip install -q "fastapi[standard]" pyjwt "pwdlib[argon2]" pytest httpx2
mkdir -p app tests
touch app/__init__.py
cat >> pyproject.toml <<'EOF'
[tool.fastapi]
entrypoint = "app.main:app"
EOF
export JWT_SECRET="tutorial-only-secret-change-me-0123456789"
export BASE=http://localhost:8000পাসওয়ার্ড: ধীর, সল্টযুক্ত হ্যাশ জমা রাখুন
পাসওয়ার্ড কখনো সরাসরি জমা রাখবেন না, "এনক্রিপ্ট" করেও না: ডেটাবেস ফাঁস হলে প্রতিটা ব্যবহারকারীের পাসওয়ার্ডও ফাঁস হয়, আর ব্যবহারকারীরা একই পাসওয়ার্ড বারবার ব্যবহার করে। জমা রাখুন ইচ্ছাকৃতভাবে ধীর ও বেশি মেমরি খরচ করা অ্যালগরিদমের হ্যাশ, প্রতিটা পাসওয়ার্ডে আলাদা র্যান্ডম সল্ট সহ, যাতে সেকেন্ডে লক্ষ লক্ষ পাসওয়ার্ড অনুমান করা আর সম্ভব না হয়। SHA-256 বা MD5-এর মতো দ্রুত হ্যাশ এখানে ভুল টুল। ২০২৬ সালে ডিফল্ট পছন্দ Argon2id, আর FastAPI-র ইকোসিস্টেমে লাইব্রেরিটা pwdlib (এটা রক্ষণাবেক্ষণ বন্ধ হয়ে যাওয়া passlib-এর জায়গা নিয়েছে):
from pwdlib import PasswordHash
passwords = PasswordHash.recommended() # Argon2id with sensible parameters
hashed = passwords.hash("correct-horse-1")
print(hashed.split("$")[1:3]) # algorithm and version, then salt and hash
print(hashed.startswith("$argon2id$v=19$"))
print(passwords.verify("correct-horse-1", hashed), passwords.verify("wrong-guess", hashed))
print(hashed == passwords.hash("correct-horse-1"))['argon2id', 'v=19']
True
True False
False- ফলাফল একটা স্বয়ংসম্পূর্ণ স্ট্রিং, যা নিজেই নিজের বর্ণনা দেয়: অ্যালগরিদম, ভার্সন, কস্ট প্যারামিটার, সল্ট ও হ্যাশ।
verifyপ্যারামিটার স্ট্রিং থেকেই পড়ে নেয়, তাই পরে কস্ট বাড়ালেও পুরোনো হ্যাশ কাজ করে (প্যারামিটার পুরোনো হয়ে গেলেverify_and_updateজমা রাখার জন্য নতুন হ্যাশও ফেরত দেয়)। - একই পাসওয়ার্ড দুবার হ্যাশ করলে র্যান্ডম সল্টের কারণে ভিন্ন স্ট্রিং আসে। হ্যাশ নিজে তুলনা করবেন না; সবসময়
verifyকল করুন। - সর্বনিম্নের পাশাপাশি সর্বোচ্চ পাসওয়ার্ড দৈর্ঘ্যও বেঁধে দিন (১২৮ যথেষ্ট): ১০ MB-এর "পাসওয়ার্ড" হ্যাশ করানো আপনার CPU পুড়িয়ে দেওয়ার সস্তা উপায়।
হ্যাশ কখনো প্রিন্ট, লগ বা রেসপন্সে ফেরত দেবেন না, আর রেসপন্স মডেলেও রাখবেন না। "চালিয়ে দেখুন" অংশে দেখবেন, স্টোর করা সারিতে হ্যাশ থাকলেও
UserOutমডেল সেটা বাদ দেয়।
টোকেন: JWT আসলে কী
লগইনের পর ক্লায়েন্টকে প্রতিটা পরের রিকোয়েস্টে নিজের পরিচয়ের প্রমাণ দিতে হয়, পাসওয়ার্ড আবার না পাঠিয়ে। JSON Web Token (JWT) একটা সাইন করা, স্বয়ংসম্পূর্ণ প্রমাণ: ডট দিয়ে জোড়া তিনটা Base64URL অংশ, header.payload.signature। সার্ভার সিক্রেট দিয়ে সাইন করে; পরে সিগনেচার মিলিয়ে ভেতরের ক্লেইমগুলোকে ডেটাবেস লুকআপ ছাড়াই বিশ্বাস করতে পারে। সিকিউরিটি মডিউলটা লিখুন (এতে FastAPI নেই, FastAPI আর্কিটেকচার: রাউটার, স্কিমা ও এরর হ্যান্ডলিং-এর সার্ভিসগুলোর মতো):
# file: app/security.py
"""Passwords, tokens, API keys and the login limiter. No FastAPI in here."""
import hashlib
import math
import os
import secrets
import time
import uuid
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
import jwt
from pwdlib import PasswordHash
ALGORITHM = "HS256"
ISSUER = "bitbyte-auth-demo"
ACCESS_TTL = timedelta(minutes=15)
REFRESH_TTL = timedelta(days=7)
passwords = PasswordHash.recommended()
DUMMY_HASH = passwords.hash("not-a-real-password") # for unknown usernames, see login
def secret_key() -> str:
return os.environ["JWT_SECRET"]
def make_token(sub: str, kind: str, *, family: str | None = None, now: datetime | None = None,
ttl: timedelta | None = None, jti: str | None = None) -> str:
"""Sign a token. `now`, `ttl` and `jti` are parameters so examples and tests can fix them."""
now = now or datetime.now(timezone.utc)
ttl = ttl or (ACCESS_TTL if kind == "access" else REFRESH_TTL)
claims = {
"iss": ISSUER, # who issued it
"sub": sub, # whose it is (the user id, as a string)
"typ": kind, # "access" or "refresh": never accept one as the other
"fam": family or uuid.uuid4().hex, # the login session this token belongs to
"jti": jti or uuid.uuid4().hex, # unique id of this token
"iat": now, # issued at
"exp": now + ttl, # expires at
}
return jwt.encode(claims, secret_key(), algorithm=ALGORITHM)
def decode_token(token: str, kind: str) -> dict:
"""Verify signature, algorithm, issuer, expiry and token type. Raises jwt.InvalidTokenError."""
claims = jwt.decode(token, secret_key(), algorithms=[ALGORITHM], issuer=ISSUER,
options={"require": ["exp", "iat", "sub", "jti"]})
if claims.get("typ") != kind:
raise jwt.InvalidTokenError(f"expected a {kind} token")
return claims
def new_api_key() -> str:
return "bb_" + secrets.token_urlsafe(24)
def hash_api_key(key: str) -> str:
"""API keys are long and random, so a fast hash is fine (unlike passwords)."""
return hashlib.sha256(key.encode()).hexdigest()
class LoginLimiter:
"""Block a key after `max_failures` failed logins within `window` seconds."""
def __init__(self, max_failures: int = 5, window: float = 60.0,
clock: Callable[[], float] = time.monotonic) -> None:
self.max_failures, self.window, self.clock = max_failures, window, clock
self._failures: dict[str, list[float]] = {}
def retry_after(self, key: str) -> int:
"""Seconds the caller must wait, or 0 if it may try."""
now = self.clock()
recent = [t for t in self._failures.get(key, []) if now - t < self.window]
self._failures[key] = recent
if len(recent) >= self.max_failures:
return math.ceil(self.window - (now - recent[0]))
return 0
def record_failure(self, key: str) -> None:
self._failures.setdefault(key, []).append(self.clock())
def reset(self, key: str) -> None:
self._failures.pop(key, None)এবার একটা নির্দিষ্ট মুহূর্তে টোকেন বানান, যাতে আউটপুট প্রতিবার একই থাকে, আর ভেতরটা দেখুন:
import base64
import json
from datetime import datetime, timezone
from app.security import make_token
def part(segment: str) -> dict:
return json.loads(base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)))
FIXED = datetime(2026, 1, 15, 4, 0, tzinfo=timezone.utc)
token = make_token("2", "access", family="demo-session", jti="demo-token", now=FIXED)
head, payload, signature = token.split(".")
print(part(head))
print(part(payload))
print(len(signature), "characters of signature"){'alg': 'HS256', 'typ': 'JWT'}
{'iss': 'bitbyte-auth-demo', 'sub': '2', 'typ': 'access', 'fam': 'demo-session', 'jti': 'demo-token', 'iat': 1768449600, 'exp': 1768450500}
43 characters of signature- হেডার সাইনিং অ্যালগরিদমের নাম বলে। পেলোড রাখে ক্লেইম: রেজিস্টার্ড ক্লেইম (
issইস্যুকারী,subবিষয়,expমেয়াদ শেষ,iatইস্যুর সময়,jtiটোকেন id, এছাড়াaudঅডিয়েন্স ওnbfnot-before) এবং আপনার নিজের (typ,fam)। সময় ইউনিক্স সেকেন্ডে: এই টোকেন ৯০০ সেকেন্ড বাঁচে। - পেলোড শুধু এনকোড করা, এনক্রিপ্ট করা নয়: টোকেন যার হাতে, সে-ই পড়তে পারে। পাসওয়ার্ড, ইমেইল, API কি বা কোনো ব্যক্তিগত তথ্য এতে রাখবেন না। আমরা ইচ্ছা করেই ব্যবহারকারীের রোল বাদ রেখেছি: রোল প্রতিটা রিকোয়েস্টে খুঁজে নেওয়া হয়, তাই অ্যাডমিনকে নামিয়ে দিলে টোকেনের মেয়াদ শেষের অপেক্ষা না করে তখনই তা কার্যকর হয়।
- সিগনেচার হলো হেডার ও পেলোডের ওপর আপনার সিক্রেট দিয়ে করা HMAC (HS256)। পেলোডের একটা অক্ষর বদলালেই আর মেলে না। একক ব্যাকএন্ডের জন্য HS256 ঠিক আছে; অন্য সার্ভিসকে টোকেন যাচাই করতে দিতে হবে, কিন্তু বানানোর ক্ষমতা দেওয়া যাবে না, এমন হলে RS256 বা ES256 ব্যবহার করুন এবং পাবলিক কি প্রকাশ করুন।
যাচাইয়ের সময় কী প্রত্যাখ্যান করতে হবে
decode_token algorithms=["HS256"] বেঁধে দেয়, ইস্যুকারী মেলায়, মূল ক্লেইমগুলো বাধ্যতামূলক করে, আর রিফ্রেশ টোকেনকে অ্যাক্সেস টোকেন হিসেবে চলতে দেয় না। এর বিরুদ্ধে ক্লাসিক আক্রমণগুলো চালিয়ে দেখুন। সময় এখানে আসল ঘড়ি থেকে আসে, কিন্তু আউটপুটে শুধু এররের ধরন ছাপা হয়, যা বদলায় না:
import jwt
from datetime import timedelta
from app.security import decode_token
forged_payload = base64.urlsafe_b64encode(json.dumps({**part(payload), "sub": "1"}).encode()).rstrip(b"=").decode()
now = datetime.now(timezone.utc)
cases = {
"valid": make_token("2", "access"),
"expired": make_token("2", "access", now=now - timedelta(hours=1)),
"payload edited": f"{head}.{forged_payload}.{signature}",
"alg none": jwt.encode({"sub": "1", "typ": "access", "iss": "bitbyte-auth-demo", "jti": "x",
"iat": now, "exp": now + timedelta(hours=1)}, key=None, algorithm="none"),
"other secret": jwt.encode({"sub": "1"}, "an-attacker-secret-that-is-long-enough", algorithm="HS256"),
"refresh as access": make_token("2", "refresh"),
}
for name, candidate in cases.items():
try:
decode_token(candidate, "access")
print(f"{name:18} accepted")
except jwt.InvalidTokenError as exc:
print(f"{name:18} rejected: {type(exc).__name__}")valid accepted
expired rejected: ExpiredSignatureError
payload edited rejected: InvalidSignatureError
alg none rejected: InvalidAlgorithmError
other secret rejected: InvalidSignatureError
refresh as access rejected: InvalidTokenErrorশুধু বৈধ টোকেনটা পার হয়। মনে রাখার মতো দুটো হলো বদলানো পেলোড (ব্যবহারকারী নিজেকে user 1 বানিয়ে ফেলছে) আর alg: none: একটা "সাইনবিহীন" টোকেন। যেসব পুরোনো লাইব্রেরি হেডারের alg-কে বিশ্বাস করত, সেগুলো এটা মেনে নিত; PyJWT প্রত্যাখ্যান করে, কারণ আপনি বলে দিয়েছেন কোন অ্যালগরিদম চলবে। সবসময় স্পষ্টভাবে algorithms=[...] দিন।
অ্যাপ্লিকেশন
একটা স্টোরে থাকে ব্যবহারকারী, চ্যাট, API কি আর রিভোকেশন সেট। এটা create_app() দিয়ে বানানো একটা অবজেক্ট, app.state-এ রাখা, তাই প্রতিটা টেস্ট নতুন একটা পায় (FastAPI আর্কিটেকচার: রাউটার, স্কিমা ও এরর হ্যান্ডলিং-এর শেষের শেয়ার্ড-সিঙ্গেলটন সমস্যার সঙ্গে মিলিয়ে দেখুন)। আসল সার্ভিসে এটা হবে আপনার SQLAlchemy সেশন আর একটা Redis সেট।
# file: app/store.py
"""In-memory data for the demo. Swap for a database and Redis in a real service."""
from app.security import LoginLimiter, passwords
class Store:
def __init__(self) -> None:
self.users: dict[int, dict] = {}
self.chats: dict[int, dict] = {}
self.api_keys: dict[str, str] = {} # sha256(key) -> service name
self.used_refresh: set[str] = set() # refresh jti values that were already exchanged
self.revoked_families: set[str] = set() # login sessions that have been ended
self.limiter = LoginLimiter()
def add_user(self, username: str, password: str, role: str = "user") -> dict:
row = {"id": len(self.users) + 1, "username": username,
"password_hash": passwords.hash(password), "role": role}
self.users[row["id"]] = row
return row
def by_name(self, username: str) -> dict | None:
return next((u for u in self.users.values() if u["username"] == username), None)এবার অ্যাপ। ওপর থেকে নিচে পড়ুন: মডেল, ডিপেন্ডেন্সি চেইন (oauth2_scheme → current_claims → current_user → require_role), তারপর রুটগুলো।
# file: app/main.py
import os
import uuid
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel, ConfigDict, Field
from app.security import DUMMY_HASH, decode_token, hash_api_key, make_token, passwords
from app.store import Store
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
class RegisterIn(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str = Field(min_length=3, max_length=30, pattern=r"^[a-z0-9_]+$")
password: str = Field(min_length=8, max_length=128)
class UserOut(BaseModel): # no password_hash here, on purpose
id: int
username: str
role: str
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshIn(BaseModel):
refresh_token: str
class ChatIn(BaseModel):
title: str = Field(min_length=1, max_length=80)
class ChatOut(BaseModel):
id: int
title: str
owner_id: int
def unauthorized(detail: str = "Could not validate credentials") -> HTTPException:
return HTTPException(401, detail, headers={"WWW-Authenticate": "Bearer"})
def get_store(request: Request) -> Store:
return request.app.state.store
StoreDep = Annotated[Store, Depends(get_store)]
def issue_pair(user: dict, family: str | None = None) -> TokenPair:
family = family or uuid.uuid4().hex
return TokenPair(access_token=make_token(str(user["id"]), "access", family=family),
refresh_token=make_token(str(user["id"]), "refresh", family=family))
def authenticate(form: OAuth2PasswordRequestForm, request: Request, store: Store) -> dict:
"""Check a username and password, with a limit on failed attempts."""
key = f"{form.username}|{request.client.host if request.client else '?'}"
wait = store.limiter.retry_after(key)
if wait:
raise HTTPException(429, "Too many failed logins, try again later", headers={"Retry-After": str(wait)})
user = store.by_name(form.username)
# Always run one hash check, even for an unknown user, so response time does not reveal which names exist.
password_ok = passwords.verify(form.password, user["password_hash"] if user else DUMMY_HASH)
if user is None or not password_ok:
store.limiter.record_failure(key)
raise unauthorized("Incorrect username or password")
store.limiter.reset(key)
return user
def current_claims(token: Annotated[str, Depends(oauth2_scheme)], store: StoreDep) -> dict:
try:
claims = decode_token(token, "access")
except jwt.ExpiredSignatureError:
raise unauthorized("Token expired") from None
except jwt.InvalidTokenError:
raise unauthorized() from None
if claims["fam"] in store.revoked_families:
raise unauthorized("Session ended")
return claims
def current_user(claims: Annotated[dict, Depends(current_claims)], store: StoreDep) -> dict:
user = store.users.get(int(claims["sub"]))
if user is None:
raise unauthorized()
return user
CurrentUser = Annotated[dict, Depends(current_user)]
def require_role(*roles: str):
def checker(user: CurrentUser) -> dict:
if user["role"] not in roles:
raise HTTPException(403, "You do not have permission to do this")
return user
return checker
def owned_chat(chat_id: int, user: CurrentUser, store: StoreDep) -> dict:
"""Resource-level check: the chat must exist AND belong to the caller (or the caller is an admin)."""
chat = store.chats.get(chat_id)
if chat is None or (chat["owner_id"] != user["id"] and user["role"] != "admin"):
raise HTTPException(404, "Chat not found") # same answer for "missing" and "not yours"
return chat
def service_name(store: StoreDep, x_api_key: Annotated[str | None, Header()] = None) -> str:
name = store.api_keys.get(hash_api_key(x_api_key)) if x_api_key else None
if name is None:
raise HTTPException(401, "Invalid or missing API key")
return name
def create_app() -> FastAPI:
app = FastAPI(title="BitByte Auth Demo")
store = Store()
store.add_user("root", "root-pass-1234", role="admin") # demo only: create admins with a CLI command
if key := os.environ.get("INGEST_API_KEY"):
store.api_keys[hash_api_key(key)] = "ingest-service"
app.state.store = store
@app.post("/auth/register", status_code=201)
def register(data: RegisterIn, store: StoreDep) -> UserOut:
if store.by_name(data.username):
raise HTTPException(409, "Username already taken")
return store.add_user(data.username, data.password)
@app.post("/auth/login")
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()], request: Request, store: StoreDep) -> TokenPair:
return issue_pair(authenticate(form, request, store))
@app.post("/auth/refresh")
def refresh(data: RefreshIn, store: StoreDep) -> TokenPair:
try:
claims = decode_token(data.refresh_token, "refresh")
except jwt.InvalidTokenError:
raise unauthorized("Invalid refresh token") from None
if claims["fam"] in store.revoked_families:
raise unauthorized("Session ended")
if claims["jti"] in store.used_refresh:
store.revoked_families.add(claims["fam"]) # replay: someone holds a stolen copy, end the session
raise unauthorized("Refresh token reuse detected, log in again")
user = store.users.get(int(claims["sub"]))
if user is None:
raise unauthorized("Invalid refresh token")
store.used_refresh.add(claims["jti"]) # each refresh token works exactly once
return issue_pair(user, family=claims["fam"])
@app.post("/auth/logout", status_code=204)
def logout(claims: Annotated[dict, Depends(current_claims)], store: StoreDep) -> None:
store.revoked_families.add(claims["fam"])
@app.get("/me")
def me(user: CurrentUser) -> UserOut:
return user
@app.get("/admin/users", dependencies=[Depends(require_role("admin"))])
def list_users(store: StoreDep) -> list[UserOut]:
return list(store.users.values())
@app.post("/chats", status_code=201)
def create_chat(data: ChatIn, user: CurrentUser, store: StoreDep) -> ChatOut:
chat = {"id": len(store.chats) + 1, "title": data.title, "owner_id": user["id"]}
store.chats[chat["id"]] = chat
return chat
@app.get("/chats/{chat_id}")
def get_chat(chat: Annotated[dict, Depends(owned_chat)]) -> ChatOut:
return chat
@app.post("/ingest")
def ingest(service: Annotated[str, Depends(service_name)]) -> dict[str, str]:
return {"accepted": "yes", "service": service}
return app
app = create_app()চালিয়ে দেখুন
একটা সার্ভিস API কি বানান (শুধু নিরাপদ অংশ ছাপা হবে) এবং সার্ভার চালু করুন। কি-টা মালিককে একবারই দেখানো হয়; সার্ভার রাখে শুধু এর SHA-256 হ্যাশ।
export INGEST_API_KEY=$(uv run python -c "from app.security import new_api_key; print(new_api_key())")
echo "${INGEST_API_KEY:0:3}... (${#INGEST_API_KEY} characters)"
uv run fastapi run --port 8000 > server.log 2>&1 &
echo $! > server.pid
curl -s --retry 20 --retry-connrefused --retry-delay 0 $BASE/docs -o /dev/null -w '%{http_code}\n'bb_... (35 characters)
200রেজিস্টার ও লগইন
curl -s -w ' %{http_code}\n' -X POST $BASE/auth/register -H 'Content-Type: application/json' \
-d '{"username": "nadia", "password": "correct-horse-1"}'
curl -s -w ' %{http_code}\n' -X POST $BASE/auth/register -H 'Content-Type: application/json' \
-d '{"username": "nadia", "password": "another-pass-9"}'
curl -s -o /dev/null -w '%{http_code}\n' -X POST $BASE/auth/register -H 'Content-Type: application/json' \
-d '{"username": "x", "password": "short", "is_admin": true}'{"id":2,"username":"nadia","role":"user"} 201
{"detail":"Username already taken"} 409
422নতুন ব্যবহারকারীের id 2 (সিড করা অ্যাডমিন ১), আর রেসপন্সে password_hash নেই, যদিও স্টোর করা সারিতে আছে: আউটপুট মডেল সেটা ছেঁকে ফেলে। নাম আগে থেকে থাকলে 409, আর ভুল বা অপ্রত্যাশিত ইনপুটে 422 (extra="forbid" এমন ক্লায়েন্টকেও ফেরায় যে is_admin পাঠাতে চায়: রেজিস্ট্রেশন ফর্মকে কখনো নিজের রোল বেছে নিতে দেবেন না)।
লগইন চলে OAuth2 "password flow"-এর ধাঁচে, যা FastAPI-র /docs বোঝে: JSON নয়, ফর্ম ফিল্ড username ও password। আপনার নিজের ফার্স্ট-পার্টি লগইনের জন্য এটা ঠিক আছে; থার্ড-পার্টি অ্যাপকে এটা দেবেন না (RFC 9700)।
curl -s -w ' %{http_code}\n' -X POST $BASE/auth/login -d 'username=nadia' -d 'password=wrong-password'
curl -s -w ' %{http_code}\n' -X POST $BASE/auth/login -d 'username=nobody' -d 'password=wrong-password'
curl -s -X POST $BASE/auth/login -d 'username=nadia' -d 'password=correct-horse-1' > login.json
export ACCESS=$(python -c "import json; print(json.load(open('login.json'))['access_token'])")
export REFRESH=$(python -c "import json; print(json.load(open('login.json'))['refresh_token'])")
python -c "import json; d = json.load(open('login.json')); print(sorted(d), d['token_type'], d['access_token'].count('.'))"{"detail":"Incorrect username or password"} 401
{"detail":"Incorrect username or password"} 401
['access_token', 'refresh_token', 'token_type'] bearer 2ভুল পাসওয়ার্ড আর অচেনা ব্যবহারকারী একই উত্তর পায়, তাই আক্রমণকারী লগইন ফর্ম দিয়ে কোন ইউজারনেম আছে তা জানতে পারে না (কোড অচেনা ব্যবহারকারীের জন্যও একটা ডামি মানের বিপরীতে হ্যাশ মেলায়, যাতে সময়ও একই থাকে)। টোকেন প্রতি লগইনে র্যান্ডম, তাই পাতাটা শুধু তাদের গঠন ছাপে: দুটো টোকেন, প্রতিটায় দুটো ডট।
অ্যাক্সেস টোকেন ব্যবহার
curl -s -w ' %{http_code}\n' $BASE/me -H "Authorization: Bearer $ACCESS"
curl -s -i $BASE/me | tr -d '\r' | grep -iE '^(HTTP|www-authenticate)|detail'
curl -s -w ' %{http_code}\n' $BASE/me -H "Authorization: Bearer ${ACCESS}x"{"id":2,"username":"nadia","role":"user"} 200
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
{"detail":"Not authenticated"}
{"detail":"Could not validate credentials"} 401টোকেন না থাকলে: 401 আর WWW-Authenticate: Bearer, যা ক্লায়েন্টকে জানায় কীভাবে অথেন্টিকেট করতে হবে। নষ্ট টোকেনেও 401, একই অস্পষ্ট বার্তাসহ (আক্রমণকারীকে ঠিক কী ভুল ছিল তা বুঝিয়ে দেবেন না)।
অ্যাক্সেস ও রিফ্রেশ টোকেন, মেয়াদ ও রিভোকেশন
JWT "ফেরত নেওয়া" যায় না: সার্ভার ডেটাবেস নয়, সিগনেচার দেখে, তাই চুরি যাওয়া টোকেন মেয়াদ শেষ না হওয়া পর্যন্ত চলে। প্রচলিত সমাধান দুটো টোকেন:
| অ্যাক্সেস টোকেন | রিফ্রেশ টোকেন | |
|---|---|---|
| কোথায় যায় | প্রতিটা API এন্ডপয়েন্টে | শুধু /auth/refresh-এ |
| আয়ু | ছোট: ৫ থেকে ১৫ মিনিট | লম্বা: কয়েক দিন |
| চুরি গেলে | কয়েক মিনিট কাজে লাগে | বিপজ্জনক, তাই একবার-ব্যবহারযোগ্য (রোটেশন) ও বাতিলযোগ্য |
| কীসের সাথে মিলিয়ে যাচাই | সিগনেচার ও মেয়াদ (সঙ্গে সস্তা একটা রিভোকেশন চেক) | সিগনেচার, মেয়াদ ও সার্ভার-পাশের স্টেট |
এই অ্যাপ রিভোকেশন সম্ভব করা দুটো সার্ভার-পাশের নিয়ন্ত্রণ বসায়:
- রোটেশন: প্রতিটা রিফ্রেশ একটা নতুন জোড়া দেয়, আর পুরোনো রিফ্রেশ টোকেনের
jti"ব্যবহৃত" হিসেবে মনে রাখা হয়। - পুনর্ব্যবহার শনাক্তকরণ ও ডিনাইলিস্ট: ব্যবহৃত রিফ্রেশ টোকেন আবার এলে হয় ব্যবহারকারী, নয় চোর সেটা রিপ্লে করছে, আর সার্ভার বলতে পারে না কে; তাই পুরো লগইন সেশন (
famফ্যামিলি) শেষ করে দেয়।logoutইচ্ছা করেই একই কাজ করে। শেষ হওয়া সেশনের এই সেটই ডিনাইলিস্ট; এন্ট্রিগুলো শুধু ততদিন রাখলেই হয়, যতদিন তাদের আওতার টোকেনগুলোর মেয়াদ থাকত, আর TTL-সহ Redis কি (key) ঠিক এই কাজের জন্য (Redis দিয়ে ক্যাশিং, রেট লিমিটিং ও নির্ভরযোগ্যতা)।
curl -s -X POST $BASE/auth/refresh -H 'Content-Type: application/json' -d "{\"refresh_token\": \"$REFRESH\"}" > rotated.json
export NEW_REFRESH=$(python -c "import json; print(json.load(open('rotated.json'))['refresh_token'])")
python -c "import os; print('new refresh token differs:', os.environ['NEW_REFRESH'] != os.environ['REFRESH'])"
echo "--- replay the OLD refresh token"
curl -s -w ' %{http_code}\n' -X POST $BASE/auth/refresh -H 'Content-Type: application/json' -d "{\"refresh_token\": \"$REFRESH\"}"
echo "--- the NEW one, and the access token, are dead too"
curl -s -w ' %{http_code}\n' -X POST $BASE/auth/refresh -H 'Content-Type: application/json' -d "{\"refresh_token\": \"$NEW_REFRESH\"}"
curl -s -w ' %{http_code}\n' $BASE/me -H "Authorization: Bearer $ACCESS"new refresh token differs: True
--- replay the OLD refresh token
{"detail":"Refresh token reuse detected, log in again"} 401
--- the NEW one, and the access token, are dead too
{"detail":"Session ended"} 401
{"detail":"Session ended"} 401একটা রিপ্লে করা রিফ্রেশ টোকেন সেশনটা সবার জন্যই শেষ করে দিল, চোর ও ভুক্তভোগী দুজনের জন্যই; ভুক্তভোগী আবার লগইন করবেন। এটাই ইচ্ছাকৃত ট্রেড-অফ। স্বাভাবিক ব্যবহার মসৃণ থাকে: ক্লায়েন্ট সবচেয়ে নতুন জোড়াটা রাখে এবং অ্যাক্সেস টোকেনের মেয়াদ ফুরানোর একটু আগে, বা 401 Token expired পেলে, রিফ্রেশ করে।
আসল ক্লায়েন্ট রিফ্রেশ কলগুলো একটার পর একটা করে পাঠায় (দুটো ট্যাবে একসাথে রিফ্রেশ করলে সেটা পুনর্ব্যবহারের মতো দেখায়), এবং লগআউটের সময় নিজের টোকেনও ফেলে দেওয়া উচিত।
রোল (RBAC) ও অবজেক্ট-পর্যায়ের পারমিশন (BOLA)
রোল-ভিত্তিক অ্যাক্সেস কন্ট্রোল ব্যবহারকারীকে রোলে আর রোলকে কাজে বাঁধে: এখানে user ও admin, কার্যকর করে require_role("admin")। রোল উত্তর দেয় "এই ধরনের ব্যবহারকারী কি এই এন্ডপয়েন্ট কল করতে পারে?" প্রশ্নের। কিন্তু "এই ব্যবহারকারী কি এই চ্যাট খুলতে পারে?" এর উত্তর দিতে পারে না। এই দ্বিতীয় প্রশ্নটাই BOLA (broken object level authorisation, OWASP API Security Top 10-এর এক নম্বর): এমন এন্ডপয়েন্ট, যে শুধু দেখে আপনি লগইন করা কি না, তারপর URL-এ যে id-ই লিখুন তাকেই বিশ্বাস করে। AI প্রোডাক্টে এই id হলো চ্যাট, আপলোড করা ডকুমেন্ট, এমবেডিং, ফাইন-টিউন করা মডেল ও ব্যবহারের রেকর্ড, আর মালিক-চেক বাদ গেলে অন্য কাস্টমারের প্রম্পট ফাঁস হয়।
সমাধানটা owned_chat-এ: অবজেক্ট লোড করুন, তার owner_id কলারের সঙ্গে মেলান, আর "আপনার নয়"-কে "নেই"-এর মতো আচরণ করুন। তিনজন ব্যবহারকারী দিয়ে চেষ্টা করুন। হেল্পার স্ক্রিপ্ট লগইন করে একটা টোকেন ছাপে:
# file: login.sh
# usage: sh login.sh USERNAME PASSWORD prints an access token
curl -s -X POST "$BASE/auth/login" -d "username=$1" -d "password=$2" | python -c "import sys, json; print(json.load(sys.stdin)['access_token'])"export NADIA=$(sh login.sh nadia correct-horse-1)
export ROOT=$(sh login.sh root root-pass-1234)
curl -s -o /dev/null -X POST $BASE/auth/register -H 'Content-Type: application/json' -d '{"username": "tanvir", "password": "tanvir-pass-77"}'
export TANVIR=$(sh login.sh tanvir tanvir-pass-77)
curl -s -w ' %{http_code}\n' -X POST $BASE/chats -H "Authorization: Bearer $NADIA" -H 'Content-Type: application/json' -d '{"title": "Refund policy questions"}'
for who in NADIA TANVIR ROOT; do
echo -n "$who reads chat 1: "
curl -s -w ' %{http_code}\n' $BASE/chats/1 -H "Authorization: Bearer ${!who}"
done
echo -n "TANVIR reads chat 99: "
curl -s -w ' %{http_code}\n' $BASE/chats/99 -H "Authorization: Bearer $TANVIR"
echo "--- roles"
curl -s -w ' %{http_code}\n' $BASE/admin/users -H "Authorization: Bearer $NADIA"
curl -s -w ' %{http_code}\n' $BASE/admin/users -H "Authorization: Bearer $ROOT"{"id":1,"title":"Refund policy questions","owner_id":2} 201
NADIA reads chat 1: {"id":1,"title":"Refund policy questions","owner_id":2} 200
TANVIR reads chat 1: {"detail":"Chat not found"} 404
ROOT reads chat 1: {"id":1,"title":"Refund policy questions","owner_id":2} 200
TANVIR reads chat 99: {"detail":"Chat not found"} 404
--- roles
{"detail":"You do not have permission to do this"} 403
[{"id":1,"username":"root","role":"admin"},{"id":2,"username":"nadia","role":"user"},{"id":3,"username":"tanvir","role":"user"}] 200তানভীর নাদিয়ার চ্যাটের জন্য আর যে চ্যাট কখনো ছিলই না তার জন্য একই 404 পায়, তাই অন্যদের id সম্পর্কে সে কিছুই জানতে পারে না। "আপনার নয়" বোঝাতে 403 ব্যবহার করাও গ্রহণযোগ্য, তবে তাতে অবজেক্টটা যে আছে সেটা নিশ্চিত হয়ে যায়। রোল চেক ভিন্ন: নাদিয়া একজন পরিচিত ব্যবহারকারী যে শুধু-অ্যাডমিন এন্ডপয়েন্ট কল করছে, তাই 403-ই সঠিক ও স্পষ্ট উত্তর।
মালিক-চেক একটা পুনর্ব্যবহারযোগ্য ডিপেন্ডেন্সিতে রাখুন, যেমন owned_chat; ফিল্টার করুন ডেটাবেস কোয়েরিতেই (WHERE id = :id AND owner_id = :user); আর id আছে এমন প্রতিটা রুটের জন্য একটা "ভুল ব্যবহারকারী" টেস্ট লিখুন। "অ্যাডমিন" রোল যখন খুব মোটা দাগের হয়ে যায়, তখন রোল বড় হয়ে স্কোপ হয় (chats:read, models:write, যেমন HTTP গভীরে: ইডেমপোটেন্সি, ক্যাশিং ও অথ-এ)।
সার্ভিসের জন্য API কি
ব্যবহারকারী লগইন করে; রাতের ইনজেশন জব বা অন্য টিমের সার্ভিস করে না। মেশিন পায় API কি (বা OAuth ক্লায়েন্ট ক্রেডেনশিয়াল)। ওপরের কোডেই দেখা যায় এমন কয়েকটা নিয়ম:
secretsদিয়ে লম্বা, র্যান্ডম কি বানান, চেনা যায় এমন প্রিফিক্সসহ (bb_), যাতে সিক্রেট স্ক্যানার ও সাপোর্ট টিম সহজে চিনতে পারে।- জমা রাখুন শুধু হ্যাশ। এখানে দ্রুত SHA-256 চলে, কারণ কি-টা ১৯২ বিট র্যান্ডম (পাসওয়ার্ডের মতো নয়, আক্রমণকারী এটা অনুমান করতে পারে না)। প্লেইনটেক্সট মালিককে একবারই দেখান।
- হেডারে পাঠান (
X-API-Key), URL-এ কখনো নয়। প্রতিটা সার্ভিসকে আলাদা কি দিন, যাতে ফাঁস হলে অন্যগুলো না ভেঙে একটাই বাতিল করা যায়, আর লগে লিখুন কোন সার্ভিস কল করল।
curl -s -w ' %{http_code}\n' -X POST $BASE/ingest
curl -s -w ' %{http_code}\n' -X POST $BASE/ingest -H "X-API-Key: $INGEST_API_KEY"
curl -s -w ' %{http_code}\n' -X POST $BASE/ingest -H "X-API-Key: $NADIA"{"detail":"Invalid or missing API key"} 401
{"accepted":"yes","service":"ingest-service"} 200
{"detail":"Invalid or missing API key"} 401ব্যবহারকারীের অ্যাক্সেস টোকেন API কি নয়, উল্টোটাও সত্য: এগুলো ভিন্ন কলারের জন্য ভিন্ন ক্রেডেনশিয়াল, ভিন্ন ডিপেন্ডেন্সি দিয়ে যাচাই হয়।
কুকি নাকি বিয়ারার টোকেন, এবং CSRF
ব্রাউজার টোকেনটা রাখে কোথায়?
| Authorization হেডার (বিয়ারার) | কুকি | |
|---|---|---|
| কে জুড়ে দেয় | আপনার JavaScript, ইচ্ছা করে | ব্রাউজার, নিজে থেকেই, আপনার ডোমেইনের প্রতিটা রিকোয়েস্টে |
| XSS-এ চুরি | হ্যাঁ, যদি localStorage-এ থাকে | HttpOnly থাকলে না (স্ক্রিপ্ট পড়তে পারে না, যদিও রিকোয়েস্ট পাঠাতে পারে) |
| CSRF ঝুঁকি | নেই: ক্ষতিকর সাইট ব্রাউজারকে দিয়ে হেডার জোড়াতে পারে না | আছে: ক্ষতিকর সাইট ব্রাউজারকে দিয়ে কুকি পাঠাতে পারে |
| সবচেয়ে ভালো কোথায় | মোবাইল অ্যাপ, স্ক্রিপ্ট, অন্য সার্ভিস, LLM ক্লায়েন্ট, পাবলিক API | নিজের ডোমেইনের ওয়েব অ্যাপ (সাধারণ backend-for-frontend সেটআপ) |
CSRF (cross-site request forgery): নাদিয়া লগইন থাকা অবস্থায় অন্য একটা সাইট তার ব্রাউজারকে দিয়ে আপনার ডোমেইনে POST /chats/1/delete পাঠায়, আর ব্রাউজার সাহায্য করে তার কুকি জুড়ে দেয়। প্রতিরক্ষা, যা একসাথে ব্যবহার করতে হয়: কুকিতে SameSite=Lax বা Strict (ব্রাউজার ক্রস-সাইট POST-এ কুকি পাঠায় না), প্রতিটা আনসেফ মেথডের জন্য এমন CSRF টোকেন যা পেজকে হেডারে ফেরত পাঠাতে হয়, এবং Origin হেডার যাচাই। বিয়ারার-হেডার API-র এসবের কিছুই লাগে না, তাই API-র জন্য এটা পছন্দ করার ভালো কারণ। জরুরি অ্যাট্রিবিউটসহ কুকি সেট করা:
from fastapi import Response
response = Response()
response.set_cookie("access_token", "<token>", max_age=900, httponly=True, secure=True, samesite="lax", path="/")
print(response.headers["set-cookie"])access_token="<token>"; HttpOnly; Max-Age=900; Path=/; SameSite=lax; SecureHttpOnly কুকিকে JavaScript থেকে লুকায়, Secure শুধু HTTPS-এ পাঠায়, SameSite=lax ক্রস-সাইট POST আটকায়, আর Max-Age টোকেনের আয়ুর সঙ্গে মেলে। কুকি-ভিত্তিক API ডিপেন্ডেন্সিতে request.cookies পড়বে এবং CSRF টোকেনও যাচাই করবে; টোকেনের লজিক আপনার কাছে আগে থেকেই আছে।
OAuth2 ও OIDC: প্রোভাইডার দিয়ে ব্যবহারকারী লগইন করানো
অনেক প্রোডাক্ট পাসওয়ার্ডই বাদ দেয়: "Google/GitHub/Microsoft দিয়ে সাইন ইন"। HTTP গভীরে পাতায় PKCE-সহ authorization code ফ্লো বোঝানো হয়েছে। আপনার ব্যাকএন্ড যোগ করে শেষ ধাপটা। রিডিরেক্টের পর তার হাতে থাকে একটা ID টোকেন, প্রোভাইডারের অ্যাসিমেট্রিক কি দিয়ে সাইন করা JWT, আর বিশ্বাস করার আগে অন্য যেকোনো টোকেনের মতোই এটা যাচাই করতে হবে:
- প্রোভাইডারের JWKS URL থেকে তার পাবলিক কি আনুন (
PyJWKClientসেগুলো ক্যাশ করে) এবং টোকেন হেডারে নাম দেওয়া কি-টা বেছে নিন। - প্রত্যাশিত অ্যালগরিদম (RS256),
iss(প্রোভাইডার),aud(আপনার ক্লায়েন্ট id: অন্য অ্যাপের জন্য ইস্যু করা টোকেন আপনার নয়), মেয়াদ, আর শুরুতে পাঠানোnonceদিয়ে সিগনেচার যাচাই করুন। - স্থির
sub-কে (ইমেইল নয়, সেটা বদলাতে পারে) আপনার নিজেরusersটেবিলের সারির সঙ্গে মেলান, প্রথম লগইনে সারি বানিয়ে, তারপর ওপরের মতো আপনার নিজের সেশন বা JWT জোড়া ইস্যু করুন। রোল ও অবজেক্ট পারমিশন সবসময় আপনার ডেটাবেসেই থাকে।
import jwt
from jwt import PyJWKClient
jwks = PyJWKClient("https://accounts.example.com/.well-known/jwks.json") # caches keys
def verify_id_token(id_token: str, *, nonce: str) -> dict:
signing_key = jwks.get_signing_key_from_jwt(id_token)
claims = jwt.decode(id_token, signing_key, algorithms=["RS256"],
audience="my-client-id", issuer="https://accounts.example.com")
if claims.get("nonce") != nonce:
raise jwt.InvalidTokenError("nonce mismatch")
return claimsএই ব্লক এখানে চালানো হয় না: এর জন্য আসল প্রোভাইডার আর PyJWT-র cryptography এক্সট্রা লাগে (pip install "pyjwt[crypto]")। প্রোডাকশনে বেশিরভাগ টিম রিডিরেক্টের ঝামেলা সামলাতে নিয়মিত রক্ষণাবেক্ষণ হয় এমন লাইব্রেরি (Authlib, অথবা Auth0, Clerk, Keycloak বা Supabase Auth-এর মতো আইডেন্টিটি সার্ভিস) ব্যবহার করে, আর ওপরের যাচাইয়ের নিয়মগুলো ধরে রাখে।
লগইন রেট লিমিটিং
পাসওয়ার্ড অনুমান করা যায়, আর অন্য সাইট থেকে ফাঁস হওয়া পাসওয়ার্ডের তালিকা আপনার সাইটে চেষ্টা করা হয় (ক্রেডেনশিয়াল স্টাফিং)। Argon2 আক্রমণকারীর প্রতিটা অনুমান ব্যয়বহুল করে, কিন্তু বড় পরিসরে অনুমান অসম্ভব করে শুধু লিমিট। অ্যাপের LoginLimiter ইউজারনেম-ও-IP জোড়াকে ৬০ সেকেন্ডে ৫ বার ব্যর্থ হলে ব্লক করে, Retry-After-সহ 429 দেয়, আর সফল হলে রিসেট করে। ম্যালরি নামে কোনো ব্যবহারকারী নেই, তবু তাকে লিমিট করা হয়, যা ঠিক:
for i in 1 2 3 4 5 6 7; do
curl -s -o /dev/null -w '%{http_code} ' -X POST $BASE/auth/login -d 'username=mallory' -d "password=guess$i"
done; echo
curl -s -i -X POST $BASE/auth/login -d 'username=mallory' -d 'password=guess8' | tr -d '\r' | grep -icE '^retry-after'
curl -s -o /dev/null -w 'nadia can still log in: %{http_code}\n' -X POST $BASE/auth/login -d 'username=nadia' -d 'password=correct-horse-1'401 401 401 401 401 429 429
1
nadia can still log in: 200শুধু ইউজারনেমে লিমিট করলে আক্রমণকারী ইচ্ছা করে যেকোনো ব্যবহারকারীকে লক আউট করে দিতে পারত, তাই IP-র সঙ্গে মেলান (প্রক্সির পেছনে আসল ক্লায়েন্ট ঠিকানা পড়ুন বিশ্বস্ত হেডার থেকে, ক্লায়েন্ট জাল করতে পারে এমন কিছু থেকে নয়)। মেমরিতে কাউন্টার একটা প্রসেসের জন্য চলে; একাধিক ওয়ার্কার থাকলে এটা Redis-এ রাখতে হবে, Redis দিয়ে ক্যাশিং, রেট লিমিটিং ও নির্ভরযোগ্যতা-এর ফিক্সড ও স্লাইডিং উইন্ডো কৌশল দিয়ে।
সার্ভার বন্ধ করুন:
kill "$(cat server.pid)"অথ টেস্ট করা
অথ কোডের টেস্ট বেশি লাগে, কারণ এর বাগ নীরব: সবকিছু ঠিকঠাক চলা ব্যবহারকারীের জন্য সবই কাজ করে। পেশাদার API টেস্ট করা-র নিয়মগুলো এখানেও প্রযোজ্য: প্রতি টেস্টে নতুন অ্যাপ, প্রতিটা রুট লগইন ছাড়া, ভুল ব্যবহারকারী ও সঠিক ব্যবহারকারী দিয়ে টেস্ট, আর যে দুর্বলতা ঠিক করেন তার জন্য একটা রিগ্রেশন টেস্ট। টেস্টের টোকেন আসে স্পষ্ট সময়সহ make_token থেকে, কখনো sleep থেকে নয়।
# file: tests/test_auth.py
import os
from datetime import datetime, timedelta, timezone
import jwt
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("JWT_SECRET", "tutorial-only-secret-change-me-0123456789")
from app.main import create_app # noqa: E402
from app.security import make_token # noqa: E402
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("INGEST_API_KEY", "bb_test-key")
with TestClient(create_app()) as tc:
yield tc
def login(client, username, password="correct-horse-1", register=True):
if register:
client.post("/auth/register", json={"username": username, "password": password})
return client.post("/auth/login", data={"username": username, "password": password})
def bearer(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def test_register_login_and_me(client):
assert login(client, "nadia").status_code == 200
token = login(client, "nadia", register=False).json()["access_token"]
me = client.get("/me", headers=bearer(token))
assert me.status_code == 200
assert me.json() == {"id": 2, "username": "nadia", "role": "user"} # no password_hash
def test_wrong_password_and_unknown_user_look_the_same(client):
login(client, "nadia")
wrong = login(client, "nadia", password="not-the-password", register=False)
unknown = login(client, "nobody", register=False)
assert wrong.status_code == unknown.status_code == 401
assert wrong.json() == unknown.json()
def bad_token(kind: str) -> str:
now = datetime.now(timezone.utc)
claims = {"iss": "bitbyte-auth-demo", "sub": "1", "typ": "access", "fam": "f", "jti": "j",
"iat": now, "exp": now + timedelta(hours=1)}
return {
"expired": make_token("1", "access", now=now - timedelta(hours=1)),
"other_secret": jwt.encode(claims, "an-attacker-secret-that-is-long-enough", algorithm="HS256"),
"alg_none": jwt.encode(claims, key=None, algorithm="none"),
"refresh_as_access": make_token("1", "refresh"),
"garbage": "not.a.token",
}[kind]
@pytest.mark.parametrize("kind", ["expired", "other_secret", "alg_none", "refresh_as_access", "garbage"])
def test_protected_route_rejects_bad_tokens(client, kind):
resp = client.get("/me", headers=bearer(bad_token(kind)))
assert resp.status_code == 401
assert resp.headers["WWW-Authenticate"] == "Bearer"
def test_refresh_rotation_and_reuse_detection(client):
first = login(client, "nadia").json()
second = client.post("/auth/refresh", json={"refresh_token": first["refresh_token"]})
assert second.status_code == 200
replay = client.post("/auth/refresh", json={"refresh_token": first["refresh_token"]})
assert replay.status_code == 401
# the replay ended the whole session: the newer tokens are dead as well
assert client.post("/auth/refresh", json={"refresh_token": second.json()["refresh_token"]}).status_code == 401
assert client.get("/me", headers=bearer(second.json()["access_token"])).status_code == 401
def test_roles_and_bola(client):
nadia = login(client, "nadia").json()["access_token"]
tanvir = login(client, "tanvir").json()["access_token"]
root = login(client, "root", password="root-pass-1234", register=False).json()["access_token"]
chat = client.post("/chats", json={"title": "Private"}, headers=bearer(nadia)).json()
url = f"/chats/{chat['id']}"
assert client.get(url, headers=bearer(nadia)).status_code == 200
assert client.get(url, headers=bearer(tanvir)).status_code == 404 # BOLA regression test
assert client.get(url, headers=bearer(root)).status_code == 200
assert client.get("/admin/users", headers=bearer(nadia)).status_code == 403
assert client.get("/admin/users", headers=bearer(root)).status_code == 200
def test_login_is_rate_limited(client):
codes = [login(client, "mallory", password=f"guess-{i}-xx", register=False).status_code for i in range(7)]
assert codes == [401] * 5 + [429] * 2
def test_api_key(client):
assert client.post("/ingest").status_code == 401
assert client.post("/ingest", headers={"X-API-Key": "bb_wrong"}).status_code == 401
ok = client.post("/ingest", headers={"X-API-Key": "bb_test-key"})
assert ok.status_code == 200 and ok.json()["service"] == "ingest-service"uv run python -m pytest -q tests........... [100%]
11 passed in 5.60sটেস্ট কখনো টোকেনের মেয়াদ ফুরানোর অপেক্ষা করে না (এক ঘণ্টা আগে ইস্যু করা টোকেন সরাসরি বানানো হয়), আর টেস্টগুলোর মধ্যে স্টেট চুঁইয়ে পড়ে না, কারণ প্রতিটা টেস্ট create_app() ডাকে। অন্য টেস্টে app.dependency_overrides[current_user] দিয়ে লগইন এড়াতে পারেন; এই অথ টেস্টগুলো সেই টেস্টই থাকুক, যেগুলো লগইন এড়ায় না।
AI কাজে এটা কোথায় দেখা যায়
- LLM গেটওয়ে প্রতি টিম বা সার্ভিসকে একটা কি দেয়, কি-র ভিত্তিতে রেট লিমিট ও মিটারিং করে, আর টোকেন ব্যবহারের পাশে কি-র নাম লগ করে, যাতে খরচ কোনো মালিকের কাছে ফেরানো যায়।
- চ্যাট প্রোডাক্ট কথোপকথন, আপলোড করা ফাইল ও এমবেডিং ব্যবহারকারী ধরে জমা রাখে। প্রতিটা রিট্রিভাল কোয়েরিতে (RAG) মালিক বা টেন্যান্ট দিয়ে ফিল্টার করতে হবে, নইলে একজন কাস্টমারের ডকুমেন্ট থেকে আরেকজনকে উত্তর দেওয়া হয়: AI-র পোশাকে BOLA।
- এজেন্ট ও MCP সার্ভার ব্যবহারকারীের হয়ে আপনার API কল করে। মাস্টার কি-র বদলে তাদের স্বল্পায়ু, সংকীর্ণ স্কোপের টোকেন (OAuth) দিন, যাতে প্রম্পট-ইনজেক্ট করা এজেন্টও শুধু সেটুকুই করতে পারে যা সেই ব্যবহারকারী পারত।
সাধারণ ভুল
- পাসওয়ার্ডের জন্য দ্রুত হ্যাশ বা প্লেইন টেক্সট। একটা টেবিল ফাঁস হলেই প্রতিটা অ্যাকাউন্ট খোলা পড়ে যায়।
pwdlibদিয়ে Argon2id ব্যবহার করুন,verifyদিয়ে মেলান, আর পাসওয়ার্ড কখনো লগ করবেন না (রিকোয়েস্ট-বডি লগসহ)। - অ্যালগরিদম না বেঁধে দেওয়া, বা ক্লেইম চেক বাদ দেওয়া।
algorithmsছাড়াjwt.decode(token, key), বা "শুধু পড়ার জন্য"verify_signature=False, এভাবেইalg: noneও জাল টোকেন ঢোকে। সবসময়algorithms,issuerও বাধ্যতামূলক ক্লেইম দিন। - দীর্ঘায়ু অ্যাক্সেস টোকেন আর বাতিলের উপায় না থাকা। ৩০ দিনের অ্যাক্সেস JWT মানে ৩০ দিনের ব্রিচ। অ্যাক্সেস টোকেন মিনিটের মধ্যে রাখুন, রোটেশনসহ রিফ্রেশ করুন, আর লগআউট ও পুনর্ব্যবহারের জন্য ডিনাইলিস্ট রাখুন।
- লগইন দেখা কিন্তু মালিকানা না দেখা। যে
GET /chats/{id}শুধু বৈধ টোকেন চায়, সেটাই BOLA। id নেয় এমন প্রতিটা রুটে মালিক-চেক রাখুন, আর দ্বিতীয় ব্যবহারকারী দিয়ে একটা টেস্ট। - সিক্রেট ও টোকেন ভুল জায়গায়। Git-এ কমিট হয়ে যাওয়া
JWT_SECRET(AI প্রজেক্টে Git: ডেটা, মডেল, LFS, DVC ও ফাঁস হওয়া সিক্রেট পরিষ্কার করার উপায় দেখায়, আর কেন আগে সিক্রেট রোটেট করতে হয়), URL বা লগে টোকেন, বা পেলোডের ভেতর ব্যক্তিগত তথ্য। সিক্রেট আসে এনভায়রনমেন্ট থেকে; টোকেন চলে হেডারে।
নিজে চেষ্টা করুন
- সহজ: নির্দিষ্ট সময়
2026-01-15 04:00 UTC-তে ব্যবহারকারী"7"-এর জন্য ৫ মিনিটের একটা অ্যাক্সেস টোকেন বানান এবং এরsubআরiatওexp-এর মধ্যের সেকেন্ড ছাপুন। তারপর দেখান, রিফ্রেশ টোকেন প্রত্যাশিত হলেdecode_tokenএকটা অ্যাক্সেস টোকেন প্রত্যাখ্যান করে। - মাঝারি: নকল ঘড়ি দিয়ে (ঘুমানো ছাড়া)
LoginLimiterব্যবহার করে দেখান, তিনবার ব্যর্থ হলে একটা কি ৬০ সেকেন্ড ব্লক থাকে, আর ৬১ সেকেন্ড পরে আবার চেষ্টা করা যায়। - কঠিন:
TestClientদিয়ে চুরি যাওয়া রিফ্রেশ টোকেনের গল্প লিখুন: লগইন করুন, একবার রিফ্রেশ করুন, প্রথম রিফ্রেশ টোকেন রিপ্লে করুন, তারপর দ্বিতীয় ধাপের রিফ্রেশ টোকেন ব্যবহার করুন। তিনটা রিফ্রেশ কলের স্ট্যাটাস কোড ও এরর বার্তা ছাপুন।
উত্তর
# 1. Easy
import os
from datetime import datetime, timedelta, timezone
import jwt
from app.security import decode_token, make_token
fixed = datetime(2026, 1, 15, 4, 0, tzinfo=timezone.utc)
short = make_token("7", "access", now=fixed, ttl=timedelta(minutes=5))
claims = jwt.decode(short, os.environ["JWT_SECRET"], algorithms=["HS256"], options={"verify_exp": False})
print(claims["sub"], claims["exp"] - claims["iat"])
try:
decode_token(make_token("7", "access"), "refresh")
except jwt.InvalidTokenError as exc:
print("rejected:", exc)
# 2. Medium
from app.security import LoginLimiter
clock = [0.0]
limiter = LoginLimiter(max_failures=3, window=60, clock=lambda: clock[0])
for _ in range(3):
limiter.record_failure("nadia|10.0.0.1")
print("blocked for", limiter.retry_after("nadia|10.0.0.1"), "seconds")
clock[0] = 61
print("after 61 s:", limiter.retry_after("nadia|10.0.0.1"))7 300
rejected: expected a refresh token
blocked for 60 seconds
after 61 s: 0# 3. Hard
from fastapi.testclient import TestClient
from app.main import create_app
with TestClient(create_app()) as tc:
tc.post("/auth/register", json={"username": "nadia", "password": "correct-horse-1"})
first = tc.post("/auth/login", data={"username": "nadia", "password": "correct-horse-1"}).json()
second = tc.post("/auth/refresh", json={"refresh_token": first["refresh_token"]})
replay = tc.post("/auth/refresh", json={"refresh_token": first["refresh_token"]})
newest = tc.post("/auth/refresh", json={"refresh_token": second.json()["refresh_token"]})
print(second.status_code, replay.status_code, newest.status_code)
print(replay.json()["detail"], "|", newest.json()["detail"])200 401 401
Refresh token reuse detected, log in again | Session endedসারসংক্ষেপ
- অথেন্টিকেশন বলে "কে", অথরাইজেশন বলে "অনুমতি আছে কি"; প্রথমটার জন্য
WWW-Authenticate-সহ 401, দ্বিতীয়টার জন্য 403 বা 404, আর বেশি অনুমানের জন্যRetry-After-সহ 429 ফেরত দিন। - পাসওয়ার্ড হ্যাশ করুন Argon2id (
pwdlib) দিয়ে, অচেনা ব্যবহারকারীকে ভুল পাসওয়ার্ডের মতো দেখান, আর হ্যাশ কখনো ফেরত দেবেন বা লগ করবেন না। - JWT সাইন করা, এনক্রিপ্ট করা নয়: ক্লেইম ন্যূনতম রাখুন,
algorithmsবেঁধে দিন,iss,expও টোকেনের ধরন যাচাই করুন, আর সিক্রেট রাখুন এনভায়রনমেন্টে। - ছোট অ্যাক্সেস টোকেন আর একবার-ব্যবহারযোগ্য রোটেটিং রিফ্রেশ টোকেন ব্যবহার করুন, লগআউট ও রিপ্লে শনাক্তের জন্য শেষ হওয়া সেশনের ডিনাইলিস্টসহ।
- BOLA ঠেকাতে রোল (RBAC)-এর সঙ্গে id আছে এমন প্রতিটা রুটে অবজেক্টের মালিকানা-চেক মেলান; সার্ভিসকে নিজস্ব হ্যাশ-করা API কি দিন; API-র জন্য বিয়ারার হেডার পছন্দ করুন, আর কুকি ব্যবহার করলে SameSite ও CSRF প্রতিরক্ষা যোগ করুন।
- লগইন OIDC প্রোভাইডারের হাতে দেওয়া যায়, তবে তার ID টোকেন যাচাই করুন (সিগনেচার,
iss,aud,nonce) এবংsub-কে নিজের ব্যবহারকারীের সঙ্গে মিলিয়ে নিন; লগইনে রেট লিমিট দিন; প্রতিটা রুট লগইন ছাড়া, ভুল ব্যবহারকারী ও সঠিক ব্যবহারকারী হিসেবে টেস্ট করুন।
এরপর: Redis দিয়ে ক্যাশিং, রেট লিমিটিং ও নির্ভরযোগ্যতা ডিনাইলিস্ট ও লগইন কাউন্টারকে Redis-এ সরায়, আর যোগ করে ক্যাশিং, সঠিক রেট লিমিট, টাইমআউট, সার্কিট ব্রেকার ও রিকোয়েস্ট ID।