小站权限实践:用 HMAC 无状态 Token 给 Flask 加管理员与临时账户两种角色
场景
自己维护一个小站,大多数时候只有我一个人用。但偶尔需要把上传/下载的能力开放给别人:他可以传文件、取文件,却不能改备注、更不能删我的东西。这类需求很小,小到不值得引入数据库和完整的用户体系,但又确实需要「角色」。
下面记录一种足够轻的做法:不建 session 存储,不引 JWT 库,只用一个签名过的 Cookie 把「谁、什么角色、什么时候过期」写在里面。
一、Token 的形状
把三段明文信息拼成 payload,再用服务端密钥做一次 HMAC,拼成第四段:
exp.user.role.signature
signature = HMAC-SHA256(SECRET, "exp.user.role").hexdigest()[:32]
服务端收到 Cookie 后重新计算一遍签名,用固定时间比较函数比对;不一致就直接拒绝。这样做的关键好处是无状态:服务重启、多 worker、换机器都不影响已登录的用户,也不需要维护一张 token 表。
二、最小实现
账号信息放在一个配置文件里,密码只存哈希:
def hash_password(pw):
return hashlib.sha256((SALT_PREFIX + pw).encode()).hexdigest()
def accounts():
cfg = json.loads(CONFIG.read_text(encoding="utf-8"))
return cfg.get("accounts") or {}
签发与校验:
def make_token(user, role):
exp = int(time.time()) + TOKEN_DAYS * 86400
payload = f"{exp}.{user}.{role}"
sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
return f"{payload}.{sig}", exp
def parse_token(token):
parts = (token or "").split(".")
if len(parts) != 4:
return None
exp, user, role, sig = parts
payload = f"{exp}.{user}.{role}"
want = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
if not hmac.compare_digest(want, sig):
return None
if int(exp) < time.time():
return None
acct = accounts().get(user)
if not acct or acct.get("role") != role: # 配置里改了角色,旧 token 立刻失效
return None
return user, role
然后是两层装饰器,一层管登录、一层管角色:
def authed(fn):
@wraps(fn)
def wrapper(*a, **kw):
data = parse_token(request.cookies.get("vault_token", ""))
if not data:
abort(401, description="请先登录")
g.user, g.role = data
return fn(*a, **kw)
return wrapper
def admin_only(fn):
@wraps(fn)
def wrapper(*a, **kw):
if getattr(g, "role", None) != "admin":
abort(403, description="临时账户没有这个权限")
return fn(*a, **kw)
return wrapper
@app.post("/api/delete")
@authed
@admin_only
def delete():
...
装饰器顺序别写反:@authed 要在里层先跑完,g.role 才有值可读。
三、前端隐藏不是权限,只是体验
拿到 /api/files 返回的 me.role 之后,把非管理员的「删除」「编辑备注」按钮直接不渲染,界面上干净很多。但这一步纯属体验优化——真正的门槛在服务端。哪怕前端一行代码都不写,@admin_only 也必须能独立把越权请求挡在 403。
同理,前端显示的权限应该是「服务端告诉我的」,而不是「前端自己猜的」。所以我会让每个列表接口顺带返回当前身份,页面顶部显示「管理员 / 临时账户」,用户一眼就知道自己是谁,不用靠输错密码来试。
四、几个真实会踩的坑
- 密码比较:
==会短路返回,理论上留出时序侧信道;用hmac.compare_digest,成本为零。 - Cookie 属性:全站 HTTPS 时记得
secure=True、httponly=True、samesite="Lax"。放在 Nginx 反代后面时,如果应用以为自己是 HTTP,secureCookie 会被浏览器丢掉,表现是「登录成功但刷新又要密码」。 - token 里带角色,就要处理改角色:校验时拿 token 里的 role 和配置里的 role 比一次,配置一改,旧 token 自然作废,不用等它过期。
- 公网上的登录接口一定有脚本在扫:按来源 IP 记失败次数,比如 5 次锁 5 分钟。同时把「失败次数」的清理放在成功登录之后,别让它永远不归零。
- 写操作要挡 CSRF:Cookie 是
Lax已经挡住大部分跨站 POST,但更省心的做法是让所有写接口要求一个自定义请求头,跨站表单发不出自定义头,天然被拒。 - 上传大文件与超时:413 通常是反代的
client_max_body_size,不是应用;应用侧的 worker timeout 也要够,否则慢网络下大文件传到一半就被判失败。 - 改代码后别忘了重载:
systemctl restart会短暂中断,kill -HUP(或systemctl reload)让 gunicorn 平滑换 worker。更坑的是「文件已经在磁盘上、进程还在跑旧代码」——线上行为和你读到的源码不一致,一定要用systemctl show -p MainPID,ExecMainStartTimestamp确认进程启动时间。
五、验证清单
上线前我会拿 curl 把矩阵跑一遍,而不是只点一次界面:
# 临时账户:上传/下载 200,改备注/删除 403
curl -s -o /dev/null -w "%{http_code}\n" -b "$GUEST" -X POST https://host/api/delete
curl -s -b "$GUEST" https://host/api/stats
curl -s -b "$ADMIN" https://host/api/stats
curl -s https://host/api/stats # 无 Cookie → 401
再额外测三种伪造:把 token 里的 .guest. 改成 .admin.(签名失效 → 401);用旧格式的两段 token(段数不对 → 401);路径穿越类的文件名参数(返回 400,不落盘)。全部符合预期,才算做完。
小结
这套方案不适合多人协作的正式产品,但对「一个人的小站需要两种权限」这种规模刚好:没有额外依赖,没有数据库迁移,密钥和配置都在一个文件里,出问题十分钟就能读完。核心只有两句话——用签名换状态,以及权限判断永远写在服务端。
- 点赞
- 收藏
- 关注作者
评论(0)