Node.js: 认证与安全
最后更新:2026-08-26
1. 故事:不设防的 API
Alice 花了两周把用户管理 API 部署上线,第二天就发现数据库被删了——她的 API 没有任何认证,任何人都能直接调用 DELETE /api/users。痛定思痛,她引入了 JWT 认证:用户必须先登录获取 Token,后续请求携带 Token 才能访问受保护路由;同时为管理员操作加了角色中间件,普通用户即使有 Token 也无法执行危险操作。一周后,她又用 helmet 给响应头加上了安全防护,API 终于固若金汤。
2. 核心概念
(1) 认证与授权的区别
- 认证(Authentication):验证"你是谁",如用户名密码校验
- 授权(Authorization):验证"你能做什么",如角色权限判断
- 两者常配合使用:先认证身份,再授权操作
(2) JWT 原理概述
- JWT(JSON Web Token)是一种无状态令牌,由三段 Base64 编码组成
- 服务端签发后无需存储,客户端保存并在请求中携带
- 适合分布式系统,避免 Session 共享问题
(3) bcrypt 密码哈希
- bcrypt 是专门为密码设计的哈希算法,内置盐值(salt)
- 可通过
cost factor调节计算耗时,抵抗暴力破解 - 同一明文每次哈希结果不同,安全性远高于 MD5/SHA
3. 技术详解
(1) JWT 的结构与工作原理
JWT 由三部分以 . 连接组成:
TEXT
📖 仅展示
Header.Payload.Signature
| 部分 | 内容 | 说明 |
|---|---|---|
| Header | { "alg": "HS256", "typ": "JWT" } |
算法与类型 |
| Payload | { "userId": 1, "role": "admin", "exp": ... } |
自定义声明 + 标准声明 |
| Signature | HMACSHA256(base64(header) + "." + base64(payload), secret) |
防篡改签名 |
工作流程:
- 用户登录,服务端验证凭据
- 验证通过,签发 JWT 返回给客户端
- 客户端将 Token 存储在 localStorage 或 cookie
- 后续请求在
Authorization: Bearer <token>头中携带 - 服务端中间件验证签名与过期时间
▶ 示例:签发与验证 JWT
JAVASCRIPT
const jwt = require('jsonwebtoken');
const SECRET = 'my_super_secret_key';
const token = jwt.sign(
{ userId: 42, role: 'admin' },
SECRET,
{ expiresIn: '2h' }
);
console.log('Token:', token);
const decoded = jwt.verify(token, SECRET);
console.log('Decoded:', decoded);
▶ 示例:解码 JWT 查看 Payload(不验证签名)
JAVASCRIPT
const decoded = jwt.decode(token, { complete: true });
console.log('Header:', decoded.header);
console.log('Payload:', decoded.payload);
(2) bcrypt 参数与使用
| 参数 | 推荐值 | 说明 |
|---|---|---|
| saltRounds | 10-12 | 计算轮次,越高越安全但越慢 |
| 密码长度 | ≥8 字符 | 配合前端校验 |
| 算法 | blowfish | bcrypt 底层算法 |
▶ 示例:密码哈希与校验
JAVASCRIPT
const bcrypt = require('bcrypt');
async function hashPassword(plainPassword) {
const saltRounds = 10;
const hash = await bcrypt.hash(plainPassword, saltRounds);
console.log('Hash:', hash);
return hash;
}
async function verifyPassword(plainPassword, hash) {
const match = await bcrypt.compare(plainPassword, hash);
console.log('Match:', match);
return match;
}
(async () => {
const hash = await hashPassword('MyPassword123');
await verifyPassword('MyPassword123', hash);
await verifyPassword('WrongPassword', hash);
})();
(3) HTTP 安全头与 helmet
helmet 通过设置 HTTP 响应头来增强安全性:
| 安全头 | 作用 | 默认启用 |
|---|---|---|
| Content-Security-Policy | 防止 XSS,限制资源加载来源 | 否(需手动配置) |
| X-Frame-Options | 防止点击劫持 | 是 |
| X-Content-Type-Options | 防止 MIME 嗅探 | 是 |
| Strict-Transport-Security | 强制 HTTPS | 是 |
| X-XSS-Protection | 浏览器 XSS 过滤 | 已弃用(helmet 不再默认启用) |
▶ 示例:集成 helmet
JAVASCRIPT
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(helmet());
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
}
}));
app.get('/', (req, res) => {
res.send('Secured with helmet!');
});
app.listen(3000);
(4) 常见安全威胁与防御
| 威胁 | 原理 | 防御手段 |
|---|---|---|
| SQL 注入 | 拼接用户输入构造恶意 SQL | 参数化查询 / ORM |
| XSS(跨站脚本) | 注入恶意脚本到页面 | 转义输出 / CSP / helmet |
| CSRF(跨站请求伪造) | 冒充已认证用户发起请求 | CSRF Token / SameSite Cookie |
| 暴力破解 | 反复尝试密码 | 限流 / bcrypt 高轮次 / 账户锁定 |
| 中间人攻击 | 拦截通信数据 | HTTPS / HSTS |
▶ 示例:防止 SQL 注入(参数化查询)
JAVASCRIPT
const { Pool } = require('pg');
const pool = new Pool();
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const result = await pool.query(
'SELECT * FROM users WHERE username = $1',
[username]
);
if (result.rows.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = result.rows[0];
const match = await bcrypt.compare(password, user.password_hash);
if (!match) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ userId: user.id, role: user.role }, SECRET, { expiresIn: '2h' });
res.json({ token });
});
▶ 示例:防止 XSS(转义输出)
JAVASCRIPT
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
const userInput = '<script>alert("xss")</script>';
console.log(escapeHtml(userInput));
▶ 示例:CSRF 防护(csurf 中间件)
JAVASCRIPT
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.get('/form', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
app.post('/submit', csrfProtection, (req, res) => {
res.json({ message: 'Form submitted successfully' });
});
4. 实战演练
(1) 注册 API
▶ 示例:用户注册接口
JAVASCRIPT
app.post('/api/register', async (req, res) => {
const { username, password, role } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const existingUser = await pool.query(
'SELECT id FROM users WHERE username = $1',
[username]
);
if (existingUser.rows.length > 0) {
return res.status(409).json({ error: 'Username already exists' });
}
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
const result = await pool.query(
'INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3) RETURNING id, username, role',
[username, passwordHash, role || 'user']
);
const user = result.rows[0];
const token = jwt.sign(
{ userId: user.id, role: user.role },
SECRET,
{ expiresIn: '2h' }
);
res.status(201).json({ user: { id: user.id, username: user.username, role: user.role }, token });
});
(2) 登录 API
▶ 示例:用户登录接口
JAVASCRIPT
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
const result = await pool.query(
'SELECT id, username, password_hash, role FROM users WHERE username = $1',
[username]
);
if (result.rows.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const user = result.rows[0];
const match = await bcrypt.compare(password, user.password_hash);
if (!match) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ userId: user.id, username: user.username, role: user.role },
SECRET,
{ expiresIn: '2h' }
);
res.json({
user: { id: user.id, username: user.username, role: user.role },
token
});
});
(3) JWT 验证中间件
▶ 示例:authenticate 中间件
JAVASCRIPT
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access denied. No token provided.' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, SECRET);
req.user = decoded;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(403).json({ error: 'Invalid token' });
}
}
(4) 角色权限中间件
▶ 示例:authorize 角色中间件
JAVASCRIPT
function authorize(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
app.get('/api/profile', authenticate, (req, res) => {
res.json({ user: req.user });
});
app.delete('/api/users/:id', authenticate, authorize('admin'), async (req, res) => {
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
res.json({ message: 'User deleted' });
});
app.get('/api/admin/dashboard', authenticate, authorize('admin'), (req, res) => {
res.json({ message: 'Welcome to admin dashboard' });
});
5. 综合示例:完整认证系统
TEXT
📖 仅展示
project/
├── server.js
├── middleware/
│ ├── auth.js
│ └── role.js
├── routes/
│ ├── auth.js
│ └── users.js
└── package.json
middleware/auth.js:
JAVASCRIPT
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET || 'fallback_dev_secret';
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access denied. No token provided.' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, SECRET);
req.user = decoded;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired. Please login again.' });
}
return res.status(403).json({ error: 'Invalid token.' });
}
}
module.exports = { authenticate, SECRET };
middleware/role.js:
JAVASCRIPT
function authorize(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required.' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden. Insufficient permissions.' });
}
next();
};
}
module.exports = { authorize };
routes/auth.js:
JAVASCRIPT
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { authenticate, SECRET } = require('../middleware/auth');
const router = express.Router();
const users = [];
router.post('/register', async (req, res) => {
const { username, password, role } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required.' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
}
const exists = users.find(u => u.username === username);
if (exists) {
return res.status(409).json({ error: 'Username already exists.' });
}
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
const newUser = {
id: users.length + 1,
username,
passwordHash,
role: role || 'user'
};
users.push(newUser);
const token = jwt.sign(
{ userId: newUser.id, username: newUser.username, role: newUser.role },
SECRET,
{ expiresIn: '2h' }
);
res.status(201).json({
user: { id: newUser.id, username: newUser.username, role: newUser.role },
token
});
});
router.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required.' });
}
const user = users.find(u => u.username === username);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials.' });
}
const match = await bcrypt.compare(password, user.passwordHash);
if (!match) {
return res.status(401).json({ error: 'Invalid credentials.' });
}
const token = jwt.sign(
{ userId: user.id, username: user.username, role: user.role },
SECRET,
{ expiresIn: '2h' }
);
res.json({
user: { id: user.id, username: user.username, role: user.role },
token
});
});
router.get('/profile', authenticate, (req, res) => {
const user = users.find(u => u.id === req.user.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
res.json({ id: user.id, username: user.username, role: user.role });
});
module.exports = router;
routes/users.js:
JAVASCRIPT
const express = require('express');
const { authenticate } = require('../middleware/auth');
const { authorize } = require('../middleware/role');
const router = express.Router();
const users = [];
router.get('/', authenticate, authorize('admin'), (req, res) => {
const safeList = users.map(u => ({ id: u.id, username: u.username, role: u.role }));
res.json(safeList);
});
router.delete('/:id', authenticate, authorize('admin'), (req, res) => {
const index = users.findIndex(u => u.id === parseInt(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'User not found.' });
}
users.splice(index, 1);
res.json({ message: 'User deleted.' });
});
module.exports = router;
server.js:
JAVASCRIPT
const express = require('express');
const helmet = require('helmet');
const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const app = express();
app.use(helmet());
app.use(express.json());
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal server error.' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
测试流程:
BASH
# 注册
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"SecurePass123","role":"admin"}'
# 登录
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"SecurePass123"}'
# 访问受保护路由
curl http://localhost:3000/api/auth/profile \
-H "Authorization: Bearer <your_token>"
# 管理员操作
curl -X DELETE http://localhost:3000/api/users/2 \
-H "Authorization: Bearer <admin_token>"
TEXT
📖 仅展示
# 注册响应
{
"user": { "id": 1, "username": "alice", "role": "admin" },
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
# 登录响应
{
"user": { "id": 1, "username": "alice", "role": "admin" },
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
# 权限不足响应
{ "error": "Forbidden. Insufficient permissions." }
6. JWT 认证流程图
sequenceDiagram
participant Client as 客户端
participant Server as 服务端
participant DB as 数据库
Client->>Server: POST /api/login {username, password}
Server->>DB: 查询用户记录
DB-->>Server: 返回用户数据
Server->>Server: bcrypt.compare() 验证密码
alt 密码正确
Server->>Server: jwt.sign() 签发 Token
Server-->>Client: 返回 { token }
Client->>Client: 存储 Token
Client->>Server: GET /api/profile<br/>Authorization: Bearer <token>
Server->>Server: jwt.verify() 验证 Token
alt Token 有效
Server-->>Client: 200 返回用户数据
else Token 无效或过期
Server-->>Client: 401/403 拒绝访问
end
else 密码错误
Server-->>Client: 401 Invalid credentials
end
7. 对比与参考
(1) JWT vs Session vs OAuth
| 维度 | JWT | Session | OAuth 2.0 |
|---|---|---|---|
| 存储位置 | 客户端 | 服务端 | 服务端 + 客户端 |
| 无状态 | 是 | 否 | 否 |
| 扩展性 | 天然支持分布式 | 需共享 Session 存储 | 需授权服务器 |
| 适用场景 | API 认证、微服务 | 传统 Web 应用 | 第三方登录 |
| 安全风险 | Token 泄露难撤销 | Session 劫持 | 重放攻击 |
| 实现复杂度 | 低 | 低 | 高 |
(2) bcrypt 参数选择
| saltRounds | 大致耗时 | 适用场景 |
|---|---|---|
| 8 | ~40ms | 开发测试 |
| 10 | ~160ms | 生产环境推荐 |
| 12 | ~640ms | 高安全要求 |
| 14 | ~2.5s | 极端安全场景 |
(3) 常见安全威胁与防御
| 威胁 | 攻击示例 | 防御方式 | 工具/库 |
|---|---|---|---|
| SQL 注入 | ' OR 1=1 -- |
参数化查询 | pg/mysql2 |
| XSS | <script>document.cookie</script> |
转义 + CSP | helmet/xss |
| CSRF | 伪造表单提交 | CSRF Token + SameSite | csurf |
| 暴力破解 | 字典攻击密码 | 限流 + bcrypt 高轮次 | express-rate-limit |
| 中间人攻击 | 嗅探 HTTP 流量 | HTTPS + HSTS | helmet/Let's Encrypt |
(4) JWT Payload 常用字段
| 字段 | 全称 | 说明 |
|---|---|---|
| iss | Issuer | 签发者标识 |
| sub | Subject | 主题(通常为用户 ID) |
| aud | Audience | 接收方标识 |
| exp | Expiration | 过期时间(Unix 时间戳) |
| iat | Issued At | 签发时间 |
| jti | JWT ID | 唯一标识(防重放) |
| userId | 自定义 | 业务用户 ID |
| role | 自定义 | 用户角色 |
❓ 常见问题
Q JWT 存在哪?
A 推荐存 httpOnly Cookie 防止 XSS 读取;localStorage 方便但易被 XSS 窃取,需配合 CSP 使用。
Q Token 过期怎么办?
A 常用双 Token 方案——短效 access token + 长效 refresh token,过期后用 refresh token 换新 access token,refresh token 存数据库可主动撤销。
Q bcrypt 为什么慢?
A bcrypt 的 cost factor 控制哈希轮次,每增加 1 轮耗时翻倍。这种"慢"是刻意设计的,让暴力破解每猜一次密码都要付出大量计算代价。
Q HTTPS 必须吗?
A 生产环境必须。HTTP 下 Token 和密码以明文传输,任何中间节点都能截获;HTTPS 加密传输是安全的基础底线。
Q 如何防止暴力破解?
A 多层防御——bcrypt 高轮次增加单次计算成本 + express-rate-limit 限流 + 账户锁定机制 + 登录失败延迟响应。
Q JWT 能主动撤销吗?
A JWT 本身无状态无法主动撤销。常见方案:维护黑名单(Redis 存已注销 Token 的 jti 直到过期)、缩短有效期配合 refresh token、敏感操作二次验证。
Q helmet 的 CSP 配置要注意什么?
A CSP 规则过严会阻断合法资源加载,建议开发时先用 Content-Security-Policy-Report-Only 观察违规报告,逐步收紧策略。
📖 小节
- 故事:不设防的 API的核心概念与使用方法
- 核心概念的核心概念与使用方法
- 技术详解的核心概念与使用方法
- 实战演练的核心概念与使用方法
- 综合示例:完整认证系统的核心概念与使用方法
- JWT 认证流程图的核心概念与使用方法
- 对比与参考的核心概念与使用方法
📝 作业
- 实现注册接口,要求密码不少于 8 字符且包含数字,使用 bcrypt 加密后存入数据库
- 实现登录接口,验证密码后签发有效期 1 小时的 JWT,payload 包含
userId和role - 编写
authenticate中间件,从Authorization: Bearer <token>中提取并验证 JWT - 编写
authorize('admin')中间件,限制只有 admin 角色才能访问DELETE /api/users/:id - 为项目集成 helmet,配置 CSP 只允许加载同源脚本和样式,并用 curl 验证响应头变化