从认证走到授权:API安全笔记

认证解决了"你是谁"的问题,授权要解决"你能干什么"。

事情从哪里开始

原来的系统是单体应用,用户登录后存个 session,所有接口走同一个域,身份问题不复杂。拆成微服务后,服务之间要互相调用,客户端要访问多个服务,session 机制开始扛不住了。OAuth2+JWT 成了顺理成章的选择,但选方案之前得先搞清楚要解决什么问题。

如果你也在纠结选哪种方案,先问自己三个问题:

  • 谁在访问 API?是内部服务、前端应用,还是第三方集成?
  • 访问的粒度要细到什么程度?是只区分登录/未登录,还是需要接口级别的权限控制?
  • 你的团队能接受多大的复杂度?OAuth2 四种模式,JWT 配合,API 网关,每一层都增加维护成本。

想清楚这三件事,后面的事情会简单很多。

认证:先确认身份

JWT 是现在最常用的方案,但用对了才算用对。最常见的问题是 token 过期时间设太长、payload 里塞太多信息、签名算法用错。这些都是真事,我都遇到过。

JWT 基础配置

我们用的环境是 Node.js + Express,token 生成用 jsonwebtoken:

const jwt = require('jsonwebtoken');

// 生成 token
function generateToken(user) {
  const payload = {
    userId: user.id,
    email: user.email,
    roles: user.roles, // 注意:roles 不要太大
  };

  const options = {
    expiresIn: '15m', // 短期 token,15分钟够用了
    issuer: 'your-domain.com',
    audience: 'your-api',
  };

  return jwt.sign(payload, process.env.JWT_SECRET, options);
}

// 验证 token
function verifyToken(token) {
  try {
    return jwt.verify(token, process.env.JWT_SECRET);
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      throw new Error('Token expired');
    }
    throw new Error('Invalid token');
  }
}

这里踩的第一个坑是 payload 太大。刚开始把用户资料都塞进去,token 长到快 4KB,每次请求 header 都传这么大,后来才意识到这事儿有多蠢。只放必要信息:用户 ID、角色、够用的标识就行。

第二个坑是过期时间。最开始设了 24 小时,结果用户登出后 token 依然有效,安全上没处理完。后来改成了 15 分钟短期 token + refresh token 的组合,每次请求都会判断 token 是否快过期,快了就自动续。

Token 刷新机制

短 token 需要配个刷新接口,这个接口要用长期的 refresh token:

// 刷新 token
function refreshAccessToken(refreshToken) {
  try {
    // refresh token 验证逻辑,通常查数据库
    const user = verifyRefreshToken(refreshToken);

    // 生成新的 access token
    return generateToken(user);
  } catch (err) {
    throw new Error('Invalid refresh token');
  }
}

refresh token 存数据库里,用户登出时就删掉。这样能保证用户主动登出时旧 token 立刻失效,而不等它自然过期。

中间件使用

Express 中间件这么写:

function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');

  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  try {
    const decoded = verifyToken(token);
    req.user = decoded; // 把用户信息挂到 req 上
    next();
  } catch (err) {
    res.status(401).json({ error: 'Invalid token' });
  }
}

// 使用
app.get('/api/profile', authMiddleware, (req, res) => {
  res.json({ userId: req.user.userId });
});

中间件加在每个需要认证的路由上,简单直接。

授权:确认能干什么

认证解决了"你是谁"的问题,授权要解决"你能干什么"。最简单的是角色授权,复杂一点的要细到接口级别。

角色授权

function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user.roles || !req.user.roles.some(role => roles.includes(role))) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

// 使用:只有 admin 能访问
app.delete('/api/users/:id', authMiddleware, requireRole('admin'), deleteUser);

这个够用了,但有个问题:角色是静态的,想动态改权限就麻烦。后来我们加了权限表,角色映射到具体权限:

async function requirePermission(permission) {
  return async (req, res, next) => {
    // 查数据库,判断用户是否有这个权限
    const hasPermission = await checkPermission(req.user.userId, permission);
    if (!hasPermission) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

// 使用:需要有 delete_user 权限
app.delete('/api/users/:id', authMiddleware, requirePermission('delete_user'), deleteUser);

动态授权灵活,但数据库查询多了一次。权衡之后决定对高频接口用角色授权,低频用动态权限。

OAuth2:第三方集成绕不过

如果要接入第三方登录,OAuth2 绕不开。我们用的是 GitHub 登录,流程相对简单。

OAuth2 授权码模式

// 1. 重定向到授权页面
app.get('/auth/github', (req, res) => {
  const params = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID,
    redirect_uri: `${process.env.BASE_URL}/auth/github/callback`,
    scope: 'read:user user:email',
    response_type: 'code',
  });

  res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});

