🛢️ 第24章 Struts2+Spring+Hibernate+MySQL实现网络商城
📄 一句话概括

注册要做三道校验:长度、两次密码一致、用户名不重复。前两道给体验,第三道给正确性。 Struts 2 的 Action 是每次请求新建一个实例,所以实例字段本来安全;把它配成单例就会串号。 密码必须加盐再哈希,而且哈希要慢——慢是防护,不是缺陷。

📝 本章和前面几章的关系

教材 24.7 讲登录注册模块:模块概述和注册模块的实现。技术点不多,但有两个坑几乎人人踩过。 第一个坑是「把当前用户放在 Action 的字段里」。Struts 2 每次请求新建 Action, 所以这样写本来是安全的;一旦有人为了省对象把它配成单例,两个用户就会串号。 第二个坑是密码。 前面 23 章量过 md5 和 pbkdf2 的差距,这一篇讲清「盐」的作用:它让「算一次撞一片」失效。 演示里用两个交错的请求把「串号」跑出来,再用同一个密码注册两次,看两次哈希为什么不同。

一、注册:三道校验,两种目的

校验项 规则 为了什么
用户名长度 4~20 个字符 体验:太短的用户名重名率极高
两次密码一致 必须相等 体验:不然用户下次登不进去
用户名不重复 先查后插 + 唯一约束 正确性:并发下也不能重复
邮箱格式 正则粗筛 体验:别等到发信才失败

四行里只有第三行是「正确性」,其余三行都是「体验」。 这个区分很重要,因为它决定了写错之后的后果:体验问题用户会抱怨,正确性问题用户不抱怨, 只是某天发现「怎么有两个同名的」。 第三行还要分两层:代码里的 SELECT 查重是给提示用的,数据库上的唯一约束才是兜底。 查询和插入之间有一个窗口,两个请求可以同时查到「不存在」, 然后都去插入——只有唯一约束能拦住第二个。

二、Action 不是单例

Struts 2 的 Action 是「一次请求一个实例」,所以把当前用户存在实例字段里本来是安全的。 不安全的是这三种写法:

写法 为什么危险
Action 配成单例 所有请求共用一个实例,字段互相覆盖
用户信息放静态字段 静态字段属于类,效果等于全局变量
用户信息放工具类的成员 工具类通常是单例,一样会串

三种写法的共同点是「这个字段活得比一次请求长」。 判断标准很简单:这个字段是不是「某一次请求的东西」?是,就只能放在方法内或 Session 里。 串号的后果很严重:A 登录后看到的可能是 B 的购物车、B 的订单。 而且它只在并发时出现,本地单线程测试永远测不出来——这也是它可怕的地方。

三、密码:盐和慢

哈希函数的设计目标是快,密码哈希正好相反。 因为攻击者手里是一堆哈希值,他要做的是「猜一个明文、算一次、比一次」——算得越快,猜得越多。

做法 同样的密码存两次 被撞库的风险
直接存明文 两行一样 库一泄露,全部账号直接可用
哈希不加盐 两行一样 算一次能撞一片,彩虹表直接查
哈希加随机盐 两行不一样 每个账号都要单独算,成本成倍上升

第三行的「两行不一样」是关键:两个用户用了同一个密码,库里也看不出他们是同一个密码。 盐不需要保密,它就明明白白跟哈希存在一起;它的作用是让「预先算好的表」失效。 登录时的验证方式也一样朴素:把库里的盐取出来,用同一个盐把用户输入的密码重算一遍,比哈希。 所以「忘记密码」只能重置,不能找回——服务端根本不知道原密码是什么。

四、拦截器:把权限检查抽出来

登录判断如果写在每个 Action 里,就会出现「新加一个页面忘了写」的情况。 拦截器的思路是「按路径拦」,不是「按方法判」:配置里写清哪些路径要登录,其余自动放行。

Session 内容 谁 拦截器返回
空 没登录 跳到登录页
role = member 普通会员 跳到 403
role = admin 管理员 放行

第二行是最容易被漏掉的一行:登录了,但不是管理员。 只判「有没有登录」不判「是不是这个人」,等于把后台对全体会员开放。 Session 超时也要在拦截器里统一处理:过期后 Session 是空的,如果代码直接取 user.getId(), 就是空指针;正确处理是当成「没登录」,跳登录页。 这一条教材放在 24.12 单独讲,因为它太常见了。

