返回首页

Node.js JWT 鉴权机制实现

1. 问题解析

JWT(JSON Web Token)是目前最流行的跨域认证解决方案。理解 JWT 的结构、签名验证机制以及在 Node.js 中的实现方式,对于构建安全的 Web 应用至关重要。JWT 的无状态特性使其特别适合分布式系统和微服务架构。

2. 核心概念

2.1 JWT 结构

JWT 由三部分组成,用点号(.)分隔:

xxxxx.yyyyy.zzzzz
↑      ↑      ↑
Header Payload Signature
部分 说明 示例
Header 声明类型和签名算法 {"alg":"HS256","typ":"JWT"}
Payload 声明(claims),包含用户数据 {"userId":1,"exp":1234567890}
Signature 签名,防止篡改 HMACSHA256(base64Url(header) + "." + base64Url(payload), secret)

2.2 JWT Claims 类型

Claim 含义 示例
iss (Issuer) 签发者 iss: "my-app"
sub (Subject) 主题(用户标识) sub: "user123"
aud (Audience) 接收方 aud: "my-api"
exp (Expiration) 过期时间(时间戳) exp: 1234567890
nbf (Not Before) 生效时间 nbf: 1234567800
iat (Issued At) 签发时间 iat: 1234567000
jti (JWT ID) 唯一标识 jti: "uuid-123"

2.3 JWT 工作流程

┌─────────┐                    ┌─────────┐                    ┌─────────┐
│  Client │ ──(1) 登录请求────> │  Server │ ──(2) 验证凭证───> │Database │
│         │                    │         │ <─(3) 返回用户信息── │         │
│         │ <─(4) 返回 JWT──── │         │                    └─────────┘
│         │                    │         │
│         │ ──(5) 请求 API ───> │         │
│         │   Authorization:   │         │
│         │   Bearer xxx.yyy   │         │
│         │                    │         │
│         │ <─(6) 返回数据──── │         │
└─────────┘                    └─────────┘

3. 详细解答

3.1 使用 jsonwebtoken 库

const jwt = require('jsonwebtoken');

// 密钥配置
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = '7d'; // 7天过期

/**
 * 生成 JWT Token
 * @param {Object} payload - 要编码的数据
 * @param {Object} options - 额外选项
 */
function generateToken(payload, options = {}) {
  return jwt.sign(payload, JWT_SECRET, {
    expiresIn: options.expiresIn || JWT_EXPIRES_IN,
    issuer: options.issuer || 'my-app',
    audience: options.audience || 'my-api',
    ...options
  });
}

/**
 * 验证 JWT Token
 * @param {string} token - JWT 字符串
 */
function verifyToken(token) {
  try {
    return jwt.verify(token, JWT_SECRET);
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      throw new Error('Token 已过期');
    } else if (err.name === 'JsonWebTokenError') {
      throw new Error('Token 无效');
    }
    throw err;
  }
}

/**
 * 解码 Token(不验证)
 * @param {string} token - JWT 字符串
 */
function decodeToken(token) {
  return jwt.decode(token, { complete: true });
}

// 使用示例
const user = { userId: 1, username: 'john', role: 'admin' };

// 生成 Token
const token = generateToken(user, { expiresIn: '2h' });
console.log('Generated Token:', token);

// 验证 Token
try {
  const decoded = verifyToken(token);
  console.log('Decoded:', decoded);
} catch (err) {
  console.error('Verification failed:', err.message);
}

3.2 Koa JWT 鉴权中间件

const Koa = require('koa');
const jwt = require('jsonwebtoken');

const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';

/**
 * JWT 认证中间件
 * @param {Object} options - 配置选项
 */
