الوثائقالوحدة 3: المستخدمون

إنشاء تطبيق ملاحظات باستخدام Nexy، الجزء 10: إنشاء حساب وتسجيل الدخول

يتيح الجزء 10 للزائر إنشاء حساب باستخدام بريد إلكتروني وكلمة مرور، وتسجيل الدخول وتسجيل الخروج، وتذكر من قام بتسجيل الدخول باستخدام ملف تعريف ارتباط الجلسة.

قبل أن تبدأ: تطبيق الجزء التاسع.

تشغيل الجلسات

الجلسة عبارة عن ملف تعريف ارتباط موقع: يضع الخادم معرف المستخدم الذي قام بتسجيل الدخول فيه، ويعيد قراءته عند كل طلب. Starlette يوقعها بـ itsdangerous:

uv add itsdangerous

السر الذي يوقع به محفوظ في .env بجوار عنوان قاعدة البيانات:

.env
DATABASE_URL=sqlite:///dev.dbSESSION_SECRET=change-me-to-a-long-random-string

اصنع سرًا حقيقيًا مع python -c "import secrets; print(secrets.token_urlsafe(32))". يمكن لأي شخص يعرف ذلك التوقيع على ملف تعريف الارتباط كأي مستخدم.

useSession يتم تشغيل الجلسات مع قراءة السر من البيئة:

nexyconfig.py
import os from dotenv import load_dotenv from nexy.core.models import NexyConfigModelfrom nexy.frontend import react load_dotenv()  class NexyConfig(NexyConfigModel):    useFF = react()    useTitle = "Nexy Web (FBR + React)"    useAliases = {"@": "src"}    useVite = True    useDevTools = True    useSession = {"secret_key": os.environ["SESSION_SECRET"]}
nexyconfig.py
import os from dotenv import load_dotenv from nexy.core.models import NexyConfigModelfrom nexy.frontend import reactfrom src.apps.app_module import AppModule load_dotenv()  class NexyConfig(NexyConfigModel):    useFF = react()    useTitle = "Nexy Web (Modular + React)"    useRouter = AppModule    useAliases = {"@": "src"}    useVite = True    useDevTools = True    useSession = {"secret_key": os.environ["SESSION_SECRET"]}

os.environ[...] وليس os.getenv(...): بدون سر، يتوقف التطبيق عند بدء التشغيل بدلاً من تشغيله بدون أي شيء.

وصف المستخدم

src/models/user_model.py
from sqlmodel import Field, SQLModel  class User(SQLModel, table=True):    id: int | None = Field(default=None, primary_key=True)    email: str = Field(unique=True, index=True)    password_hash: str

والمستخدم ميزة خاصة به، في src/apps/users/:

src/apps/users/user_model.py
from sqlmodel import Field, SQLModel  class User(SQLModel, table=True):    id: int | None = Field(default=None, primary_key=True)    email: str = Field(unique=True, index=True)    password_hash: str

يحتفظ الجدول بتجزئة كلمة المرور، وليس كلمة المرور أبدًا: قاعدة البيانات المسروقة لا تكشف عن كلمات المرور. unique=True يجعل قاعدة البيانات ترفض حساب ثاني بنفس البريد الإلكتروني.

nx migrate -m "create users"nx migrate --upgrade

تجد الهجرة النموذج الجديد من تلقاء نفسها: فهي تقرأ كل *_model.py من src/.

التجزئة والتحقق من كلمة المرور

hashlib.scrypt، من المكتبة القياسية، عبارة عن تجزئة تم إنشاؤها بشكل بطيء عن قصد، لذا فإن تخمين كلمات المرور واحدة تلو الأخرى يكلف المهاجم سنوات. تحصل كل كلمة مرور على الملح العشوائي الخاص بها:

src/accounts.py
import hashlibimport hmacimport secrets from sqlalchemy.exc import IntegrityErrorfrom sqlmodel import Session, select from src.configs.db import enginefrom src.models.user_model import User  def hash_password(password: str) -> str:    salt = secrets.token_bytes(16)    digest = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)    return f"{salt.hex()}:{digest.hex()}"  def password_matches(password: str, stored: str) -> bool:    salt, digest = stored.split(":")    found = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=2**14, r=8, p=1)    return hmac.compare_digest(found.hex(), digest)  def create_user(email: str, password: str) -> User | None:    """The new user, or None when the email already has an account."""    user = User(email=email, password_hash=hash_password(password))    with Session(engine) as session:        session.add(user)        try:            session.commit()        except IntegrityError:            return None        session.refresh(user)    return user  def authenticate(email: str, password: str) -> User | None:    with Session(engine) as session:        user = session.exec(select(User).where(User.email == email)).first()    if user is None or not password_matches(password, user.password_hash):        return None    return user
src/accounts.py
import hashlibimport hmacimport secrets from sqlalchemy.exc import IntegrityErrorfrom sqlmodel import Session, select from src.apps.users.user_model import Userfrom src.configs.db import engine  def hash_password(password: str) -> str:    salt = secrets.token_bytes(16)    digest = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)    return f"{salt.hex()}:{digest.hex()}"  def password_matches(password: str, stored: str) -> bool:    salt, digest = stored.split(":")    found = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=2**14, r=8, p=1)    return hmac.compare_digest(found.hex(), digest)  def create_user(email: str, password: str) -> User | None:    """The new user, or None when the email already has an account."""    user = User(email=email, password_hash=hash_password(password))    with Session(engine) as session:        session.add(user)        try:            session.commit()        except IntegrityError:            return None        session.refresh(user)    return user  def authenticate(email: str, password: str) -> User | None:    with Session(engine) as session:        user = session.exec(select(User).where(User.email == email)).first()    if user is None or not password_matches(password, user.password_hash):        return None    return user

