从认证走到授权: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 安全没有银弹,但有套路:
- 先搞清楚场景,再选方案
- 认证授权分开,JWT 认证,RBAC/ABAC 授权
- HTTPS 是底线,不能省
- Token 短期,refresh token 长期,登出能注销
- 网关统一处理,后端信任网关
- 限流防刷,IP+用户双维度
- 日志和监控,异常能及时发现
做 API 安全跟修房子一样,先确认进来的是谁,再确认他该干什么,最后把门锁好。没有什么绝对安全的系统,但至少可以让它安全到大多数黑客觉得划不来。
可用性说明:本文发布于 2021 年 6 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-从认证走到授权:API安全笔记(https://blog.thinkmoon.cn/post/132-api-security-auth-authorization-practice-guide/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。