function jwtMiddleware(options = {}) {
  const {
    secret = JWT_SECRET,
    exclude = [],           // 排除校验的路径
    extractToken = (ctx) => {
      const authHeader = ctx.headers.authorization;
      if (authHeader && authHeader.startsWith('Bearer ')) {
        return authHeader.substring(7);
      }
      return null;
    }
  } = options;

  return async function auth(ctx, next) {
    // 检查是否在排除列表
    if (exclude.some(path => ctx.path.startsWith(path))) {
      return await next();
    }

    // 提取 Token
    const token = extractToken(ctx);

    if (!token) {
      ctx.status = 401;
      ctx.body = {
        error: 'Unauthorized',
        message: '缺少访问令牌'
      };
      return;
    }

    try {
      // 验证 Token
      const decoded = jwt.verify(token, secret);

      // 将用户信息挂载到 ctx.state
      ctx.state.user = decoded;

      await next();
    } catch (err) {
      ctx.status = 401;
      ctx.body = {
        error: 'Unauthorized',
        message: err.name === 'TokenExpiredError'
          ? 'Token 已过期'
          : '无效的访问令牌'
      };
    }
  };
}

// 角色权限中间件
function requireRole(...allowedRoles) {
  return async function roleCheck(ctx, next) {
    const user = ctx.state.user;

    if (!user) {
      ctx.status = 401;
      ctx.body = { error: '未认证' };
      return;
    }

    if (!allowedRoles.includes(user.role)) {
      ctx.status = 403;
      ctx.body = { error: 'Forbidden', message: '权限不足' };
      return;
    }

    await next();
  };
}

// 使用示例
const app = new Koa();
const Router = require('@koa/router');
const router = new Router();

// 应用 JWT 中间件
app.use(jwtMiddleware({
  secret: JWT_SECRET,
  exclude: ['/login', '/register', '/health']
}));

// 登录接口
router.post('/login', async (ctx) => {
  const { username, password } = ctx.request.body;

  // 验证用户凭证(实际应从数据库验证)
  if (username === 'admin' && password === '123456') {
    const user = { userId: 1, username, role: 'admin' };
    const token = jwt.sign(user, JWT_SECRET, { expiresIn: '2h' });

    ctx.body = {
      success: true,
      token,
      user
    };
  } else {
    ctx.status = 401;
    ctx.body = { error: '用户名或密码错误' };
  }
});

// 需要登录的接口
router.get('/profile', async (ctx) => {
  // ctx.state.user 由 JWT 中间件设置
  ctx.body = {
    user: ctx.state.user
  };
});

// 需要管理员权限的接口
router.delete('/users/:id',
  requireRole('admin'),
  async (ctx) => {
    ctx.body = { message: '用户已删除' };
  }
);

app.use(router.routes());
app.listen(3000);

3.3 使用 koa-jwt 中间件

const Koa = require('koa');
const koajwt = require('koa-jwt');
const jwt = require('jsonwebtoken');
const Router = require('@koa/router');

const app = new Koa();
const router = new Router();

const JWT_SECRET = 'your-secret-key';

// 使用 koa-jwt 中间件
app.use(koajwt({
  secret: JWT_SECRET,
  algorithms: ['HS256']
}).unless({
  // 排除不需要认证的路径
  path: [/^\/login/, /^\/register/, /^\/health/]
}));

// 错误处理
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    if (err.status === 401) {
      ctx.status = 401;
      ctx.body = {
        error: 'Unauthorized',
        message: '无效的访问令牌'
      };
    } else {
      throw err;
    }
  }
});

// 登录
router.post('/login', async (ctx) => {
  const { username, password } = ctx.request.body;

  // 验证用户
  if (username === 'user' && password === 'pass') {
    const token = jwt.sign(
      { userId: 1, username, role: 'user' },
      JWT_SECRET,
      { expiresIn: '1h' }
    );

    ctx.body = { token };
  } else {
    ctx.status = 401;
    ctx.body = { error: '认证失败' };
  }
});

// 受保护的路由
router.get('/api/data', async (ctx) => {
  // ctx.state.user 包含解码后的 JWT payload
  ctx.body = {
    data: 'secret data',
    user: ctx.state.user
  };
});

app.use(router.routes());
app.listen(3000);

3.4 Refresh Token 机制

const jwt = require('jsonwebtoken');
const crypto = require('crypto');

