‹ 返回博客

个人网站(三):后台登录与安全——一个人的系统也要按"被攻击"来设计

2026-09-20 · 约 9 分钟读完 · #编程

一个人做个人网站(三):后台登录与安全——一个人的系统也要按"被攻击"来设计

个人网站需要谈安全吗?我的答案是:登录接口是整个站点唯一对外开放的"写操作入口",它面对的不是"会不会有人攻击我",而是"互联网上永远有扫号器在随机撞库"。哪怕你的站一天只有十个访客,登录接口也可能在凌晨被脚本请求几百次。

好消息是,一个只有单一管理员的内容站,安全模型比多租户系统简单得多。这一篇讲我为「小欢喜」后台登录做的五件事:JWT 签发与主动吊销、一次性图形验证码、按 IP 失败锁定、反代后的真实 IP、以及前端三层配合。每一件都不复杂,但漏掉任何一件,防线就是漏的。

一、先想清楚威胁模型,再选方案

动手前我列了这个接口实际会遇到的威胁:

  1. 撞库/暴力破解:脚本拿常见账号密码字典无限 POST /api/auth/login;
  2. 验证码重放:如果验证码答案能反复用,加验证码等于没加;
  3. Token 无法吊销:JWT 签发后在有效期内"永远正确",改了密码、发现 token 泄露,旧 token 依然畅通无阻;
  4. 伪造 IP 绕过锁定:应用在 Nginx 后面,request.remote_addr 拿到的永远是 127.0.0.1,按它限流等于全站共用一个计数桶;攻击者还可能伪造 X-Forwarded-For;
  5. 把前端路由守卫当安全:router.beforeEach 只是体验层,接口不鉴权的话,任何人 curl 直达。

对应措施一图概览:

登录请求
  │
  ├─ 取真实 IP(X-Real-IP 优先,XFF 仅兜底)
  ├─ 该 IP 是否在锁定期? ──是──► 429,直接拒绝
  ├─ 图形验证码是否正确且未使用过? ──否──► 400
  ├─ 账号密码是否正确?
  │     └─ 否:失败计数 +1,满 5 次锁 15 分钟
  │     └─ 是:删除失败记录,签发 JWT(7 天)
  │
后续管理请求
  └─ Bearer Token → 验签/验过期 → 验"吊销时间戳" → 取出用户

二、JWT:自包含凭证,服务端不存会话

登录态我没有用 Flask-Login 那套 server session,而是选了 JWT。理由很实际:后端是 Gunicorn 多进程、将来还可能多实例,自包含 token 不依赖任何服务端会话存储,水平扩展零成本;前端把它放进 Authorization 头,也比 cookie + CSRF 那套简单。

签发逻辑很短:

def create_token(user):
    now = datetime.datetime.now(datetime.timezone.utc)
    payload = {
        'sub': user.id,
        'username': user.username,
        'iat': now,
        'exp': now + current_app.config['JWT_EXPIRES'],   # 默认 7 天
    }
    return jwt.encode(payload, current_app.config['SECRET_KEY'],
                      algorithm=current_app.config['JWT_ALGORITHM'])     # HS256

几个刻意的选择:

鉴权装饰器 @login_required 上一篇亮过相:取 Bearer token → 解码(区分"过期"和"无效"两种 401 文案)→ 查用户 → 挂到 g.current_user。这里补上它最关键的一步——吊销检查。

三、改密码即全端登出:给无状态 JWT 加一个"全局开关"

JWT 最大的短板是无法主动作废。我的解法是利用站点配置表存一个时间戳 admin_tokens_before:签发时间(iat)早于它的 token,一律拒绝。

def _token_revoked(payload):
    """token 签发时间是否早于"改密作废时间"。"""
    row = SiteConfig.query.filter_by(key=TOKENS_INVALID_BEFORE_KEY).first()
    if not row or not row.value:
        return False
    cutoff = datetime.datetime.fromisoformat(row.value)
    if cutoff.tzinfo is None:
        cutoff = cutoff.replace(tzinfo=datetime.timezone.utc)
    issued_at = datetime.datetime.fromtimestamp(payload['iat'], tz=datetime.timezone.utc)
    return issued_at < cutoff

修改密码接口在更新哈希的同时,把这个时间戳写成当前 UTC 时间:

g.current_user.set_password(new_password)

# 让所有已签发的旧 token 立即失效(下一次请求即被拦回登录页)
cutoff = datetime.now(timezone.utc).isoformat()
row = SiteConfig.query.filter_by(key=TOKENS_INVALID_BEFORE_KEY).first()
if row:
    row.value = cutoff
else:
    db.session.add(SiteConfig(key=TOKENS_INVALID_BEFORE_KEY, value=cutoff))
db.session.commit()

