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

JWT، کاربر جاری و نقش‌ها

ورود موفق را به یک توکن دسترسی JWT تبدیل می‌کنیم، در هر درخواست می‌فهمیم چه کسی صدا زده است و تصمیم می‌گیریم چه کاری مجاز است: نقش‌های مدیر، مدرس و دانشجو با یک کارخانه وابستگی قابل استفاده دوباره، و قاعده‌هایی که به داده بستگی دارند، مثل «مدرس فقط دوره‌های خودش را تغییر دهد». کلید موقت X-API-Key فصل‌های پیش حذف می‌شود و بیشتر وقت کلاس به حمله به این قاعده‌ها از Bruno می‌گذرد.

در این فصل می‌خوانید
  1. JWT: کارت شناسایی امضاشده‌ای که کلاینت نگه می‌دارد
  2. ادعاها و انقضا
  3. وابستگی کاربر جاری
  4. نقش‌ها: کارخانه وابستگی
  5. فراتر از نقش: قاعده مالکیت
  6. OAuth2 scopes، برای شناختن
  7. پروژه: JWT و نقش‌ها
  8. خلاصه فصل

۱. JWT: کارت شناسایی امضاشده‌ای که کلاینت نگه می‌دارد

یک JWT سه بخش base64 است که با نقطه از هم جدا شده‌اند:

