⚠️ 免责声明 :本文项目仅供技术学习参考,严禁直接作为毕业设计或课程作业提交,更不得用于任何未授权的真实系统测试。所有演示均在本人本地项目上进行。学术诚信、合法合规从我做起 🙂
先说结论
答辩现场最尴尬的问题,排得上号的一个是:"你用户忘了密码怎么办?"
你要是回"后台能改"或"联系管理员",老师多半会追一句:"那系统上线了,管理员天天帮人改密码?"
找回密码看着小,实际踩在账号安全、邮件服务、限流、令牌管理四个点上。写好了加分,写糙了就是个筛子。
我这版改了三次,前两次都是被自己攻破的。下面按"把流程拆成环节,看每个环节能被怎么打"来讲,你拿清单对着自己的项目逐条过就行。
一、先把正常流程走通
标准找回密码分三步:
① 用户填邮箱 → ② 收验证码,填验证码 + 新密码 → ③ 重置成功,跳登录
对应三个接口:
python
# ① 发送验证码
@app.route('/auth/send_code', methods=['POST'])
def send_code():
...
# ② 校验验证码,拿到重置令牌
@app.route('/auth/verify_code', methods=['POST'])
def verify_code():
...
# ③ 用令牌重置密码
@app.route('/auth/reset_password', methods=['POST'])
def reset_password():
...
为什么要拆三步,不一次提交"邮箱+验证码+新密码"?
因为验证码只能证明"他收得到这封邮件",证明不了"他愿意现在就改密码"。中间垫一层令牌,把"验证"和"执行"分开,令牌能单独设时效、单独作废。后面不少攻击,都是靠这层设计挡下来的。
二、环节①:发送验证码
这个接口看着最无害,其实是攻击面最宽的一个。
坑 1:用户枚举
python
# ❌ 直接告诉用户这个邮箱存不存在
user = User.query.filter_by(email=email).first()
if not user:
return jsonify({'code': 1, 'message': '该邮箱未注册'})
攻击者拿一份邮箱列表批量打,就能筛出哪些是你家系统的用户。这份名单拿去撞库、发钓鱼邮件,值钱得很。
python
# ✅ 不管存不存在,都返回同样的结果
user = User.query.filter_by(email=email).first()
if user:
send_reset_mail(email)
return jsonify({'code': 0, 'message': '如果该邮箱已注册,验证码将发送至邮箱'})
前端文案也得一起改。前端还写着"邮箱不存在",后端改得再对也白搭。
坑 2:邮件轰炸
不加频率限制,一个人写个 for 循环就能让邮件服务商把你封号。这也是拿你的服务器,去打别人邮箱的一种方式。
python
def check_send_limit(email, ip):
"""两道限制:同邮箱 60 秒一次、同 IP 每小时 10 次"""
email_key = f'limit:code:email:{email}'
ip_key = f'limit:code:ip:{ip}'
# 同邮箱 60 秒冷却
if redis_client.exists(email_key):
return False, '发送太频繁,请 60 秒后再试'
# 同 IP 每小时 10 次
ip_count = int(redis_client.get(ip_key) or 0)
if ip_count >= 10:
return False, '操作过于频繁,请稍后再试'
# 写入计数
redis_client.setex(email_key, 60, 1)
pipe = redis_client.pipeline()
pipe.incr(ip_key)
pipe.expire(ip_key, 3600)
pipe.execute()
return True, ''
@app.route('/auth/send_code', methods=['POST'])
def send_code():
email = (request.json.get('email') or '').strip().lower()
if not email:
return jsonify({'code': 1, 'message': '请输入邮箱'})
ok, msg = check_send_limit(email, request.remote_addr)
if not ok:
return jsonify({'code': 1, 'message': msg})
user = User.query.filter_by(email=email).first()
if user:
code = generate_code()
# 验证码存 Redis,5 分钟过期
redis_client.setex(f'reset:code:{email}', 300, code)
# 失败次数单独计数
redis_client.delete(f'reset:fail:{email}')
send_mail(email, code)
return jsonify({'code': 0, 'message': '如果该邮箱已注册,验证码将发送至邮箱'})
验证码一定存 Redis,别存内存字典。 内存字典一重启就没了,gunicorn 多 worker 时还各存各的,用户刷新落到另一个进程,就永远验证不过。这坑我踩过------本地单进程测一切正常,一上服务器全挂。
坑 3:验证码可预测
python
# ❌ 六位随机数用了不安全的随机源
import random
code = str(random.randint(100000, 999999))
random 是可预测的伪随机。实际利用门槛不算低,但没理由不用对的:
python
import secrets
def generate_code(length=6):
# ✅ secrets 基于系统熵源,专为安全场景设计
return ''.join(secrets.choice('0123456789') for _ in range(length))
三、环节②:校验验证码
这是整个流程的核心关卡,也是最容易写漏的一步。
坑 4:验证码可以无限次试
六位数字是 100 万种可能。听着挺多,但没有次数限制的话,脚本跑一晚上也就试完了。
python
@app.route('/auth/verify_code', methods=['POST'])
def verify_code():
email = (request.json.get('email') or '').strip().lower()
code = (request.json.get('code') or '').strip()
fail_key = f'reset:fail:{email}'
fail_count = int(redis_client.get(fail_key) or 0)
# ✅ 连续错 5 次直接锁掉这个邮箱的验证码
if fail_count >= 5:
redis_client.delete(f'reset:code:{email}')
return jsonify({'code': 1, 'message': '错误次数过多,请重新获取验证码'})
saved = redis_client.get(f'reset:code:{email}')
if not saved:
return jsonify({'code': 1, 'message': '验证码已过期,请重新获取'})
if code != saved.decode():
pipe = redis_client.pipeline()
pipe.incr(fail_key)
pipe.expire(fail_key, 300)
pipe.execute()
return jsonify({'code': 1, 'message': '验证码错误'})
# 验证通过,立刻销毁验证码,一次性使用
redis_client.delete(f'reset:code:{email}')
redis_client.delete(fail_key)
# 签发重置令牌,10 分钟有效
token = generate_reset_token(email)
return jsonify({'code': 0, 'token': token})
"连续错 5 次就作废验证码"比"限制 IP"更管用。攻击者能换 IP,换不了目标邮箱。
坑 5:令牌可以被伪造
令牌不能是邮箱的哈希,也不能是自增 ID------都能猜。用 itsdangerous,Flask 生态自带的签名工具:
python
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
def get_serializer():
return URLSafeTimedSerializer(current_app.config['SECRET_KEY'],
salt='password-reset')
def generate_reset_token(email):
return get_serializer().dumps(email)
def verify_reset_token(token, max_age=600):
"""返回邮箱;无效或过期返回 None"""
try:
return get_serializer().loads(token, max_age=max_age)
except SignatureExpired:
return None
except BadSignature:
return None
dumps 出来的令牌带时间戳和签名,改一个字符签名就失效,max_age=600 让它 10 分钟自动过期。签名密钥放环境变量,别硬编码------毕设代码要上 GitHub 的话,这是最容易被翻出来的东西。
坑 6:令牌可以重复使用
签名令牌本身无状态,只要没过期就能一直用。用户重置完密码,那个令牌 10 分钟内还能再重置一次。
解决办法:数据库里存一个"密码版本号",令牌带上它:
python
def generate_reset_token(email):
user = User.query.filter_by(email=email).first()
return get_serializer().dumps({'email': email, 'pv': user.pwd_version or 0})
def verify_reset_token(token, max_age=600):
try:
data = get_serializer().loads(token, max_age=max_age)
except (SignatureExpired, BadSignature):
return None
user = User.query.filter_by(email=data['email']).first()
if not user:
return None
# ✅ 密码改过一次,版本号变了,老令牌自动失效
if (user.pwd_version or 0) != data['pv']:
return None
return user
重置密码时 user.pwd_version += 1。顺带把"改完密码强制其他设备下线"也解决了,一举两得。
四、环节③:重置密码
python
@app.route('/auth/reset_password', methods=['POST'])
def reset_password():
token = request.json.get('token') or ''
new_pwd = request.json.get('password') or ''
# 密码强度先校验
if len(new_pwd) < 8 or new_pwd.isdigit():
return jsonify({'code': 1, 'message': '密码至少 8 位,且不能全是数字'})
user = verify_reset_token(token)
if not user:
return jsonify({'code': 1, 'message': '链接已失效,请重新获取'})
# 新密码不能和旧密码一样
if check_password_hash(user.password, new_pwd):
return jsonify({'code': 1, 'message': '新密码不能与原密码相同'})
user.password = generate_password_hash(new_pwd)
user.pwd_version = (user.pwd_version or 0) + 1
db.session.commit()
# ✅ 关键:清掉这个用户所有已登录的 session
clear_user_sessions(user.id)
return jsonify({'code': 0, 'message': '密码已重置,请重新登录'})
坑 7:改完密码,别人的登录态还活着
这条最容易被忽略,也最要命。
用户重置密码,多半是怀疑账号被盗。结果密码改了,盗号者那边的 session 还在,照样能操作------那改密码还有什么意义?
Flask 默认用客户端 cookie 存 session,服务端没法主动失效。要么用 Redis 存 session(Flask-Session),要么每次请求都校验一下 pwd_version:
python
@app.before_request
def check_session_valid():
if current_user.is_authenticated:
# session 里存的版本号和数据库对不上,说明密码被改过
if session.get('pwd_version', 0) != (current_user.pwd_version or 0):
logout_user()
session.clear()
return jsonify({'code': 401, 'message': '密码已修改,请重新登录'}), 401
登录成功把 pwd_version 写进 session,改密码时加一。老 session 下一次请求就被踢掉。
五、还有几个细节,答辩前自查一遍
别把验证码打到日志或响应里。 我调试的时候图省事写过 return jsonify({'code': 0, 'debug_code': code}),忘了删,等于把验证码直接送给攻击者。上线前全局搜一遍 debug。
邮件内容别泄露账号信息。 正文只写"您的验证码是 XXXXXX,5 分钟内有效,如非本人操作请忽略",不要写"您的账号 XXX 于 X 时在 X 地申请重置"。
别忘了大小写和空格。 邮箱统一 .strip().lower(),不然 A@qq.com 和 a@qq.com 会变成两个账号,用户自己都找不回来。
验证码用一次就删。 上面代码里已经做了,但一定要确认------验证通过后不删,那个码在 5 分钟内还能再换一个令牌。
加个"密码修改通知"。 重置成功后发一封"您的密码已于 X 时修改"的邮件。这是给真实用户留的最后一道人工兜底:如果本人没操作,看到这封邮件还能立刻反应。
答辩高频题
1. 为什么验证码要存 Redis 而不是数据库或内存?
答:验证码是短期高频的临时数据,写数据库会产生大量无意义的写入和清理成本,内存字典在多 worker 部署下各存各的、重启就丢。Redis 天然支持过期时间,多进程共享,读写又快,是这个场景最合适的存储。
2. 验证码为什么限制错误次数比限制 IP 更有效?
答:IP 可以换,代理池成本很低,攻击者绕过 IP 限制很容易;但目标邮箱是固定的,限制"同一个邮箱连续错 5 次就作废验证码"直接掐死了爆破路径,攻击者只能重新发码,而发码本身又被频率限制卡住。
3. 为什么要用令牌而不是直接把邮箱和验证码传给重置接口?
答:验证码只能证明"能收到邮件",不能证明"愿意现在改密码"。拆出令牌之后,验证和执行分离,令牌可以单独设时效、单独作废(通过密码版本号),而且令牌本身是签名过的,客户端改不了内容。
4. 用户改完密码,为什么还要处理已登录的会话?
答:用户重置密码通常意味着怀疑账号被盗。如果旧会话不失效,盗号者依然能继续操作,重置就失去了意义。做法是在用户表加密码版本号,session 里存一份,每次请求比对,不一致就强制登出。
5. 用户枚举有什么危害,怎么防?
答:攻击者可以批量探测哪些邮箱是你系统的注册用户,得到一份有效账号清单,用于撞库、钓鱼或者定向攻击。防护方式是无论邮箱是否存在都返回相同文案、相同响应时间,避免通过提示语或响应延迟泄露信息。
写在最后
找回密码代码量不大,却是个照妖镜。
新手写完只关心"能不能跑通"------收得到邮件、改得了密码,完事。
多想一层的人会问:"拿别人的邮箱一直请求会怎样?""猜验证码会怎样?""令牌被人捡到会怎样?"
安全这回事,说到底就是不停问"如果"。
答辩老师问的那些题,很多时候只是替你把"如果"问出来而已。
梅雅达编程笔记原创 · 首发 CSDN · 转载请注明出处