الوثائقالوحدة 3: المستخدمون

إنشاء تطبيق ملاحظات باستخدام 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 يغلق.