hmac.compare_digest تتم المقارنة في وقت لا يعتمد على مكان اختلاف التجزئة، وبالتالي فإن وقت الإجابة لا يخبر المهاجم بأي شيء.

قم بالتسجيل، قم بتسجيل الدخول، قم بتسجيل الخروج

ثلاثة إجراءات. كل واحد ينجح يكتب الجلسة، أو يفرغها:

src/actions/accounts.py
from urllib.parse import urlencode from nexy import useSessionfrom nexy.decorators import Actionfrom nexy.http import RedirectResponsefrom src.accounts import authenticate, create_user MIN_PASSWORD = 8  def go(path: str, error: str = "") -> RedirectResponse:    query = f"?{urlencode({'error': error})}" if error else ""    return RedirectResponse(f"{path}{query}", status_code=303)  @Actionasync def sign_up(email: str, password: str) -> RedirectResponse:    email = email.strip().lower()    if len(password) < MIN_PASSWORD:        return go("/signup", f"A password has at least {MIN_PASSWORD} characters.")    user = create_user(email, password)    if user is None:        return go("/signup", "This email already has an account.")    useSession()["user_id"] = user.id    return go("/notes")  @Actionasync def sign_in(email: str, password: str) -> RedirectResponse:    user = authenticate(email.strip().lower(), password)    if user is None:        return go("/signin", "Wrong email or password.")    useSession()["user_id"] = user.id    return go("/notes")  @Actionasync def sign_out() -> RedirectResponse:    useSession().clear()    return go("/")

بريد إلكتروني أو كلمة مرور خاطئة، لا أي منهما على الإطلاق: الرسالة التي تقول "لا يوجد حساب بهذا البريد الإلكتروني" ستخبر أي شخص عن عناوين البريد الإلكتروني التي تحتوي على حساب.

الشكلين

src/routes/signup.nexy
---from nexy import useSearchParams error = useSearchParams().get("error", "")---<section class="app-page">  <h1>Create an account</h1>   {% if error %}  <p class="note-error" role="alert">{{ error | e }}</p>  {% endif %}   <form method="post" action="{{ __actions_path['sign_up'] }}">    <label>Email <input type="email" name="email" autocomplete="email" required></label>    <label>Password <input type="password" name="password" autocomplete="new-password" minlength="8" required></label>    <button type="submit">Create the account</button>  </form>   <a href="/signin">I already have an account</a></section>
src/routes/signin.nexy
---from nexy import useSearchParams error = useSearchParams().get("error", "")---<section class="app-page">  <h1>Sign in</h1>   {% if error %}  <p class="note-error" role="alert">{{ error | e }}</p>  {% endif %}   <form method="post" action="{{ __actions_path['sign_in'] }}">    <label>Email <input type="email" name="email" autocomplete="email" required></label>    <label>Password <input type="password" name="password" autocomplete="current-password" required></label>    <button type="submit">Sign in</button>  </form>   <a href="/signup">Create an account</a></section>
src/apps/users/signup_view.nexy
---from nexy import useSearchParams error = useSearchParams().get("error", "")---<section class="app-page">  <h1>Create an account</h1>   {% if error %}  <p class="note-error" role="alert">{{ error | e }}</p>  {% endif %}   <form method="post" action="{{ __actions_path['sign_up'] }}">    <label>Email <input type="email" name="email" autocomplete="email" required></label>    <label>Password <input type="password" name="password" autocomplete="new-password" minlength="8" required></label>    <button type="submit">Create the account</button>  </form>   <a href="/signin">I already have an account</a></section>
src/apps/users/signin_view.nexy
---from nexy import useSearchParams error = useSearchParams().get("error", "")---<section class="app-page">  <h1>Sign in</h1>   {% if error %}  <p class="note-error" role="alert">{{ error | e }}</p>  {% endif %}   <form method="post" action="{{ __actions_path['sign_in'] }}">    <label>Email <input type="email" name="email" autocomplete="email" required></label>    <label>Password <input type="password" name="password" autocomplete="current-password" required></label>    <button type="submit">Sign in</button>  </form>   <a href="/signup">Create an account</a></section>
src/apps/users/users_controller.py
from nexy.decorators import Controllerfrom nexy.hooks import useViews LAYOUT = "src/apps/layout.nexy"  def page(view: str):    return useViews(LAYOUT, context={"children": useViews(view)})  @Controller("/signup")class SignUpController:    async def get(self):        return page("src/apps/users/signup_view.nexy")  @Controller("/signin")class SignInController:    async def get(self):        return page("src/apps/users/signin_view.nexy")
src/apps/app_module.py
from nexy.decorators import Module from .app_controller import AboutController, AppControllerfrom .notes.notes_controller import NoteController, NotesApiController, NotesControllerfrom .users.users_controller import SignInController, SignUpController  @Module()class AppModule:    controllers = [        AppController,        AboutController,        NotesController,        NoteController,        NotesApiController,        SignUpController,        SignInController,    ]

