小Cの已经记不起来的博客

从零实现一个简易的鉴权体系

最近在折腾一个小项目,本来只想加个登录功能,结果翻了翻现成的鉴权方案,要么是一整套 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 天,结果发现这玩意要么太好用、要么太危险,中间没有舒服的点。后来改成了两种票:

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,能挡掉大部分场景,剩下的没挡住的,就认了。

最后

整套东西写下来不到两百行,逻辑干净,出问题的时候基本一眼就能定位到是签名、过期还是密码没对上。它当然不够完善,没有限流、没有防爆破、没有多设备管理,但作为一个“理解了原理”的起点,我觉得挺值的。

真上生产的话,还是去看看成熟方案吧,密码学和边界情况这两块,自己造轮子是真的会翻车的。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放