DocsUnit 3: users

Build a notes app with Nexy, part 12: reset a forgotten password by email

Part 12 sends a link by email to a user who forgot their password. The link opens a form for a new password, works for an hour, and only once.

Before you start: the app of part 11.

Send an email

smtplib, from the standard library, talks to any mail server: your provider's, or a service such as Postmark, Resend or Amazon SES, which all accept SMTP. Without a server, the email is printed in the terminal of nexy dev, which is all you need on your computer:

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)

To send for real, add the server to .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() encrypts the connection before the password is sent.

The link carries a token signed with the session secret, so no one can make one by hand. itsdangerous writes the time into it, and max_age refuses it after an hour. The token also holds the end of the password hash: once the password changes, the hash changes, and the link stops working.

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 answers the same way whether the email has an account or not: the page says a link may be on its way, so it tells no one which emails are registered.

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" makes a screen reader read the message without interrupting, where role="alert" interrupts: a confirmation is not an error.

Open http://localhost:3000/forgot, type the email of your account and send. The terminal of nexy dev prints the email: open its link, choose a new password, and sign in with it. Open the link a second time: it is refused.

What you learned

  • smtplib sends an email through any SMTP server; in development, printing it is enough.
  • A signed, timed token from itsdangerous makes a link no one can forge, valid for an hour.
  • Putting part of the password hash in the token makes the link work only once.

Next steps

Edit on GitHub

Enter opens the result, Esc closes.