注册要做三道校验:长度、两次密码一致、用户名不重复。前两道给体验,第三道给正确性。 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 秒实验
- 把
guard()改成「只判有没有登录」,再看第二种 Session 会返回什么。 - 把
pbkdf2_hmac的迭代次数从一万改成二十万,量一下耗时变化。 - 把
BadAction换成「把用户放在模块级变量里」,看结果是否一样。
常见误区
- 把当前用户存在 Action 的实例字段里,还把 Action 配成单例。并发时必串号, 而且本地单线程测试永远测不出来。用户信息只放 Session 或方法参数。
- 密码加盐但用固定盐。固定盐等于全库一个盐,算一次还是能撞一片。 盐必须每个账号一个,注册时随机生成。
- 拦截器只判「登录了没有」。登录了但不是管理员,照样能进后台。权限要判到角色,不能只判身份。
自测三问(点开对答案)
1. 注册的四道校验里,哪一道是「正确性」而不是「体验」? —— 用户名不重复。 其余三道错了只是体验差,这一道错了会出现两个同名账号,之后「按用户名登录」结果就不确定了。 2. 为什么说 Struts 2 的 Action 不是单例? —— 它每次请求新建一个实例,所以实例字段在请求之间不会共享。 危险的是有人为了省对象把它配成单例,或者把用户信息放到静态字段里。 3. 盐的作用是什么? —— 让同样的密码算出不同的哈希。 这样彩虹表这类「预先算好」的办法失效,攻击者只能对每个账号单独暴力破解,成本成倍上升。 盐不需要保密。能登录了,下一篇写前台最热的那条路:商品查询。 这一篇的每个条件都是拼出来的,所以每个条件都是注入点。
- ☐ (本篇完,下一篇 24.8 前台商品信息查询模块设计)