header . payload . signature
{"alg":"HS256"} . {"sub":"7","role":"student","exp":...} . <HMAC of both>
def main() -> None:
    token = jwt.encode({'sub': '7', 'role': 'student'}, SECRET_KEY, algorithm=ALGORITHM)
    print(token)

    header, payload, signature = token.split('.')
    padded = payload + '=' * (-len(payload) % 4)
    print('anyone can read the payload:', json.loads(base64.urlsafe_b64decode(padded)))

    print('decoded by the server:', jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]))

    # A student makes themself admin by editing the payload...
    fake_payload = base64.urlsafe_b64encode(b'{"sub":"7","role":"admin"}').decode().rstrip('=')
    forged = f'{header}.{fake_payload}.{signature}'
    try:
        jwt.decode(forged, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.InvalidSignatureError as error:
        print('forged token rejected:', error)

    try:
        jwt.decode(token, 'another-server-with-another-secret-key-0000000', algorithms=[ALGORITHM])
    except jwt.InvalidSignatureError as error:
        print('wrong key rejected   :', error)

    # `algorithms=[...]` is a white list: never let the token choose ("alg": "none" attack)
    unsigned = jwt.encode({'sub': '1', 'role': 'admin'}, key=None, algorithm='none')
    try:
        jwt.decode(unsigned, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.InvalidAlgorithmError as error:
        print('alg=none rejected    :', error)

خواندن، جعل و رد شدن: نمونه 01_jwt_encode_decode/app.py

anyone can read the payload: {'sub': '7', 'role': 'student'}
forged token rejected: Signature verification failed
wrong key rejected   : Signature verification failed
alg=none rejected    : The specified alg value is not allowed
algorithms یک فهرست سفید است

در jwt.decode(..., algorithms=['HS256']) هرگز نگذارید خود توکن الگوریتم را انتخاب کند. حمله معروف "alg": "none" توکنی بی‌امضا می‌فرستد و امیدوار است سرور آن را بپذیرد.

۲. ادعاها و انقضا

ادعاهای استاندارد: sub یعنی چه کسی (یک رشته، طبق قاعده JWT)، exp زمان انقضا و iat زمان صدور. به‌علاوه ادعای خود ما: role.

def create_token(user_id: int, minutes: float) -> str:
    now = datetime.now(UTC)
    claims = {'sub': str(user_id), 'iat': now, 'exp': now + timedelta(minutes=minutes)}
    return jwt.encode(claims, SECRET_KEY, algorithm='HS256')


def check(token: str, leeway: int = 0) -> str:
    try:
        claims = jwt.decode(
            token,
            SECRET_KEY,
            algorithms=['HS256'],
            options={'require': ['exp', 'iat', 'sub']},
            leeway=leeway,
        )
    except jwt.ExpiredSignatureError:
        return 'EXPIRED -> 401, please log in again'
    except jwt.MissingRequiredClaimError as error:
        return f'INVALID -> {error}'
    expires = datetime.fromtimestamp(claims['exp'], UTC)
    return f'valid for user {claims["sub"]}, expires {expires:%H:%M:%S}'

انقضا و ادعاهای الزامی: نمونه 02_token_expiry/app.py

توکن دزدیده‌شده تا زمان انقضا کار می‌کند؛ سرور فهرستی ندارد که آن را از رویش پاک کند. پس توکن‌های دسترسی عمر کوتاهی دارند، معمولاً پانزده تا شصت دقیقه.

۳. وابستگی کاربر جاری

@app.post('/auth/token')
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> dict[str, str]:
    user = next((u for u in USERS.values() if u.email == form.username.lower()), None)
    if user is None or not password_hash.verify(form.password, user.hashed_password):
        raise CREDENTIALS_ERROR
    expires = datetime.now(UTC) + timedelta(minutes=30)
    token = jwt.encode(
        {'sub': str(user.id), 'role': user.role, 'exp': expires}, SECRET_KEY, ALGORITHM
    )
    return {'access_token': token, 'token_type': 'bearer'}


def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
    try:
        claims = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.InvalidTokenError:  # bad signature, expired, malformed...
        raise CREDENTIALS_ERROR from None
    user = USERS.get(int(claims['sub']))
    if user is None:
        raise CREDENTIALS_ERROR
    if not user.is_active:
        raise HTTPException(status.HTTP_403_FORBIDDEN, detail='Account disabled')
    return user


CurrentUser = Annotated[User, Depends(get_current_user)]


@app.get('/users/me')
def read_me(user: CurrentUser) -> dict:
    return {'id': user.id, 'email': user.email, 'role': user.role}

از توکن تا کاربر: نمونه 03_current_user/app.py

get_current_user قلب ماجراست:

  1. OAuth2PasswordBearer هدر را می‌خواند (اگر نباشد ۴۰۱).
  2. JWT رمزگشایی و امضا و انقضایش بررسی می‌شود (اگر نامعتبر باشد ۴۰۱).
  3. کاربر از «پایگاه داده» بارگذاری می‌شود (اگر نباشد ۴۰۱ و اگر غیرفعال باشد ۴۰۳).

هر endpoint محافظت‌شده فقط CurrentUser را می‌خواهد. همین.

۴. نقش‌ها: کارخانه وابستگی

def require_roles(*roles: str) -> Callable[[User], User]:
    def checker(user: CurrentUser) -> User:
        if user.role not in roles:
            raise HTTPException(
                status.HTTP_403_FORBIDDEN, detail=f'Requires one of the roles: {", ".join(roles)}'
            )
        return user

    return checker


AdminUser = Annotated[User, Depends(require_roles('admin'))]
StaffUser = Annotated[User, Depends(require_roles('admin', 'instructor'))]
StudentUser = Annotated[User, Depends(require_roles('student'))]


@app.get('/users/me')
def me(user: CurrentUser) -> User:
    return user


@app.post('/courses')
def create_course(user: StaffUser) -> dict[str, str]:
    return {'created by': user.email}


@app.delete('/courses/{course_id}')
def delete_course(course_id: int, admin: AdminUser) -> dict[str, str | int]:
    return {'deleted': course_id, 'by': admin.email}


@app.post('/courses/{course_id}/enrollments')
def enroll(course_id: int, student: StudentUser) -> dict[str, str | int]:
    return {'enrolled': student.email, 'course': course_id}

require_roles و نام‌های مستعار: نمونه 04_role_dependency/app.py

این همان تابع مرتبه بالای فصل اول است: require_roles یک بستار برمی‌گرداند که roles را به خاطر دارد. endpointها مثل یک جمله خوانده می‌شوند: def delete_course(course_id: int, admin: AdminUser).

کاربرساختن دورهحذف دورهثبت‌نام
admin@kadoos.ir۲۰۰۲۰۰۴۰۳
teacher@kadoos.ir۲۰۰۴۰۳۴۰۳
sara@example.com۴۰۳۴۰۳۲۰۰
بدون توکن۴۰۱۴۰۱۴۰۱

۵. فراتر از نقش: قاعده مالکیت

نقش می‌گوید کاربر از چه نوعی است. اما «مدرس دوره‌های خودش را تغییر دهد» را نقش به‌تنهایی نمی‌تواند تعیین کند؛ باید دوره را بارگذاری کنیم و course.instructor_id را با user.id مقایسه کنیم. این قاعده‌ها در سرویس، درست بعد از بارگذاری شیء، و به شکل توابع ساده بولی نوشته می‌شوند:

"""Authorization rules that depend on the data, not only on the role."""

from app.exceptions import PermissionDeniedError
from app.models import Course, User


def can_manage_course(course: Course, user: User) -> bool:
    """Admins manage every course; instructors only their own."""
    if user.role == 'admin':
        return True
    return user.role == 'instructor' and course.instructor_id == user.id


def ensure_can_manage_course(course: Course, user: User) -> None:
    if not can_manage_course(course, user):
        raise PermissionDeniedError('You can only manage your own courses')

قاعده‌های وابسته به داده: 07_project/app/permissions.py

۶. OAuth2 scopes، برای شناختن

نقش‌ها درشت‌اند. scopeها توانایی‌های تکی را نام می‌برند: courses:read، courses:write. توکن فهرست scopeهای اعطاشده را دارد و هر endpoint با Security(...) به جای Depends(...) می‌گوید چه چیزی لازم دارد:

def get_current_user(
    security_scopes: SecurityScopes, token: Annotated[str, Depends(oauth2_scheme)]
) -> dict:
    try:
        claims = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
    except jwt.InvalidTokenError:
        raise HTTPException(401, detail='Invalid token') from None
    missing = [s for s in security_scopes.scopes if s not in claims['scopes']]
    if missing:
        raise HTTPException(
            status.HTTP_403_FORBIDDEN,
            detail=f'Missing scopes: {missing}',
            headers={'WWW-Authenticate': f'Bearer scope="{security_scopes.scope_str}"'},
        )
    return claims


@app.get('/courses')
def list_courses(
    user: Annotated[dict, Security(get_current_user, scopes=['courses:read'])],
) -> dict:
    return {'courses': ['FastAPI'], 'your scopes': user['scopes']}


@app.post('/courses')
def create_course(
    user: Annotated[dict, Security(get_current_user, scopes=['courses:write'])],
) -> dict:
    return {'created by': user['sub']}

Security و SecurityScopes: نمونه 06_scopes/app.py

کاربرد رایج: یک برنامه شخص ثالث توکنی می‌گیرد که فقط courses:read دارد، حتی اگر کسی که به آن اجازه داده مدیر باشد. در این دوره مجوز را با نقش‌ها پیاده می‌کنیم؛ scopeها را معرفی می‌کنیم تا در پروژه‌های دیگر آنها را بشناسید.

۷. پروژه: JWT و نقش‌ها

def create_access_token(user_id: int, role: str, settings: Settings) -> str:
    now = datetime.now(UTC)
    claims = {
        'sub': str(user_id),
        'role': role,
        'iat': now,
        'exp': now + timedelta(minutes=settings.access_token_expire_minutes),
    }
    return jwt.encode(claims, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)


def decode_access_token(token: str, settings: Settings) -> TokenPayload:
    """Check signature and expiry; every problem becomes AuthenticationError (401)."""
    try:
        claims = jwt.decode(
            token,
            settings.jwt_secret_key,
            algorithms=[settings.jwt_algorithm],  # a white list, never from the token
            options={'require': ['sub', 'exp', 'iat']},
        )
        return TokenPayload.model_validate(claims)
    except jwt.ExpiredSignatureError:
        raise AuthenticationError('Token has expired') from None
    except (jwt.InvalidTokenError, ValidationError):
        raise AuthenticationError('Could not validate credentials') from None

ساختن و بررسی توکن: 07_project/app/security.py

SettingsDep = Annotated[Settings, Depends(get_settings)]
DbSession = Annotated[Session, Depends(get_db)]

# Reads "Authorization: Bearer <token>" (401 if missing) and adds the
# Authorize button to /docs, which posts the login form to tokenUrl.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/auth/token')
TokenDep = Annotated[str, Depends(oauth2_scheme)]


def get_current_user(token: TokenDep, db: DbSession, settings: SettingsDep) -> User:
    payload = decode_access_token(token, settings)
    user = db.get(User, int(payload.sub))
    if user is None:
        raise AuthenticationError('The user of this token no longer exists')
    if not user.is_active:
        raise PermissionDeniedError('This account is disabled')
    return user


CurrentUser = Annotated[User, Depends(get_current_user)]


def require_roles(*roles: str) -> Callable[[User], User]:
    """A dependency factory: `Depends(require_roles('admin', 'instructor'))`."""

    def checker(user: CurrentUser) -> User:
        if user.role not in roles:
            raise PermissionDeniedError(f'This action requires the role: {" or ".join(roles)}')
        return user

    return checker


AdminUser = Annotated[User, Depends(require_roles('admin'))]
StaffUser = Annotated[User, Depends(require_roles('admin', 'instructor'))]
StudentUser = Annotated[User, Depends(require_roles('student'))]

کاربر جاری و نقش‌ها: 07_project/app/deps.py

کارچه کسی
دیدن دوره‌هاهمه
ساختن دورهمدیر (مدرس را انتخاب می‌کند) یا مدرس (برای خودش)
تغییر دورهمدیر، یا مدرسِ همان دوره
حذف دوره، مدیریت کاربرانمدیر
دیدن دانشجویان یک دورهمدیر، یا مدرس آن دوره
ثبت‌نام و لغودانشجوی واردشده، برای خودش
/users/me و ثبت‌نام‌های منهر کاربر واردشده

در مجموعه Bruno این فصل، چهار درخواست ورود اول توکن‌ها را در متغیرهای adminToken، teacherToken، teacher2Token و studentToken ذخیره می‌کنند و بقیه درخواست‌ها از آنها استفاده می‌کنند. بیشتر درخواست‌ها عمداً باید ۴۰۱ یا ۴۰۳ بگیرند.

امتحان کنید

یک توکن پروژه را در jwt.io بچسبانید. چه چیزی می‌توانید بخوانید؟ برای تغییر آن بدون اینکه لو بروید چه چیزی لازم دارید؟

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

پوشه S10-jwt-and-roles هفت پوشه دارد. در نمونه ۰۴ با دکمه Authorize در /docs با admin@kadoos.ir، teacher@kadoos.ir یا sara@example.com (رمز Password123) وارد شوید و همه endpointها را امتحان کنید. پروژه: alembic upgrade head، python seed.py، python main.py.

۸. خلاصه فصل

ابزاروظیفه آن
jwt.encode، jwt.decode(..., algorithms=[...])ساختن و بررسی توکن
sub، exp، iat، roleادعاهای توکن
ExpiredSignatureError، InvalidTokenErrorتوکن منقضی یا نامعتبر، یعنی ۴۰۱
OAuth2PasswordBearerخواندن هدر Bearer
get_current_user و CurrentUserاز توکن تا کاربر
require_roles(...)کارخانه وابستگی نقش
can_manage_course(course, user)قاعده مالکیت
Security(...) و SecurityScopesمجوز با scope
JWT_SECRET_KEY در .envکلید مخفی، هرگز در کد

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

  1. در 01_jwt_encode_decode ادعای email اضافه کنید. آیا باید آنجا باشد؟ password_hash چطور؟
  2. ACCESS_TOKEN_EXPIRE_MINUTES=1 را در .env پروژه بگذارید، وارد شوید، صبر کنید و /users/me را صدا بزنید. بعد JWT_SECRET_KEY را عوض کنید. توکن‌های قبلی چه می‌شوند؟
  3. در 04_role_dependency نقش support بسازید که فهرست کاربران را بخواند ولی چیزی حذف نکند.
  4. در 05_ownership_rules تغییر نام دوره را فقط پیش از شروع دوره مجاز کنید. برای «دیر شده» چه کدی مناسب است؟
  5. در 06_scopes به عنوان مدیر فقط با courses:read وارد شوید و سعی کنید دوره بسازید.
  6. در پروژه PATCH /users/me و POST /users/me/password را بنویسید.
  7. با Bruno ثابت کنید کاربر غیرفعال با توکن قدیمی‌اش هم رد می‌شود و خط کدی که این کار را می‌کند پیدا کنید.
  8. تکلیف: کتابخانه را با JWT و نقش‌های librarian و member محافظت کنید: اعضا فقط برای خودشان امانت می‌گیرند و فقط امانت‌های خودشان را می‌بینند و کتابداران همه چیز را مدیریت می‌کنند. برای هر حالت ۴۰۳ یک درخواست Bruno بنویسید.
فصل قبل: احراز هویت و امنیت رمز عبور فصل بعد: هدر، کوکی، فرم، فایل و CORS