前端转 Agent 开发之后端知识 - 03. 认证与授权

6 阅读8分钟

03. 认证与授权

前端常把“登录”理解成拿到一个 token。后端必须再往前一步:你要分清用户是谁、他能做什么、这套机制怎么失效和续期。

先分清两个词

  • 认证(Authentication):确认“你是谁”。
  • 授权(Authorization):确认“你能做什么”。

一条登录链路最少包含什么

  1. 用户提交账号密码。
  2. 服务端校验密码是否正确。
  3. 服务端签发会话凭证。
  4. 后续请求携带凭证访问受保护接口。
  5. 服务端验证凭证并恢复用户身份。
  6. 鉴权逻辑再判断这个身份有没有权限访问当前资源。

必懂 6 件事

1. 密码不是直接比对明文

密码存储必须是“哈希后再存”,而不是明文。

import bcrypt from 'bcryptjs';

const passwordHash = await bcrypt.hash(plainPassword, 12);
await db.user.create({
  data: {
    email,
    passwordHash,
  },
});

const isValid = await bcrypt.compare(inputPassword, user.passwordHash);
if (!isValid) {
  throw new Error('invalid credentials');
}

关键点:

  • bcryptargon2 这类专门为密码设计的算法
  • 不要自己发明“md5 + salt”这种方案
  • 不要把密码写进日志
2. Session / Cookie
  • Cookie 存在浏览器里,Session 一般存在服务端。
  • 服务端可以随时让某个 Session 失效,控制力强。
  • 缺点是服务端要保存状态,扩容时要考虑共享存储。

典型流程:

  1. 登录成功后,服务端生成 sessionId
  2. sessionId 写到 Redis 或数据库
  3. 用 Cookie 把 sessionId 发给浏览器
  4. 后续请求再从 Cookie 中读出 sessionId

Session 方案的优点是“可控”,例如退出登录时可以立即删除服务端会话。

3. JWT
  • JWT 是服务端签发的令牌,服务端可直接验证,不必每次查 Session。
  • 优点是简单、适合前后端分离和多端接入。
  • 缺点是撤销和续期更麻烦,所以不要把 JWT 当成万能方案。

这里默认说的 JWT,通常指的是 access token,不是 refresh token

先看一条最小链路:

  1. 用户提交账号密码
  2. 服务端校验密码
  3. 服务端签发 access token
  4. 客户端把它放到 Authorization: Bearer ...
  5. 服务端在受保护接口里校验 JWT
  6. JWT 过期后,客户端再用 refresh token 换新的 access token

一个最小的登录签发示例:

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

async function login(email: string, password: string) {
  const user = await db.user.findUnique({
    where: { email },
  });

  if (!user) {
    throw new UnauthorizedError('invalid credentials');
  }

  const ok = await bcrypt.compare(password, user.passwordHash);
  if (!ok) {
    throw new UnauthorizedError('invalid credentials');
  }

  const accessToken = jwt.sign(
    {
      sub: user.id,
      roles: user.roles,
      tokenVersion: user.tokenVersion,
      jti: crypto.randomUUID(),
    },
    process.env.JWT_SECRET!,
    {
      expiresIn: '15m',
      issuer: 'tech-articles',
      audience: 'tech-articles-web',
    },
  );

  return { accessToken };
}

JWT 校验示例:

import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';

export function authMiddleware(req: Request, _res: Response, next: NextFunction) {
  try {
    const authHeader = req.header('Authorization');
    const token = authHeader?.replace('Bearer ', '');

    if (!token) {
      throw new Error('missing token');
    }

    const payload = jwt.verify(token, process.env.JWT_SECRET!);
    req.user = payload;
    next();
  } catch {
    next(new UnauthorizedError('invalid token'));
  }
}

JWT 里应该放什么:

  • 用户唯一标识,例如 sub
  • 极少量权限信息,例如 roles
  • 少量与登录态强相关的信息,例如 tokenVersion
  • 过期时间、签发方等标准字段

JWT 里不应该放什么:

  • 密码
  • 大量敏感业务数据
  • 需要频繁变更的状态

常见字段解释:

  • sub:谁登录了
  • exp:什么时候过期
  • iss:谁签发的
  • aud:给谁用的
  • jti:这张 token 的唯一 ID

