Django 从入门到精通:知行博客实战(6.1) · 第 16 / 36 节

注册、登录和退出

吴秀峰 3 次阅读 0 条评论 ↓

学习目标

完成可用的账号流程,使用 UserCreationForm 和 Django 内置认证视图,正确处理密码、会话、CSRF、跳转和 POST 退出。

为什么复用认证机制

密码不能以明文保存,登录后需要轮换会话信息,跳转地址不能指向攻击者网站,退出也应避免被第三方 GET 链接触发。自己从零实现这些细节很容易遗漏。Django 的认证组件已经覆盖基础行为,我们只补充符合博客需求的注册表单和模板。

邮箱转换为小写并检查重复,数据库唯一约束处理同时提交的竞争。捕获 IntegrityError 时使用独立 atomic 块,让事务正确结束后再展示表单错误。这里的注册成功意味着可以立即登录;邮件地址所有权验证和反机器人校验是公开运营前按风险追加的流程,本站已有实现可在学完主线后阅读。

具体怎么做

  1. 添加 accounts 的 forms、views、urls,根路由挂载到 /accounts/。
  2. 使用 Django 自带 LoginView、LogoutView,不编写手动比较密码的代码。
  3. 模板显示 form.as_p,它包含 label、帮助信息和字段错误;每个 POST 表单加入 csrf_token。
  4. 基础导航根据用户登录状态展示登录注册或用户名与退出。退出是小型 POST 表单,不是普通链接。
  5. 登录模板保留 next 隐藏字段,由内置视图检查目的地址是否安全。

本章新增或替换的完整文件

以下路径均相对于练习项目根目录。替换同名文件的全部内容;未列出的文件保持上一章版本。

accounts/forms.py

from django import forms
from django.contrib.auth.forms import UserCreationForm
from .models import User

class RegisterForm(UserCreationForm):
    class Meta(UserCreationForm.Meta):
        model = User
        fields = ("username", "email")

    def clean_email(self):
        email = self.cleaned_data["email"].strip().lower()
        if User.objects.filter(email__iexact=email).exists():
            raise forms.ValidationError("这个邮箱不能用于注册,请尝试登录或找回密码。")
        return email

accounts/urls.py

from django.contrib.auth import views as auth_views
from django.urls import path
from . import views
urlpatterns = [
    path("register/", views.register, name="register"),
    path("login/", auth_views.LoginView.as_view(), name="login"),
    path("logout/", auth_views.LogoutView.as_view(), name="logout"),
]

accounts/views.py

from django.contrib import messages
from django.db import IntegrityError, transaction
from django.shortcuts import render, redirect
from django.views.decorators.http import require_http_methods
from .forms import RegisterForm

@require_http_methods(["GET", "POST"])
def register(request):
    if request.user.is_authenticated:
        return redirect("blog:home")
    form = RegisterForm(request.POST if request.method == "POST" else None)
    if request.method == "POST" and form.is_valid():
        try:
            with transaction.atomic():
                form.save()
        except IntegrityError:
            form.add_error(None, "用户名或邮箱不可用,请换一个再试。")
        else:
            messages.success(request, "注册成功,请使用用户名登录。")
            return redirect("login")
    return render(request, "registration/register.html", {"form": form})

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"

config/urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [path("accounts/", include("accounts.urls")), path("admin/", admin.site.urls), path("", include("blog.urls"))]

templates/base.html

{% load static %}
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}知行博客{% endblock %}</title>
<link rel="stylesheet" href="{% static 'blog/site.css' %}"></head>
<body>
<a href="#main">跳到正文</a>
<header><nav aria-label="主导航"><a href="{% url 'blog:home' %}">知行博客</a> <a href="{% url 'blog:about' %}">关于</a>{% if user.is_authenticated %}<span>{{ user.username }}</span><form method="post" action="{% url 'logout' %}">{% csrf_token %}<button type="submit">退出</button></form>
{% else %}<a href="{% url 'login' %}">登录</a><a href="{% url 'register' %}">注册</a>{% endif %}</nav></header>
<main id="main">{% if messages %}<ul class="messages" role="status">{% for message in messages %}<li>{{ message }}</li>{% endfor %}</ul>{% endif %}{% block content %}{% endblock %}</main>
<footer>知行博客 · 记录、实践、分享</footer>
</body></html>

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>{% endblock %}

templates/registration/register.html

{% extends "base.html" %}
{% block title %}注册 · 知行博客{% endblock %}
{% block content %}<h1>注册</h1><form method="post" class="panel">{% csrf_token %}
{{ form.as_p }}<button type="submit">创建账户</button></form>{% endblock %}

如何确认成功

用新的用户名和邮箱注册,输入弱密码应看到明确错误。正确注册后跳到登录;成功登录后导航出现用户名;点击退出回到首页。浏览器刷新不会重复注册,因为成功处理后使用重定向。

验证重复邮箱、用户名、两次密码不一致、错误密码和空表单。访问 /accounts/login/?next=https://example.invalid/ 后登录,不应跳转到外部站点。直接 GET /accounts/logout/ 返回 405,POST 退出才有效。

练习:创建两个普通账号,在两个不同浏览器会话中登录,说明为什么不能使用全局 Python 变量保存“当前用户”。查看数据库中 password 字段的结构即可,不要将其复制到聊天或日志。

常见问题

创建超级用户的密码和普通注册规则一样吗? 两者都使用哈希,但不同入口可能对弱密码有不同交互;部署时应使用强随机密码。用户存在为什么还登录不了? 检查输入的是用户名、账号是否 active、密码是否可用;演示作者故意没有可用密码。这就能抵御撞库了吗? 内置认证不自动提供全面限流,部署章节加入 Nginx 入口限制;公开社区还应结合共享缓存、账号维度限流、验证码和监控。

官方参考

把学习连起来

登录后可保存阅读位置、标记章节完成,并在其他设备继续学习。

登录并保存学习进度 →
联系站长

微信:VIPdjango。技术问题建议先在 社区讨论 中交流,方便更多人参与和查阅。

章节讨论 0