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:
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.
Faça um link que funcione uma vez, por uma hora
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.
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()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()Peça o link e use-o
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.
As duas páginas e um link para elas
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>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>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>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>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>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>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")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
smtplibenvia um email através de qualquer servidor SMTP; no desenvolvimento, imprimir é suficiente.- Um token assinado e cronometrado de
itsdangerouscria 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
- Unidade 4, parte 13: pesquise as notas enquanto você digita
- Middleware e segurança: sessões, CORS e hosts confiáveis