ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

协图云图库登录鉴权实战:双Token方案与权限模型详解

协图云图库登录鉴权实战:双Token方案与权限模型详解 咱们这个智能协图云图库项目走到第三天终于轮到一块硬骨头了用户登录与鉴权。前两天把图库的基础存储和协图工作流搭了起来但整套系统还处于“裸奔”状态谁都能直接调接口存取图。说起来有点不好意思第二天晚上我临时拿Session顶了一晚但心里清楚这东西撑不住多端协作的场景——协图云图库的核心价值是多人实时协作这意味着同一份图纸库可能同时被设计端、审核端、业主端访问Session的黏性会话机制在这种场景下很快就会变成瓶颈。所以第三天我干脆把登录和鉴权整套重写了从会话方案选型到token的生命周期管理再到权限模型整个过程踩了不少坑也沉淀了不少值得记录的经验今天一篇讲透。这篇内容适合谁一种是正在做类似“云图库”“网盘类系统”“协作平台”的后端开发尤其是登录模块还没想清楚怎么做鉴权的朋友另一种是前端同学想弄明白为什么登录接口要返回两个token、为什么刷新token不能放在localStorage。我会把设计思路、实操步骤、踩坑记录全部分享出来保证不是照搬理论而是能直接抄作业的那种。2. 整体设计为什么我放弃了Session改用双Token方案2.1 登录体系的基本盘先分清“认证”和“鉴权”很多新手把登录这套东西笼统叫“登录功能”但真正动手设计时一定要先把两个概念拆开认证是确认“你是谁”鉴权是确认“你能干什么”。认证解决的是用户拿着账号密码来系统怎么确认这个人确实是本人鉴权解决的是用户登录之后系统怎么知道他能不能删图、能不能改协作成员、能不能导出版本记录。这两个东西如果混在一起设计后面做权限扩展时一定会被自己坑死。在协图云图库这个场景里认证和鉴权的边界更加重要。因为我们不仅有普通用户还有项目管理员、企业管理员、只读访客等角色不同角色对图纸的操作权限差异很大。认证只需要做一次就是登录那一刻但鉴权在每一次请求都需要执行——他这次调用是查看图还是删除图对应的权限完全不同。2.2 为什么Session方案撑不住这个场景第二天临时用的Session方案其实在传统Web应用里是完全OK的。服务端保存sessionId对应的用户数据客户端拿着cookie每次请求带过来服务端查一下session是否有效。但到了协图云图库的项目里Session方案有几个天生痛点一是多端问题。我们图库有Web端、小程序端后面还计划上桌面端。Cookie在Web端好用但小程序和桌面客户端的网络库对Cookie的支持各有各的毛病。二是横向扩展问题。服务端如果部署多台实例Session默认存在单台内存里用户第一次请求落到A机器第二次请求被负载均衡转发到B机器B机器没有这个Session用户就被踢下线了除非引入Session共享比如存到Redis里统一管理。三是CSRF问题。Cookie方案天然面临跨站请求伪造的风险只要用户登录状态还在恶意网站就可以通过表单提交等方式冒充用户操作。第三点暂时不说安全攻击层面的事单就协图场景来说我们内部协作链接经常要在IM里传来传去被第三方站点打开的概率极高CSRF风险被成倍放大。2.3 双Token方案的选型逻辑accessToken refreshToken既然Session不合适自然会想到Token方案。最朴素的做法是登录成功后发一个Token客户端每次请求带在请求头里服务端验签就行。但这样直接做有个问题——Token一旦签发在过期之前无法主动作废。如果用户点了“退出登录”或者账号被管理员禁用这个Token在剩余有效期内依然能访问接口。所以我采用了现在业界比较成熟的“双Token”方案短期的accessToken加长期的refreshToken。accessToken有效期设短一些比如2小时过期后客户端拿refreshToken换新的refreshToken有效期设长一些比如7天保存在服务端Redis里可以随时主动作废。这样兼顾了安全性和体验。2.4 登录链路注册、登录、续期、登出完整链路拆出来是四段注册时创建账号登录时校验凭证并签发TokenaccessToken过期时通过refreshToken续期退出登录时把refreshToken拉黑让整套凭证彻底失效。其中注册和登录是入口续期是保障体验的关键登出是安全收尾。四段链路环环相扣任何一段设计得不严谨整个鉴权体系就会出现漏洞。比如续期接口如果不做设备指纹校验攻击者拿到refreshToken后可以无限续期理论上等于拿到了永久登录权限。3. 核心细节解析注册与密码安全实操要点3.1 密码存储绝对不能明文保存也不能只用一次MD5密码存储是登录系统的第一道底线。明文保存等于裸奔这个不用多说。但有些团队会用MD5直接加密这也是不对的。MD5本身是摘要算法不是加密算法而且彩虹表极其成熟你拿“123456”去MD5一下网上随便一个在线工具就能反查出来。正确做法是使用带盐的慢哈希算法。目前在业界公认度最高的是bcrypt其次是scrypt和argon2。我在协图云图库里选的是bcrypt理由有三点一是计算速度可控地慢单次哈希能控制在100ms级别攻击者暴力破解的成本会被大幅拉高二是盐值自动引入每次哈希输出的结果都不同同一密码两次存储值完全不同三是有完善的库支持Java和Node.js都有精确的实现。bcrypt的cost因子我设的是10。这个值意味着哈希计算大约进行2的10次方次迭代在目前的主流服务器上单次验证大概耗40到80毫秒。不要为了追求性能盲目降到4或5那会让暴力破解的成本低到几乎可以忽略。3.2 注册接口的字段设计与约束注册接口看起来简单但设计不好很容易被刷或者被注入脏数据。我提供的字段是用户名、邮箱、密码、确认密码。确认密码这个字段其实后端不需要存它的作用是让前端在提交前做一次比对减少无效请求。用户名和邮箱都要做唯一性校验但注意一个细节唯一性校验的SQL查询要处理大小写问题。数据库默认排序规则下“Admin”和“admin”可能被当成两个不同的值导致同一个邮箱被注册两次。我在数据库设计时把邮箱字段设置了unique索引同时在插入前先做一次标准化处理统一转小写。密码强度校验是另一个必须做的前置条件。我定的是最少8位必须同时包含字母和数字。不要只在前端做校验因为接口可以被直接调用跳过前端逻辑后端必须再做一次同样强度的校验。3.3 登录失败的统一处理不暗示“用户不存在”登录接口如果返回“用户不存在”和“密码错误”两种不同的错误信息等于帮攻击者做账号探测。他只需要跑一遍字典就能筛选出哪些邮箱是已注册的后续可以针对这些账号做定向攻击。正确的做法是无论用户名不存在还是密码错误都返回同一个错误提示比如“用户名或密码错误”。在代码实现上就是先查用户查不到时也照常用一个假哈希跑一遍比对流程保证两次请求的耗时基本一致从时间侧也抹掉可探测性。3.4 登录限流防止暴力破解限流是个老生常谈但极容易被忽略的点。我见过太多系统上线时没做登录限流结果被人用脚本怼了几百万次密码尝试后台一点感知都没有。我的方案是按IP加按账号双层限流。按IP的是每分钟最多10次登录尝试防止一个攻击者用不同密码轮询同一个账号按账号的是每15分钟最多20次防止分布式IP攻打同一个账号。超过限制直接拒绝请求并返回“尝试次数过多请稍后再试”。这个限流用Redis的INCR加EXPIRE实现实际的Redis操作代码结构一般是local key KEYS[1] local limit tonumber(ARGV[1]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, ARGV[2]) end if current limit then return 0 end return 1这段Lua脚本相当于在Redis端做了一次原子操作先自增计数第一次自增时设置过期时间超过阈值就返回0拒绝请求。由于整个判断在Redis内部完成不会出现并发下多个请求同时通过校验的竞态问题。4. 实操过程与核心环节实现Token签发与校验的完整落地4.1 登录成功后签发Token的标准流程登录成功后服务端要做的事情其实不少。我这里用Node.js伪代码展示核心逻辑其他语言思路完全一样async function login(email, password) { const user await db.users.findByEmail(email); if (!user) { // 即使查不到用户也要执行一次假比对防止时间侧信道 await bcrypt.compare(password, $2b$10$fakehash...); throw new LoginError(用户名或密码错误); } const match await bcrypt.compare(password, user.passwordHash); if (!match) { throw new LoginError(用户名或密码错误); } const accessToken signAccessToken(user); const refreshToken await createRefreshToken(user.id); return { accessToken, refreshToken, expiresIn: 7200, user: safeUser(user) }; } async function createRefreshToken(userId) { const token randomBytes(48).toString(hex); await redis.set( refresh:${token}, JSON.stringify({ userId, createdAt: Date.now() }), EX, 7 * 24 * 3600 ); return token; }注意这里refreshToken我直接用randomBytes(48)生成而不是用JWT。原因是refreshToken需要支持主动作废能力——如果它也是无状态的JWT服务端就无法在登出或密码修改后让它立刻失效。用随机字符串加Redis存储的方式删掉Redis里的key就等同于吊销了这个token。accessToken用JWT签发载荷里放userId、角色、token类型。注意token类型一定要区分access和refresh防止有人把refreshToken拿来当accessToken用function signAccessToken(user) { return jwt.sign( { sub: user.id, role: user.role, type: access }, process.env.JWT_SECRET, { expiresIn: 2h } ); }4.2 刷新Token的流程设备维度校验refreshToken换新accessToken的接口学名叫“续期”。这一步除了校验refreshToken本身是否有效还需要校验请求方的身份标识。我在签发refreshToken时会同时生成一个设备指纹客户端拿到后存起来续期时必须带着这个指纹过来服务端比对Redis里存的指纹值是否一致。这样做的意义在于如果用户的refreshToken被偷了攻击者拿着token可以续期但他拿不到原始的设备指纹服务端比对失败后直接拒绝续期同时把这个refreshToken作废把用户挤下线重新登录。实现上就是在签发时多存一个deviceId字段续期时先取出Redis中的值做比对一致才生成新tokenasync function refreshAccessToken(refreshToken, deviceId) { const data await redis.get(refresh:${refreshToken}); if (!data) throw new AuthError(登录已过期请重新登录); const session JSON.parse(data); if (session.deviceId ! deviceId) { // 设备不匹配可能token泄露主动吊销 await redis.del(refresh:${refreshToken}); throw new AuthError(登录状态异常请重新登录); } const user await db.users.findById(session.userId); const newAccessToken signAccessToken(user); return { accessToken: newAccessToken, expiresIn: 7200 }; }4.3 请求鉴权中间件一次验签一次查库每次请求进来都需要经过一层鉴权中间件。它的核心工作是三件事从请求头里取出accessToken、验签确认token没被篡改、从Redis里检查这个token是否在白名单内。为什么有验签了还要查Redis白名单因为JWT无状态意味着签发后服务端无法主动让它失效。但实际场景中我们必须支持“强制下线”“踢人”这类操作于是我在登录签发token的同时把accessToken的jtiJWT ID也写入Redis设置与token相同的过期时间。每次请求进来时检查这个jti是否存在如果不存在说明token已被吊销。中间件的伪代码如下async function authMiddleware(req, res, next) { const header req.headers.authorization; if (!header || !header.startsWith(Bearer )) { return res.status(401).json({ message: 未登录 }); } const token header.slice(7); try { const payload jwt.verify(token, process.env.JWT_SECRET); if (payload.type ! access) throw new Error(无效token类型); const whitelistKey access:${payload.jti}; const exists await redis.exists(whitelistKey); if (!exists) throw new Error(token已失效); req.user { id: payload.sub, role: payload.role }; next(); } catch (err) { return res.status(401).json({ message: 登录已过期或无效 }); } }注意验签失败不区分“过期”和“无效”更合理统一返回401即可避免给攻击者透露更多信息。4.4 跨域与Token传递为什么不能用localStorage一存了事协图云图库在Web端一定存在跨域访问的情况比如前端页面跑在app.yourdomain.comAPI跑在api.yourdomain.com。前端把accessToken存在localStorage请求时手动加Authorization头这样逻辑上没问题但存在XSS风险——一旦页面里被注入恶意脚本localStorage里的token会被直接拖走。更稳妥的做法是把accessToken存在内存变量里刷新页面后通过refreshToken自动续期恢复登录态refreshToken存放在HttpOnly的Cookie里这样任何JavaScript都读不到它XSS攻击拿不到refreshToken就没法续期。HttpOnly Cookie跨域需要设置SameSiteNone; Secure同时后端要在CORS配置里显式允许携带凭证。这一点很容易踩坑如果没有配置好浏览器会直接拦截响应表现为“请求成功但前端拿不到任何数据”。4.5 数据库层面的用户表结构参考我这里贴一下用户表的核心字段设计便于新人直接抄。实际生产环境还要加更多字段但下面这些是登录鉴权的最小集CREATE TABLE users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, email VARCHAR(255) NOT NULL UNIQUE, username VARCHAR(50) NOT NULL, password_hash VARCHAR(100) NOT NULL, role ENUM(admin, editor, viewer) DEFAULT viewer, status TINYINT DEFAULT 1 COMMENT 1正常 0禁用, last_login_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status (status) );邮箱统一存储小写避免大小写导致的重复账号问题。角色这个字段先留着后续如果要扩展更细粒度权限可以再加一张角色权限表用户表里面只存角色ID外键。5. 常见问题与排查技巧实录登录与鉴权避坑指南5.1 问题一登录接口返回401但参数明明是对的这类问题绝大多数出在中间件读取token的方式上。我已经见过太多次有人把token放在Authorization头里但中间件却用req.query.token去取或者是前端把token放在了自定义头里面而服务器端CORS配置里没有允许这个头携带。排查思路第一步看浏览器开发者工具里的请求头确认Authorization头是否存在第二步看响应头如果真的因为CORS拦截了控制台会有明确的跨域错误第三步在后端日志里把req.headers.authorization打出来确认收到的是什么。5.2 问题二登录成功后刷新页面又变回未登录这个现象十有八九是localStorage或Cookie在前端和服务端的域名规则没对齐。比如前端页面是app.yourdomain.com/login登录成功后跳转到app.yourdomain.com/projects这通常没问题但如果你同时用了yourdomain.com和www.yourdomain.comCookie的domain属性没设置成yourdomain.com就会出现“登录成功、跳转即掉线”的灵异事件。解决方式是统一Cookie的domain或者干脆只保留一个主子域名。如果是双token方案这个问题主要发生在refreshToken续期失败时——续期接口返回401前端拿不到新accessToken刷新页面后就只能重新登录。重点检查refreshToken的存储位置是否跨了域。5.3 问题三多设备登录时把彼此挤下线这是很多登录系统第一个版本就会踩的坑签发refreshToken用的key是refresh:${userId}同一个人在两台设备登录第二台会把第一台的token覆盖掉表现为“两台设备互相踢”。正确做法是在redis key里加入一个随机标识比如refresh:${userId}:${uuid}每个session独立存在。如果要做“同一账号最多同时登录N个设备”的约束才需要按userId去维护一个登录session列表每次新登录时检查数量上限超出就按最早登录的session先踢。5.4 问题四JWT密钥泄露或需要轮换生产环境JWT_SECRET一旦泄露攻击者可以自己伪造任意用户身份的token。早期项目通常容易把密钥硬编码在代码里这等于把保险柜钥匙放在保险柜旁边。建议把JWT_SECRET放到环境变量或密钥管理服务里同时加入密钥版本号。JWT的payload里增加一个kid字段标识使用哪个密钥版本签发。轮换时新签发的token用新版本旧的token在验签时根据kid找到旧密钥验证保证不会突然把所有用户踢下线。5.5 问题五接口被刷、验证码形同虚设登录接口开了图形验证码但测试后发现很容易被绕过——只要用脚本先请求验证码接口识别出答案再带上验证码去登录就行了。验证码只能挡住无脑脚本挡不住专门写代码攻击的人。更有效的做法是把验证码逻辑和登录限流结合第一次登录失败不需要验证码连续失败两次后要求带验证码第五次后锁死15分钟。这样可以最大限度减少正常用户的打扰同时增加攻击者的成本。5.6 问题六用户改了密码旧的accessToken还能用accessToken的有效期是2小时用户改完密码后在2小时内旧token依然能访问接口。这在多数系统里可以接受但如果要做严格的安全控制就要在改密码时把该用户的所有accessToken和refreshToken全部吊销。实现方法是维护一个token_version字段改密码时把版本号加一JWT签发时把版本号放进payload里。中间件验签后对比当前版本号如果payload里的版本号小于数据库中的版本号就判定token已失效。5.7 问题七审计日志里分辨不出是谁操作了图纸云图库的协作特性导致同一个图可能被多个角色改过出了问题要追溯时如果日志里只有IP和操作时间排查起来非常痛苦。所以登录模块从一开始就要把userId和role塞进日志上下文里每个接口的日志都带上这两个字段。我在实现方式上是中间件把用户信息放到Node.js的请求上下文里日志打印时自动带上user_id。后续不管是排查误删、追踪导出记录还是分析协作行为这些数据都是最底层的支撑。6. 权限模型与防绕过鉴权设计里最容易忽视的几个死角6.1 三层权限模型接口级别、资源级别、字段级别登录鉴权只解决“你是不是合法用户”的问题但协图云图库里不同角色对同一份图纸的可见字段和操作范围是不同的。管理端能看到图纸的所有版本记录和团队成员信息普通协作者只能看最新版本和评论业主端甚至只能看只读预览。这三层权限逻辑不能都塞在中间件里判断否则中间件会越来越臃肿。我的做法是接口级别权限用装饰器或路由配置声明比如requiresRole(admin)资源级别权限在业务逻辑里查询资源归属再判断比如“只有图纸创建者或项目管理员能删除版本记录”字段级别权限在序列化输出时根据角色过滤敏感字段。6.2 越权操作的三种典型绕过手法协图类系统最常见的越权漏洞是水平越权普通用户可以访问同级别其他用户的私有图库。比如请求删除接口时传的是/api/drawing/123/delete如果后端只是判断“登录用户是不是有效用户”而没有校验“这个用户有没有权限操作123号图纸”任何人都可以删别人的图。另外两种是垂直越权和IDOR。垂直越权是低权限用户通过修改请求参数碰高权限接口IDOR是直接枚举资源ID批量访问他人数据。防越权的核心原则只有一条每一次资源操作都必须做“当前用户与目标资源的关系校验”不能只靠登录中间件兜底。6.3 前端按钮级权限不能替代后端校验前端可以按角色隐藏“删除”按钮但后端绝对不能在鉴权时假设“前端没显示就不会发请求”。攻击者直接用curl调接口绕过前端界面是零成本的。所以所有敏感操作的后端必须再次鉴权类似于“前端是安检闸机的一次预检后端才是通行证真正的签发点”。我在实现时专门写了一套接口测试用例覆盖“viewer角色的用户直接调用删除接口”“未登录用户访问私有图库详情”“普通成员篡改项目配置”等场景每一条都验证返回403或401确保后端鉴权不是空架子。6.4 操作审计与“无限授权”场景的应对标题里提到的“无限授权鉴权系统”其实指向一类真实需求在某些协作场景里管理员希望给外部合作方开一个不过期的访问入口但又不想让对方拿到正式账号。这种场景用传统的“账号密码登录”做不了必须引入临时授权令牌或访客链接机制。我的做法是增加一种guest角色管理员可以生成一次性访客链接链接自带一个专门的short-lived token只有查看特定图库的权限过期后自动失效。这个访客token不走refreshToken体系到期即死无法续期最大程度缩小暴露面。操作审计日志同样记录访客的访问行为保证出了问题也能回溯到是哪个链接泄露了。7. 实测验证用一份联调记录检验整个鉴权链路7.1 联调用例设计清单第三天的最后阶段我整理了一份登录鉴权的联调用例清单覆盖正常流程和异常流程。分享出来供大家参考用例编号场景预期结果AUTH-01正确邮箱正确密码登录返回200拿到双tokenAUTH-02错误邮箱正确密码登录返回401统一错误提示AUTH-03正确邮箱错误密码登录返回401统一错误提示AUTH-04连续5次错误密码登录返回429触发限流AUTH-05accessToken过期后访问受保护接口返回401AUTH-06过期后用refreshToken续期返回200拿到新accessTokenAUTH-07使用已被吊销的refreshToken续期返回401强制重新登录AUTH-08viewer角色的用户调用删除接口返回403禁止访问AUTH-09未登录访问受保护接口返回401AUTH-10访客链接过期后访问图库返回403链接失效7.2 双token轮换的压力测试结果我用wrk对登录接口做了个简单压测并发50个线程持续60秒。bcrypt的cost设为10时单机TPS大概在480左右比明文比对低很多但这属于预期内的代价——每秒钟能撑起400多次登录尝试对云图库这个体量完全够用。反而容易忽略的是Redis的性能。每次登录要做4次Redis读写登录计数、token白名单、refreshToken存储、限流每个受保护接口要做2次Redis查询白名单校验、token状态检查。所以Redis的延迟直接决定了接口的整体延迟我在联调时把Redis从默认的局域网直连改为走连接池单个操作延迟稳定在0.3ms左右。7.3 安全自查清单上线前先过一遍这份清单最后分享一个我每次做登录鉴权都要过一遍的自查清单密码存储是否使用bcrypt/scrypt/argon2而不是MD5或SHA登录错误提示是否统一不会泄露账号是否存在登录接口是否做了IP账号双层限流accessToken的有效期是否在2小时内refreshToken是否支持主动吊销用户改密码后旧token是否能立即失效水平越权和垂直越权的测试用例是否覆盖前端隐藏按钮是否在后端同样做了权限校验日志是否记录了userId和role密钥是否通过环境变量注入而不是硬编码在代码里这套清单看着简单但每条背后都是我或者身边的同行踩过的真实深坑。比如第4条我见过把accessToken有效期设成30天的理由是“用户嫌频繁登录烦”结果token泄露后整整一个月内攻击者都可以自由进出系统。第7条很多团队在测试时只测了正常流程没测越权场景上线不到一周就被用户反馈“我能看到别的公司的图纸”那种事故的善后成本远高于前期写几条测试用例。8. 写在最后的实操心得第三天把登录与鉴权整个链路重新落地一遍最大的体会是登录功能看着简单真正做好做稳是需要宏观设计能力的。Session方案在单体时代确实是银弹但到了多端、多实例、高协作的场景主动引入双token机制、把accessToken和refreshToken分离、让每种token都保持可控的生命周期这套架构带来的价值是立竿见影的。如果你也在做类似的系统我建议不要跳过“临时授权”这个需求的设计哪怕第一版并没有外部协作方。因为图库类产品的本质是“让一群人围绕同一批文件高效协作”而协作的前提是安全的边界。访客链接、临时授权、角色权限这些设计越早想清楚后面扩展成本越低。还有一点是我个人强烈建议从第一天就坚持的所有鉴权日志必须带上userId和role宁可多打一条日志也不要在出问题时对着海量IP地址大海捞针。
返回列表