不需要黑名单、不需要 Redis,一行时间戳就实现了"改密后所有设备掉线"。代价仅是每个鉴权请求多一次按主键的配置表查询——对这个流量级别可以忽略。注意 cutoff 和 iat 都统一用带时区的 UTC,否则服务器时区一变就会出现"刚改的密码却踢不掉自己"的灵异现象。

四、一次性图形验证码:答案哈希入库,用过即删

验证码我没有引第三方服务,Pillow 几十行就能画:4 位字符(剔除了 0/O/1/I/L 这些容易混淆的字形)、随机偏移和颜色、干扰线干扰点,返回 base64 data URL,前端直接塞进 <img src>。

真正需要讲究的是答案怎么存。不能放 session(虽然这里存 DB 也天然规避了多进程 session 问题),更不能明文落库:

captcha_id = uuid.uuid4().hex
db.session.add(Captcha(
    id=captcha_id,
    answer_hash=_hash_answer(code),                        # sha256(lower(code))
    expires_at=datetime.now() + timedelta(minutes=10),
))
Captcha.query.filter(Captcha.expires_at < datetime.now()).delete()   # 顺手清过期

校验时先查存在性和有效期,比对哈希,无论对错都删除记录:

def verify_captcha(captcha_id, answer, consume=True):
    if not captcha_id or not answer:
        return False
    captcha = db.session.get(Captcha, str(captcha_id))
    if not captcha or captcha.expires_at < datetime.now():
        return False
    ok = captcha.answer_hash == _hash_answer(answer)
    if consume:
        db.session.delete(captcha)      # 一次性:猜错也作废,必须换新
        db.session.commit()
    return ok

三个细节:

  1. 答案转小写再哈希,用户输 aBcD 和 abcd 等价,体验更好且不降低安全强度;
  2. UUID 作为验证码凭证,答案和图片之间没有可猜测的关联,攻击者无法通过自增 id 枚举;
  3. 生成时顺带删除过期行,这张表永远不会膨胀,不需要单独的定时任务。

前端配合一条规矩:登录失败(不管是验证码错还是密码错)立刻清空输入框并重新拉验证码,因为旧的已经被消费掉了。

五、按 IP 失败锁定:5 次错,锁 15 分钟

锁定状态用一张极简的表 login_attempts(ip, fail_count, locked_until) 承载。登录接口开头先拦锁定期,再走验证码和密码:

ip = client_ip()
attempt = LoginAttempt.query.filter_by(ip=ip).first()

# 锁定中直接拒绝
if attempt and attempt.locked_until and attempt.locked_until > datetime.now():
    remain = int((attempt.locked_until - datetime.now()).total_seconds() / 60) + 1
    return fail(f'失败次数过多,请 {remain} 分钟后再试', 429)

# 先校验图形验证码
if not verify_captcha(captcha_id, captcha_code):
    return fail('图形验证码不正确或已过期', 400)

user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password):
    if not attempt:
        attempt = LoginAttempt(ip=ip, fail_count=0)
        db.session.add(attempt)
    attempt.fail_count += 1
    if attempt.fail_count >= current_app.config['LOGIN_MAX_FAILS']:   # 5
        attempt.locked_until = datetime.now() + timedelta(
            minutes=current_app.config['LOGIN_LOCK_MINUTES'])          # 15
        attempt.fail_count = 0
        db.session.commit()
        return fail(f'...已连续失败 5 次,登录锁定 15 分钟', 429)
    db.session.commit()
    return fail(f'账号或密码不正确,还可尝试 {5 - attempt.fail_count} 次', 401)

# 登录成功,清除失败记录
if attempt:
    db.session.delete(attempt)

这里有几个设计决策值得说:

六、反代之后,怎么拿到不可伪造的真实 IP

这套限流全部建立在"IP 可信"之上,而 Nginx 反代让这件事变微妙了。直接读 request.remote_addr,后端看到的永远是 Nginx 的 127.0.0.1——全天下的攻击者共享一个计数桶,锁定毫无意义。

Nginx 侧已经在反代时注入了两个头(见第 1 篇的 nginx 配置):

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

后端的取法有严格的优先级:

def client_ip():
    # X-Real-IP 取 $remote_addr,客户端无法伪造,优先级最高
    real_ip = request.headers.get('X-Real-IP', '').strip()
    if real_ip:
        return real_ip
    # XFF 第一段由客户端自己提供、可伪造;只在本地直连开发时
    # 取最右一段(可信代理写入)兜底
    xff = request.headers.get('X-Forwarded-For', '').strip()
    if xff:
        return xff.split(',')[-1].strip()
    return request.remote_addr or '0.0.0.0'

