بناء نظام مصادقة JWT باستخدام FastAPI
مقدمة: لماذا JWT مع FastAPI؟
عندما تبدأ في بناء تطبيق ويب أو API حديث، ستجد أن واحدة من أولى التحديات الحقيقية ليست في كتابة المسارات أو إدارة قواعد البيانات، بل في الإجابة عن سؤال بسيط ظاهريًا ومعقد عمليًا: كيف أعرف أن هذا المستخدم هو فعلًا من يدّعي أنه؟ وكيف أسمح له بالوصول إلى الموارد المناسبة دون أن أفتح الباب للجميع؟
هنا يظهر دور المصادقة، وهنا أيضًا يظهر JWT كأحد أكثر الحلول انتشارًا في عالم الـ APIs. وFastAPI، بما أنه إطار سريع وحديث ومبني على Python مع دعم ممتاز لـ type hints والـ validation، يجعل بناء هذا النوع من الأنظمة أمرًا أنيقًا وممتعًا في نفس الوقت. لكن “ممتع” لا تعني “سهلًا بشكل مخادع”. لأن المصادقة ليست مجرد وضع مكتبة وتوليد رمز عشوائي ثم اعتبار المشكلة انتهت. المصادقة الجيدة تحتاج فهمًا، وتنظيمًا، وقرارات صحيحة منذ البداية.
JWT اختصار لـ JSON Web Token، وهو أسلوب يسمح لك بإرسال بيانات موقعة رقميًا بين العميل والخادم بشكل آمن نسبيًا، بحيث يستطيع الخادم التحقق من صحة التوكن دون الحاجة إلى تخزين جلسة كاملة لكل مستخدم في الذاكرة. وهذا ما يجعل JWT مناسبًا جدًا للتطبيقات التي تعتمد على APIs، أو تطبيقات الهاتف، أو الواجهات المنفصلة عن الـ backend، أو حتى المشاريع الصغيرة التي تريد حلًا نظيفًا وقابلًا للتوسع.
في هذا المقال سنبني نظام مصادقة كاملًا باستخدام FastAPI، يبدأ من إنشاء المستخدم وتخزين كلمة المرور بشكل آمن، ثم تسجيل الدخول، ثم إصدار JWT، ثم حماية المسارات، ثم الحديث عن refresh tokens، ثم أفضل الممارسات، ثم الأخطاء الشائعة التي يقع فيها الكثيرون عند تطبيق JWT لأول مرة.
الفكرة العامة قبل كتابة الكود
قبل أن نكتب أي سطر كود، دعنا نرتب الصورة في أذهاننا. عندما يسجل المستخدم دخوله، لا نرسل له كلمة مروره مرة أخرى، بل نتحقق منها في الخادم، ثم نصدر له توكنًا موقّعًا. هذا التوكن يصبح بمثابة بطاقة تعريف رقمية. كلما أراد المستخدم الوصول إلى مسار محمي، يرسل هذا التوكن في رأس الطلب Authorization. الخادم يفك التوكن، يتحقق من التوقيع، يقرأ البيانات الموجودة بداخله، ثم يسمح أو يمنع الوصول.
الجميل في هذا الأسلوب أنه يقلل الحاجة إلى تخزين sessions على الخادم في كثير من الحالات، لكنه لا يلغي مسؤولية الأمان. بالعكس، أحيانًا يزيدها، لأن أي خطأ في تصميم التوكن أو مدة صلاحيته أو طريقة حفظه في الواجهة الأمامية قد يفتح بابًا غير مرغوب فيه.
لذلك سنبني النظام بطريقة عملية جدًا، مع التركيز على ما هو مهم فعلًا في الإنتاج:
تشفير كلمات المرور بشكل صحيح
إنشاء JWT مع مدة صلاحية محددة
حماية المسارات الحساسة
فهم الفرق بين authentication و authorization
التعامل مع انتهاء التوكن
التفكير في refresh token بطريقة واقعية
تجهيز المشروع
سنفترض أنك تعمل على مشروع FastAPI جديد. سنحتاج إلى بعض الحزم الأساسية. المثال التالي مناسب جدًا كبداية:
pip install fastapi uvicorn python-jose[cryptography] passlib[bcrypt] pydantic[email]
إذا أردت ربطه بقاعدة بيانات لاحقًا مثل PostgreSQL أو SQLite باستخدام SQLAlchemy أو SQLModel، يمكنك فعل ذلك بسهولة، لكننا سنركز أولًا على منطق المصادقة نفسه حتى لا يضيع التركيز.
هيكل مقترح للمشروع:
app/
│
├── main.py
├── auth.py
├── security.py
├── schemas.py
├── database.py
└── users.py
هذا التقسيم ليس إلزاميًا، لكنه مفيد جدًا. لأن نظام المصادقة يتضخم بسرعة عندما تضع كل شيء في ملف واحد. وصدقني، كثير من المشاريع لا تتعطل بسبب ضعف التقنية، بل بسبب الفوضى في ترتيب الملفات.
تشفير كلمات المرور بالطريقة الصحيحة
أول قاعدة ذهبية: لا تخزن كلمة المرور كما هي أبدًا.
حتى لو كانت قاعدة البيانات داخلية، وحتى لو كانت البيئة “آمنة”، وحتى لو كنت تستخدم كلمة مرور بسيطة للمشروع التجريبي، لا تجعل من نفسك نسخة مستقبلية من قصة أمنية سيئة. استخدم hashing، وليس encryption. الفرق مهم: التشفير encryption يمكن عكسه بمفتاح، أما hashing فلا يعود إلى النص الأصلي بسهولة. نحن لا نحتاج أن نعرف كلمة المرور الأصلية بعد حفظها، بل نحتاج فقط أن نقارن المدخل الحالي مع النسخة المشتقة المخزنة.
سنستخدم passlib مع bcrypt:
# security.py
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
هذه الدالتان تبدوان بسيطتين، لكنهما من أهم أجزاء النظام كله. كثير من الناس يركزون على JWT نفسه وينسون أن الأمان يبدأ من كلمة المرور. لو سقط هذا الجزء، فكل ما بعده يصبح مجرد زينة فوق أرضية متشققة.
إنشاء نموذج المستخدم والـ schemas
في FastAPI من الأفضل استخدام Pydantic schemas لتنظيم البيانات التي تدخل وتخرج من API. هذا يجعل النظام أكثر وضوحًا ويمنع الكثير من الأخطاء.
# schemas.py
from pydantic import BaseModel, EmailStr
from typing import Optional
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: Optional[str] = None
class UserResponse(BaseModel):
username: str
email: EmailStr
is_active: bool = True
السبب في فصل UserCreate عن UserLogin بسيط جدًا: النموذج المستخدم للتسجيل قد يختلف عن النموذج المستخدم لتسجيل الدخول. كذلك، UserResponse لا يجب أن يحتوي على أي معلومة حساسة مثل كلمة المرور أو hash الخاص بها. هذا النوع من الانضباط يمنع تسرب البيانات الحساسة عن طريق الخطأ.
قاعدة بيانات بسيطة للتجربة
سنستخدم مثالًا مبسطًا بذاكرة داخلية لأغراض الشرح، ثم سنوضح كيف تفكر عندما تنتقل إلى قاعدة بيانات حقيقية. في التطبيقات الحقيقية، ستحتاج تخزين المستخدمين في قاعدة بيانات فعلية. لكن الفكرة هنا أن نفهم المنطق أولًا.
# users.py
from typing import Dict
from security import hash_password, verify_password
fake_users_db: Dict[str, dict] = {}
def create_user(username: str, email: str, password: str):
if email in fake_users_db:
return None
hashed = hash_password(password)
user = {
"username": username,
"email": email,
"hashed_password": hashed,
"is_active": True,
}
fake_users_db[email] = user
return user
def authenticate_user(email: str, password: str):
user = fake_users_db.get(email)
if not user:
return None
if not verify_password(password, user["hashed_password"]):
return None
return user
في مشروع حقيقي، هذه الوظائف ستتعامل مع ORM أو queries مباشرة. لكن حتى الآن، المهم أن ترى التسلسل المنطقي: إنشاء مستخدم → hashing → تخزين → لاحقًا تحقق من كلمة المرور عبر المقارنة.
إنشاء JWT: الفكرة والهيكل
JWT عادة يتكون من ثلاثة أجزاء:
Header
Payload
Signature
الـ payload يحتوي على البيانات التي نريد حملها داخل التوكن، مثل البريد الإلكتروني أو معرف المستخدم أو وقت انتهاء الصلاحية. لكن يجب أن نتذكر شيئًا مهمًا جدًا: لا تضع معلومات حساسة داخل JWT لمجرد أنه “موقّع”. التوقيع يمنع التلاعب، لكنه لا يشفّر المحتوى. أي شخص يحصل على التوكن يمكنه فك ترميز الـ payload وقراءته.
لهذا السبب نضع فقط البيانات الضرورية، مثل sub أو email أو user_id، مع exp لتحديد انتهاء الصلاحية.
إعداد مفاتيح التوقيع والخوارزمية
سنستخدم python-jose لإنشاء وفك JWT:
# auth.py
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
SECRET_KEY = "change-this-secret-key-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
لاحظ أن SECRET_KEY هنا مجرد مثال. في الإنتاج يجب أن تكون قيمة قوية جدًا، عشوائية، ومخزنة في متغيرات البيئة، وليس داخل الكود.
يمكنك توليد مفتاح قوي مثل هذا:
import secrets
print(secrets.token_urlsafe(32))
إذا كانت لديك بيئة إنتاج حقيقية، فاحفظ SECRET_KEY في environment variable أو في secret manager مناسب. لا تتركه في المستودع البرمجي. كثيرون يفعلون ذلك مرة “للتجربة”، ثم تنتهي التجربة بتسريب خطير.
دالة إنشاء access token
# auth.py
from datetime import datetime, timedelta, timezone
from jose import jwt
SECRET_KEY = "change-this-secret-key-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
هذه الدالة هي قلب النظام تقريبًا. ما الذي يحدث هنا؟
ننسخ البيانات التي نريد وضعها داخل التوكن، ثم نضيف تاريخ الانتهاء، ثم نقوم بتوقيعها بالمفتاح السري والخوارزمية المحددة. بعدها نحصل على سلسلة نصية يمكن إرسالها للعميل.
دالة فك التوكن واستخراج المستخدم
# auth.py
from jose import jwt, JWTError
def decode_access_token(token: str):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
email: str = payload.get("sub")
if email is None:
return None
return {"email": email}
except JWTError:
return None
في التطبيقات الحقيقية قد تستخدم HTTPException بدل None حسب الموضع الذي تستعمل فيه هذه الدالة. لكن الفكرة الأساسية هي نفسها: حاول فك التوكن، وإذا كان غير صالح أو منتهي الصلاحية أو تم التلاعب به، ارفضه فورًا.
بناء مسارات التسجيل وتسجيل الدخول
الآن سنجمع كل شيء داخل main.py.
# main.py
from datetime import timedelta
from fastapi import FastAPI, HTTPException, Depends, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from schemas import UserCreate, UserLogin, Token, UserResponse
from users import create_user, authenticate_user, fake_users_db
from auth import create_access_token, decode_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
مسار التسجيل
@app.post("/register", response_model=UserResponse)
def register(user: UserCreate):
created_user = create_user(user.username, user.email, user.password)
if not created_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already exists"
)
return {
"username": created_user["username"],
"email": created_user["email"],
"is_active": created_user["is_active"],
}
هنا نقوم بإنشاء المستخدم، ثم نعيد فقط البيانات الآمنة التي يمكن عرضها. لا نعيد hash كلمة المرور، ولا أي بيانات داخلية أخرى.
مسار تسجيل الدخول
@app.post("/login", response_model=Token)
def login(user: UserLogin):
authenticated_user = authenticate_user(user.email, user.password)
if not authenticated_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": authenticated_user["email"]},
expires_delta=access_token_expires,
)
return {"access_token": access_token, "token_type": "bearer"}
في هذا المثال، بمجرد نجاح التحقق، نُصدر access token. لاحظ أننا نخزن البريد الإلكتروني داخل sub. هذا نمط شائع جدًا، لأن sub تعني subject، أي صاحب التوكن أو الموضوع الأساسي الذي يشير إليه.
حماية المسارات باستخدام التوكن
الآن نأتي إلى الجزء الذي يجعل JWT مفيدًا فعلًا: حماية المسارات. نريد أن نقول: “هذا المسار لا يصل إليه إلا من يملك توكنًا صالحًا”.
from fastapi import Depends
from jose import JWTError
from fastapi import HTTPException, status
def get_current_user(token: str = Depends(oauth2_scheme)):
token_data = decode_access_token(token)
if not token_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = fake_users_db.get(token_data["email"])
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
return user
ثم نستعملها في مسار محمي:
@app.get("/me")
def read_current_user(current_user: dict = Depends(get_current_user)):
return {
"username": current_user["username"],
"email": current_user["email"],
"is_active": current_user["is_active"],
}
هنا يحدث السحر الحقيقي: FastAPI يقرأ التوكن من Authorization: Bearer <token>، ثم يمرره إلى الدالة get_current_user، والتي تتحقق من صحة التوكن، ثم تعيد المستخدم الحالي. هذا النمط نظيف جدًا، وقابل لإعادة الاستخدام في عشرات المسارات.
كيف يرسل العميل التوكن؟
بعد تسجيل الدخول بنجاح، سيحصل العميل على access token. عند طلب أي مورد محمي، يرسله في رأس الطلب:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
في واجهات JavaScript أو React أو Flutter أو تطبيقات الهاتف، هذا النمط شائع جدًا. وعند استخدام Swagger UI في FastAPI، ستلاحظ أنك تستطيع الضغط على زر authorize وإدخال التوكن ليتم تمريره تلقائيًا إلى المسارات المحمية.
هذا من الجوانب الجميلة في FastAPI: التجربة التفاعلية مع التوثيق تجعل اختبار المصادقة أكثر سهولة من كثير من الأطر الأخرى.
الفرق بين Authentication و Authorization
كثير من المطورين يخلطون بين المفهومين، ثم يبنون نظامًا “يبدو صحيحًا” لكنه في الواقع مشوش.
Authentication تعني: هل هذا المستخدم هو فعلًا من يدّعي أنه؟
Authorization تعني: هل هذا المستخدم لديه صلاحية القيام بهذا الفعل؟
مثال بسيط: عندما يسجل المستخدم دخوله بنجاح، فقد تم التحقق من هويته. لكن هل يمكنه الوصول إلى لوحة الإدارة؟ ليس بالضرورة. قد يكون مستخدمًا عاديًا فقط. هنا نحتاج authorization.
يمكنك تخزين role داخل قاعدة البيانات ثم قراءة هذا الدور من المستخدم الحالي:
def get_current_admin(current_user: dict = Depends(get_current_user)):
if current_user.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
ثم:
@app.get("/admin")
def admin_dashboard(admin_user: dict = Depends(get_current_admin)):
return {"message": f"Welcome admin {admin_user['username']}"}
هذا الفرق مهم جدًا، لأن كثيرًا من أخطاء الأمان تأتي من الخلط بين الوصول إلى النظام نفسه والوصول إلى مستوى معين داخله.
استخدام OAuth2PasswordBearer بطريقة صحيحة
في FastAPI، OAuth2PasswordBearer ليس نظام OAuth2 كاملًا بالمفهوم الواسع، بل هو جزء يساعد على تطبيق أسلوب شائع لتبادل التوكنات مع مسارات الحماية.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
هذا يعني أن FastAPI يعرف أن التوكن سيأتي عبر endpoint تسجيل الدخول المشار إليه. وعندما تستخدم Depends(oauth2_scheme)، فسيقوم FastAPI باستخراج التوكن من رأس Authorization تلقائيًا.
إذا أهملت هذا التصميم، قد تضطر إلى قراءة الهيدر يدويًا في كل مرة، وهذا يضيف ضوضاء إلى الكود. بينما مع هذا النهج يصبح النظام أكثر نظافة وقابلية للصيانة.
إضافة refresh token: لماذا نحتاجه؟
هنا نصل إلى نقطة مهمة جدًا. access token عادة يكون قصير العمر، وهذا جيد من ناحية الأمان. لكن لو جعلته قصيرًا جدًا، سيضطر المستخدم إلى تسجيل الدخول كثيرًا، وهذا سيئ من ناحية التجربة. ولو جعلته طويل العمر، فأنت تزيد المخاطر إذا تم تسريبه.
الحل الشائع هو استخدام access token قصير و refresh token أطول عمرًا.
الفكرة باختصار:
access token: يستخدم للوصول إلى الموارد المحمية
refresh token: يستخدم فقط للحصول على access token جديد عندما ينتهي الأول
هذا يحقق توازنًا جميلًا بين الأمان والراحة. لكن في الوقت نفسه، refresh tokens تحتاج إدارة أكثر حذرًا، ويفضل أحيانًا تخزينها في قاعدة بيانات أو جدول خاص، مع إمكانية إلغائها عند تسجيل الخروج أو الاشتباه في خرق أمني.
مثال مبسط لإنشاء refresh token
REFRESH_TOKEN_EXPIRE_DAYS = 7
def create_refresh_token(data: dict):
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
ثم عند فك التوكن:
def decode_refresh_token(token: str):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "refresh":
return None
return payload
except JWTError:
return None
مسار تجديد access token
@app.post("/refresh")
def refresh_access_token(refresh_token: str):
payload = decode_refresh_token(refresh_token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
new_access_token = create_access_token(
data={"sub": payload["sub"]},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
return {"access_token": new_access_token, "token_type": "bearer"}
في الإنتاج، غالبًا لن ترسل refresh token بهذا الشكل المباشر إلا ضمن استراتيجية واضحة، وقد تحفظه في HttpOnly cookie أو في storage آمن حسب التطبيق. المهم أن تفهم المبدأ قبل أن تختار التطبيق.
تخزين التوكن في الواجهة الأمامية: أين وكيف؟
هذا الجزء يسبب كثيرًا من الالتباس. JWT نفسه ليس مشكلة، بل طريقة تخزينه هي التي قد تصنع المشكلة.
هناك عدة أساليب شائعة:
1) LocalStorage
سهل جدًا، لكنه أقل أمانًا ضد XSS. إذا نجح مهاجم في تنفيذ JavaScript داخل الصفحة، فقد يقرأ التوكن.
2) SessionStorage
مشابه تقريبًا لكنه ينتهي بانتهاء الجلسة. أيضًا ليس مثاليًا ضد XSS.
3) HttpOnly Cookies
غالبًا أكثر أمانًا لبعض السيناريوهات، لأنه لا يمكن قراءته من JavaScript مباشرة. لكن هنا يجب التعامل بعناية مع CSRF، خاصة إذا كان التطبيق يعتمد على cookies.
في كثير من التطبيقات، القرار يعتمد على نوع الواجهة، وطبيعة المخاطر، وطريقة النشر. لا يوجد حل واحد مثالي لكل الحالات. لكن هناك قاعدة ذهبية: اختر التخزين بناء على تهديداتك الأمنية، لا بناء على سهولة التنفيذ فقط.
التعامل مع انتهاء الصلاحية والأخطاء
أي نظام JWT محترم يجب أن يتعامل مع هذه الحالات بوضوح:
التوكن مفقود
التوكن منتهي الصلاحية
التوكن معدّل أو غير صالح
المستخدم غير موجود
المستخدم غير نشط
المستخدم لا يملك الصلاحية المناسبة
مثال على رسالة خطأ واضحة:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired or is invalid",
headers={"WWW-Authenticate": "Bearer"},
)
الرسائل الدقيقة تساعد المستخدم والمطور أيضًا. تخيل أنك تدير API يستخدمه فريق آخر، ثم تأتيه رسالة عامة مثل “Error occurred”. هذه ليست رسالة، هذا لغز بلا فائدة.
تحسين النظام ليصبح أقرب إلى الإنتاج
النسخة التي بنيناها حتى الآن جيدة للفهم، لكنها تحتاج تحسينات قبل الإنتاج الحقيقي. وهذه بعض التحسينات المهمة:
استخدام قاعدة بيانات حقيقية
بدل fake_users_db، استخدم SQLAlchemy أو SQLModel مع جدول مستخدمين فعلي.
استخدام متغيرات البيئة
لا تضع SECRET_KEY داخل الكود. استخدم os.getenv() أو مكتبة مثل python-dotenv.
إضافة الحقول المهمة للمستخدم
مثل:
idcreated_atis_activerolelast_login
منع إعادة استخدام refresh tokens المسروقة
في الأنظمة المتقدمة، قد تخزن refresh tokens في قاعدة بيانات، وتبطلها عند اللزوم.
استخدام HTTPS دائمًا
JWT لا يحميك إذا أرسلت التوكن عبر شبكة غير آمنة. النقل الآمن أساسي.
تدوير المفاتيح
في بعض الأنظمة الكبيرة، تحتاج إلى key rotation. هذا يعني أن مفاتيح التوقيع تتغير بمرور الوقت وفق سياسة محددة.
مثال أكثر تنظيمًا مع مسارات كاملة
إليك مثالًا منسقًا أكثر يضم الأجزاء الأساسية في صياغة واحدة أوضح:
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, HTTPException, Depends, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from passlib.context import CryptContext
from pydantic import BaseModel, EmailStr
app = FastAPI()
SECRET_KEY = "change-this-secret-key-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
fake_users_db = {}
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class Token(BaseModel):
access_token: str
token_type: str
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta if expires_delta else timedelta(minutes=15)
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def register_user(username: str, email: str, password: str):
if email in fake_users_db:
return None
fake_users_db[email] = {
"username": username,
"email": email,
"hashed_password": hash_password(password),
"is_active": True,
"role": "user",
}
return fake_users_db[email]
def authenticate_user(email: str, password: str):
user = fake_users_db.get(email)
if not user:
return None
if not verify_password(password, user["hashed_password"]):
return None
return user
def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
email = payload.get("sub")
if email is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user = fake_users_db.get(email)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
return user
@app.post("/register")
def register(user: UserCreate):
created = register_user(user.username, user.email, user.password)
if not created:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User already exists"
)
return {
"username": created["username"],
"email": created["email"],
"is_active": created["is_active"],
}
@app.post("/login", response_model=Token)
def login(user: UserLogin):
authenticated = authenticate_user(user.email, user.password)
if not authenticated:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(
data={"sub": authenticated["email"]},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/profile")
def profile(current_user: dict = Depends(get_current_user)):
return {
"message": "This is a protected profile",
"user": {
"username": current_user["username"],
"email": current_user["email"],
"role": current_user["role"],
}
}
هذا المثال يُظهر النظام كاملًا بشكل مبسط، ويمكنك البناء عليه بسهولة.
ماذا يحدث عند تسجيل الخروج؟
في أنظمة JWT البسيطة، تسجيل الخروج قد يكون مجرد حذف التوكن من جانب العميل. لكن إن كنت تستخدم refresh tokens، فالأمر يصبح أكثر تعقيدًا، لأن access token قد يبقى صالحًا حتى انتهاء صلاحيته.
لذلك في الأنظمة الجادة، قد تحتاج إلى واحدة من هذه الاستراتيجيات:
جعل access token قصير العمر جدًا
إلغاء refresh token من قاعدة البيانات
استخدام token blacklist في بعض الحالات
ربط التوكن بجهاز أو session محددة
الموضوع هنا ليس “هل JWT جيد أم لا”، بل “كيف تصممه بشكل صحيح حسب حاجتك”.
أخطاء شائعة يجب تجنبها
1) استخدام SECRET_KEY ضعيف
هذا من أخطر الأخطاء. المفتاح السري هو حجر الأساس في التوقيع.
2) تخزين كلمات المرور بنص واضح
هذا خط أحمر.
3) وضع بيانات حساسة داخل JWT
التوكن قابل للقراءة من أي طرف يحصل عليه.
4) جعل مدة صلاحية التوكن طويلة جدًا
هذا يرفع الخطر عند التسريب.
5) تجاهل refresh token
هذا قد يجعل تجربة المستخدم سيئة جدًا أو يدفعك لجعل access token طويلًا بشكل غير آمن.
6) عدم التحقق من صلاحية المستخدم من قاعدة البيانات
لا تعتمد على التوكن وحده إذا كان بإمكان المستخدم أن يُحظر أو يُعطل لاحقًا.
7) الخلط بين 401 و403
401 تعني غير موثق أو توثيق غير صالح، أما 403 فتعني موثق لكن ليس لديه صلاحية كافية.
التفكير الأمني الصحيح أثناء البناء
أجمل شيء في FastAPI أنه يعطيك أدوات نظيفة جدًا، لكن الأدوات الجيدة لا تعني أن النتيجة ستكون آمنة تلقائيًا. الأمان يبدأ من قرارات صغيرة:
هل تستخدم HTTPS؟
هل مفاتيحك سرية؟
هل مدة التوكن مناسبة؟
هل كلمة المرور hashed؟
هل هناك rate limiting على login؟
هل لديك حماية ضد brute force؟
هل المستخدم الموقوف يمكنه تسجيل الدخول؟
هل الـ refresh token قابل للإبطال؟
هذه الأسئلة تبدو كثيرة، لكنها في الحقيقة تفصل بين مشروع تجريبي ومشروع يمكن الاعتماد عليه.
إضافة rate limiting ومحاولات تسجيل الدخول
في التطبيقات الحقيقية، من الحكمة أن تحمي endpoint تسجيل الدخول من المحاولات الكثيفة. لأن المهاجم قد يحاول brute force على البريد وكلمة المرور. يمكن تنفيذ ذلك عبر middleware أو أدوات خارجية أو إعدادات على مستوى البنية التحتية مثل Nginx وCloudflare أو خدمات مخصصة.
حتى لو لم تطبق rate limiting في البداية، ضعها ضمن خطة التحسين. لأن أي login endpoint بدون حماية إضافية هو هدف واضح جدًا.
نصائح عملية لبناء API احترافي
في النهاية، المصادقة ليست مجرد تفاصيل تقنية، بل جزء من ثقة المستخدم في التطبيق. عندما يبني المطور نظامًا واضحًا، سريعًا، وآمنًا، فهو لا يحمي البيانات فقط، بل يحمي تجربة المستخدم أيضًا.
هذه بعض النصائح التي أعتبرها مهمة جدًا:
افصل منطق المصادقة عن منطق الأعمال
اجعل functions صغيرة ومحددة
اكتب رسائل خطأ مفهومة
لا تضع الأسرار داخل الكود
اختبر انتهاء الصلاحية
اختبر المستخدم المحذوف أو المعطل
لا تفترض أن العميل الأمامي سيتصرف بشكل صحيح دائمًا
راقب السجلات logs دون تخزين معلومات حساسة فيها
أحيانًا تبدو المصادقة كأنها “جزء ثانوي” من المشروع، ثم تكتشف لاحقًا أنها أكثر جزء يحتاج عناية. وهذا طبيعي. لأن النظام الذي يفتح الباب للناس هو نفسه النظام الذي يقرر من يدخل ومن لا يدخل.
خاتمة
بناء نظام مصادقة JWT باستخدام FastAPI ليس مجرد تجربة تقنية عابرة، بل تمرين ممتاز على فهم الأمان، وتنظيم الكود، وتصميم APIs بشكل احترافي. بدأت الفكرة من hashing كلمة المرور، ثم مررنا على إنشاء JWT، ثم حماية المسارات، ثم الفرق بين authentication وauthorization، ثم refresh token، ثم التخزين الآمن، ثم الأخطاء الشائعة، ثم مجموعة من التحسينات العملية.
إذا نظرت إلى الصورة الكاملة، ستجد أن FastAPI يمنحك أساسًا قويًا جدًا لبناء هذا النوع من الأنظمة، لكنه يترك لك أيضًا حرية كبيرة في التصميم. وهذه الحرية جميلة، لكنها تتطلب وعيًا. فكل اختيار صغير في المصادقة له أثر كبير لاحقًا: اختيار الخوارزمية، مدة التوكن، مكان التخزين، شكل الردود، آلية الإبطال، وطريقة الحماية من الهجمات الشائعة.