توسعه بک‌اند با FastAPI فهرست مطالب
فصل ۹

احراز هویت و امنیت رمز عبور

به کاربران اجازه می‌دهیم ثابت کنند چه کسی هستند. رمز را تنها به شیوه قابل قبول ذخیره می‌کنیم، یعنی یک درهم‌ساز کند و نمک‌دار Argon2، ثبت‌نام و ورود را با ابزارهای فرم OAuth2 خود FastAPI می‌سازیم و همه حالت‌های شکست را درست مدیریت می‌کنیم: رمز اشتباه، کاربر ناشناخته، ایمیل تکراری، حساب غیرفعال و رمز ضعیف. فصل بعد ورود موفق را به یک توکن JWT تبدیل می‌کند.

در این فصل می‌خوانید
  1. احراز هویت یا مجوز؟
  2. رمز را هرگز ذخیره نکنید، فقط درهم‌سازش را
  3. ثبت‌نام
  4. جریان رمز OAuth2
  5. شکست‌های ورود، درست و امن
  6. پروژه: ثبت‌نام و ورود
  7. خلاصه فصل

۱. احراز هویت یا مجوز؟

واژهسؤالپاسخ منفی
احراز هویت (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!)

در این دوره از 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 هیچ‌کدام را ندارد.

ثبت‌نام عمومی هرگز role نمی‌پذیرد

وگرنه هر کسی به عنوان مدیر ثبت‌نام می‌کند. 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

۵. شکست‌های ورود، درست و امن

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

۶. پروژه: ثبت‌نام و ورود

یک ستون تازه یعنی یک مهاجرت تازه. اما جدول کاربران از قبل ردیف دارد و ستون 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

ورود فعلاً «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همراه هر پاسخ ۴۰۱
یک پیام برای دو شکستجلوگیری از شناسایی کاربران
درهم‌ساز ساختگیجلوگیری از حمله زمانی
'!' در مهاجرتستون رمز برای کاربران موجود

تمرین‌های این فصل

  1. سه بخش یک رشته درهم‌ساز Argon2 را چاپ کنید. کدام بخش مخفی است؟ (سؤال انحرافی است.)
  2. در 02_register فیلد password_repeat و بررسی برابری آن را اضافه کنید و مطمئن شوید ذخیره نمی‌شود.
  3. در 04_login_errors خط درهم‌ساز ساختگی را حذف کنید و زمان بیست ورود با ایمیل ناشناخته و بیست ورود با رمز اشتباه را با یک اسکریپت httpx اندازه بگیرید.
  4. محدودیت تلاش را طوری تغییر دهید که بعد از پنج دقیقه بازنشانی شود.
  5. در پروژه POST /auth/change-password بنویسید؛ رمز قدیمی اشتباه ۴۰۱ بگیرد.
  6. ستون last_login_at را با نسخه ۰۰۰۴ اضافه و در authenticate مقداردهی کنید.
  7. هر ورود ناموفق را با logging ثبت کنید: ایمیل و زمان، هرگز رمز.
  8. تکلیف: حساب اعضا را به کتابخانه اضافه کنید: ثبت‌نام، ورود با فرم OAuth2، Argon2، یک پیام برای ایمیل ناشناخته و رمز اشتباه و ۴۰۳ برای اعضای مسدود.
فصل قبل: Alembic، صفحه‌بندی، فیلتر و مرتب‌سازی فصل بعد: JWT، کاربر جاری و نقش‌ها