// 双 Token 策略:Access Token + Refresh Token
const JWT_SECRET = 'access-secret';
const REFRESH_SECRET = 'refresh-secret';

// Refresh Token 存储(生产环境使用 Redis)
const refreshTokenStore = new Map();

/**
 * 生成 Access Token
 * 有效期短(如 15 分钟),用于 API 访问
 */
function generateAccessToken(payload) {
  return jwt.sign(payload, JWT_SECRET, {
    expiresIn: '15m',
    issuer: 'my-app'
  });
}

/**
 * 生成 Refresh Token
 * 有效期长(如 7 天),用于获取新的 Access Token
 */
function generateRefreshToken(userId) {
  const token = crypto.randomBytes(40).toString('hex');
  const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7天

  refreshTokenStore.set(token, {
    userId,
    expiresAt,
    createdAt: Date.now()
  });

  return token;
}

/**
 * 验证 Refresh Token
 */
function verifyRefreshToken(token) {
  const data = refreshTokenStore.get(token);

  if (!data) {
    throw new Error('Refresh Token 不存在');
  }

  if (Date.now() > data.expiresAt) {
    refreshTokenStore.delete(token);
    throw new Error('Refresh Token 已过期');
  }

  return data;
}

/**
 * 刷新 Access Token
 */
function refreshAccessToken(refreshToken, userData) {
  const data = verifyRefreshToken(refreshToken);

  // 生成新的 Access Token
  const newAccessToken = generateAccessToken({
    userId: data.userId,
    ...userData
  });

  return newAccessToken;
}

/**
 * 撤销 Refresh Token(登出)
 */
function revokeRefreshToken(token) {
  refreshTokenStore.delete(token);
}

// API 实现
const Koa = require('koa');
const Router = require('@koa/router');
const app = new Koa();
const router = new Router();

// 登录 - 返回双 Token
router.post('/auth/login', async (ctx) => {
  const { username, password } = ctx.request.body;

  // 验证用户(示例)
  const user = await validateUser(username, password);
  if (!user) {
    ctx.status = 401;
    ctx.body = { error: '认证失败' };
    return;
  }

  const accessToken = generateAccessToken({
    userId: user.id,
    username: user.username,
    role: user.role
  });

  const refreshToken = generateRefreshToken(user.id);

  ctx.body = {
    accessToken,
    refreshToken,
    expiresIn: 900 // 15分钟(秒)
  };
});

// 刷新 Token
router.post('/auth/refresh', async (ctx) => {
  const { refreshToken } = ctx.request.body;

  try {
    const data = verifyRefreshToken(refreshToken);
    const user = await getUserById(data.userId);

    const newAccessToken = generateAccessToken({
      userId: user.id,
      username: user.username,
      role: user.role
    });

    ctx.body = {
      accessToken: newAccessToken,
      expiresIn: 900
    };
  } catch (err) {
    ctx.status = 401;
    ctx.body = { error: err.message };
  }
});

// 登出 - 撤销 Refresh Token
router.post('/auth/logout', async (ctx) => {
  const { refreshToken } = ctx.request.body;
  revokeRefreshToken(refreshToken);
  ctx.body = { message: '登出成功' };
});

app.use(router.routes());

4. 深入理解

4.1 JWT 安全性考虑

const jwt = require('jsonwebtoken');
const crypto = require('crypto');

/**
 * JWT 安全最佳实践
 */
class JWTSecurity {
  constructor() {
    // 使用强密钥
    this.secret = process.env.JWT_SECRET || crypto.randomBytes(64).toString('hex');
    this.refreshSecret = process.env.JWT_REFRESH_SECRET || crypto.randomBytes(64).toString('hex');

    // Token 黑名单(使用 Redis 更好)
    this.blacklist = new Set();
  }

  /**
   * 生成安全的 JWT
   */
  generateSecureToken(payload, options = {}) {
    const jti = crypto.randomUUID(); // JWT ID,用于撤销

    return jwt.sign(
      { ...payload, jti },
      this.secret,
      {
        algorithm: 'HS256', // 避免使用不安全的 'none'
        expiresIn: options.expiresIn || '15m',
        issuer: 'my-app',
        audience: 'my-api',
        notBefore: '0s' // 立即生效
      }
    );
  }

