दस्तावेज़यूनिट 3: उपयोगकर्ता

नेक्सी के साथ एक नोट्स ऐप बनाएं, भाग 10: एक खाता बनाएं और साइन इन करें

भाग 10 विज़िटर को ईमेल और पासवर्ड के साथ एक खाता बनाने, साइन इन करने और साइन आउट करने की सुविधा देता है, और सत्र कुकी के साथ यह याद रखने देता है कि किसने साइन इन किया है।

शुरू करने से पहले: भाग 9 का ऐप।

सत्र चालू करें

एक सत्र एक हस्ताक्षरित कुकी है: सर्वर इसमें साइन-इन किए गए उपयोगकर्ता की आईडी डालता है, और इसे हर अनुरोध पर वापस पढ़ता है। 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

माइग्रेशन अपने आप ही नया मॉडल ढूंढ लेता है: यह src/ में से प्रत्येक *_model.py को पढ़ता है।

हैश करें और पासवर्ड जांचें

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 बंद करता है।