🪟 第20章 Java与SQL Server实现学生成绩管理系统
📄 一句话概括

Web 登录的关键在 Session:身份存服务端,每次请求带一个会话号。 「自己是谁」永远从 Session 取,绝不从 URL 参数取——从参数取就等于开门。 每个页面都手写一遍校验迟早会漏,所以标准做法是写成过滤器,全站一次生效。

📝 本章和前面几章的关系

第 19 章的登录只解决「能不能进来」,这一章的登录还要解决「进来之后能看多少」。 因为 Session 里存的那个 stuId,决定了后面每一条查询的 WHERE。 教材 20.6 分三小节:登录模块概述、技术分析、实现过程。 「技术分析」这一节讲 Session 和密码哈希,两件事都很短,但都很关键。 沙箱没有 Tomcat 也没有 Session,所以登录状态用「一个字典模拟会话」来复刻, 密码哈希用 Python 的 hashlib 真跑——算法一样,只是调用方式不同。

一、Web 登录和桌面登录:四个根本区别

维度 桌面(第 19 章) Web(本章)
登录状态存哪 内存里的一个对象 Session,服务端存
身份怎么带 直接传对象引用 每次请求带会话号
能不能绕过 能(直连数据库) 绕过会跳到登录页
超时 程序不关就一直登着 Session 有超时,默认 30 分钟

最关键的是第三行:Web 的登录状态在服务端,所以「绕过」难度高得多。 但注意——「难度高」不等于「不可能」。 如果每个页面忘了校验 Session,直接敲 URL 就能进。 这是 JSP 项目最高频的漏洞: 首页校验了,二级页面忘了校验。 对策是把校验做成过滤器(Filter),而不是每个页面各写一遍。

二、密码存储:这一版用 SHA-256 加盐

做法 教材里的写法 够不够
明文 upass = '123456' 绝对不行
MD5 HASHBYTES('MD5', ...) 不够,太快
SHA-256 加盐 HASHBYTES('SHA2_256', upass + salt) 这一版够用
PBKDF2/bcrypt 代码里算,SQL 没有内置 更好,但要多写几行

教材用的是「SHA-256 + 盐」, 比 MD5 强,但还是「快哈希」——一张显卡算 SHA-256 也是每秒几十亿次。 真正的解法是「慢哈希」,代价是登录变慢一百毫秒。 为什么教材这一版还够用? 因为学生成绩系统的账号是教务统一发的,密码有最低长度要求,而且不是对外开放的网站。 威胁模型不同,防护等级可以不同——关键是「知道自己选的是哪一档」。 盐必须每人一个、随机生成、跟哈希存在一起(盐不用保密)。 它的作用是「让两个人的哈希不一样」,不是「让别人算不出来」。

三、Session 里存什么,决定了后面所有查询

角色 登录后跳转 Session 里存什么
管理员 admin/main.jsp role、uname
教师 teacher/main.jsp role、uname、classId
学生 student/main.jsp role、uname、stuId

Session 里存的东西,决定了后面所有查询能带什么条件。 学生登录时就该把 stuId 塞进去—— 这样「查自己成绩」永远是 WHERE stu_id = (Session 里的 stuId),根本不给页面传 id 的机会。 这一条是本章最重要的安全习惯: 「自己是谁」永远从 Session 取,绝不从 URL 参数取。 score.jsp?stuId=5 这种写法,把 5 改成 6 就能看别人的。

四、三次失败锁定:真跑一遍

第几次 输入 结果
1 正确密码 成功,写 Session
2~4 错的 失败,次数累加
4 第三次错 锁定 5 分钟
5 正确密码 失败,账号已锁定

第 5 行是重点:密码是对的,但被锁了。 顺序必须是「先查锁定、再比密码」—— 反了的话,锁定永远不会触发。 Web 里这一行还多一层含义: 锁的是账号,不是 IP, 所以别人可以故意锁死你的号。 正式系统一般还要加「按 IP 限流」。 「锁定状态存哪」也有讲究。 存数据库里,重启不丢;存内存里,重启就清零。 教材存数据库,代价是每次登录多一次 UPDATE。