  /**
   * 验证 JWT 并检查黑名单
   */
  verifySecureToken(token) {
    const decoded = jwt.verify(token, this.secret, {
      algorithms: ['HS256'], // 明确指定允许的算法
      issuer: 'my-app',
      audience: 'my-api'
    });

    // 检查黑名单
    if (this.blacklist.has(decoded.jti)) {
      throw new Error('Token 已被撤销');
    }

    return decoded;
  }

  /**
   * 撤销 Token
   */
  revokeToken(jti, exp) {
    this.blacklist.add(jti);

    // 设置过期时间自动清理(实际使用 Redis TTL)
    const ttl = exp * 1000 - Date.now();
    setTimeout(() => this.blacklist.delete(jti), ttl);
  }

  /**
   * 密钥轮换
   */
  rotateSecret() {
    // 保存旧密钥用于验证现有 Token
    this.oldSecret = this.secret;
    // 生成新密钥
    this.secret = crypto.randomBytes(64).toString('hex');

    // 一段时间后移除旧密钥
    setTimeout(() => {
      this.oldSecret = null;
    }, 24 * 60 * 60 * 1000); // 24小时
  }
}

// 防止 JWT 泄露的措施
function setupSecurityHeaders(ctx) {
  // 防止 XSS 窃取 Token
  ctx.set('X-Content-Type-Options', 'nosniff');
  ctx.set('X-Frame-Options', 'DENY');
  ctx.set('Content-Security-Policy', "default-src 'self'");

  // 使用 HttpOnly Cookie 存储 Refresh Token
  ctx.cookies.set('refreshToken', token, {
    httpOnly: true,
    secure: true,      // HTTPS only
    sameSite: 'strict', // CSRF 防护
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
  });
}

4.2 非对称加密(RS256)

const jwt = require('jsonwebtoken');
const fs = require('fs');

/**
 * 使用 RSA 密钥对(推荐生产环境)
 * 私钥签名,公钥验证
 */

// 生成密钥对(命令行)
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -pubout -out public.pem

const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');

/**
 * 使用私钥签名生成 JWT
 */
function generateTokenWithRSA(payload) {
  return jwt.sign(payload, privateKey, {
    algorithm: 'RS256',
    expiresIn: '1h'
  });
}

/**
 * 使用公钥验证 JWT
 */
function verifyTokenWithRSA(token) {
  return jwt.verify(token, publicKey, {
    algorithms: ['RS256']
  });
}

// 优势:
// 1. 私钥只保存在认证服务器
// 2. 其他服务只需要公钥即可验证
// 3. 适合微服务架构

4.3 多设备登录管理

const Redis = require('ioredis');
const redis = new Redis();

/**
 * 多设备 Token 管理
 */
class MultiDeviceAuth {
  /**
   * 用户登录
   */
  async login(userId, deviceInfo) {
    const deviceId = crypto.randomUUID();

    const accessToken = jwt.sign(
      { userId, deviceId },
      JWT_SECRET,
      { expiresIn: '15m' }
    );

    const refreshToken = crypto.randomBytes(40).toString('hex');

    // 存储设备信息
    await redis.hset(`user:${userId}:devices`, deviceId, JSON.stringify({
      deviceId,
      deviceInfo,
      refreshToken,
      loginAt: Date.now()
    }));

    // 设置 Refresh Token 过期
    await redis.setex(`refresh:${refreshToken}`, 7 * 24 * 60 * 60, userId);

    return { accessToken, refreshToken, deviceId };
  }

  /**
   * 获取用户所有设备
   */
  async getUserDevices(userId) {
    const devices = await redis.hgetall(`user:${userId}:devices`);
    return Object.values(devices).map(d => JSON.parse(d));
  }

