从零实现一个简易的鉴权体系
最近在折腾一个小项目,本来只想加个登录功能,结果翻了翻现成的鉴权方案,要么是一整套 OAuth 流程,要么直接上 SSO、OIDC,配置文件和概念堆得比我整个项目的代码还多。然后我就想,算了,自己写一个吧,反正就是“注册、登录、证明你是谁”这三件事。
下面这套东西不是拿来上生产的,但它确实能跑,而且整个过程能让你把鉴权到底在干什么搞明白。我做的时候是用的 Node.js,不过思路换成任何语言都一样。
先想清楚要解决什么问题
我给自己列了个最小的需求清单:
- 用户能注册,密码不能明文存
- 用户能登录,登录后拿到一个凭证
- 之后的每个请求带着这个凭证,服务端能认出来是谁
- 凭证会过期
- 用户能主动退出
就这五条。剩下的多设备登录、单点踢人、权限粒度这些,先不管,因为它们只是在这个骨架上加东西,先把骨架搭起来更重要。
密码不能明文存
这几乎是唯一一个我想强调“别自己发挥”的地方。哈希这块老老实实用标准库就行,Node 自带的 crypto 里就有 scryptSync,不用装依赖:
const crypto = require('crypto')
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex')
const hash = crypto.scryptSync(password, salt, 64).toString('hex')
return `${salt}:${hash}`
}
function verifyPassword(password, stored) {
const [salt, hash] = stored.split(':')
const check = crypto.scryptSync(password, salt, 64).toString('hex')
return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(check, 'hex'))
}
盐是随机生成的,每个用户一份,存的时候和哈希拼在一起。校验的时候先把盐拆出来,再用同样的盐算一遍,最后用 timingSafeEqual 比较。这里用普通 === 其实也能用,但比较时间会随着前缀匹配的长度变化,理论上能被拿来做侧信道,既然库里有现成的常量时间比较,顺手用上就行。
登录成功后,得给客户端一张票
登录成功之后,服务端不能再问你一遍密码,所以要发一张票。这张票可以是 session id,也可以是 token。我选了 token,因为不用在服务端存状态,实现起来省事。
最简单的做法是签名一个 payload。用一个只有服务端知道的密钥,对内容做 HMAC,客户端拿到内容 + 签名,下次带回来,服务端重算一遍签名比对一下就行:
function sign(payload, secret) {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
const sig = crypto.createHmac('sha256', secret).update(body).digest('base64url')
return `${body}.${sig}`
}
基本就是 JWT 的手工简化版,没别的魔法。payload 里放 uid 和 exp 就够用了,别往里塞密码、手机号之类的东西,因为它只是签名了,没有加密,base64 一解谁都能看。
校验这张票
校验要做的就三件事:拆开、重算签名、看有没有过期。
function verify(token, secret) {
const [body, sig] = String(token).split('.')
if (!body || !sig) return null
const expect = crypto.createHmac('sha256', secret).update(body).digest('base64url')
const a = Buffer.from(sig)
const b = Buffer.from(expect)
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null
const payload = JSON.parse(Buffer.from(body, 'base64url').toString())
if (payload.exp && Date.now() > payload.exp) return null
return payload
}
签名对不上就返回 null,过期也返回 null,调用方只关心“有没有拿到 payload”,逻辑会简单很多。
把它挂到路由上
有了 verify,中间件就是几行的事:
function auth(req, res, next) {
const header = req.headers.authorization || ''
const token = header.startsWith('Bearer ') ? header.slice(7) : ''
const payload = verify(token, SECRET)
if (!payload) {
return res.status(401).json({ error: 'unauthorized' })
}
req.user = payload
next()
}
app.get('/api/me', auth, (req, res) => {
res.json({ uid: req.user.uid })
})
后面这个 req.user 就是全剧终的地方,业务代码再也不用关心鉴权是怎么来的。
过期了怎么办
第一版我把有效期设成了 7 天,结果发现这玩意要么太好用、要么太危险,中间没有舒服的点。后来改成了两种票:
- access token,有效期 15 分钟,跟着每次请求走
- refresh token,有效期 7 天,只用来换新的 access token
refresh token 我在服务端存了一份,这样能主动作废。access token 就不存了,反正 15 分钟就过期了,被偷了窗口期也短。这套东西完整做起来会比上面的代码多不少,但我建议你先跑一遍单 token 的版本,知道痛点在哪,再补这一层会顺很多。
退出登录其实有点尴尬
因为我是无状态的 token,服务端其实“不知道”你退出了。用户点退出,只能让客户端把 token 删掉,服务端手上那张票在过期前依然是有效的。
如果这个不能接受,就得加一个黑名单,或者在服务端存 token 的版本号,用户退出时把版本号加一,校验的时候对比一下。我最后选了版本号,因为黑名单要处理过期清理,越写越像在重新发明 session。
几个我踩过的坑
第一个是 base64url,一开始我用的 base64,结果 token 里带上 + 和 /,塞进 URL 之后直接被截断,调了半天才发现是编码的问题。
第二个是时间单位。exp 我写的毫秒时间戳,前端那边按秒处理,差了 1000 倍,一直显示已过期,两个人都怀疑是对方的问题。
第三个是跨域。前端取 token 存哪里都别扭,localStorage 怕 XSS,httpOnly cookie 又要处理 CSRF。我最后用了 cookie + SameSite=Lax,能挡掉大部分场景,剩下的没挡住的,就认了。
最后
整套东西写下来不到两百行,逻辑干净,出问题的时候基本一眼就能定位到是签名、过期还是密码没对上。它当然不够完善,没有限流、没有防爆破、没有多设备管理,但作为一个“理解了原理”的起点,我觉得挺值的。
真上生产的话,还是去看看成熟方案吧,密码学和边界情况这两块,自己造轮子是真的会翻车的。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。