五、动手 Demo:登录注册:Action 不是单例,Session 才是

这一段先用五条表单数据跑一遍注册的四道校验,再用两个交错的请求复刻「单例 Action 串号」, 然后看同一个密码注册两次为什么哈希不同,最后用三种 Session 内容试拦截器。

import hashlib, os, re, sqlite3

def reg(c, u_, p1, p2, mail):
    if not (4 <= len(u_) <= 20):
        return u"拒绝:用户名长度不对"
    if p1 != p2:
        return u"拒绝:两次密码不一致"
    if not re.match(u"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", mail):
        return u"拒绝:邮箱格式不对"
    if c.execute(u"SELECT 1 FROM tb_member WHERE uname = ?", (u_,)).fetchone():
        return u"拒绝:用户名已被占用"
    c.execute(u"INSERT INTO tb_member (uname, upass, email) VALUES (?,?,?)",
              (u_, p1, mail))
    return u"通过"

forms = [(u"ab", u"123456", u"123456", u"[email protected]"),
         (u"newbie", u"123456", u"1234567", u"[email protected]"),
         (u"newbie", u"123456", u"123456", u"not-an-email"),
         (u"newbie", u"123456", u"123456", u"[email protected]"),
         (u"member001", u"123456", u"123456", u"[email protected]")]
for f in forms:
    print(u"%s -> %s" % (f[0], reg(c, *f)))

# 单例 Action 串号:两个请求交错
class BadAction(object):
    user = None
    def login(self, who):
        self.user = who        # 放在实例字段里
        return self.user

a = BadAction()
a.login((u"A", u"张三"))    # 请求 A 登录
b = a.login((u"B", u"李四"))  # 请求 B 登录,覆盖了字段
print(u"请求 A 的页面显示:%s" % a.user[1])

# 同一个密码,两次哈希为什么不同
def phash(pw, salt=None):
    salt = salt or os.urandom(8)
    dk = hashlib.pbkdf2_hmac(u"sha256", pw.encode(u"utf-8"), salt, 10000)
    return salt.hex(), dk.hex()[:16]

s1, h1 = phash(u"123456")
s2, h2 = phash(u"123456")
print(u"第一次 %s / %s" % (s1[:8], h1))
print(u"第二次 %s / %s" % (s2[:8], h2))
print(u"同一个盐重算 = 原哈希:%s" % (phash(u"123456", bytes.fromhex(s1))[1] == h1))

# 拦截器:三种 Session
def guard(session):
    if not session:
        return u"跳到登录页"
    return u"放行" if session.get(u"role") == u"admin" else u"跳到 403"
print(guard({}), guard({u"role": u"member"}), guard({u"role": u"admin"}))

实跑输出:

--------------------------------------------------------------------
登录注册:Action 不是单例,Session 才是
--------------------------------------------------------------------

**教材 24.7 讲登录注册模块:模块概述和注册模块的实现。**
**这一节的技术点不多,但有两个坑几乎每个 SSH 新手都会踩。**

  ① 注册的三道校验
  --------------------------------------------------------------------
校验项        规则                 不做会怎样
-------------------------------------------------------------
用户名长度    4~20 个字符         一个字的用户名,重名率极高
两次密码一致  必须相等             用户自己打错,下次登不进去
用户名不重复  先查后插 + 唯一约束  并发下出现两个同名账号
邮箱格式      正则粗筛             发验证邮件时才失败,体验差
用户名     两次密码  邮箱          结果
-------------------------------------------------------
ab         一致      [email protected]         拒绝:用户名长度不对
newbie     不一致    [email protected]         拒绝:两次密码不一致
newbie     一致      not-an-email  拒绝:邮箱格式不对
newbie     一致      [email protected]         通过:写入 id=21
member001  一致      [email protected]         拒绝:用户名已被占用
**五行里只有第四行通过,其余四行各被一条规则拦下。**
**第五行是拿同一个用户名再注册一次,被那条 `SELECT 1` 拦住的——它就是第四行的对照组。**
**但这条 `SELECT` 只是友好提示,真正兜底的是 `uname` 上的唯一约束。**
**两个请求同时查到「不存在」,第二条插入会被数据库拦住——这一点在 23 章量过。**

  ② 单例 Action 会串号
  --------------------------------------------------------------------
