Retrieve password logic: integrate Flask-Mail to send verification emails

📂 Stage: Stage 3 - User System (Security) 🔗 Related chapters: Flask-Login 实战 · 密码安全加密


Why is it necessary to design like this? First sort out the overall process

Password retrieval is a "back-up security feature" of the user system. It must be simple enough to allow users to quickly reset their passwords; at the same time, it must be rigorous enough to prevent issues such as mailbox enumeration attacks and Token abuse. The picture below summarizes the complete link we want to implement, with each step being securely designed:

flowchart LR
    A[忘记密码入口] --> B[输入邮箱提交]
    B --> C{数据库查邮箱存在?}
    C -->|无论是否| D[统一提示:已发送(若注册)]
    D --> E[跳转登录页]
    C -->|存在| F[生成限时 URLSafe Token]
    F --> G[构造带外部域名的重置链接]
    G --> H[Flask-Mail 发送验证邮件]
    H --> I[用户点击邮件链接]
    I --> J{Token 校验(过期/篡改)}
    J -->|失败| K[提示失效,重新申请]
    K --> A
    J -->|成功| L[展示新密码设置页]
    L --> M[提交后哈希更新密码]
    M --> N[成功跳转登录页]

There are two "counter-intuitive" points to the entire process:

  • Regardless of whether the email address is registered or not, the same prompt is given to the user to avoid leaking registration information.
  • Token is one-time, with expiration time and exclusive "salt" to prevent Tokens from borrowing each other in different scenarios.

Install dependencies

We only need two third-party libraries:

  • flask-mail: Helps you send emails with a few lines of code.
  • itsdangerous: Generates an encrypted, timestamped security token, which Flask's built-in signature mechanism is based on.

One-click installation:

pip install flask-mail itsdangerous

Step 1: Initialize Flask-Mail and global configuration

🛡️ Security reminder: In a production environment, never hardcode your email password into the code. used here.envDocument coordinationpython-dotenvLoad sensitive information.

Create global extension object

To avoid circular imports, we useapp/extensions.pyThere is only one declaration inMailInstance, wait until the factory function creates the app before binding it.

# app/extensions.py
from flask_mail import Mail

mail = Mail()

Configure the mail service in the factory function

Take Gmail as an example (you need to turn on "Application-specific password"), other email services are similar, you only need to modifyMAIL_SERVERand port.

# app/__init__.py
from flask import Flask
from dotenv import load_dotenv
import os
from app.extensions import mail

load_dotenv()

def create_app():
    app = Flask(__name__)

    app.config["SECRET_KEY"] = os.getenv("SECRET_KEY")

    # Flask-Mail 核心配置
    app.config["MAIL_SERVER"] = "smtp.gmail.com"
    app.config["MAIL_PORT"] = 587
    app.config["MAIL_USE_TLS"] = True          # Gmail 强制要求 TLS
    app.config["MAIL_USERNAME"] = os.getenv("MAIL_USERNAME")   # 你的 Gmail 地址
    app.config["MAIL_PASSWORD"] = os.getenv("MAIL_PASSWORD")   # Gmail 应用专用密码
    app.config["MAIL_DEFAULT_SENDER"] = ("道满博客", os.getenv("MAIL_USERNAME"))

    mail.init_app(app)   # 延迟绑定

    return app

MAIL_DEFAULT_SENDERIt is a tuple, representing the sender's name and email address respectively, so that the received email will display "Daoman Blog" instead of a naked mailbox.


Step 2: Design a secure Token manager

Password reset requires a one-time, limited-time, user-specific credential.itsdangerousofURLSafeTimedSerializerJust meet these three points:

  • LIMITED TIME: PASSmax_ageParameters automatically determine whether they have expired.
  • Bind mailbox: Encode the mailbox as data and directly decrypt it when using it.
  • Scene Isolation: use differentsalt(Salt) Distinguish between different services such as "password reset" and "email verification" to prevent mixed use of tokens.