为什么不能信 X-Forwarded-For 的第一段?因为这个头客户端可以随便写——curl 带一个 -H 'X-Forwarded-For: 1.2.3.4',每次请求换一个值,按第一段限流就形同虚设。X-Real-IP 由 Nginx 用 $remote_addr(TCP 连接的真实对端)覆盖式写入,客户端伪造不了,所以它是第一信源;XFF 仅在没有 Nginx 的本地开发场景兜底。

七、前端三层配合:守卫体验、拦截收口、入口刷新

安全完全在后端,但前端有三处配合让整条链路顺滑:

第一层,路由守卫,只管体验不管安全:

router.beforeEach((to) => {
  if (to.meta.requiresAuth && !localStorage.getItem('site_token')) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }
  return true
})

没有 token 根本不加载后台页面,但要清楚:这挡的是"手滑"和用户体验,curl 直接打接口时这段代码不存在。

第二层,axios 响应拦截器,401 统一收口。 任何一个后台接口收到 401(token 过期、被改密吊销),自动清除本地登录态并跳回登录页,业务代码无需各自处理(第 2 篇已展示,此处不重复)。同时宠物接口的 401 走例外分支,不株连后台登录态。

第三层,登录页的验证码纪律。 无论失败原因是什么,catch 里都清空验证码输入并重新拉取——与后端"用过即删"严格对应;登录成功则跳到 redirect 参数指向的原目标页,没有就进 /admin。

token 存 localStorage 是个有意识的取舍:它对 XSS 敏感,不如 httpOnly cookie 稳;但这个站后台是唯一写入面、输出均经 Vue 模板转义、不引第三方脚本,攻击面很小,换来的是实现简单和跨标签页状态一致。安全方案永远是攻击面与成本的权衡,不存在脱离场景的标准答案。

八、同一套模式复用到公开接口:宠物聊天的双窗口限流

登录这套"按 IP 计数 + 超限 429"的模式,后来原样复用到了 AI 宠物接口——那是一个未登录也能调用、但每次都花真金白银(DeepSeek token)的接口,更需要防刷。它用了分钟/自然日双窗口:

if row.minute_count >= current_app.config['PET_RATE_MINUTE']:   # 默认 10 条/分钟
    return fail('你说话太快啦,缓一缓,过一分钟再来找我嘛~', 429), None
if row.day_count >= current_app.config['PET_RATE_DAY']:         # 默认 100 条/天
    return fail('今天聊了好多好多,我先去补充能量,明天再陪你聊!', 429), None
row.minute_count += 1
row.day_count += 1

多一个贴心细节:AI 调用失败时退还本次计数,服务商抽风不能让访客白白损失每日额度。口令解锁失败则复用了和登录一模一样的锁定表结构(PetUnlockAttempt)。安全基建一旦搭好,复用时只需填参数。

九、踩过的坑与检查清单

  1. 前端守卫不是安全,接口装饰器才是。 我在联调早期就用 curl 裸打每个 /api/admin/* 接口验证过——没挂 @login_required 的一律补上。
  2. 时间全部用 UTC 且带 tzinfo。 吊销时间戳最早我用过本地时间,服务器时区配置和开发机不一致时,旧 token 该废不废。
  3. 锁定阈值和时长做成配置项(LOGIN_MAX_FAILS、LOGIN_LOCK_MINUTES),被撞库时可以临时在 .env 调严,不用改代码重新部署。
  4. 别把密码哈希算法忘在第 2 篇的坑里:Werkzeug 3.x 默认 scrypt 与老 OpenSSL 不兼容,全站统一显式 pbkdf2:sha256。
  5. 上线自查清单:.env 里 SECRET_KEY 是否换成了随机值?Nginx 是否设置了 X-Real-IP?初始化的默认密码登录后是否立即修改?(seed 只负责提示,修改动作靠人。)
  6. 错误信息不要帮助枚举账号。 "账号不存在"和"密码错误"我统一返回"账号或密码不正确",攻击者无法据此判断哪些用户名真实存在。

十、小结

一个人的内容站,登录安全做到这个程度的性价比最高:

防线的哲学是:不追求绝对安全(那也不存在),而是让攻击你的成本远高于收益。当扫号器发现每个请求都要过验证码、试 5 次就被锁 15 分钟,它自然会转向更软的目标。

下一篇回到内容本身,看整个站点最核心的博客模块:草稿/发布状态机怎么设计、Markdown 为什么要双端各渲染一次、阅读量去重的 cookie 怎么放、以及上一篇/下一篇的排序为什么是个坑——第 4 篇:博客内容模块,一篇文章从草稿到发布的完整旅程。

系列导航:① 开篇与总体架构 → ② Flask 后端骨架 → ③ 登录与安全(本文)→ ④ 博客内容模块 → ⑤ Markdown 编辑器 → ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享 → ⑧ 打卡热力图与小游戏 → ⑨ AI 网页宠物与收官