第9章 · Session 与登录态

第8章那个博客谁都能进——今天给它装一把。先把 HTTP 为什么需要 Session 这件事讲清楚,再动手写出一个能登录、能退出的完整流程。

🎯 学完后:博客必须登录才能看,退出后再访问会跳回登录页

从"能看"到"能用"差一个登录

第8章的博客虽然能跑,但有一个明显的硬伤:任何人打开浏览器访问 /article/ 都能看到文章列表。一个真正能用的博客系统,得先校验"你是谁、有没有权限"——这就是登录态

这节课我们用 Django 内置的 Session 会话机制,把"登录态"加到第8章的博客上。结束时博客就会变成:

  • 没登录访问 /article/ → 自动跳到登录页
  • 输入正确账号密码 → 跳回文章列表,能看了
  • 点"退出登录" → 又跳回登录页,再也看不到列表
💡 本章前提
第8章博客已经能跑(访问 /article/ 能看到文章列表)。如果重启了电脑或换了机器,先在 blog 项目目录下用 python3 manage.py runserver 0.0.0.0:8000 把服务启起来,再继续。
1 概念

为什么需要 Session?

💬 一句话先说清
HTTP 协议是无状态的——服务器接到一个请求,处理完就忘了,它不会记得"刚才那个浏览器是谁、做过什么"。
所以登录这件事必须靠"额外手段":用户提交正确的账号密码后,服务器在自己这边记一笔"这家伙登过了",下次他再来就放行。这一笔记录就叫 Session

HTTP 的"金鱼记忆"

想象你去食堂打饭:

  • 第一次:你跟阿姨说"我要一份土豆丝",阿姨打给你。
  • 第二次:你又来了,问"我刚才点的什么?"——阿姨完全不记得你,更不记得你点过啥。

HTTP 就是这样。每个请求都是独立的,服务器处理完就忘。所以光靠 HTTP 本身,登录就是个不可能完成的任务——服务器记不住"上一个请求是你登的"。

Session 是怎么补上这个洞的

解决思路就一句话:"服务器记不住没关系,我们替它记下来"。具体做法:

  1. 用户登录成功后,服务器在自己的数据库里写一条"已登录"的记录(叫 Session)。
  2. 同时给浏览器发一个"小牌子"(叫 Cookie,里面装着一串号码 sessionid)。
  3. 之后浏览器每次发请求,都会自动把这张小牌子带上。
  4. 服务器一看小牌子上的号码,就能在自己的数据库里找回"哦,是 admin"。
🎒 储物柜的类比
去超市寄存东西,工作人员给你一个写着 "38 号"的小牌子。东西本身锁在 38 号柜子里(在服务器那边),你手上只有那个号码(在浏览器那边)。
取东西时,你把牌子一亮,工作人员就能从 38 号柜里把你的东西拿出来。
小牌子 = Cookie(sessionid),柜子里的东西 = Session 数据

看一遍它怎么工作

下面是一次"登录→访问列表→退出"的完整过程。注意每一步浏览器发了什么、服务器干了什么

🌐 浏览器 🖥️ Django 服务器
1
提交登录表单
POST /article/login/
携带 username/password
校验账号密码
对了:在 django_session 表写一条记录 {'username':'admin'}
2
收到响应,保存 Cookie
sessionid=abc123...
返回响应,并在 HTTP 头里塞一句 Set-Cookie: sessionid=abc123
3
访问文章列表
GET /article/
自动带上 Cookie: sessionid=abc123
abc123 在 session 表里查到 username=admin
→ 已登录,返回列表 HTML
4
点退出
GET /article/logout/
删掉那条 session 记录
跳转到登录页
5
再次访问 /article/
Cookie 还在,但服务器那边记录已删
查不到 session → 没登录 → 跳登录页
💡 你只需要管第 1 步和第 4 步
第 2、3 步浏览器和 Django 之间是怎么传 Cookie、怎么查表的,框架全都替你做了。你写代码时只要:登录成功就 request.session['username'] = ...,退出就 request.session.flush(),剩下的不用操心。

Django 默认把 Session 存哪?

默认存在数据库的 django_session里。第8章你跑过 migrate,这张表已经建好。所以——

✅ 零配置可用
Django 默认就启用了 Session 中间件和数据库存储,你不用改 settings.py,直接在视图里写 request.session[...] 就能用。
2 API

三个 API 就够用了

Session 在 Django 里被封装成 request.session——一个"带数据库自动同步功能的字典"。下面三个 API 覆盖了 99% 的日常需要:

写法作用
request.session['key'] = value 存一笔。写完后框架自动同步到数据库,不用你手动保存
request.session.get('key') 取出来。没存过返回 None,不会报错
request.session.flush() 清空这个用户的全部 session(用来"退出登录")

每个的小例子

PYTHON 三个 API 的用法示例
# ① 存值——把当前登录的用户名记下来
# 用法和字典一模一样:用 [] 加键名,等号后面是要存的内容
request.session['username'] = 'admin'

# 也能存其他东西,比如用户 id、购物车列表、当前主题色...
request.session['cart'] = ['书', '笔']
request.session['theme'] = 'dark'


# ② 取值——记得用 .get() 而不是 []
# 推荐写法(安全):
name = request.session.get('username')
# 如果之前没存过,name 就是 None,程序不会崩

# 不推荐写法(如果没存过会抛 KeyError 异常,把页面搞 500):
# name = request.session['username']


# ③ 清空——彻底"忘记"这个用户
# 调用后:数据库那条记录被删,浏览器的 sessionid Cookie 也失效
# 一句话:注销专用
request.session.flush()
⚠️ .get()[] 的区别要记牢
这是初学者最容易踩的坑。
request.session['xxx']——如果 xxx 没存过,会抛异常,整个页面变成 500 错误。
request.session.get('xxx')——如果没存过,悄悄返回 None,程序继续跑。
"判断是否登录"这种场景,永远用 .get()
3 实操

动手实现登录系统(5 个小步骤)

下面把"登录—列表保护—退出"这个完整流程做出来。一共要改 4 个文件,新建 1 个文件:

article/ # 第8章的应用 ├── views.py # 改:加 login_view、logout_view,给 article_list 加门卫 ├── urls.py # 改:注册两条新路由 └── templates/article/ ├── login.html # 新建:登录表单 └── list.html # 改:加欢迎语 + 退出链接

步骤 1:写登录视图和退出视图

打开 article/views.py保留第8章已有的 article_list,在文件下面追加两个新函数:

PYTHON article/views.py(在原有代码下面追加)
# 文件顶部的 import 如果第8章没加 redirect,这里补上
# redirect 是"让浏览器去访问另一个网址"的函数
from django.shortcuts import render, redirect


# ════════════════════════════════════════════
#   登录视图:处理 /article/login/ 这个地址
# ════════════════════════════════════════════
# 一个视图函数要处理两种请求:
#   GET  —— 用户在浏览器地址栏输入网址回车,浏览器来"取页面"
#   POST —— 用户填完表单点了"登录"按钮,浏览器来"交数据"
# 我们要在同一个函数里把这两种情况都处理掉
def login_view(request):

    # request.method 告诉我们这次是 GET 还是 POST(字符串)
    if request.method == 'POST':
        # ──── POST 分支:用户提交了账号密码 ────

        # request.POST 是一个类似字典的对象,装着表单数据
        # .get('username') 取出 name="username" 那个 input 的值
        # 用 .get() 是为了:即使用户没填,也只是返回 None,不报错
        username = request.POST.get('username')
        password = request.POST.get('password')

        # 校验账号密码——课堂演示用硬编码(写死在代码里)
        # ⚠️ 实际项目千万不能这样写:要查数据库 + 密码加盐哈希
        # 这里只是为了把 Session 这件事讲清楚,先不引入用户表
        if username == 'admin' and password == '123456':

            # ★★★ 整个登录机制的核心就在这一行 ★★★
            # 把用户名存进 session,相当于在服务器记一笔"admin 登过了"
            # Django 在背后会做三件事(我们不用管):
            #   1. 在 django_session 表里建一条记录
            #   2. 给浏览器塞一个 sessionid Cookie
            #   3. 之后浏览器每次来,凭这个 Cookie 找回这条记录
            request.session['username'] = username

            # 登录成功,跳到文章列表页
            # redirect 的本质:返回一个 302 响应,让浏览器去访问另一个网址
            return redirect('/article/')

        else:
            # 账号或密码错了,重新渲染登录页,并附带错误提示
            # 第二个参数是模板路径,第三个参数是给模板的数据(字典)
            # 模板里就能用 {{ error }} 显示这条错误信息
            return render(request, 'article/login.html',
                          {'error': '用户名或密码错误'})

    # ──── GET 分支:用户第一次打开登录页 ────
    # 走到这里说明 request.method 不是 POST
    # 直接把空白的登录表单渲染给用户看
    return render(request, 'article/login.html')