We encapsulate Token related logic into a class to facilitate global calls:

# app/utils/token.py
from itsdangerous import URLSafeTimedSerializer, BadData
from flask import current_app

class PasswordResetTokenManager:
    """密码重置专属 Token 管理器"""
    SALT = "password-reset-salt-v1"   # 盐值带版本号,方便以后升级
    DEFAULT_EXPIRATION = 3600         # 1 小时后过期

    @classmethod
    def generate(cls, email: str) -> str:
        serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
        return serializer.dumps(email, salt=cls.SALT)

    @classmethod
    def verify(cls, token: str, expiration: int | None = None) -> str | None:
        """验证成功返回绑定的邮箱,否则返回 None"""
        if expiration is None:
            expiration = cls.DEFAULT_EXPIRATION

        serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
        try:
            return serializer.loads(token, salt=cls.SALT, max_age=expiration)
        except BadData:   # 签名错误、过期、盐不匹配都会抛出 BadData
            return None

BadDataIt can catch almost all exceptions and avoid distinguishing "signature error" and "timeout expiration" ourselves, which is very convenient.


Step 3: Write business routing

3.1 Forgot password - Submit email and send email

The most important security measure in this step is: Always give the user a consistent prompt, regardless of whether the email address is registered or not. This prevents attackers from enumerating our user list by submitting different email addresses and observing page feedback or response times.

# app/routes/auth.py
from flask import Blueprint, render_template, redirect, url_for, flash
from flask_mail import Message
from app.extensions import mail, db
from app.models.user import User
from app.forms.auth import ForgotPasswordForm, ResetPasswordForm
from app.utils.token import PasswordResetTokenManager
from datetime import datetime

auth_bp = Blueprint("auth", __name__, url_prefix="/auth")

@auth_bp.route("/forgot-password", methods=["GET", "POST"])
def forgot_password():
    form = ForgotPasswordForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data.lower()).first()

        if user:
            # 1. 生成 Token
            token = PasswordResetTokenManager.generate(user.email)
            # 2. 构造外部可访问的完整 URL
            reset_url = url_for("auth.reset_password", token=token, _external=True)
            # 3. 发送纯文本 + HTML 双格式邮件
            msg = Message(
                subject="【道满博客】紧急:您的密码重置请求",
                recipients=[user.email],
                body=f"""
您好,{user.username or user.email.split('@')[0]}

我们于 {datetime.now().strftime('%Y-%m-%d %H:%M:%S UTC')} 收到了您对道满博客账户「{user.email}」的密码重置请求。

请在 1 小时内点击以下链接设置新密码:
{reset_url}

⚠️ 如果这不是您本人的操作,请**立即忽略此邮件**,您的账户密码不会被修改,账户安全也不会受到影响。

如有疑问,请联系 support@daoman.blog

—— 道满博客安全团队
                """,
                html=f"""
<p>您好,{user.username or user.email.split('@')[0]}:</p>
<p>我们于 <strong>{datetime.now().strftime('%Y-%m-%d %H:%M:%S UTC')}</strong> 收到了您对道满博客账户「{user.email}」的密码重置请求。</p>
<p>请在 1 小时内点击以下链接设置新密码:</p>
<p><a href="{reset_url}" style="background:#4CAF50;color:white;padding:10px 20px;text-decoration:none;border-radius:4px;display:inline-block;">立即重置密码</a></p>
<p>如果链接无法点击,请复制到浏览器地址栏打开:<br>{reset_url}</p>
<hr>
<p>⚠️ 如果这不是您本人的操作,请<strong>立即忽略此邮件</strong>,您的账户密码不会被修改,账户安全也不会受到影响。</p>
<p>如有疑问,请联系 <a href="mailto:support@daoman.blog">support@daoman.blog</a></p>
<p>—— 道满博客安全团队</p>
                """
            )
            mail.send(msg)

        # 无论用户是否存在都返回相同提示,防止邮箱枚举
        flash("如果该邮箱已注册,我们已发送带有重置链接的邮件,请查收。", "info")
        return redirect(url_for("auth.login"))

    return render_template("auth/forgot_password.html", form=form)