五、动手 Demo:Session 模拟、加盐哈希、三次锁定

这一段用一个字典模拟 Session,把「登录 → 拿到会话 → 用会话里的 id 查数据」整条链跑一遍。 重点看第三节:同一个查询,换一个会话,看到的数据完全不同。

import hashlib, secrets

def salt():
    return secrets.token_hex(16)

def hpass(pw, s):
    return hashlib.sha256((pw + s).encode()).hexdigest()

# 三种角色,三条账号
for uname, role, sid, cid in (('admin', '管理员', None, None),
                              ('liu01', '教师', None, 1),
                              ('20260101', '学生', 1, None)):
    s = salt()
    c.execute('INSERT INTO tb_user'
              ' (uname, upass, salt, role, stu_id, class_id)'
              ' VALUES (?,?,?,?,?,?)',
              (uname, hpass(uname + '@123', s), s, role, sid, cid))

# 模拟 Session:登录成功就往里塞
SESSION = {}
def login(uname, pw):
    r = c.execute('SELECT id, upass, salt, role, stu_id, class_id'
                  ' FROM tb_user WHERE uname = ?', (uname,)).fetchone()
    if r is None or hpass(pw, r[2]) != r[1]:
        return '失败:用户名或密码错'
    SESSION.update(role=r[3], stu_id=r[4], class_id=r[5])
    return '成功:' + r[3]

# 同一个查询,三种会话看到三份不同的数据
def scores():
    sql = 'SELECT sno, sname, score FROM v_score WHERE subject = ?'
    args = ['数学']
    if SESSION['role'] == '学生':
        sql += ' AND sno = (SELECT sno FROM tb_student WHERE id = ?)'
        args.append(SESSION['stu_id'])
    elif SESSION['role'] == '教师':
        sql += ' AND cname = (SELECT cname FROM tb_class WHERE id = ?)'
        args.append(SESSION['class_id'])
    return c.execute(sql, args).fetchall()

实跑输出:

--------------------------------------------------------------------
登录模块:三种角色三个入口
--------------------------------------------------------------------


  ① Web 登录和桌面登录的根本区别
  --------------------------------------------------------------------
| 维度 | 桌面(第 19 章) | Web(本章) |
|---|---|---|
| 登录状态存哪 | 内存里的一个对象 | Session,服务端存 |
| 身份怎么带 | 直接传对象引用 | 每次请求带 Cookie/SessionId |
| 能不能绕过 | 能(直连数据库) | 绕过登录会 302 跳到登录页 |
| 超时 | 程序不关就一直登着 | Session 有超时,默认 30 分钟 |

  最关键的是第三行:Web 的登录状态在服务端,所以「绕过」难度高得多。
  但注意——「难度高」不等于「不可能」。如果每个页面忘了校验 Session,直接敲 URL 就能进。
  这是 JSP 项目最高频的漏洞: 首页校验了,二级页面忘了校验。
  对策是把校验做成过滤器(Filter),而不是每个页面各写一遍。

  ② 密码存储:这一版用 SHA-256 加盐
  --------------------------------------------------------------------
| 做法 | 教材里的写法 | 够不够 |
|---|---|---|
| 明文 | upass = '123456' | 绝对不行 |
| MD5 | HASHBYTES('MD5', ...) | 不够,太快 |
| SHA-256 加盐 | HASHBYTES('SHA2_256', upass + salt) | 这一版够用 |
| PBKDF2/bcrypt | 要用代码算,SQL 里没有 | 更好,但要多写几行 |

  教材用的是「SHA-256 + 盐」,比 MD5 强,但还是「快哈希」——一张显卡算 SHA-256 也是每秒几十亿次。
  真正的解法是「慢哈希」,代价是登录变慢一百毫秒(第 19 章量过)。
  为什么教材这一版还够用?
  因为学生成绩系统的账号是教务统一发的,密码有最低长度要求,而且不是对外开放的网站。
  威胁模型不同,防护等级可以不同——关键是「知道自己选的是哪一档」。

  ③ 三种角色,登录后去三个地方
  --------------------------------------------------------------------
