全栈项目从 0 到 1 实战(4):用户认证与权限

发布时间:2026/8/28 20:18:03
全栈项目从 0 到 1 实战(4):用户认证与权限 上一篇用约束和事务建立了多租户数据底座。本篇推进到身份安全认证回答“你是谁”授权回答“你能对这个资源做什么”。我们实现密码哈希、短期访问令牌、可轮换刷新令牌以及工作区角色加资源归属的双层检查。一、痛点登录成功只是安全链路的起点最常见的高危缺陷不是不会校验密码而是接口拿到任务 ID 后直接更新没有验证任务所属工作区与当前成员关系这类问题称为对象级授权缺失。另一个隐患是把长期 JWT 存入浏览器本地存储一次 XSS 就能读取并长期复用。安全设计必须同时考虑令牌被盗、密码库泄露、账号枚举、重复刷新、退出登录和管理员误操作。浏览器方案采用短期访问令牌与长期刷新令牌。访问令牌只活 10 分钟并放内存刷新令牌使用HttpOnly、Secure、合适SameSite的 Cookie。刷新时执行轮换旧令牌立即失效新令牌与前一个令牌属于同一家族若已使用的旧令牌再次出现撤销整个家族视为泄露信号。Cookie 自动携带因此刷新端点还要防 CSRF至少校验 SameSite 策略、Origin必要时使用 CSRF token。二、原理密码应慢哈希令牌应最小授权密码不能加密后保存也不能用快速 SHA-256 单独哈希。应使用 Argon2id、scrypt 或 bcrypt并为每个密码生成盐成本参数让离线猜测变贵参数还需随硬件升级。下面使用 Python 标准库的scrypt展示完整存储与恒定时间比较。生产项目可选 Argon2 库并按服务器性能校准至可接受延迟。importbase64importhashlibimporthmacdefencode_password(password:str,salt:bytes)-str:digesthashlib.scrypt(password.encode(utf-8),saltsalt,n2**14,r8,p1,dklen32,)returnscrypt$16384$8$1$base64.b64encode(salt).decode()$base64.b64encode(digest).decode()defverify_password(password:str,encoded:str)-bool:algorithm,n,r,p,salt_text,digest_textencoded.split($)ifalgorithm!scrypt:returnFalsesaltbase64.b64decode(salt_text)expectedbase64.b64decode(digest_text)actualhashlib.scrypt(password.encode(utf-8),saltsalt,nint(n),rint(r),pint(p),dklenlen(expected),)returnhmac.compare_digest(actual,expected)storedencode_password(correct horse battery staple,bytes(range(16)))print(validstr(verify_password(correct horse battery staple,stored)).lower())print(invalidstr(verify_password(wrong password,stored)).lower())print(schemestored.split($,1)[0])运行输出validtrue invalidfalse schemescrypt访问令牌只携带稳定且必要的声明主体sub、签发方iss、受众aud、签发和过期时间、唯一 ID。角色可能随时变化不宜把长期角色快照当最终权限API 仍从数据库读取当前成员关系。校验算法必须固定不能信任令牌头部随意指定还要校验签发方、受众、过期和时钟偏差。三、实现将授权写成可组合策略角色采用 owner、admin、member、viewer。RBAC 适合表达粗粒度能力资源归属进一步限制对象范围member 可编辑自己负责的任务admin 可编辑工作区内任意任务viewer 只读。默认拒绝每个路由声明所需动作服务层再次接收经过验证的身份上下文。fromdataclassesimportdataclass ROLE_ACTIONS{owner:{workspace:manage,task:read,task:update,task:delete},admin:{member:invite,task:read,task:update,task:delete},member:{task:read,task:update},viewer:{task:read},}dataclass(frozenTrue)classPrincipal:user_id:strworkspace_id:strrole:strdataclass(frozenTrue)classTask:task_id:strworkspace_id:strassignee_id:str|Nonedefauthorize(principal:Principal,action:str,task:Task)-bool:ifprincipal.rolenotinROLE_ACTIONS:returnFalseiftask.workspace_id!principal.workspace_id:returnFalseifactionnotinROLE_ACTIONS[principal.role]:returnFalseifactiontask:updateandprincipal.rolemember:returntask.assignee_idprincipal.user_idreturnTruealicePrincipal(u1,w1,member)adminPrincipal(u2,w1,admin)own_taskTask(t1,w1,u1)foreign_taskTask(t2,w2,u1)print(member_ownstr(authorize(alice,task:update,own_task)).lower())print(admin_deletestr(authorize(admin,task:delete,own_task)).lower())print(cross_tenantstr(authorize(admin,task:read,foreign_task)).lower())运行输出member_owntrue admin_deletetrue cross_tenantfalse登录接口对“邮箱不存在”和“密码错误”返回相同外部消息与相近耗时减少账号枚举内部日志可记录原因但不记录密码和完整令牌。按账号与 IP 组合限速连续失败采用渐进延迟避免简单 IP 限制误伤共享网络。敏感动作如改邮箱、关闭多因素认证、删除工作区要求近期重新认证。刷新令牌数据库只保存其密码学摘要、用户、家族、过期时间、使用时间和撤销时间。轮换必须在事务中以条件更新实现确保同一旧令牌只有一个并发请求成功。退出当前设备撤销一条家族修改密码或发现泄露撤销该用户全部家族。密钥轮换通过kid选择当前与上一把验证密钥先发布验证能力再切换签发最后等待旧令牌过期后移除。四、踩坑状态码和前端隐藏都不是授权前端隐藏“删除”按钮只是体验优化攻击者可直接调用 API。后端每次访问对象都做授权并避免先返回对象存在再报无权对不可见资源统一返回 404 可减少 ID 探测。401 表示缺少或无效认证403 表示身份有效但无权操作两者语义不要混用。不要自创加密算法不要在日志、URL 查询串和分析事件中放令牌。CORS 不是授权机制CSRF 与 XSS 也不能互相替代。JWT 的“无状态”不等于无需撤销高风险会话仍需要服务端状态、短过期时间或版本号。权限缓存必须在角色变更时失效并设置短 TTL。五、验证从攻击者视角写权限矩阵为每个动作建立角色×资源关系测试未登录、同租户 viewer、本人 member、他人 member、admin、跨租户 admin。测试过期令牌、错误受众、旧签名密钥、重复刷新、并发刷新、撤销后重放和 Cookie 缺少 Origin。日志应包含用户 ID、工作区 ID、动作、结果和请求 ID但敏感值脱敏。完成认证与授权后后端终于知道“谁在做什么”。下一篇将这些规则装进任务 CRUD 接口补齐校验、幂等、乐观并发、分页和统一错误契约。参考来源OWASP认证备忘单OWASP授权备忘单RFC 7519JSON Web TokenRFC 9700OAuth 2.0 安全最佳实践 觉得有用就点个赞 收藏方便回头查阅有疑问直接在评论区留言我看到都会回。 本文属于《全栈项目从 0 到 1 实战》系列持续更新关注不迷路。 文章里的代码都能直接跑。想要可直接 clone 的完整工程 配套部署脚本 / 踩坑清单评论一声或发邮件到cj2664qq.com我免费发你。如果你正好在做类似系统、或有工程化难题想找人做也欢迎邮件聊一句——我按实际情况评估能落地的就接单或出方案。评论和邮件都能直接找到我不用跳别的平台。

相关新闻