文档第三单元:用户

使用 Nexy 构建笔记应用程序,第 12 部分:通过电子邮件重置忘记的密码

第 12 部分通过电子邮件向忘记密码的用户发送链接。该链接会打开一个新密码表单,有效期为一小时,且只能使用一次。

开始之前:第 11 部分的应用程序。

发送电子邮件

smtplib,来自标准库,可与任何邮件服务器通信:您的提供商的邮件服务器,或 Postmark、Resend 或 Amazon SES 等服务,它们都接受 SMTP。如果没有服务器,电子邮件将打印在 nexy dev 的终端中,这就是您在计算机上所需要的一切:

src/mail.py
import osimport smtplibfrom email.message import EmailMessage  def send_email(to: str, subject: str, body: str) -> None:    """Send through the SMTP server named in `.env`; without one, print the email instead."""    host = os.getenv("SMTP_HOST")    if not host:        print(f"To: {to}\nSubject: {subject}\n\n{body}", flush=True)        return    message = EmailMessage()    message["From"] = os.getenv("MAIL_FROM", "notes@localhost")    message["To"] = to    message["Subject"] = subject    message.set_content(body)    with smtplib.SMTP(host, int(os.getenv("SMTP_PORT", "587"))) as smtp:        smtp.starttls()        smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])        smtp.send_message(message)

要真实发送,请将服务器添加到 .env:

SMTP_HOST=smtp.example.comSMTP_PORT=587SMTP_USER=notes@example.comSMTP_PASSWORD=the-password-of-the-mailboxMAIL_FROM=notes@example.comAPP_URL=https://notes.example.com

starttls() 在发送密码之前对连接进行加密。

创建一个仅有效一次、持续一小时的链接

该链接带有一个使用会话密钥签名的令牌,因此没有人可以手动制作一个令牌。 itsdangerous 将时间写入其中,max_age 在一小时后拒绝它。该令牌还保存密码哈希的末尾:一旦密码更改,哈希就会更改,并且链接将停止工作。

src/password_reset.py
import os from itsdangerous import BadSignature, URLSafeTimedSerializerfrom sqlmodel import Session, select from src.accounts import hash_passwordfrom src.configs.db import enginefrom src.models.user_model import User ONE_HOUR = 3600  def signer() -> URLSafeTimedSerializer:    return URLSafeTimedSerializer(os.environ["SESSION_SECRET"], salt="password-reset")  def find_by_email(email: str) -> User | None:    with Session(engine) as session:        return session.exec(select(User).where(User.email == email)).first()  def reset_token(user: User) -> str:    """A token that names the user and the end of their current password hash."""    return signer().dumps([user.id, user.password_hash[-16:]])  def user_of(token: str) -> User | None:    """The user of a token less than an hour old, whose password has not changed since."""    try:        user_id, fingerprint = signer().loads(token, max_age=ONE_HOUR)    except BadSignature:        return None    with Session(engine) as session:        user = session.get(User, user_id)    if user is None or user.password_hash[-16:] != fingerprint:        return None    return user  def set_password(user_id: int, password: str) -> None:    with Session(engine) as session:        user = session.get(User, user_id)        user.password_hash = hash_password(password)        session.commit()
src/password_reset.py
import os from itsdangerous import BadSignature, URLSafeTimedSerializerfrom sqlmodel import Session, select from src.accounts import hash_passwordfrom src.apps.users.user_model import Userfrom src.configs.db import engine ONE_HOUR = 3600  def signer() -> URLSafeTimedSerializer:    return URLSafeTimedSerializer(os.environ["SESSION_SECRET"], salt="password-reset")  def find_by_email(email: str) -> User | None:    with Session(engine) as session:        return session.exec(select(User).where(User.email == email)).first()  def reset_token(user: User) -> str:    """A token that names the user and the end of their current password hash."""    return signer().dumps([user.id, user.password_hash[-16:]])  def user_of(token: str) -> User | None:    """The user of a token less than an hour old, whose password has not changed since."""    try:        user_id, fingerprint = signer().loads(token, max_age=ONE_HOUR)    except BadSignature:        return None    with Session(engine) as session:        user = session.get(User, user_id)    if user is None or user.password_hash[-16:] != fingerprint:        return None    return user  def set_password(user_id: int, password: str) -> None:    with Session(engine) as session:        user = session.get(User, user_id)        user.password_hash = hash_password(password)        session.commit()

请求链接,然后使用它

src/actions/password_reset.py
import osfrom urllib.parse import urlencode from nexy.decorators import Actionfrom nexy.http import RedirectResponsefrom src.mail import send_emailfrom src.password_reset import find_by_email, reset_token, set_password, user_of MIN_PASSWORD = 8  def go(path: str, **query: str) -> RedirectResponse:    return RedirectResponse(f"{path}?{urlencode(query)}" if query else path, status_code=303)  @Actionasync def request_reset(email: str) -> RedirectResponse:    user = find_by_email(email.strip().lower())    if user is not None:        link = f"{os.getenv('APP_URL', 'http://localhost:3000')}/reset?token={reset_token(user)}"        body = f"Open this link within an hour to choose a new password:\n\n{link}\n"        send_email(user.email, "Reset your password", body)    return go("/forgot", sent="1")  @Actionasync def reset_password(token: str, password: str) -> RedirectResponse:    user = user_of(token)    if user is None:        return go("/forgot", error="This link has expired or was used. Ask for a new one.")    if len(password) < MIN_PASSWORD:        return go("/reset", token=token, error=f"A password has at least {MIN_PASSWORD} characters.")    set_password(user.id, password)    return go("/signin", reset="1")