💡 Tips:url_for(..., _external=True)Complete domain names are automatically completed (e.g.http://localhost:5000/...), in the production environment as long as yourSERVER_NAMEIf configured correctly, the link can be used directly.

3.2 Reset password - verify Token and update password

After the user clicks the email link, they will enter this route, first verify the token, and then display the form for setting a new password. After submission, directly assign the plaintext password touser.password, automatically completing the hash using the setters we defined previously in the model.

@auth_bp.route("/reset-password/<token>", methods=["GET", "POST"])
def reset_password(token):
    # 第一步:校验 Token
    email = PasswordResetTokenManager.verify(token)
    if not email:
        flash("重置链接已过期或无效,请重新申请密码重置。", "danger")
        return redirect(url_for("auth.forgot_password"))

    # 第二步:防止 Token 有效但用户已被删除的极端情况
    user = User.query.filter_by(email=email.lower()).first()
    if not user:
        flash("该账户不存在,请检查您的注册邮箱或重新注册。", "danger")
        return redirect(url_for("auth.register"))

    form = ResetPasswordForm()
    if form.validate_on_submit():
        # 第三步:更新密码(setter 中已经包含自动哈希的逻辑)
        user.password = form.new_password.data
        user.updated_at = datetime.utcnow()
        db.session.commit()

        flash("密码重置成功!请使用新密码登录。", "success")
        return redirect(url_for("auth.login"))

    return render_template("auth/reset_password.html", form=form, token=token)

Safety Points Review

Password retrieval is the “easiest to be targeted” link in the user system. Let’s compare this implementation to see what has been done correctly and which pitfalls must be avoided.

✅ Security measures taken by this implementationDescription
Token salt + timestampTokens between different businesses cannot be used universally, and will automatically expire when they expire
Give a unified prompt regardless of whether the email address existsEliminate time-consuming enumeration of registered users through feedback copywriting or interfaces
New passwords are automatically hashed after submissionClear text passwords are never saved in the database
Reset the link to use an external domain nameCan be accessed normally in both local and online environments
Email supports plain text + HTML dual formatsCompatible with various email clients to avoid being marked as spam
❌ Dangerous practices that must be avoidedPossible risks
Send new password directly in emailOnce the email is leaked, the account will be stolen immediately
Token has no expiration timeThe old reset link can be permanently changed if maliciously used
The reset Token and the email activation Token share the same salt valueAn attacker can use the activation Token to reset the password
The page directly displays "This email address is not registered"Malicious users can enumerate registered email addresses in batches

Production environment optimization suggestions

  1. Switch to a professional email service provider Personal Gmail or self-built SMTP has a low delivery rate and is easily classified as spam. It is recommended to use SendGrid, Mailgun, Alibaba Cloud email push and other services.

  2. Increase frequency limit For example, the same email address can be applied for at most 3 times in 1 hour, and the same IP can be applied for at most 10 times in 1 hour. You can use Redis to make a simple counter.

  3. Record password reset log Record the time of each application, IP address, whether the reset is successful, etc. to facilitate subsequent troubleshooting of security issues.

  4. Force old sessions to expire after reset You can clear the user's currentsession_id, or call Flask-Login'slogout_user()And clear the session cache in Redis to ensure that the old login status is immediately invalidated.


Summary

Behind a seemingly simple "retrieve password" function, there are actually many security details hidden. We implemented it in four steps:

  1. Generate a salted, time-limited, URL-safe Token;
  2. Construct a reset link and use Flask-Mail to send an email containing both plain text and HTML content;
  3. Strictly verify the Token’s signature, salt and validity period after the user clicks;
  4. After the new password is submitted, it is automatically hashed and written into the database.

This process not only ensures the operating experience of ordinary users, but also effectively prevents most attacks on password retrieval.


🔗 Extended reading