# ════════════════════════════════════════════
#   退出视图:处理 /article/logout/ 这个地址
# ════════════════════════════════════════════
def logout_view(request):

    # flush() 做的事:
    #   1. 删掉 django_session 表里这个用户的记录
    #   2. 让浏览器那个 sessionid Cookie 失效
    # 一句话:彻底"忘记"这个用户登过
    request.session.flush()

    # 退出后跳回登录页,整个流程闭合
    return redirect('/article/login/')
💬 为什么登录视图要写成"if POST else GET"
因为登录页这个网址(/article/login/)会被访问两次——
第一次是用户打开页面(GET),第二次是用户点登录按钮提交(POST)。
两次都用同一个函数处理,靠 request.method 判断走哪条路。这是 Django 表单的通用写法,后面所有带表单的视图都长这个样子。

步骤 2:给文章列表加"门卫"

现在只是写好了登录视图,但文章列表本身还在裸奔——不登录也能进。我们要给 article_list 加一段"通行证检查"。
把第8章的 article_list 改成下面这样(新增的就是开头那 3 行):

PYTHON article/views.py(修改已有的 article_list)
def article_list(request):

    # ┌─────────── 新增的门卫逻辑 ───────────┐
    # 从 session 取 username
    #   登过 → 取到 'admin' → not 'admin' 是 False → 不进 if,继续往下走
    #   没登 → 取到 None     → not None    是 True  → 进 if,跳登录页
    if not request.session.get('username'):
        return redirect('/article/login/')
    # └─────────────────────────────────────┘

    # 走到这里,说明用户已经登录
    # Article.objects.all() 是第8章学的:取出 article 表所有记录
    articles = Article.objects.all()

    # 把文章列表 和 当前用户名 一起传给模板
    # 这样模板里就能用 {{ username }} 显示"你好,admin"
    return render(request, 'article/list.html',
                  {'articles': articles,
                   'username': request.session.get('username')})
💡 "门卫"为什么放在函数最开头
因为要在做任何业务之前先检查权限。如果放在查数据库之后再判断,相当于"先把东西拿出来给小偷看,再问他有没有钥匙"——白干一场不说,还可能泄露数据。
门卫永远写在函数的第一段。

步骤 3:注册两条新路由

视图函数光写好不够,得告诉 Django"这个网址对应这个函数"。修改 article/urls.py

PYTHON article/urls.py
from django.urls import path
from . import views

# urlpatterns 是一个列表,Django 启动时会扫一遍
# 每个 path('网址段', 视图函数) 表示一条对应关系
urlpatterns = [
    # 注意:这里的网址是相对路径,前缀 /article/ 由项目级 urls.py 拼上

    path('',         views.article_list),  # /article/        → 文章列表
    path('login/',   views.login_view),    # /article/login/  → 登录页
    path('logout/',  views.logout_view),   # /article/logout/ → 退出
]

步骤 4:新建登录页模板

article/templates/article/ 目录下新建一个 login.html。这是用户输入账号密码的页面:

