文章 / 全栈

全栈项目的第一道坎:接口鉴权怎么设计才不返工

自己写全栈项目,前端画页面不难,难的是登录。我第一版把 token 塞进 localStorage,过期时间设了七天,看起来能跑。直到有次线上排查 XSS,发现任何人都能用一段脚本把这七天的凭证捞走——那之后我把整套方案重做了。

结论先放这:access token 短命且无状态,refresh token 长命且可撤销,两个都放 HttpOnly Cookie。

为什么是两个 Token

单 token 的死结在于:想安全就得短,想体验好就得长,两者不能兼得。

拆成两个之后,各自只干一件事:

access tokenrefresh token
有效期15 分钟14 天
存放HttpOnly CookieHttpOnly Cookie
校验方式验签即可,不查库查库,且用完即换
泄露后果15 分钟内有效可立即吊销

15 分钟意味着即使被截获,窗口也很小;而长期凭证能随时被服务端注销,用户改密码、退出登录都能立刻生效。

签发逻辑

import jwt from 'jsonwebtoken';
import crypto from 'node:crypto';

const ACCESS_SECRET = process.env.ACCESS_SECRET;
const ACCESS_TTL = '15m';
const REFRESH_TTL_DAYS = 14;

function issueAccessToken(user) {
  return jwt.sign(
    { sub: String(user.id), role: user.role },
    ACCESS_SECRET,
    { expiresIn: ACCESS_TTL, algorithm: 'HS256' }
  );
}

async function issueRefreshToken(db, userId, meta) {
  // 存哈希,不存明文:库被拖了也换不出可用凭证
  const raw = crypto.randomBytes(32).toString('base64url');
  const hash = crypto.createHash('sha256').update(raw).digest('hex');

  await db.insert('refresh_tokens', {
    user_id: userId,
    token_hash: hash,
    user_agent: meta.userAgent,
    ip: meta.ip,
    expires_at: new Date(Date.now() + REFRESH_TTL_DAYS * 864e5),
  });

  return raw;
}

两个细节值得强调。第一,refresh token 用随机串而不是 JWT,因为它本来就要查库,没必要再背一份签名开销。第二,入库前先哈希,这点和存密码是同一个道理。

Cookie 配置

Cookie 配置错了,前面的设计全白搭:

const cookieBase = {
  httpOnly: true,                          // JS 读不到,XSS 拿不走
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',                         // 防 CSRF 的第一道闸
  path: '/',
};

res.cookie('at', accessToken, { ...cookieBase, maxAge: 15 * 60 * 1000 });
res.cookie('rt', refreshToken, { ...cookieBase, path: '/api/auth/refresh', maxAge: 14 * 864e5 });

注意 rt 的 path 收窄到了刷新接口。这样浏览器只会在这一个请求上带 refresh token,其它接口即便被注入脚本也没法顺手读走它。

sameSite: 'lax' 覆盖了绝大多数场景。如果你的前端和后端不同域,需要 sameSite: 'none' + secure: true,这时候务必补上 CSRF token,因为浏览器的默认防护已经让位了。

校验中间件

function requireAuth(req, res, next) {
  const token = req.cookies?.at;
  if (!token) return res.status(401).json({ code: 'NO_TOKEN' });

  try {
    // 显式限定算法,堵掉 alg 伪造
    const payload = jwt.verify(token, ACCESS_SECRET, { algorithms: ['HS256'] });
    req.user = { id: Number(payload.sub), role: payload.role };
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ code: 'BAD_TOKEN' });
  }
}

algorithms: ['HS256'] 这一行是必须写的。早期 JWT 库读到 alg: none 会直接放行,虽然现在默认拦了,但显式声明总归是零成本的保险。

区分 TOKEN_EXPIRED 和 BAD_TOKEN 也有用:前端只在前者触发刷新,后者直接清凭证跳登录,避免拿伪造 token 去反复打刷新接口。

刷新时的旋转

refresh token 每用一次就作废,换一个新的,这是拦截重放的关键:

router.post('/refresh', async (req, res) => {
  const raw = req.cookies?.rt;
  if (!raw) return res.status(401).json({ code: 'NO_REFRESH' });

  const hash = crypto.createHash('sha256').update(raw).digest('hex');
  const row = await db.findRefreshToken(hash);

  if (!row) return res.status(401).json({ code: 'REFRESH_REVOKED' });

  if (row.used_at) {
    // 同一个 token 被用了两次:大概率是泄露后被重放,全量吊销
    await db.revokeAllForUser(row.user_id);
    return res.status(401).json({ code: 'REPLAY_DETECTED' });
  }

  if (row.expires_at < new Date()) {
    return res.status(401).json({ code: 'REFRESH_EXPIRED' });
  }

  await db.markUsed(row.id);
  const user = await db.findUser(row.user_id);
  const next = await issueRefreshToken(db, user.id, {
    userAgent: req.get('user-agent'),
    ip: req.ip,
  });

  res.cookie('at', issueAccessToken(user), { ...cookieBase, maxAge: 15 * 60 * 1000 });
  res.cookie('rt', next, { ...cookieBase, path: '/api/auth/refresh', maxAge: 14 * 864e5 });
  res.json({ ok: true });
});

row.used_at 那段是整套方案里我最喜欢的设计:攻击者拿到旧 token 来刷新,说明真凭证已经泄露,这时候全量吊销并让用户重新登录,比单纯拒绝请求更安全。

前端的并发问题

页面初始加载时并发发五个请求,access token 恰好过期,五个 401 一起打到刷新接口——这会导致第一个请求刷新成功、后四个拿着已作废的 refresh token 触发重放检测,用户被莫名其妙踢下线。

解决办法是让刷新请求全局唯一:

let refreshing = null;

async function request(url, options = {}) {
  let res = await fetch(url, { ...options, credentials: 'include' });
  if (res.status !== 401) return res;

  const body = await res.clone().json().catch(() => ({}));
  if (body.code !== 'TOKEN_EXPIRED') throw new Error('未登录');

  // 同一时刻只允许一个刷新在飞,其余请求等它
  refreshing ??= fetch('/api/auth/refresh', {
    method: 'POST',
    credentials: 'include',
  }).finally(() => { refreshing = null; });

  await refreshing;
  return fetch(url, { ...options, credentials: 'include' });
}

refreshing ??= ... 加 finally 清空,就是最简的"单飞 + 复用"写法。注意重试只做一次,否则刷新接口本身 401 会变成死循环。

数据库表

CREATE TABLE refresh_tokens (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash  CHAR(64) NOT NULL UNIQUE,
  user_agent  TEXT,
  ip          INET,
  used_at     TIMESTAMPTZ,
  revoked_at  TIMESTAMPTZ,
  expires_at  TIMESTAMPTZ NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_rt_user ON refresh_tokens (user_id) WHERE revoked_at IS NULL;
CREATE INDEX idx_rt_expires ON refresh_tokens (expires_at);

保留 user_agent 和 ip 是为了做设备管理——用户能看到"我的登录设备",可疑的直接踢掉。清理过期记录挂个定时任务就行,别在请求路径上做。

上线前的检查项

  • access token 有效期不超过 30 分钟
  • refresh token 存哈希,且每次使用后旋转
  • 两个 Cookie 都是 httpOnly,生产环境都带 secure
  • refresh token 的 path 收窄到刷新接口
  • jwt.verify 显式指定 algorithms
  • 前端刷新做单飞,且只重试一次
  • 提供"退出所有设备",能全量吊销

这套方案跑了大半年没返工。它不复杂,复杂的是那些一开始图省事留下的口子——补起来比重写还累。