DokumentasiUnit 3: pengguna

Bangun aplikasi catatan dengan Nexy, bagian 10: buat akun dan masuk

Bagian 10 memungkinkan pengunjung membuat akun dengan email dan kata sandi, masuk dan keluar, dan mengingat siapa yang masuk dengan cookie sesi.

Sebelum Anda memulai: aplikasi bagian 9.

Aktifkan sesi

Sesi adalah cookie yang ditandatangani: server memasukkan id pengguna yang masuk ke dalamnya, dan membacanya kembali pada setiap permintaan. Starlette menandatanganinya dengan itsdangerous:

uv add itsdangerous

Rahasia yang ditandatanganinya disimpan di .env, di sebelah alamat database:

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

Buatlah rahasia nyata dengan python -c "import secrets; print(secrets.token_urlsafe(32))". Siapa pun yang mengetahuinya dapat menandatangani cookie sebagai pengguna mana pun.

useSession mengaktifkan sesi, dengan rahasia yang dibaca dari lingkungan:

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[...] dan bukan os.getenv(...): tanpa rahasia, aplikasi berhenti saat startup alih-alih berjalan tanpa rahasia.

Jelaskan seorang pengguna

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

Pengguna adalah fitur tersendiri, di 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

Tabel menyimpan hash kata sandi, bukan kata sandi: database yang dicuri tidak akan memberikan kata sandinya. unique=True membuat database menolak akun kedua dengan email yang sama.

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

Migrasi menemukan model baru dengan sendirinya: ia membaca setiap *_model.py dari src/.

Hash dan periksa kata sandi

hashlib.scrypt, dari perpustakaan standar, adalah hash yang sengaja dibuat lambat, jadi menebak kata sandi satu per satu membutuhkan waktu bertahun-tahun bagi penyerang. Setiap kata sandi mendapatkan garam acaknya sendiri:

src/akun.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/akun.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 membandingkan dalam waktu yang tidak bergantung pada perbedaan kedua hash, sehingga waktu jawaban tidak memberi tahu penyerang apa pun.

Daftar, masuk, keluar

Tiga Tindakan. Masing-masing yang berhasil menulis sesi tersebut, atau mengosongkannya:

src/aksi/akun.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("/")

Email atau kata sandi salah, tidak pernah yang mana: pesan yang mengatakan "tidak ada akun dengan email ini" akan memberi tahu siapa pun email mana yang memiliki akun.

Kedua bentuk tersebut

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" memungkinkan pengelola kata sandi menawarkan kata sandi yang kuat, dan current-password memungkinkannya mengisi kata sandi yang disimpannya.

Tunjukkan siapa yang masuk

Halaman catatan menyambut pengguna dan menampilkan tombol keluar; pengunjung malah melihat tautan ke dua formulir:

src/rute/catatan/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>

Buka http://localhost:3000/signup, buat akun: Anda membuka catatan dengan tombol Keluar. Keluar, lalu masuk lagi di http://localhost:3000/signin.

Apa yang Anda pelajari

  • useSession di nexyconfig.py mengaktifkan cookie sesi yang ditandatangani; rahasianya ada di .env.
  • Basis data menyimpan hash kata sandi yang lambat dan asin, diperiksa dengan hmac.compare_digest.
  • Suatu Tindakan menulis sesi dengan useSession(); sebuah halaman membacanya dengan cara yang sama.

Langkah selanjutnya

Sunting di GitHub

Enter membuka hasil, Esc menutup.