忘记密码只能找客服:Flask邮箱验证码重置密码加限流防爆破

⚠️ 免责声明 :本文项目仅供技术学习参考,严禁直接作为毕业设计或课程作业提交,更不得用于任何未授权的真实系统测试。所有演示均在本人本地项目上进行。学术诚信、合法合规从我做起 🙂


先说结论

答辩现场最尴尬的问题,排得上号的一个是:"你用户忘了密码怎么办?"

你要是回"后台能改"或"联系管理员",老师多半会追一句:"那系统上线了,管理员天天帮人改密码?"

找回密码看着小,实际踩在账号安全、邮件服务、限流、令牌管理四个点上。写好了加分,写糙了就是个筛子。

我这版改了三次,前两次都是被自己攻破的。下面按"把流程拆成环节,看每个环节能被怎么打"来讲,你拿清单对着自己的项目逐条过就行。


一、先把正常流程走通

标准找回密码分三步:

复制代码
① 用户填邮箱  →  ② 收验证码,填验证码 + 新密码  →  ③ 重置成功,跳登录

对应三个接口:

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 · 转载请注明出处

相关推荐
这个昵称不容易3 小时前
旅游管理系统的设计与实现(SpringBoot + MySQL + Vue 前后端分离)
java·springboot·毕设
这个昵称不容易1 天前
SpringBoot 美容院管理系统:232个Java文件315个接口,5套角色从预约到发货一整条链路
java·springboot·毕设
hhcgchpspk1 天前
flask服务端页面预防被爬措施
后端·python·flask·网络爬虫·防爬
这个昵称不容易2 天前
SpringBoot「衣依」服装销售平台:144个Java文件180个接口,从挑衣服到收货评价整条链路都在
java·springboot·毕设
软件技术NINI2 天前
基于SpringBoot和Vue的博物馆预约平台管理系统的设计与实现
java·毕业设计·毕设
这个昵称不容易3 天前
SpringBoot 读书笔记共享平台部署实战:97 个 Java 文件 / 113 个接口 / 10 张表
java·springboot·毕设
Blockbuater_drug3 天前
《经典机器学习实战》第 5 课:把模型做成 Web 应用(UFO)
flask·pickle·模型部署·web应用·机器学习上线·ufo数据集·rest接口
梅雅达编程笔记5 天前
下单不付款库存被占?Flask定时任务自动取消超时订单+回滚库存
flask·毕设·定时任务·电商系统·apscheduler·myd电商·超时订单
软件技术NINI5 天前
基于SpringBoot和Vue的项目评审系统
javascript·vue.js·spring boot·后端·spring·毕业设计·毕设