| 角色 | 登录后跳转 | Session 里存什么 |
|---|---|---|
| 管理员 | admin/main.jsp | role、uname |
| 教师 | teacher/main.jsp | role、uname、classId |
| 学生 | student/main.jsp | role、uname、stuId |

  Session 里存的东西,决定了后面所有查询能带什么条件。
  学生登录时就该把 stuId 塞进去——这样「查自己成绩」永远是 WHERE stu_id = (Session 里的 stuId),
  根本不给页面传 id 的机会。
  这一条是本章最重要的安全习惯: 「自己是谁」永远从 Session 取,绝不从 URL 参数取。
  score.jsp?stuId=5 这种写法,把 5 改成 6 就能看别人的。

  ④ 三次失败锁定:真跑一遍
  --------------------------------------------------------------------

  20260101   / 20260101@123   → 成功:角色 = 学生
  20260101   / x              → 失败:第 1 次
  20260101   / x              → 失败:第 2 次
  20260101   / x              → 失败:第 3 次 → 锁定 5 分钟
  20260101   / 20260101@123   → 失败:已锁定至 2026-10-31 12:05
  admin      / admin@123      → 成功:角色 = 管理员
  第 5 行是重点:密码是对的,但被锁了。
  Web 里这一行还多一层含义——「锁 5 分钟」是对账号锁,不是对 IP 锁,所以别人可以故意锁死你的号。
  所以正式系统一般还要加「按 IP 限流」。
  「锁定状态存哪」也有讲究。 存数据库里,重启不丢;存内存里,重启就清零。
  教材存数据库,代价是每次登录多一次 UPDATE。

SESSION 这个字典就是 Session 的全部本质—— 服务端存一个键值对,客户端拿一个号来换。 JSP 里 session.getAttribute("stuId") 干的就是这件事。 注意 scores() 那个函数:三种角色共用一个函数,差别只在两个 if。 这就是「一个页面 + 数据范围」的代码形态。 如果按角色写三个查询,第三个角色的条件迟早会和第二个写重。 写重了不会报错,只会「多看到几行」——而多看到几行,就是数据泄露。

30 秒实验

  1. 把 hpass 里的盐去掉(只算密码),看两个人的密码相同时哈希是不是也一样。 一样——所以盐不能省。
  2. 把 scores() 里学生的那个 if 注释掉,用学生身份登录再看。 他会看到全级的数学成绩——这就是「漏了一行」。
  3. 想一想:如果把 stu_id 改成从 URL 参数取,攻击者要做什么? 改一个数字。 成本几乎为零。

常见误区

  1. 「Session 里存个用户名就够了」。 后面每次查询都要靠 id 过滤。 只存用户名,每次都得反查一次 id。
  2. 「从 URL 取 id 更方便测试」。 上线忘了改就是开门。 测试可以用参数,生产必须从 Session 取。
  3. 「密码用 MD5 就行了」。 MD5 一秒几十亿次。 至少 SHA-256 加盐,最好慢哈希。
自测三问(点开对答案) 1. `Session` 里为什么要存 `stuId`? —— **因为后面每一条查询的 `WHERE` 都要用它。** **存了之后,「查自己」永远是 `WHERE stu_id = 会话里的值`。** 2. 为什么校验要写成 `Filter` 而不是每页写一遍? —— **每页写一遍迟早漏一个**,**漏的那个就能匿名访问。** **`Filter` 全站一次生效。** 3. 为什么锁定检查要在密码校验之前? —— **反了的话,攻击者可以一直试,锁定永远不触发。** **「密码对了却登不进去」才是锁定生效的证据。**

下一篇 20.7,进主窗体: 框架页怎么拼、菜单怎么按角色算、以及「同一个页面三种数据范围」在页面上怎么落地。

相关笔记