// 2. 回调处理
app.get('/auth/github/callback', async (req, res) => {
  const { code } = req.query;

  // 用 code 换 access_token
  const tokenResponse = await axios.post('https://github.com/login/oauth/access_token', {
    client_id: process.env.GITHUB_CLIENT_ID,
    client_secret: process.env.GITHUB_CLIENT_SECRET,
    code,
  }, {
    headers: { Accept: 'application/json' },
  });

  const accessToken = tokenResponse.data.access_token;

  // 用 access_token 获取用户信息
  const userResponse = await axios.get('https://api.github.com/user', {
    headers: { Authorization: `Bearer ${accessToken}` },
  });

  // 在这里处理用户注册/登录逻辑,生成自己的 JWT
  const token = generateTokenForGithubUser(userResponse.data);

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

OAuth2 的坑在回调 URL 必须匹配、client_secret 不能泄露、state 参数防 CSRF。这三个点都遇到过问题,后来都解决了。

API 网关:统一处理入口

微服务架构下,每个服务都要处理认证授权重复且麻烦,网关就成了必然选择。我们用 Nginx + Lua 做的网关,简单又够用。

Nginx JWT 认证

location /api/ {
  access_by_lua_block {
    local jwt = require "resty.jwt"
    local jwt_token = ngx.var.http_authorization

    if not jwt_token then
      ngx.status = 401
      ngx.say('No token provided')
      ngx.exit(401)
    end

    -- 去掉 "Bearer " 前缀
    local token = jwt_token:match("^Bearer%s+(.+)$")
    if not token then
      ngx.status = 401
      ngx.say('Invalid token format')
      ngx.exit(401)
    end

    -- 验证 token
    local jwt_obj = jwt:verify("your-secret", token)
    if not jwt_obj.valid then
      ngx.status = 401
      ngx.say('Invalid token')
      ngx.exit(401)
    end

    -- 把用户信息放到 header 里转发给后端
    ngx.req.set_header("X-User-Id", jwt_obj.payload.userId)
    ngx.req.set_header("X-User-Email", jwt_obj.payload.email)
  }

  proxy_pass http://backend/;
}

网关统一处理认证,后端服务只需要信任 header 里的用户信息就行了。这样做的好处是逻辑集中,改一次就行。

限流配置

# 定义限流规则
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

location /api/ {
  # 每秒 100 个请求
  limit_req zone=api_limit burst=20 nodelay;
  # 每个 IP 最多 50 个并发连接
  limit_conn conn_limit 50;

  # 限流返回 429
  limit_req_status 429;
  limit_conn_status 429;

  proxy_pass http://backend/;
}

限流防刷这块,IP 限流最简单,但容易误伤。后来加了个用户级别的限流,用 token 里的 userId 作为 key:

limit_req_zone $http_x_user_id zone=user_limit:10m rate=200r/s;

两个规则都配上,既能防刷又不会误伤。

踩过的几个坑

HTTPS 不是可选的

刚开始在开发环境用 HTTP 测试,结果发现 token 很容易被中间人抓到。后来强制所有接口走 HTTPS,开发环境也用自签名证书。

生产环境不要省证书的钱,Let’s Encrypt 免费的够用了。

Token 存放位置

token 放 localStorage 还是 sessionStorage?其实都不太安全,XSS 攻击时都能被盗。后来决定用 httpOnly cookie:

// 登录成功后设置 cookie
res.cookie('token', token, {
  httpOnly: true,
  secure: true, // HTTPS only
  sameSite: 'strict',
  maxAge: 15 * 60 * 1000, // 15分钟
});

httpOnly cookie 不会被 JavaScript 访问,XSS 攻击时偷不到。但这有个问题:CSRF 攻击。后来加了 CSRF token:

app.use(csrf({ cookie: true }));

app.get('/csrf-token', (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

前端每次请求都带上 CSRF token,双保险。

签名算法别用 none

JWT 有个坑是允许算法为 none,也就是不验证签名。黑客可以伪造 token,把算法改成 none 就能通过验证。

// 不要这样
const decoded = jwt.verify(token, secret, { algorithms: ['none'] });

// 要这样
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });

验证时明确指定算法,不要让它自动推断。

日志里不要打印 token

调试时容易把 token 打印到日志里,这是安全隐患。如果必须打印,至少截断一下:

console.log('Token:', token.substring(0, 10) + '...');

或者干脆不要打日志。

该到什么时候上 OAuth2

不是所有场景都得上 OAuth2。如果只是前后端分离、单点登录,JWT 自己就够用了。OAuth2 复杂度高,要维护 client_id、client_secret、授权服务器,维护成本不小。

什么时候该上 OAuth2:

  • 第三方要集成你的 API,需要统一的授权机制
  • 多个系统要共享用户身份,SSO 是刚需
  • 你要给第三方应用开放平台能力

如果只是自己的前端应用,简单点,够用就行。

最后一点建议

API 安全没有银弹,但有套路:

  1. 先搞清楚场景,再选方案
  2. 认证授权分开,JWT 认证,RBAC/ABAC 授权
  3. HTTPS 是底线,不能省
  4. Token 短期,refresh token 长期,登出能注销
  5. 网关统一处理,后端信任网关
  6. 限流防刷,IP+用户双维度
  7. 日志和监控,异常能及时发现

做 API 安全跟修房子一样,先确认进来的是谁,再确认他该干什么,最后把门锁好。没有什么绝对安全的系统,但至少可以让它安全到大多数黑客觉得划不来。

可用性说明:本文发布于 2021 年 6 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。

版权声明: 本文首发于 指尖魔法屋-从认证走到授权:API安全笔记https://blog.thinkmoon.cn/post/132-api-security-auth-authorization-practice-guide/) 转载或引用必须申明原指尖魔法屋来源及源地址!