autocomplete="new-password" يتيح لمدير كلمات المرور تقديم كلمة مرور قوية، و current-password يتيح له ملء كلمة المرور التي حفظها.

إظهار من قام بتسجيل الدخول

ترحب صفحة الملاحظات بالمستخدم وتظهر زر تسجيل الخروج؛ يرى الزائر الروابط إلى النموذجين بدلاً من ذلك:

src/routes/notes/index.nexy
---from "@/components/NoteCard.nexy" import NoteCardfrom nexy import useSearchParams, useSessionfrom src.notes import notes_page params = useSearchParams()error = params.get("error", "")asked = params.get("page", "1")number = max(int(asked), 1) if asked.isdigit() else 1found = notes_page(number)notes = found[0]more = found[1]signed_in = "user_id" in useSession()---<section class="app-page">  <h1>Notes</h1>   {% if signed_in %}  <form method="post" action="{{ __actions_path['sign_out'] }}">    <button type="submit">Sign out</button>  </form>  {% else %}  <p><a href="/signin">Sign in</a> or <a href="/signup">create an account</a>.</p>  {% endif %}   {% if error %}  <p class="note-error" role="alert">{{ error | e }}</p>  {% endif %}   <form method="post" action="{{ __actions_path['create_note'] }}">    <input name="text" placeholder="A new note" maxlength="200" required>    <button type="submit">Add</button>  </form>   {% for note in notes %}  <NoteCard id="{{ note.id }}" text="{{ note.text }}" />  {% endfor %}   <nav class="note-pages" aria-label="Pages of notes">    {% if number > 1 %}<a href="/notes?page={{ number - 1 }}" rel="prev">Newer</a>{% endif %}    {% if more %}<a href="/notes?page={{ number + 1 }}" rel="next">Older</a>{% endif %}  </nav></section>
src/apps/notes/notes_view.nexy
---from "@/components/NoteCard.nexy" import NoteCardfrom nexy import useSearchParams, useSessionfrom src.notes import notes_page params = useSearchParams()error = params.get("error", "")asked = params.get("page", "1")number = max(int(asked), 1) if asked.isdigit() else 1found = notes_page(number)notes = found[0]more = found[1]signed_in = "user_id" in useSession()---<section class="app-page">  <h1>Notes</h1>   {% if signed_in %}  <form method="post" action="{{ __actions_path['sign_out'] }}">    <button type="submit">Sign out</button>  </form>  {% else %}  <p><a href="/signin">Sign in</a> or <a href="/signup">create an account</a>.</p>  {% endif %}   {% if error %}  <p class="note-error" role="alert">{{ error | e }}</p>  {% endif %}   <form method="post" action="{{ __actions_path['create_note'] }}">    <input name="text" placeholder="A new note" maxlength="200" required>    <button type="submit">Add</button>  </form>   {% for note in notes %}  <NoteCard id="{{ note.id }}" text="{{ note.text }}" />  {% endfor %}   <nav class="note-pages" aria-label="Pages of notes">    {% if number > 1 %}<a href="/notes?page={{ number - 1 }}" rel="prev">Newer</a>{% endif %}    {% if more %}<a href="/notes?page={{ number + 1 }}" rel="next">Older</a>{% endif %}  </nav></section>

افتح http://localhost:3000/signup، وأنشئ حسابًا: يمكنك الوصول إلى الملاحظات باستخدام زر تسجيل الخروج. قم بتسجيل الخروج، ثم قم بتسجيل الدخول مرة أخرى على http://localhost:3000/signin.

ما تعلمته

  • useSession في nexyconfig.py يقوم بتشغيل ملف تعريف الارتباط للجلسة الموقعة؛ سرها يعيش في .env.
  • تحتفظ قاعدة البيانات بتجزئة مملحة وبطيئة لكلمة المرور، ويتم التحقق منها باستخدام hmac.compare_digest.
  • الإجراء يكتب الجلسة بـ useSession()؛ تقرأها الصفحة بنفس الطريقة.

الخطوات التالية

حرِّر على GitHub

Enter يفتح النتيجة، وEsc يغلق.