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:
DATABASE_URL=sqlite:///dev.dbSESSION_SECRET=change-me-to-a-long-random-stringBuatlah 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:
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"]}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
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: strPengguna adalah fitur tersendiri, di src/apps/users/:
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: strTabel 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:
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 userimport 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 userhmac.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:
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
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>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>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>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>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")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:
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>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
useSessiondinexyconfig.pymengaktifkan 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
- Bagian 11: berikan catatan mereka sendiri kepada setiap pengguna
- Middleware dan keamanan: sesi, CORS, dan host tepercaya