一个人做个人网站(三):后台登录与安全——一个人的系统也要按"被攻击"来设计
个人网站需要谈安全吗?我的答案是:登录接口是整个站点唯一对外开放的"写操作入口",它面对的不是"会不会有人攻击我",而是"互联网上永远有扫号器在随机撞库"。哪怕你的站一天只有十个访客,登录接口也可能在凌晨被脚本请求几百次。
好消息是,一个只有单一管理员的内容站,安全模型比多租户系统简单得多。这一篇讲我为「小欢喜」后台登录做的五件事:JWT 签发与主动吊销、一次性图形验证码、按 IP 失败锁定、反代后的真实 IP、以及前端三层配合。每一件都不复杂,但漏掉任何一件,防线就是漏的。
一、先想清楚威胁模型,再选方案
动手前我列了这个接口实际会遇到的威胁:
- 撞库/暴力破解:脚本拿常见账号密码字典无限 POST
/api/auth/login; - 验证码重放:如果验证码答案能反复用,加验证码等于没加;
- Token 无法吊销:JWT 签发后在有效期内"永远正确",改了密码、发现 token 泄露,旧 token 依然畅通无阻;
- 伪造 IP 绕过锁定:应用在 Nginx 后面,
request.remote_addr拿到的永远是127.0.0.1,按它限流等于全站共用一个计数桶;攻击者还可能伪造X-Forwarded-For; - 把前端路由守卫当安全:
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
几个刻意的选择:
- 算法锁死 HS256。单服务自己签自己验,不需要非对称;解码时也显式传
algorithms=['HS256'],杜绝alg=none之类的伪造 token; - payload 只放身份,不放权限、不放敏感信息。这个站只有一个角色,
sub足够; SECRET_KEY从.env注入,开发环境有个兜底默认值,生产环境没配密钥就是事故(部署文档里把"生成随机 SECRET_KEY"列为上线前必做项)。
鉴权装饰器 @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
三个细节:
- 答案转小写再哈希,用户输
aBcD和abcd等价,体验更好且不降低安全强度; - UUID 作为验证码凭证,答案和图片之间没有可猜测的关联,攻击者无法通过自增 id 枚举;
- 生成时顺带删除过期行,这张表永远不会膨胀,不需要单独的定时任务。
前端配合一条规矩:登录失败(不管是验证码错还是密码错)立刻清空输入框并重新拉验证码,因为旧的已经被消费掉了。
五、按 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)
这里有几个设计决策值得说:
- 锁定计数在触发锁定时清零。15 分钟后用户拿到的是全新的 5 次机会,而不是"锁完一次还剩 0 次、再错又锁",避免把偶尔输错密码的真人永久困在锁定循环里;
- 验证码先于密码校验。这样撞库脚本每试一个密码都得先过一道图形验证码,自动化成本直接拉满;
- 锁定响应使用 429 而非 401。429 语义是"请求过多",前端可以据此直接展示倒计时文案,而不会误导用户以为是密码错;
- 成功登录删除记录,计数不跨会话累积。
六、反代之后,怎么拿到不可伪造的真实 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)。安全基建一旦搭好,复用时只需填参数。
九、踩过的坑与检查清单
- 前端守卫不是安全,接口装饰器才是。 我在联调早期就用 curl 裸打每个
/api/admin/*接口验证过——没挂@login_required的一律补上。 - 时间全部用 UTC 且带 tzinfo。 吊销时间戳最早我用过本地时间,服务器时区配置和开发机不一致时,旧 token 该废不废。
- 锁定阈值和时长做成配置项(
LOGIN_MAX_FAILS、LOGIN_LOCK_MINUTES),被撞库时可以临时在.env调严,不用改代码重新部署。 - 别把密码哈希算法忘在第 2 篇的坑里:Werkzeug 3.x 默认 scrypt 与老 OpenSSL 不兼容,全站统一显式
pbkdf2:sha256。 - 上线自查清单:
.env里SECRET_KEY是否换成了随机值?Nginx 是否设置了X-Real-IP?初始化的默认密码登录后是否立即修改?(seed 只负责提示,修改动作靠人。) - 错误信息不要帮助枚举账号。 "账号不存在"和"密码错误"我统一返回"账号或密码不正确",攻击者无法据此判断哪些用户名真实存在。
十、小结
一个人的内容站,登录安全做到这个程度的性价比最高:
- JWT + HS256 拿到无状态的水平扩展能力;
- 一个时间戳补上 JWT 无法吊销的短板,改密即全端登出;
- 验证码哈希入库、用过即删、过期自清,用最低成本挡住自动化撞库;
- 按真实 IP 的失败锁定,信任锚点放在客户端伪造不了的
X-Real-IP; - 后端装饰器鉴权、前端只做体验,这条边界任何时候都不能搞反。
防线的哲学是:不追求绝对安全(那也不存在),而是让攻击你的成本远高于收益。当扫号器发现每个请求都要过验证码、试 5 次就被锁 15 分钟,它自然会转向更软的目标。
下一篇回到内容本身,看整个站点最核心的博客模块:草稿/发布状态机怎么设计、Markdown 为什么要双端各渲染一次、阅读量去重的 cookie 怎么放、以及上一篇/下一篇的排序为什么是个坑——第 4 篇:博客内容模块,一篇文章从草稿到发布的完整旅程。
系列导航:① 开篇与总体架构 → ② Flask 后端骨架 → ③ 登录与安全(本文)→ ④ 博客内容模块 → ⑤ Markdown 编辑器 → ⑥ OSS 直传全攻略 → ⑦ SPA 的 SEO 与微信分享 → ⑧ 打卡热力图与小游戏 → ⑨ AI 网页宠物与收官


