DocsUnidade 3: usuários

Crie um aplicativo de notas com Nexy, parte 12: redefina uma senha esquecida por e-mail

A Parte 12 envia um link por email para um usuário que esqueceu sua senha. O link abre um formulário para nova senha, funciona por uma hora e apenas uma vez.

Antes de começar: o aplicativo da parte 11.

Envie um e-mail

smtplib, da biblioteca padrão, se comunica com qualquer servidor de e-mail: o do seu provedor, ou um serviço como Postmark, Resend ou Amazon SES, todos aceitando SMTP. Sem servidor, o email é impresso no terminal do nexy dev, que é tudo que você precisa no seu computador:

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)

Para enviar de verdade, adicione o servidor em .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() criptografa a conexão antes que a senha seja enviada.

O link carrega um token assinado com o segredo da sessão, para que ninguém possa fazer um manualmente. itsdangerous escreve a hora nele e max_age recusa depois de uma hora. O token também contém o final do hash da senha: quando a senha muda, o hash muda e o link para de funcionar.

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 responde da mesma forma se o e-mail tem conta ou não: a página diz que um link pode estar a caminho, então não informa a ninguém quais e-mails estão registrados.

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" faz um leitor de tela ler a mensagem sem interromper, onde role="alert" interrompe: uma confirmação não é um erro.

Abra http://localhost:3000/forgot, digite o email da sua conta e envie. O terminal de nexy dev imprime o e-mail: abra seu link, escolha uma nova senha e faça login com ela. Abra o link uma segunda vez: ele é recusado.

O que você aprendeu

  • smtplib envia um email através de qualquer servidor SMTP; no desenvolvimento, imprimir é suficiente.
  • Um token assinado e cronometrado de itsdangerous cria um link que ninguém pode falsificar, válido por uma hora.
  • Colocar parte do hash da senha no token faz com que o link funcione apenas uma vez.

Próximas etapas

Editar no GitHub

Enter abre o resultado, Esc fecha.