小站权限实践:用 HMAC 无状态 Token 给 Flask 加管理员与临时账户两种角色

举报
早睡早起的Mikey 发表于 2026/10/19 12:01:17 2026/10/19
【摘要】 个人小站要给别人开放上传下载、又不允许改删,不值得上数据库和完整用户体系。本文记录一种轻量做法:把过期时间、用户名、角色拼成 payload,用服务端密钥做 HMAC 签名写进 Cookie,再用 authed / admin_only 两层装饰器校验;并整理密码比较、Cookie 属性、角色变更失效、登录爆破限制、CSRF 自定义头、上传超时与重载等踩坑点,附一份 curl 验证清单。

场景

自己维护一个小站,大多数时候只有我一个人用。但偶尔需要把上传/下载的能力开放给别人:他可以传文件、取文件,却不能改备注、更不能删我的东西。这类需求很小,小到不值得引入数据库和完整的用户体系,但又确实需要「角色」。

下面记录一种足够轻的做法:不建 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,secure Cookie 会被浏览器丢掉,表现是「登录成功但刷新又要密码」。
  • 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,不落盘)。全部符合预期,才算做完。

小结

这套方案不适合多人协作的正式产品,但对「一个人的小站需要两种权限」这种规模刚好:没有额外依赖,没有数据库迁移,密钥和配置都在一个文件里,出问题十分钟就能读完。核心只有两句话——用签名换状态,以及权限判断永远写在服务端。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。