JWT 的优点是“无状态校验快”,但代价是“发出去后不好撤销”。所以它很适合短期 access token,不适合拿来当永久登录态。

4. Refresh Token 与退出登录

如果 access token 很短,就通常要配 refresh token。

要先明确一件事:

  • refresh token 通常不是“写在 JWT 里面的一个字段”
  • refresh token 是登录成功后另外签发的一张独立凭证
  • 它可以是随机字符串,也可以本身也是一个 JWT,但它和 access token 不是同一个东西

推荐思路:

  • access token:短期有效,例如 15 分钟
  • refresh token:更长有效期,例如 7 天
  • refresh token 存库或存 Redis,支持吊销

登录成功时,服务端通常会同时签发两样东西:

{
  "accessToken": "eyJhbGciOi...",
  "refreshToken": "a2d9b8c7-6e5f-4a3b-9c1d-123456789abc"
}

或者更常见一点:

  • access token 放在响应体里,给前端带到 Authorization 头里
  • refresh token 放进 HttpOnly Cookie

刷新流程通常长这样:

  1. access token 过期
  2. 客户端调用 /auth/refresh
  3. 服务端校验 refresh token
  4. 校验通过后重新签发新的 access token
  5. 必要时轮换 refresh token

登录时生成 refresh token:

const refreshToken = randomUUID();

await redis.set(
  `refresh-token:${refreshToken}`,
  JSON.stringify({ userId: user.id }),
  'EX',
  7 * 24 * 60 * 60,
);

刷新接口伪代码:

app.post('/auth/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) {
    throw new UnauthorizedError('missing refresh token');
  }

  const session = await redis.get(`refresh-token:${refreshToken}`);
  if (!session) {
    throw new UnauthorizedError('invalid refresh token');
  }

  const { userId } = JSON.parse(session);
  const accessToken = jwt.sign(
    { sub: userId },
    process.env.JWT_SECRET!,
    { expiresIn: '15m' },
  );

  res.json({ accessToken });
});

更稳的做法通常还会“轮换 refresh token”:

  1. 校验旧 refresh token
  2. 删除旧 refresh token
  3. 生成新 refresh token
  4. 重新写入 Redis 或数据库
  5. 返回新的 access token 和 refresh token

这样可以降低 refresh token 泄露后的复用风险。

JWT 方案怎么退出登录,要分 3 层看:

  1. 普通退出登录
  2. 立即让当前 access token 失效
  3. 让所有设备全部下线

最小的退出登录一般是:

  • 删除服务端保存的 refresh token
  • 清掉客户端的 cookie 或本地存储
  • 让现有 access token 自然过期

最小示例:

app.post('/auth/logout', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (refreshToken) {
    await redis.del(`refresh-token:${refreshToken}`);
  }

  res.clearCookie('refreshToken');
  res.status(204).end();
});

如果你要“立即让老 access token 失效”,常见有两种办法:

  • 黑名单:把 jti 记到 Redis,直到 token 过期
  • tokenVersion:用户退出所有设备时,把数据库里的 tokenVersion 加 1

tokenVersion 思路:

await db.user.update({
  where: { id: userId },
  data: { tokenVersion: { increment: 1 } },
});

之后每次鉴权时,把 JWT 里的 tokenVersion 和数据库当前值比一下,不一致就视为失效。

一句话总结:

  • Session 方案:删除 session 就能立刻失效
  • JWT 方案:通常是删 refresh token,并让 access token 短期自然过期
  • 高安全场景再叠加黑名单或 tokenVersion
5. OAuth 2.0
  • 当你需要“用第三方身份登录”或“授权第三方访问资源”时,才需要理解它。
  • 如果你只是做自己系统的账号登录,先把 Session 或 JWT 学明白更重要。

前端工程师最常遇到的是“用 GitHub / Google / 微信登录”这类场景。更准确地说,这通常是:

  • OAuth 2.0:解决授权
  • OpenID Connect:在 OAuth 2.0 上补“用户身份”能力

现代 Web / App 最常用的是 Authorization Code + PKCE

先看参与方:

  • 用户
  • 你的应用
  • 第三方授权服务器
  • 第三方资源服务器,例如 /userinfo

