邮件配置与密码重置
学习目标
使用 Django 6.1 的 MAILERS 设置邮件后端,完成密码重置的申请、邮件、确认和完成页,验证令牌过期与一次使用行为。
为什么重置密码应该交给成熟组件
密码重置链接证明申请者能读取注册邮箱。链接需要有效期,并在密码改变后失效;不能把旧密码发送给用户,也不能在日志里公开令牌。Django 内置视图、表单和令牌生成器已经协同完成基础流程,我们补齐所有模板和邮件配置。
MAILERS 是 6.1 的新配置结构,可以按别名管理多个后端。本章 default 在未配置 SMTP 时打印邮件到终端,便于本地验证;设置 EMAIL_HOST 后使用 SMTP OPTIONS。这里 EMAIL_HOST 等是我们自己约定的环境变量,再转换成 MAILERS,不等于继续使用 Django 旧的 EMAIL_BACKEND 设置。本站当前仍沿用旧配置,本课程不修改它。
具体怎么做
- 更新设置、accounts 路由和登录页链接,创建四个页面模板,以及邮件正文和单行主题文件。
- 先用终端邮件后端运行,注册一个有可用密码和邮箱的普通账号。
- 打开
/accounts/password-reset/,提交邮箱,在开发终端找到链接并打开。 - 两次输入新密码,完成后用新密码登录。邮件中的用户名用于帮助有多个账号的读者确认目标账户。
- 接入 SMTP 时,在启动 Python 的环境中设置 EMAIL_HOST、EMAIL_PORT、EMAIL_HOST_USER、EMAIL_HOST_PASSWORD 和 DEFAULT_FROM_EMAIL。本例使用 587/STARTTLS;供应商要求 465 时应将 OPTIONS 改为 use_ssl=True、use_tls=False,不能同时启用两者。
本章新增或替换的完整文件
以下路径均相对于练习项目根目录。替换同名文件的全部内容;未列出的文件保持上一章版本。
accounts/urls.py
from django.contrib.auth import views as auth_views
from django.urls import path
from . import views
urlpatterns = [
path("password-reset/", auth_views.PasswordResetView.as_view(), name="password_reset"),
path("password-reset/done/", auth_views.PasswordResetDoneView.as_view(), name="password_reset_done"),
path("reset/<uidb64>/<token>/", auth_views.PasswordResetConfirmView.as_view(), name="password_reset_confirm"),
path("reset/done/", auth_views.PasswordResetCompleteView.as_view(), name="password_reset_complete"),
path("register/", views.register, name="register"),
path("login/", auth_views.LoginView.as_view(), name="login"),
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
]
config/settings.py
import os
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1"
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "development-only-never-use-this-key-in-production")
if not DEBUG and (SECRET_KEY.startswith("development-") or len(SECRET_KEY) < 50):
raise ImproperlyConfigured("生产环境必须设置至少 50 字符的独立 DJANGO_SECRET_KEY")
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,[::1]").split(",")
INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles",
"accounts.apps.AccountsConfig", "blog.apps.BlogConfig",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"], "APP_DIRS": True,
"OPTIONS": {"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]},
}]
WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.asgi.application"
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
MAILERS = {"default": {"BACKEND": "django.core.mail.backends.console.EmailBackend"}}
AUTH_USER_MODEL = "accounts.User"
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "blog:home"
LOGOUT_REDIRECT_URL = "blog:home"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DATA_UPLOAD_MAX_MEMORY_SIZE = 3 * 1024 * 1024
FILE_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024
# SMTP 可选;没有配置时只打印到开发终端。
if os.environ.get("EMAIL_HOST"):
MAILERS = {"default": {
"BACKEND": "django.core.mail.backends.smtp.EmailBackend",
"OPTIONS": {
"host": os.environ["EMAIL_HOST"],
"port": int(os.environ.get("EMAIL_PORT", "587")),
"username": os.environ.get("EMAIL_HOST_USER", ""),
"password": os.environ.get("EMAIL_HOST_PASSWORD", ""),
"use_tls": True,
"timeout": 10,
},
}}
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@example.invalid")
PASSWORD_RESET_TIMEOUT = 3600
examples/send_email.py
from django.core.mail import send_mail
from django.conf import settings
sent = send_mail("知行博客测试邮件", "如果你看到了这段文字,邮件后端已工作。",
settings.DEFAULT_FROM_EMAIL, ["reader@example.invalid"], using="default")
print("后端接受的邮件数量:", sent)
templates/registration/login.html
{% extends "base.html" %}
{% block title %}登录 · 知行博客{% endblock %}
{% block content %}<h1>登录</h1><form method="post" class="panel">{% csrf_token %}
{{ form.as_p }}<input type="hidden" name="next" value="{{ next }}"><button type="submit">登录</button></form><a href="{% url 'password_reset' %}">忘记密码</a>{% endblock %}
templates/registration/password_reset_complete.html
{% extends "base.html" %}
{% block title %}密码已更新 · 知行博客{% endblock %}
{% block content %}<h1>密码已更新</h1><p>现在可以使用新密码登录。</p><a href="{% url 'login' %}">去登录</a>{% endblock %}
templates/registration/password_reset_confirm.html
{% extends "base.html" %}
{% block title %}设置新密码 · 知行博客{% endblock %}
{% block content %}<h1>设置新密码</h1>{% if validlink %}<form method="post">{% csrf_token %}{{ form.as_p }}<button type="submit">保存新密码</button></form>{% else %}<p>链接已使用或已过期。</p><a href="{% url 'password_reset' %}">重新申请</a>{% endif %}{% endblock %}
templates/registration/password_reset_done.html
{% extends "base.html" %}
{% block title %}请检查邮箱 · 知行博客{% endblock %}
{% block content %}<h1>请检查邮箱</h1><p>如果账户符合重置条件,邮件已经发出。也请检查垃圾邮件目录。</p><a href="{% url 'login' %}">返回登录</a>{% endblock %}
templates/registration/password_reset_email.html
你收到了知行博客的密码重置申请。请打开下面的链接设置新密码:
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
用户名:{{ user.get_username }}
如果不是你发起的申请,请忽略此邮件。不要向任何人转发此链接。
templates/registration/password_reset_form.html
{% extends "base.html" %}
{% block title %}找回密码 · 知行博客{% endblock %}
{% block content %}<h1>找回密码</h1><p>输入注册邮箱。若该邮箱有可重置的账户,你将收到一封邮件。</p><form method="post">{% csrf_token %}{{ form.as_p }}<button type="submit">发送重置邮件</button></form>{% endblock %}
templates/registration/password_reset_subject.txt
知行博客:重置密码
如何确认成功
python manage.py shell -c "exec(open('examples/send_email.py').read())"
开发后端应在终端打印邮件并返回接受数量 1。这只说明后端接受,不证明真实收件箱成功送达。SMTP 使用自己的收件地址进行验证,不要向 example.invalid 发送真实邮件。
已注册邮箱和不存在的邮箱都进入同一个完成页;后者不会发送邮件。更改密码后再次使用旧链接,应显示无效;PASSWORD_RESET_TIMEOUT 设置为 3600 秒,即一小时。演示作者使用不可登录密码,不适合作为重置流程测试账号。
练习:将有效期临时改短做本地过期实验,然后恢复;检查主题不能包含换行。用邮件测试后端代替真实 SMTP 的方法在下一章完整演示。
常见问题
邮件为什么只出现在终端? 使用的是 console 后端,生产上线前必须配置可用 SMTP。统一结果页是否完全阻止账号枚举? 不能保证耗时相同,公开运营还需要限流、统一异步处理和监控。邮件里的域名不对? 链接来自请求主机和协议,生产必须限制 ALLOWED_HOSTS 并正确配置受信任代理头。发送失败怎么办? 不应静默忽略;监控异常并给出可重试反馈,避免用户反复请求造成邮件风暴。
官方参考
登录后可保存阅读位置、标记章节完成,并在其他设备继续学习。
登录并保存学习进度 →联系站长
微信:VIPdjango。技术问题建议先在 社区讨论 中交流,方便更多人参与和查阅。
章节讨论 0