Node.js: 认证与安全

最后更新:2026-08-26

1. 故事:不设防的 API

Alice 花了两周把用户管理 API 部署上线,第二天就发现数据库被删了——她的 API 没有任何认证,任何人都能直接调用 DELETE /api/users。痛定思痛,她引入了 JWT 认证:用户必须先登录获取 Token,后续请求携带 Token 才能访问受保护路由;同时为管理员操作加了角色中间件,普通用户即使有 Token 也无法执行危险操作。一周后,她又用 helmet 给响应头加上了安全防护,API 终于固若金汤。



2. 核心概念

(1) 认证与授权的区别

(2) JWT 原理概述

(3) bcrypt 密码哈希



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) 防篡改签名

工作流程:

  1. 用户登录,服务端验证凭据
  2. 验证通过,签发 JWT 返回给客户端
  3. 客户端将 Token 存储在 localStorage 或 cookie
  4. 后续请求在 Authorization: Bearer <token> 头中携带
  5. 服务端中间件验证签名与过期时间

▶ 示例:签发与验证 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

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 认证流程图

100%
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 观察违规报告,逐步收紧策略。

📖 小节

📝 作业

  1. 实现注册接口,要求密码不少于 8 字符且包含数字,使用 bcrypt 加密后存入数据库
  2. 实现登录接口,验证密码后签发有效期 1 小时的 JWT,payload 包含 userIdrole
  3. 编写 authenticate 中间件,从 Authorization: Bearer <token> 中提取并验证 JWT
  4. 编写 authorize('admin') 中间件,限制只有 admin 角色才能访问 DELETE /api/users/:id
  5. 为项目集成 helmet,配置 CSP 只允许加载同源脚本和样式,并用 curl 验证响应头变化

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