DokuEinheit 3: Benutzer
Erstellen Sie eine Notizen-App mit Nexy, Teil 12: Zurücksetzen eines vergessenen Passworts per E-Mail
Teil 12 sendet einen Link per E-Mail an einen Benutzer, der sein Passwort vergessen hat. Der Link öffnet ein Formular für ein neues Passwort, funktioniert eine Stunde lang und nur einmal.
Bevor Sie beginnen: die App von Teil 11.
Senden Sie eine E-Mail
smtplib aus der Standardbibliothek kommuniziert mit jedem Mailserver: dem Ihres Anbieters oder einem Dienst wie Postmark, Resend oder Amazon SES, die alle SMTP akzeptieren. Ohne Server wird die E-Mail im Terminal von nexy dev gedruckt, was alles ist, was Sie auf Ihrem Computer benötigen:
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)Um wirklich zu senden, fügen Sie den Server zu .env hinzu:
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() verschlüsselt die Verbindung, bevor das Passwort gesendet wird.
Erstellen Sie einen Link, der einmal für eine Stunde funktioniert
Der Link enthält ein mit dem Sitzungsgeheimnis signiertes Token, sodass niemand eines von Hand erstellen kann. itsdangerous schreibt die Uhrzeit hinein und max_age lehnt sie nach einer Stunde ab. Das Token enthält auch das Ende des Passwort-Hashs: Sobald sich das Passwort ändert, ändert sich auch der Hash und der Link funktioniert nicht mehr.
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()Fragen Sie nach dem Link und nutzen Sie ihn
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 antwortet auf die gleiche Weise, unabhängig davon, ob die E-Mail ein Konto hat oder nicht: Die Seite sagt, dass ein Link möglicherweise unterwegs ist, sodass niemandem mitgeteilt wird, welche E-Mails registriert sind.
Die beiden Seiten und ein Link dazu
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" lässt einen Bildschirmleser die Nachricht ohne Unterbrechung lesen, wobei role="alert" unterbricht: Eine Bestätigung ist kein Fehler.
Öffnen Sie http://localhost:3000/forgot, geben Sie die E-Mail-Adresse Ihres Kontos ein und senden Sie. Das Terminal von nexy dev druckt die E-Mail: Öffnen Sie den Link, wählen Sie ein neues Passwort und melden Sie sich damit an. Öffnen Sie den Link ein zweites Mal: Er wird abgelehnt.
Was du gelernt hast
smtplibsendet eine E-Mail über einen beliebigen SMTP-Server; In der Entwicklung reicht es aus, es zu drucken.- Ein signierter, zeitgesteuerter Token von
itsdangerousstellt einen Link her, den niemand fälschen kann und der eine Stunde lang gültig ist. - Wenn Sie einen Teil des Passwort-Hashs in das Token einfügen, funktioniert der Link nur einmal.
Nächste Schritte
- Einheit 4, Teil 13: Durchsuchen Sie die Notizen während der Eingabe
- Middleware und Sicherheit: Sitzungen, CORS und vertrauenswürdige Hosts