احراز هویت و امنیت رمز عبور
به کاربران اجازه میدهیم ثابت کنند چه کسی هستند. رمز را تنها به شیوه قابل قبول ذخیره میکنیم، یعنی یک درهمساز کند و نمکدار Argon2، ثبتنام و ورود را با ابزارهای فرم OAuth2 خود FastAPI میسازیم و همه حالتهای شکست را درست مدیریت میکنیم: رمز اشتباه، کاربر ناشناخته، ایمیل تکراری، حساب غیرفعال و رمز ضعیف. فصل بعد ورود موفق را به یک توکن JWT تبدیل میکند.
۱. احراز هویت یا مجوز؟
| واژه | سؤال | پاسخ منفی |
|---|---|---|
| احراز هویت (authentication) | «شما کیستید؟» | ۴۰۱ Unauthorized |
| مجوز (authorization) | «اجازه دارید؟» | ۴۰۳ Forbidden |
این فصل درباره اولی است: کاربر با رمزش ثابت میکند کیست. فصل دهم درباره دومی است.
۲. رمز را هرگز ذخیره نکنید، فقط درهمسازش را
اگر پایگاه داده لو برود، رمزهای خام فاجعهاند؛ مردم رمزشان را همهجا تکرار میکنند. درهمساز یک تابع یکطرفه است: محاسبهاش آسان است و برگرداندنش ناممکن. برای بررسی ورود، آنچه کاربر تایپ کرده را درهمساز میکنیم و مقایسه میکنیم.
اما هر درهمسازی مناسب نیست:
import hashlib
import time
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended() # Argon2 with sensible settings
def main() -> None:
first = password_hash.hash('Password123')
second = password_hash.hash('Password123')
print(first)
print('same password, same hash?', first == second) # False: different salts
print('correct password:', password_hash.verify('Password123', first))
print('wrong password :', password_hash.verify('password123', first))
started = time.perf_counter()
for _ in range(10):
password_hash.hash('Password123')
argon = (time.perf_counter() - started) / 10
started = time.perf_counter()
for _ in range(100_000):
hashlib.sha256(b'Password123').hexdigest()
sha = (time.perf_counter() - started) / 100_000
print(f'argon2: {argon * 1000:.1f} ms per hash')
print(f'sha256: {sha * 1_000_000:.2f} microseconds per hash ({argon / sha:,.0f}x faster!)')
print('An attacker wants the FAST one. We choose the slow one.')
if __name__ == '__main__':
main()
Argon2 در برابر SHA-256: نمونه 01_password_hashing/app.py
$argon2id$v=19$m=65536,t=3,p=4$T2AXTwwq52nd4808+7ll0Q$1lr0b6XKLYlRX7Auae+fW3cIHkLiC8pP13uUSBQva5I same password, same hash? False correct password: True wrong password : False argon2: 52.5 ms per hash sha256: 0.27 microseconds per hash (196,446x faster!)
- MD5 و SHA-256 سریعاند: یک کارت گرافیک میلیاردها حدس در ثانیه امتحان میکند.
- Argon2 و bcrypt عمداً کندند و از یک نمک تصادفی استفاده میکنند؛ پس یک رمز هر بار درهمساز متفاوتی دارد و جدولهای از پیش محاسبهشده بیفایده میشوند.
- رشته درهمساز خودش الگوریتم، تنظیمات و نمک را در خود دارد.
در این دوره از pwdlib با Argon2 استفاده میکنیم؛ همان چیزی که مستندات FastAPI پیشنهاد میکند.
۳. ثبتنام
def strong_password(value: str) -> str:
if value.isdigit() or value.isalpha():
raise ValueError('use letters AND digits')
if value.lower() in {'password1', 'password123', 'qwerty123'}:
raise ValueError('this password is too common')
return value
Password = Annotated[str, Field(min_length=8, max_length=128), AfterValidator(strong_password)]
class UserCreate(BaseModel):
email: EmailStr
full_name: str = Field(min_length=2)
password: Password
class UserRead(BaseModel):
id: int
email: EmailStr
full_name: str
role: str
USERS: dict[str, dict] = {} # email -> stored user
app = FastAPI()
@app.post('/auth/register', status_code=status.HTTP_201_CREATED)
def register(data: UserCreate) -> UserRead:
email = data.email.lower()
if email in USERS:
raise HTTPException(status.HTTP_409_CONFLICT, detail='Email already registered')
user = {
'id': len(USERS) + 1,
'email': email,
'full_name': data.full_name,
'role': 'student', # ALWAYS student here
'hashed_password': password_hash.hash(data.password),
}
USERS[email] = user
return UserRead(**user)
اعتبارسنجی رمز، ذخیره درهمساز: نمونه 02_register/app.py
سه طرحواره فصل سوم حالا دلیل امنیتی دارند: UserCreate رمز خام را دارد (فقط در حافظه و فقط در مسیر ورودی)، کاربر ذخیرهشده hashed_password دارد و UserRead هیچکدام را ندارد.
وگرنه هر کسی به عنوان مدیر ثبتنام میکند. Pydantic فیلدهای ناشناخته را نادیده میگیرد، پس کلاینتی که "role": "admin" بفرستد باز هم دانشجو میشود.
۴. جریان رمز OAuth2
روش استاندارد ورود به یک API: کلاینت یک فرم (نه JSON) با username و password به نشانی توکن میفرستد و یک توکن دسترسی میگیرد. بعد همان توکن را در هر درخواست میفرستد:
POST /auth/token body: username=sara@example.com&password=...
<- {"access_token": "...", "token_type": "bearer"}
GET /users/me header: Authorization: Bearer <access_token>
@app.post('/auth/token')
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> dict[str, str]:
user = USERS.get(form.username.lower())
if user is None or not password_hash.verify(form.password, user['hashed_password']):
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
detail='Incorrect email or password',
headers={'WWW-Authenticate': 'Bearer'},
)
token = secrets.token_urlsafe(32)
TOKENS[token] = user['email']
return {'access_token': token, 'token_type': 'bearer'}
def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> dict:
email = TOKENS.get(token)
if email is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
detail='Invalid token',
headers={'WWW-Authenticate': 'Bearer'},
)
return USERS[email]
@app.get('/users/me')
def read_me(user: Annotated[dict, Depends(get_current_user)]) -> dict[str, str]:
return {'email': user['email'], 'full_name': user['full_name']}
فرم ورود و خواندن توکن: نمونه 03_oauth2_password_form/app.py
OAuth2PasswordRequestFormفرم را میخواند و بهpython-multipartنیاز دارد. فیلدusernameنام دارد حتی وقتی ایمیل است؛ OAuth2 اینطور تعریف کرده.OAuth2PasswordBearer(tokenUrl=...)وابستگیای است که هدرAuthorization: Bearer ...را میخواند (اگر نباشد ۴۰۱) و در/docsیک دکمه Authorize اضافه میکند.- توکن این مثال یک رشته تصادفی در دیکشنری است. فصل دهم آن را با JWT امضاشده عوض میکند که به ذخیره در سرور نیاز ندارد.
۵. شکستهای ورود، درست و امن
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended()
DUMMY_HASH = password_hash.hash('dummy-password')
MAX_FAILURES = 5
USERS = {
'sara@example.com': {'hashed_password': password_hash.hash('Password123'), 'is_active': True},
'old@example.com': {'hashed_password': password_hash.hash('Password123'), 'is_active': False},
}
FAILURES: dict[str, int] = {}
app = FastAPI()
def invalid_credentials() -> HTTPException:
return HTTPException(
status.HTTP_401_UNAUTHORIZED,
detail='Incorrect email or password',
headers={'WWW-Authenticate': 'Bearer'},
)
@app.post('/auth/login')
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> dict:
started = time.perf_counter()
email = form.username.lower()
if FAILURES.get(email, 0) >= MAX_FAILURES:
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many attempts, wait')
user = USERS.get(email)
if user is None:
password_hash.verify(form.password, DUMMY_HASH) # spend the same time
raise invalid_credentials()
valid, new_hash = password_hash.verify_and_update(form.password, user['hashed_password'])
if not valid:
FAILURES[email] = FAILURES.get(email, 0) + 1
raise invalid_credentials()
if not user['is_active']:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail='This account is disabled')
if new_hash:
user['hashed_password'] = new_hash
FAILURES.pop(email, None)
return {'login': 'ok', 'took_ms': round((time.perf_counter() - started) * 1000)}
if __name__ == '__main__':
uvicorn.run('app:app', reload=True)
شناسایی کاربران، حمله زمانی و تلاشهای زیاد: نمونه 04_login_errors/app.py
| وضعیت | پاسخ | پیام |
|---|---|---|
| ایمیل ناشناخته | ۴۰۱ | Incorrect email or password |
| رمز اشتباه | ۴۰۱ | Incorrect email or password |
| حساب غیرفعال | ۴۰۳ | This account is disabled |
| تلاشهای زیاد | ۴۲۹ | Too many attempts |
- یک پیام برای ایمیل ناشناخته و رمز اشتباه. در غیر این صورت مهاجم میفهمد کدام ایمیلها ثبت شدهاند (شناسایی کاربران).
- درهمساز ساختگی برای ایمیل ناشناخته. درهمسازی حدود پنجاه میلیثانیه طول میکشد. بدون آن «کاربر ناشناخته» خیلی سریعتر از «رمز اشتباه» پاسخ میگیرد و مهاجم همان اطلاعات را از زمان پاسخ میفهمد.
- بررسی «غیرفعال» بعد از بررسی رمز است: فقط صاحب واقعی حساب میفهمد حساب غیرفعال است.
verify_and_updateاگر درهمساز ذخیرهشده با تنظیمات قدیمیتر ساخته شده باشد، درهمساز تازه برمیگرداند و رمزها هنگام ورود بیصدا ارتقا مییابند.
۶. پروژه: ثبتنام و ورود
یک ستون تازه یعنی یک مهاجرت تازه. اما جدول کاربران از قبل ردیف دارد و ستون NOT NULL تازه نمیتواند خالی بماند:
"""add users.hashed_password (session 9)
Revision ID: 0003
Revises: 0002
Create Date: 2026-09-24
Existing users get the value '!', which is not a valid hash: nobody can log
in with it, so those users must set a new password. Never invent real
passwords in a migration.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = '0003'
down_revision: str | None = '0002'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(
sa.Column('hashed_password', sa.String(length=255), nullable=False, server_default='!')
)
def downgrade() -> None:
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('hashed_password')
ستون رمز برای کاربرانی که از قبل هستند: 03_add_user_hashed_password.py
مقدار '!' یک درهمساز معتبر نیست؛ هیچکس نمیتواند با آن وارد شود و این کاربران باید رمز تازه بگذارند. هرگز در مهاجرت رمز واقعی اختراع نکنید.
def authenticate(self, email: str, password: str) -> User:
"""Return the user if the credentials are right, otherwise raise.
Unknown e-mail and wrong password give the SAME error, so an attacker
can not find out which e-mails are registered.
"""
user = self.db.scalar(select(User).where(User.email == email.lower()))
if user is None:
verify_password(password, '!') # same time as a real check
raise AuthenticationError('Incorrect email or password')
valid, new_hash = verify_password(password, user.hashed_password)
if not valid:
raise AuthenticationError('Incorrect email or password')
if not user.is_active:
raise PermissionDeniedError('This account is disabled')
if new_hash is not None:
user.hashed_password = new_hash
self.db.commit()
return user
سرویس احراز هویت: 05_project/app/services.py
POST /auth/registerثبتنام عمومی است و همیشه دانشجو میسازد.POST /auth/loginفرم OAuth2 میگیرد و برای شکستها همان جدول بالا را اجرا میکند.POST /usersحالا فقط مخصوص مدیر است و کاربر را با رمز و هر نقشی میسازد.
ورود فعلاً «Login successful» و خود کاربر را برمیگرداند. فصل بعد آن را به یک توکن دسترسی JWT تبدیل میکند و X-API-Key را با کاربران و نقشهای واقعی جایگزین میکند.
در /docs endpoint ورود یک فرم است. آن را آنجا و در Bruno امتحان کنید. در Bruno درخواستی بفرستید که ورود را با JSON به جای فرم انجام دهد. چه کدی میگیرید؟
پوشه S09-authentication پنج پوشه دارد. برای پروژه: alembic upgrade head (نسخه ۰۰۰۳ ستون رمز را اضافه میکند)، بعد python seed.py و python main.py. رمز همه حسابهای نمونه Password123 است.
۷. خلاصه فصل
| ابزار | وظیفه آن |
|---|---|
PasswordHash.recommended() | Argon2 با تنظیمات مناسب |
hash، verify، verify_and_update | درهمسازی، بررسی، ارتقای بیصدا |
Annotated[str, Field(...), AfterValidator(...)] | قاعده رمز قابل استفاده دوباره |
OAuth2PasswordRequestForm | خواندن فرم ورود |
OAuth2PasswordBearer(tokenUrl=...) | خواندن توکن و دکمه Authorize |
هدر WWW-Authenticate: Bearer | همراه هر پاسخ ۴۰۱ |
| یک پیام برای دو شکست | جلوگیری از شناسایی کاربران |
| درهمساز ساختگی | جلوگیری از حمله زمانی |
'!' در مهاجرت | ستون رمز برای کاربران موجود |
تمرینهای این فصل
- سه بخش یک رشته درهمساز Argon2 را چاپ کنید. کدام بخش مخفی است؟ (سؤال انحرافی است.)
- در
02_registerفیلدpassword_repeatو بررسی برابری آن را اضافه کنید و مطمئن شوید ذخیره نمیشود. - در
04_login_errorsخط درهمساز ساختگی را حذف کنید و زمان بیست ورود با ایمیل ناشناخته و بیست ورود با رمز اشتباه را با یک اسکریپت httpx اندازه بگیرید. - محدودیت تلاش را طوری تغییر دهید که بعد از پنج دقیقه بازنشانی شود.
- در پروژه
POST /auth/change-passwordبنویسید؛ رمز قدیمی اشتباه ۴۰۱ بگیرد. - ستون
last_login_atرا با نسخه ۰۰۰۴ اضافه و درauthenticateمقداردهی کنید. - هر ورود ناموفق را با
loggingثبت کنید: ایمیل و زمان، هرگز رمز. - تکلیف: حساب اعضا را به کتابخانه اضافه کنید: ثبتنام، ورود با فرم OAuth2، Argon2، یک پیام برای ایمیل ناشناخته و رمز اشتباه و ۴۰۳ برای اعضای مسدود.