文档第三单元:用户
使用 Nexy 构建笔记应用程序,第 10 部分:创建帐户并登录
第 10 部分允许访问者使用电子邮件和密码创建帐户、登录和注销,并使用会话 cookie 记住谁登录。
开始之前:第 9 部分的应用程序。
打开会话
会话是一个签名的 cookie:服务器将登录用户的 ID 放入其中,并在每次请求时将其读回。 Starlette 用 itsdangerous 签名:
uv add itsdangerous
它签名的秘密保存在数据库地址旁边的 .env 中:
DATABASE_URL=sqlite:///dev.dbSESSION_SECRET=change-me-to-a-long-random-string用 python -c "import secrets; print(secrets.token_urlsafe(32))" 创造一个真正的秘密。任何知道它的人都可以以任何用户的身份签署 cookie。
useSession 打开会话,并从环境中读取秘密:
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[...] 而不是 os.getenv(...):如果没有秘密,应用程序将在启动时停止,而不是在没有秘密的情况下运行。
描述一个用户
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/中:
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,来自标准库,是一种故意变慢的哈希,因此攻击者需要花费数年时间来逐一猜测密码。每个密码都有自己的随机盐:
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 进行比较的时间不依赖于两个哈希值的不同之处,因此应答时间不会告诉攻击者任何信息。
注册、登录、退出
三项行动。每个成功的会话都会写入会话,或者清空会话:
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("/")错误的电子邮件或密码,永远不会是哪一个:一条显示“此电子邮件没有帐户”的消息会告诉任何人哪些电子邮件有帐户。
两种形式
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" 允许密码管理器提供强密码,current-password 让它填充它保存的密码。
显示谁已登录
注释页面向用户致意并显示退出按钮;访问者会看到两个表单的链接:
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>打开 http://localhost:3000/signup,创建一个帐户:您可以使用 注销 按钮登录笔记。注销,然后在 http://localhost:3000/signin 处重新登录。
你学到了什么
nexyconfig.py中的useSession打开签名会话 cookie;它的秘密在于.env。- 数据库保留密码的加盐慢哈希值,并使用
hmac.compare_digest检查。 - Action 用
useSession()写入会话;页面以同样的方式读取它。
后续步骤
- 第 11 部分:为每个用户提供自己的注释
- 中间件和安全性:会话、CORS 和可信主机