写法                  请求 A 拿到   请求 B 拿到
-----------------------------------------------
单例 + 实例字段       李四(串了)  李四
多例 + 参数/Session  张三          李四
**第一行:请求 A 登录后,字段被请求 B 覆盖,A 的页面就显示成了李四。**
**这不是理论问题——两个用户在同一秒点登录,就会发生。**
**Struts 2 的 Action 是每次请求新建一个实例,所以实例字段本来是安全的。**
**踩坑的是有人为了「省对象」,把 Action 配成单例(Spring 里加 `scope="singleton"`),**
**或者把用户信息放进一个静态的工具类里。**
**判断标准很简单:这个字段是不是「某一次请求的东西」?是,就只能放在方法内或 Session 里。**

  ③ 密码:同一个密码,两次哈希不该一样
  --------------------------------------------------------------------
第一次       盐 9d210b15954a5dcc 哈希 1a3067d550e044a3
第二次       盐 9f74dd7224c519b5 哈希 a6dbf26b85e3b2f6
两次相同吗   盐 不同,哈希 不同
用旧盐重算   哈希 1a3067d550e044a3,能验过
**第三个问题故意问「两次相同吗」,答案是「都不同」——因为每次注册都换了随机盐。**
**这样即使两个用户用了同一个密码,库里的哈希也不一样,攻击者没法「算一次撞一片」。**
**第四行是登录时的验证方式:把库里的盐取出来,用同一个盐重算一遍,比哈希。**
**盐不用保密,它就明明白白存在库里;它的作用是让「预先算好的表」失效。**

  ④ 拦截:没登录就不能进后台
  --------------------------------------------------------------------
Session 内容        谁        拦截器返回
------------------------------------------------------------
{}                  没登录    跳到登录页(Session 为空)
{'role': 'member'}  普通会员  跳到 403(登录了但不是管理员)
{'role': 'admin'}   管理员    放行
**三行的差别就是拦截器存在的意义:把「有没有权限」这件事从每个 Action 里抽出来,**
**统一在一个地方判。**
**写在拦截器里的好处是「新增一个页面时,忘了加判断也不会漏」——因为拦截是按路径配的,**
**不是按方法配的。**

五条表单里只有第四条通过,第五行是拿同一个用户名再注册一次,被那条 SELECT 1 拦住。 这两行的对照说明「查重」确实在工作;但它只是提示,真正兜底的是唯一约束。 串号那一段只用了三行代码,就把问题跑出来了:请求 A 登录后,字段被请求 B 覆盖, A 的页面显示成李四。 真实环境里这个覆盖窗口只有几毫秒,但用户量一大就一定会撞上。 两次哈希不同、同一个盐重算能对上——这两条就是「加盐」的全部内容。 注意盐只取了前八位显示,完整的是十六个十六进制字符,也就是八个字节。

30 秒实验

  1. 把 guard() 改成「只判有没有登录」,再看第二种 Session 会返回什么。
  2. 把 pbkdf2_hmac 的迭代次数从一万改成二十万,量一下耗时变化。
  3. 把 BadAction 换成「把用户放在模块级变量里」,看结果是否一样。

常见误区

  1. 把当前用户存在 Action 的实例字段里,还把 Action 配成单例。并发时必串号, 而且本地单线程测试永远测不出来。用户信息只放 Session 或方法参数。
  2. 密码加盐但用固定盐。固定盐等于全库一个盐,算一次还是能撞一片。 盐必须每个账号一个,注册时随机生成。
  3. 拦截器只判「登录了没有」。登录了但不是管理员,照样能进后台。权限要判到角色,不能只判身份。
自测三问(点开对答案) 1. 注册的四道校验里,哪一道是「正确性」而不是「体验」? —— 用户名不重复。 其余三道错了只是体验差,这一道错了会出现两个同名账号,之后「按用户名登录」结果就不确定了。 2. 为什么说 Struts 2 的 Action 不是单例? —— 它每次请求新建一个实例,所以实例字段在请求之间不会共享。 危险的是有人为了省对象把它配成单例,或者把用户信息放到静态字段里。 3. 盐的作用是什么? —— 让同样的密码算出不同的哈希。 这样彩虹表这类「预先算好」的办法失效,攻击者只能对每个账号单独暴力破解,成本成倍上升。 盐不需要保密。

能登录了,下一篇写前台最热的那条路:商品查询。 这一篇的每个条件都是拼出来的,所以每个条件都是注入点。

相关笔记