完整流程是:

  1. 用户跳到第三方授权页
  2. 你的应用带上 client_idredirect_uriscopestatecode_challenge
  3. 用户在第三方登录并同意授权
  4. 第三方回调你的服务,带回 code
  5. 你的服务用 code + code_verifier 去换 token
  6. 第三方返回 access_token,OIDC 场景下还会返回 id_token
  7. 你的服务再拉取用户信息并建立本地账户
  8. 你的系统最后签发自己的 session 或 JWT

如果你只是做自家账号体系,不用先陷进 OAuth 2.0 细节。

授权跳转大概长这样:

GET https://provider.com/oauth/authorize?
  response_type=code&
  client_id=xxx&
  redirect_uri=https://your-app.com/callback&
  scope=openid profile email&
  state=abc123&
  code_challenge=yyy&
  code_challenge_method=S256

回调后换 token:

POST https://provider.com/oauth/token
grant_type=authorization_code
code=returned_code
redirect_uri=https://your-app.com/callback
client_id=xxx
client_secret=yyy
code_verifier=original_verifier

回调处理伪代码:

async function oauthCallback(code: string, codeVerifier: string) {
  const tokenSet = await exchangeCodeForToken(code, codeVerifier);
  const profile = await fetchUserProfile(tokenSet.access_token);

  let user = await db.user.findUnique({
    where: {
      provider_providerUserId: {
        provider: 'google',
        providerUserId: profile.id,
      },
    },
  });

  if (!user) {
    user = await db.user.create({
      data: {
        email: profile.email,
        provider: 'google',
        providerUserId: profile.id,
      },
    });
  }

  return issueLocalTokens(user);
}

这里最重要的判断是:

  • 第三方 token 是用来和第三方通信的
  • 你的系统内部,最好还是签发你自己的 session 或 JWT

这样你的权限体系、退出登录逻辑和用户模型才不会被第三方绑死。

还要记住 4 个关键参数:

  • state:防 CSRF,回调时必须校验
  • scope:最小权限原则
  • redirect_uri:必须严格匹配
  • code_verifier / code_challenge:PKCE 防授权码被截获

OAuth 2.0 的退出登录也要分两层:

  • 退出你自己的系统
  • 是否还要退出第三方登录状态

通常先做的是:

  1. 删除你本地的 session / refresh token
  2. 清掉本地 cookie
  3. 如有保存第三方 refresh token,则同时删除或撤销

但这不等于把 Google / GitHub 本身也登出。用户浏览器里如果第三方仍然在线,下次点“第三方登录”仍可能直接回来。

6. RBAC
  • RBAC 是基于角色的授权控制。
  • 常见做法是用户拥有角色,角色拥有权限,接口再校验权限。
  • 不要把“是否登录”混同于“是否有权限”。

一个最小角色校验中间件:

export function requireRole(...roles: string[]) {
  return (req, _res, next) => {
    const userRoles = req.user?.roles ?? [];
    const allowed = roles.some((role) => userRoles.includes(role));

    if (!allowed) {
      return next(new ForbiddenError('permission denied'));
    }

    next();
  };
}

安全底线

  • 不信任客户端传来的任何身份和角色字段。
  • 在系统边界做输入校验,而不是只靠前端表单规则。
  • 密码要哈希存储,不要明文保存。
  • 敏感接口要有过期、续期、退出登录和审计意识。
  • Cookie 方案要配合 HttpOnlySecureSameSite
  • JWT 方案要控制有效期,不要默认发超长生命周期 token

最小实践

  • 做注册、登录、刷新 token、获取当前用户信息这 4 个接口。
  • 登录后签发 access token;受保护接口必须校验 token。
  • 给管理接口加一个最简单的角色判断,例如 admin

常见误区

  • 只验证 token,不验证用户状态。用户被封禁后,老 token 仍可能继续使用。
  • 把整个用户对象塞进 JWT。令牌会过大,也不利于状态收敛。
  • 用一个长期不失效的 JWT 试图解决所有登录问题。实际只会放大安全风险。

学会的标准

  • 你能说明 Session 和 JWT 的主要取舍。
  • 你能区分“没登录”和“没权限”。
  • 你知道认证方案的核心不是 token 名字,而是状态管理和安全边界。
  • 你能写出最小的登录、验 token、刷新 token、角色校验代码。