HTML article/templates/article/login.html(手动新建)
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>登录</title>
    <style>
        /* 把登录框居中、收窄,看着舒服 */
        body { font-family: sans-serif; max-width:360px;
               margin:80px auto; padding:0 20px; }
        input { width:100%; padding:8px; margin:6px 0;
                box-sizing:border-box; }
        button { width:100%; padding:10px; background:#3B6B9A;
                 color:#fff; border:none; cursor:pointer; }
        .err { color:#B83B2E; }
    </style>
</head>
<body>
    <h2>🔐 博客登录</h2>

    <!-- method="post" 告诉浏览器:用 POST 方式提交,把数据塞在请求体里 -->
    <!-- 没写 action,默认提交到当前网址(即 /article/login/)-->
    <form method="post">

        <!-- ★★ 这一行非常重要,少了 Django 会返回 403 错误 ★★ -->
        <!-- {% csrf_token %} 会渲染成一个隐藏的 input,里面是一段随机字符串 -->
        <!-- 作用:防止"跨站请求伪造"(别人写个假表单骗你点击) -->
        <!-- Django 默认开启了 CSRF 防护,所有 POST 表单都得带它 -->
        {% csrf_token %}

        <!-- name 属性必须和 views.py 里 request.POST.get('xxx') 的名字对应 -->
        <input name="username" placeholder="用户名">
        <input name="password" type="password" placeholder="密码">

        <button>登录</button>
    </form>

    <!-- 校验失败时,视图会传一个 error 变量过来 -->
    <!-- {% if %} 块:有 error 才显示,没 error 就什么也不渲染 -->
    {% if error %}
        <p class="err">❌ {{ error }}</p>
    {% endif %}

    <p style="color:#888">课堂账号:admin / 123456</p>
</body>
</html>
🚨 最容易忘的一行:{% csrf_token %}
只要表单是 method="post"就必须在 form 内部写 {% csrf_token %}
忘了就会得到一个赤红色的 403 Forbidden 报错页面,提示 "CSRF verification failed"。
初学者一定会踩这个坑至少一次——记牢这一行的位置:紧跟在 <form> 后面

步骤 5:在列表页加欢迎语和退出按钮

登录成功后,用户在文章列表页应该能看到"你好,admin"和一个"退出"链接。打开 article/templates/article/list.html,在原来的 <h1> 下面加一段:

HTML article/templates/article/list.html(在 <h1> 下面加)
<h1>📝 我的博客</h1>

<!-- ↓↓↓ 下面这段是新增的 ↓↓↓ -->
<!-- {{ username }} 显示视图里传来的当前用户名 -->
<!-- 点"退出登录"会触发 logout_view,把 session 清掉 -->
<p>
    👋 你好,<strong>{{ username }}</strong>
    | <a href="/article/logout/">退出登录</a>
</p>

🎉 完整流程测试一遍

1️⃣ 用无痕窗口打开 http://127.0.0.1:8000/article/

2️⃣ 应该自动跳到登录页 → 输入 admin / 123456 → 点登录

3️⃣ 跳回文章列表,看到"👋 你好,admin"

4️⃣ 点"退出登录" → 跳回登录页

5️⃣ 再访问 /article/ → 又被踢回登录页 ✅

⚠️ 常见错误自查
  • 403 Forbidden:登录页 form 里忘写 {% csrf_token %}
  • 登录后还是跳登录页:检查视图里写的是不是 session['username'] = ...(不是 username = ...
  • 退出后还能看列表:浏览器有缓存。用无痕窗口测,或按 Ctrl+F5 强制刷新
  • 页面 500 报错:八成是 request.session['xxx'] 拿了一个没存过的 key——改成 .get()

📋 本节小结

三个 API 速查

场景代码
登录成功——记下用户身份request.session['username'] = username
"门卫"——判断是否登录if not request.session.get('username'): ...
退出登录——清掉记录request.session.flush()

记住这几个关键认识

  • HTTP 是无状态的——每个请求都是独立的,服务器自己记不住。
  • Session 在服务器端,Cookie 在浏览器端——Cookie 里只有一串号码,真正的数据在服务器。
  • 取 session 用 .get()——避免 KeyError。
  • POST 表单必须带 {% csrf_token %}——少一个就 403。
  • 门卫写在视图最开头——查数据之前先验权限。

本课不讲、但你应该知道的

  • Django 自带一个完整的用户系统(django.contrib.auth),里面有 User 模型、密码加密、login()/logout() 函数、@login_required 装饰器。本课为了讲清 Session 本身,用了硬编码的最朴素写法。下一阶段你会用到那一套。
  • Session 数据除了存数据库,还能存 Redis、文件、纯 Cookie——改 settings.pySESSION_ENGINE 一行字就能切换。

课堂练习

两组共 12 题:第一组考概念,第二组考代码细节。点选项即可看答案。

第一组

Session 概念与原理(6 题)

把"为什么需要 Session、它怎么工作"想清楚。

Q1单选

为什么 HTTP 协议本身处理不了"登录状态"?

AHTTP 太慢了,跟不上登录的速度
BHTTP 是无状态的——服务器处理完一个请求就忘了,不会记得上一个请求是谁
C浏览器不支持发送密码
DHTTP 协议是单向的,服务器不能回数据
解析:"无状态"是关键词。每个 HTTP 请求都是独立的,服务器接到、处理完、回响应、就忘。所以登录态必须靠 Cookie/Session 这种额外机制来"补救"。
Q2单选

Django 默认把 session 数据存到哪里?

A浏览器的 Cookie 里
B服务器端的数据库(django_session 表)
C服务器内存里,重启就没
D不存,每次重新计算
解析:默认存数据库的 django_session 表。也能改成 Redis、文件、纯加密 Cookie——看 settings.pySESSION_ENGINE 配置。
Q3单选

Cookie 和 Session 是什么关系?

A是同一个东西,只是叫法不同
B两者完全独立,互不相关
CCookie 是浏览器里的"小牌子"(一串 sessionid),Session 是服务器里的"账本"(真实数据),两者配合工作
DCookie 在服务器,Session 在浏览器
解析:这是新人最容易混的概念。Cookie 只是一段小标识(在浏览器),真实数据在 Session(在服务器)。打个比方:寄存东西时手上拿到的"38 号"牌子是 Cookie,38 号柜子里的东西才是 Session 数据。
Q4单选

用户登录成功,request.session['username'] = 'admin' 这一行执行后,Django 会做什么?

A只在内存里改一下,不持久化
B把 'admin' 直接写到浏览器 Cookie 里,明文
C只发邮件提醒管理员
Ddjango_session 表里建/更新一条记录,并通过响应头让浏览器保存 sessionid Cookie
解析:框架背后自动做了"写数据库 + 给浏览器发 Cookie"两件事。浏览器拿到的只是 sessionid(一串随机号码),不是 'admin' 本身——真实数据始终在服务器。
Q5判断

所有用 POST 方法提交的表单,都必须在 <form> 里写 {% csrf_token %},否则 Django 会返回 403。

解析:对。这是 Django 默认开启的 CSRF(跨站请求伪造)防护。新人最容易忘的一行——记牢它紧跟在 <form> 后面。
Q6判断

课堂里把账号密码硬编码在视图里(if username == 'admin' and password == '123456'),实际上线项目也应该这样写。

解析:错。实际项目要用 Django 自带的 User 模型 + authenticate(),密码经过加盐哈希存储。今天硬编码只是为了把 Session 机制讲清楚,不引入用户表的复杂度。
第二组

代码细节与实战(6 题)

把动手实现里的关键代码看明白。

Q1单选

怎样"安全"地从 session 取值,即使 key 不存在也不报错?

Arequest.session['username']
Brequest.session.username
Crequest.session.get('username')
Dsession.read('username')
解析:.get() 在 key 不存在时返回 None,不抛异常;和 Python 字典的 .get() 一个道理。[] 取不到会抛 KeyError 把页面搞成 500。
Q2单选

"退出登录"用哪个方法最干净?

Arequest.session.clear_db()
Bdel request.session
Crequest.session.flush()
D关闭浏览器就行
解析:flush() 会清掉这个用户的所有 session 数据并让浏览器的 sessionid 失效——彻底注销。仅靠"关闭浏览器"不可靠,Django 默认 session 有效期 2 周。
Q3单选

下面是 article_list 的代码片段:
def article_list(request):
    articles = Article.objects.all()
    if not request.session.get('username'):
        return redirect('/article/login/')
    return render(...)
这段代码有什么问题?

A没问题
B"门卫"应该写在最开头——现在是先查了数据库再判断登录,白干一场不说,还可能多读不该读的数据
C缺少 csrf_token
Dredirect 用错了
解析:门卫必须放在视图函数的第一段。先验权限再做任何业务——否则相当于"先把东西拿出来给小偷看,再问他有没有钥匙"。
Q4单选

在登录视图 login_view 里,if request.method == 'POST': 这个判断的作用是?

A判断是否在国内访问
B区分"用户来取登录页(GET)"还是"用户提交了登录表单(POST)",同一个函数兼顾两种情况
C判断密码是否正确
D没什么作用,删掉也行
解析:登录这个网址会被访问两次:一次 GET(打开页面),一次 POST(点登录按钮提交)。同一个视图函数靠 request.method 分流处理——这是 Django 表单的标准写法。
Q5单选

登录页 HTML 里的 <input name="username">,这个 name="username" 对应视图里哪一行?

Areturn render(request, 'article/login.html')
Brequest.session['username'] = username
Cusername = request.POST.get('username') ——括号里的字符串要和 input 的 name 一致
D都不对应,name 属性可以随便起
解析:inputname 属性就是表单数据的"键"。HTML 里写 name="username",Python 里就用 request.POST.get('username') 把它取出来。两边的字符串必须一字不差
Q6判断

视图里写 request.session.get('username'),如果用户没登录过,这一行会抛 KeyError 异常导致页面 500。

解析:错。.get() 在 key 不存在时悄悄返回 None,不会抛异常。会抛 KeyError 的是 request.session['username'](用方括号)。这就是为什么判断登录态永远用 .get()