request_reset 无论电子邮件是否有帐户,都会以相同的方式回答:页面显示链接可能正在路上,因此它不会告诉任何人哪些电子邮件已注册。

这两个页面以及指向它们的链接

src/routes/forgot.nexy
---from nexy import useSearchParams params = useSearchParams()sent = params.get("sent") == "1"error = params.get("error", "")---<section class="app-page">  <h1>Forgot your password?</h1>   {% if sent %}  <p role="status">If an account uses this email, a link to reset its password is on its way.</p>  {% endif %}  {% if error %}  <p class="note-error" role="alert">{{ error | e }}</p>  {% endif %}   <form method="post" action="{{ __actions_path['request_reset'] }}">    <label>Email <input type="email" name="email" autocomplete="email" required></label>    <button type="submit">Send the link</button>  </form></section>
src/routes/reset.nexy
---from nexy import useSearchParams params = useSearchParams()token = params.get("token", "")error = params.get("error", "")---<section class="app-page">  <h1>Choose a new password</h1>   {% if error %}  <p class="note-error" role="alert">{{ error | e }}</p>  {% endif %}   <form method="post" action="{{ __actions_path['reset_password'] }}">    <input type="hidden" name="token" value="{{ token | e }}">    <label>New password <input type="password" name="password" autocomplete="new-password" minlength="8" required></label>    <button type="submit">Save the password</button>  </form></section>
src/routes/signin.nexy
---from nexy import useSearchParams params = useSearchParams()error = params.get("error", "")reset = params.get("reset") == "1"---<section class="app-page">  <h1>Sign in</h1>   {% if reset %}  <p role="status">Your password is changed: sign in with the new one.</p>  {% endif %}  {% 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="/forgot">Forgot your password?</a>  <a href="/signup">Create an account</a></section>
src/apps/users/forgot_view.nexy
---from nexy import useSearchParams params = useSearchParams()sent = params.get("sent") == "1"error = params.get("error", "")---<section class="app-page">  <h1>Forgot your password?</h1>   {% if sent %}  <p role="status">If an account uses this email, a link to reset its password is on its way.</p>  {% endif %}  {% if error %}  <p class="note-error" role="alert">{{ error | e }}</p>  {% endif %}   <form method="post" action="{{ __actions_path['request_reset'] }}">    <label>Email <input type="email" name="email" autocomplete="email" required></label>    <button type="submit">Send the link</button>  </form></section>
src/apps/users/reset_view.nexy
---from nexy import useSearchParams params = useSearchParams()token = params.get("token", "")error = params.get("error", "")---<section class="app-page">  <h1>Choose a new password</h1>   {% if error %}  <p class="note-error" role="alert">{{ error | e }}</p>  {% endif %}   <form method="post" action="{{ __actions_path['reset_password'] }}">    <input type="hidden" name="token" value="{{ token | e }}">    <label>New password <input type="password" name="password" autocomplete="new-password" minlength="8" required></label>    <button type="submit">Save the password</button>  </form></section>
src/apps/users/signin_view.nexy
---from nexy import useSearchParams params = useSearchParams()error = params.get("error", "")reset = params.get("reset") == "1"---<section class="app-page">  <h1>Sign in</h1>   {% if reset %}  <p role="status">Your password is changed: sign in with the new one.</p>  {% endif %}  {% 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="/forgot">Forgot your password?</a>  <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")  @Controller("/forgot")class ForgotController:    async def get(self):        return page("src/apps/users/forgot_view.nexy")  @Controller("/reset")class ResetController:    async def get(self):        return page("src/apps/users/reset_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 (    ForgotController,    ResetController,    SignInController,    SignUpController,)  @Module()class AppModule:    controllers = [        AppController,        AboutController,        NotesController,        NoteController,        NotesApiController,        SignUpController,        SignInController,        ForgotController,        ResetController,    ]

role="status" 使屏幕阅读器在不中断的情况下读取消息,其中 role="alert" 中断:确认不是错误。

打开 http://localhost:3000/forgot,输入您帐户的电子邮件并发送。 nexy dev 的终端打印电子邮件:打开其链接,选择新密码,然后使用它登录。再次打开链接:被拒绝。

你学到了什么

  • smtplib 通过任何 SMTP 服务器发送电子邮件;在开发中,打印就足够了。
  • 来自 itsdangerous 的签名、定时令牌使得任何人都无法伪造链接,有效期一小时。
  • 将部分密码散列放入令牌中使链接只能工作一次。

后续步骤

在 GitHub 上编辑

Enter 打开结果,Esc 关闭。