You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flask登录邮箱验证如何防范邮件滥发导致域名被标记为垃圾邮件

风险属性说明

这是所有具备邮箱注册/验证功能的站点都会面临的通用滥发风险,并非个别实现独有的问题。攻击者利用无限制的发信触发逻辑批量向陌生邮箱推送站点邮件,本质是盗用你的邮件服务器发送垃圾信息,轻则导致发件域名被邮箱服务商标记为垃圾源,重则域名被拉黑、正规邮件发送通道被封禁。

现有逻辑的核心缺陷

你当前的实现存在两个明显的可利用点:

  • 触发逻辑无门槛:未验证账号只要登录成功就自动发信,不需要用户主动触发,攻击者只要掌握自己注册的虚假未验证账号密码,就可以通过反复登录批量刷发信
  • 无频率控制:没有任何发信冷却、次数限制机制,同一邮箱可以在极短时间内收到成百上千封验证邮件,非常容易触发收件人的垃圾邮件举报
可落地的优化方案

1. 取消登录自动发信,改为用户主动触发

将未验证用户登录后的自动发信逻辑移除,登录后统一跳转到专门的邮箱验证提示页,页面展示账号未验证的提示,同时放置一个【重新发送验证邮件】的可点击按钮,仅当用户主动点击该按钮时才触发发信逻辑。按钮点击后直接置灰60秒,防止前端连点。
这一改动可以直接把批量刷信的成本从“脚本自动跑登录请求”拉高到“需要模拟用户交互点击”,大幅降低攻击效率。

2. 全链路加发信频率限制

在用户表新增两个字段用于发信频控:

  • last_verification_sent_at:DateTime类型,记录上一次发送验证邮件的时间
  • verification_send_count:Integer类型,记录统计周期内的发信次数

发信前做两层校验:

  • 冷却拦截:距离上一次发信不足60秒的,直接拒绝发信请求,提示用户稍后再试
  • 次数拦截:单个账号24小时内最多发送5封验证邮件,达到阈值后锁定发信权限24小时,到期自动重置

3. 前置注册环节防护

从注册源头减少虚假账号存量:

  • 注册接口加滑块/图形验证码,拦截机器批量注册
  • 单IP1小时内最多允许注册3个账号,超出后临时限制注册权限
  • 新注册用户仅在注册成功时自动发送1封验证邮件,后续发信全部走主动触发+频控逻辑

4. 降低被标记为垃圾邮件的概率

  • 所有验证邮件正文明确标注发信原因:“您于[时间]在[站点名]注册账号,如非本人操作请直接忽略本邮件”,不要使用垃圾邮件高频敏感词
  • 配置好发件域名的SPF、DKIM、DMARC解析记录,提升域名发信信誉,降低单用户举报对域名整体信誉的影响
  • 对累计发送3封以上验证邮件仍未完成验证、且所有邮件链接均无点击记录的邮箱,自动加入静默列表,后续不再向其发送任何站点邮件,避免持续骚扰
代码修改参考

首先修改原有登录路由,移除自动发信逻辑:

@app.route('/login', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        flash('您已完成登录')
        return redirect(url_for('index'))
    form = LoginForm()
    if request.method == 'GET' and 'email' in request.args:
        form.email.data = request.args.get('email')
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        idea = request.args.get('idea', '')
        if user is None or not user.check_password(form.password.data):
            flash('用户名或密码错误', 'error')
            return redirect(url_for('login', idea=idea))
        login_user(user)
        next_page = request.args.get('next')
        if not next_page or url_parse(next_page).netloc != '':
            next_page = url_for('idea', idea=idea) if idea else url_for('home')
        # 移除自动发信逻辑,未验证用户跳转到专门的提示页
        if not user.confirmed:
            flash(f'账号绑定邮箱{user.email}尚未验证,请完成验证后使用完整功能', 'warning')
            return redirect(url_for('verify_notice'))
        return redirect(next_page)
    return render_template('login.html', title="登录", form=form)

新增主动重发验证邮件的接口,内置频控逻辑:

from datetime import datetime, timedelta
from flask_login import login_required, current_user

@app.route('/resend_verify_email', methods=['POST'])
@login_required
def resend_verify_email():
    # 已验证用户不需要发信
    if current_user.confirmed:
        return redirect(url_for('home'))
    now = datetime.utcnow()
    # 60秒冷却拦截
    if current_user.last_verification_sent_at and \
       now - current_user.last_verification_sent_at < timedelta(seconds=60):
        flash('操作过于频繁,请1分钟后再尝试', 'error')
        return redirect(url_for('verify_notice'))
    # 24小时次数拦截
    day_ago = now - timedelta(days=1)
    if current_user.last_verification_sent_at and \
       current_user.last_verification_sent_at > day_ago and \
       current_user.verification_send_count >= 5:
        flash('今日验证邮件发送次数已达上限,请24小时后再尝试', 'error')
        return redirect(url_for('verify_notice'))
    # 满足条件发信
    send_verification_email(current_user)
    # 更新发信统计字段
    current_user.last_verification_sent_at = now
    current_user.verification_send_count = (current_user.verification_send_count + 1) if \
        (current_user.last_verification_sent_at and current_user.last_verification_sent_at > day_ago) else 1
    db.session.commit()
    flash('验证邮件已发送,请查收收件箱(含垃圾邮件文件夹)', 'success')
    return redirect(url_for('verify_notice'))

对应User模型需要新增两个字段:

class User(UserMixin, db.Model):
    # 原有字段(id、email、password_hash、confirmed等)保持不变
    last_verification_sent_at = db.Column(db.DateTime)
    verification_send_count = db.Column(db.Integer, default=0)

内容的提问来源于stack exchange,提问作者dannypernik

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 11:09:47