  /**
   * 踢出指定设备
   */
  async logoutDevice(userId, deviceId) {
    const deviceData = await redis.hget(`user:${userId}:devices`, deviceId);
    if (deviceData) {
      const { refreshToken } = JSON.parse(deviceData);
      await redis.del(`refresh:${refreshToken}`);
      await redis.hdel(`user:${userId}:devices`, deviceId);
    }
  }

  /**
   * 踢出所有其他设备
   */
  async logoutOtherDevices(userId, currentDeviceId) {
    const devices = await redis.hgetall(`user:${userId}:devices`);

    for (const [deviceId, deviceData] of Object.entries(devices)) {
      if (deviceId !== currentDeviceId) {
        const { refreshToken } = JSON.parse(deviceData);
        await redis.del(`refresh:${refreshToken}`);
        await redis.hdel(`user:${userId}:devices`, deviceId);
      }
    }
  }
}

5. 最佳实践

5.1 完整的认证流程

const Koa = require('koa');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const Router = require('@koa/router');

const app = new Koa();
const router = new Router();

const JWT_CONFIG = {
  secret: process.env.JWT_SECRET,
  accessExpiresIn: '15m',
  refreshExpiresIn: '7d'
};

// 密码哈希
async function hashPassword(password) {
  return bcrypt.hash(password, 10);
}

async function verifyPassword(password, hash) {
  return bcrypt.compare(password, hash);
}

// 登录
router.post('/auth/login', async (ctx) => {
  const { username, password, deviceId } = ctx.request.body;

  // 1. 验证用户
  const user = await db.users.findOne({ username });
  if (!user || !(await verifyPassword(password, user.passwordHash))) {
    ctx.status = 401;
    ctx.body = { error: '用户名或密码错误' };
    return;
  }

  // 2. 检查账户状态
  if (user.isLocked) {
    ctx.status = 403;
    ctx.body = { error: '账户已被锁定' };
    return;
  }

  // 3. 生成 Token
  const accessToken = jwt.sign(
    {
      userId: user.id,
      username: user.username,
      role: user.role,
      deviceId
    },
    JWT_CONFIG.secret,
    { expiresIn: JWT_CONFIG.accessExpiresIn }
  );

  const refreshToken = await generateRefreshToken(user.id, deviceId);

  // 4. 记录登录日志
  await db.loginLogs.create({
    userId: user.id,
    deviceId,
    ip: ctx.ip,
    userAgent: ctx.headers['user-agent'],
    loginAt: new Date()
  });

  ctx.body = {
    accessToken,
    refreshToken,
    expiresIn: 900,
    user: {
      id: user.id,
      username: user.username,
      role: user.role
    }
  };
});

// 刷新 Token
router.post('/auth/refresh', async (ctx) => {
  const { refreshToken } = ctx.request.body;

  const tokenData = await validateRefreshToken(refreshToken);
  if (!tokenData) {
    ctx.status = 401;
    ctx.body = { error: 'Refresh Token 无效' };
    return;
  }

  const user = await db.users.findById(tokenData.userId);
  if (!user || user.isLocked) {
    ctx.status = 403;
    ctx.body = { error: '账户异常' };
    return;
  }

  // 生成新的 Access Token
  const newAccessToken = jwt.sign(
    {
      userId: user.id,
      username: user.username,
      role: user.role,
      deviceId: tokenData.deviceId
    },
    JWT_CONFIG.secret,
    { expiresIn: JWT_CONFIG.accessExpiresIn }
  );

  ctx.body = {
    accessToken: newAccessToken,
    expiresIn: 900
  };
});

// 登出
router.post('/auth/logout', async (ctx) => {
  const { refreshToken } = ctx.request.body;

  // 撤销 Refresh Token
  await revokeRefreshToken(refreshToken);

  // 如果有 Access Token,加入黑名单
  const authHeader = ctx.headers.authorization;
  if (authHeader?.startsWith('Bearer ')) {
    const token = authHeader.substring(7);
    const decoded = jwt.decode(token);
    if (decoded?.jti) {
      await addToBlacklist(decoded.jti, decoded.exp);
    }
  }

  ctx.body = { message: '登出成功' };
});

// 修改密码
router.post('/auth/change-password',
  jwtMiddleware(),
  async (ctx) => {
    const { oldPassword, newPassword } = ctx.request.body;
    const userId = ctx.state.user.userId;

    const user = await db.users.findById(userId);

    if (!(await verifyPassword(oldPassword, user.passwordHash))) {
      ctx.status = 400;
      ctx.body = { error: '原密码错误' };
      return;
    }

    // 更新密码
    user.passwordHash = await hashPassword(newPassword);
    await user.save();

    // 撤销所有 Refresh Token,强制重新登录
    await revokeAllUserTokens(userId);

    ctx.body = { message: '密码修改成功,请重新登录' };
  }
);

5.2 前端 Token 管理

// api.js - 前端 API 客户端
class APIClient {
  constructor() {
    this.baseURL = 'http://api.example.com';
    this.accessToken = localStorage.getItem('accessToken');
    this.refreshToken = localStorage.getItem('refreshToken');
    this.refreshPromise = null;
  }

  async request(url, options = {}) {
    const headers = {
      'Content-Type': 'application/json',
      ...options.headers
    };

    if (this.accessToken) {
      headers.Authorization = `Bearer ${this.accessToken}`;
    }

    try {
      const response = await fetch(`${this.baseURL}${url}`, {
        ...options,
        headers
      });

      // Token 过期,尝试刷新
      if (response.status === 401 && this.refreshToken) {
        const newToken = await this.refreshAccessToken();
        if (newToken) {
          headers.Authorization = `Bearer ${newToken}`;
          return fetch(`${this.baseURL}${url}`, {
            ...options,
            headers
          });
        }
      }

      return response;
    } catch (error) {
      throw error;
    }
  }

  async refreshAccessToken() {
    // 防止重复刷新
    if (this.refreshPromise) {
      return this.refreshPromise;
    }

    this.refreshPromise = fetch(`${this.baseURL}/auth/refresh`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken: this.refreshToken })
    }).then(async (response) => {
      if (response.ok) {
        const data = await response.json();
        this.accessToken = data.accessToken;
        localStorage.setItem('accessToken', data.accessToken);
        return data.accessToken;
      } else {
        // 刷新失败,清除 Token 并跳转登录
        this.clearTokens();
        window.location.href = '/login';
        return null;
      }
    }).finally(() => {
      this.refreshPromise = null;
    });

    return this.refreshPromise;
  }

  clearTokens() {
    this.accessToken = null;
    this.refreshToken = null;
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
  }
}

export const api = new APIClient();

6. 面试要点

  1. JWT 的三部分组成

    • Header:算法和类型声明
    • Payload:用户数据和声明(claims)
    • Signature:签名,确保 Token 未被篡改
  2. JWT 与传统 Session 的区别

    • JWT:无状态,服务端不存储,可扩展性好
    • Session:有状态,服务端存储,需要共享 Session 存储
    • JWT 适合分布式系统,Session 适合需要服务端控制会话的场景
  3. JWT 的安全注意事项

    • 使用强密钥(至少 256 位)
    • 设置合理的过期时间
    • 使用 HTTPS 传输
    • 不要在 Payload 中存放敏感信息(只是 base64 编码)
    • 实现 Token 黑名单机制用于撤销
    • 使用 RS256 等非对称算法时保护好私钥
  4. Refresh Token 机制的作用

    • Access Token 有效期短,减少泄露风险
    • Refresh Token 有效期长,用于获取新的 Access Token
    • 实现无感刷新,提升用户体验
    • Refresh Token 通常存储在 HttpOnly Cookie 中
  5. JWT 验证失败的处理

    • TokenExpiredError:提示 Token 过期,引导刷新
    • JsonWebTokenError:Token 格式错误或被篡改
    • NotBeforeError:Token 尚未生效
  6. 实际应用中的考虑

    • 多设备登录管理
    • Token 撤销(黑名单)
    • 密钥轮换
    • 权限控制(RBAC)
    • 登录日志和异常检测