📖 第06课:认证与授权(JWT/OAuth2)

阶段二:RESTful进阶

认证(Authentication)回答"你是谁",授权(Authorization)回答"你能做什么"。这两个概念是API安全的基石。本课从Session到JWT,从API Key到OAuth2,深入理解主流认证授权方案的原理、实现和选型。

🔐 认证方案对比

方案状态适用场景优点缺点
Session+Cookie有状态传统Web应用简单成熟不支持跨域,难扩展
JWT无状态API/微服务无状态、可扩展令牌较大、难撤销
API Key无状态服务间调用简单轻量权限粒度粗
OAuth2可变第三方授权标准化、安全实现复杂

🗝️ JWT深度解析

JWT(JSON Web Token)由三部分组成:Header.Payload.Signature,用.分隔。

// JWT结构
// Header(算法和类型)
const header = { alg: 'HS256', typ: 'JWT' };

// Payload(声明/claims)
const payload = {
  sub: '42',                    // Subject - 用户ID
  name: '张三',                  // 自定义声明
  role: 'admin',                // 自定义声明
  iat: 1705312800,              // Issued At - 签发时间
  exp: 1705399200,              // Expiration - 过期时间
  iss: 'api.example.com',       // Issuer - 签发者
  aud: 'app.example.com',       // Audience - 接收者
  jti: 'abc123',                // JWT ID - 唯一标识(防重放)
};

// Signature(签名)
// HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
// 确保令牌未被篡改

// 编码后的JWT
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiLlvKDkuYoiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MDUzMTI4MDAsImV4cCI6MTcwNTM5OTIwMH0.signature

JWT实现

// auth-jwt.js - JWT认证完整实现
const crypto = require('crypto');

// ===== JWT工具 =====

function base64UrlEncode(data) {
  const str = typeof data === 'string' ? data : JSON.stringify(data);
  return Buffer.from(str)
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

function base64UrlDecode(str) {
  const padded = str + '='.repeat((4 - str.length % 4) % 4);
  return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString();
}

function sign(payload, secret, algorithm = 'HS256') {
  const header = { alg: algorithm, typ: 'JWT' };
  const headerEncoded = base64UrlEncode(header);
  const payloadEncoded = base64UrlEncode(payload);

  const signatureInput = `${headerEncoded}.${payloadEncoded}`;
  const signature = crypto.createHmac('sha256', secret)
    .update(signatureInput)
    .digest('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  return `${headerEncoded}.${payloadEncoded}.${signature}`;
}

function verify(token, secret) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('无效的JWT格式');

  const [headerEncoded, payloadEncoded, signature] = parts;

  // 验证签名
  const expectedSignature = crypto.createHmac('sha256', secret)
    .update(`${headerEncoded}.${payloadEncoded}`)
    .digest('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  if (signature !== expectedSignature) {
    throw new Error('签名验证失败');
  }

  // 解析payload
  const payload = JSON.parse(base64UrlDecode(payloadEncoded));

  // 检查过期
  if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
    throw new Error('令牌已过期');
  }

  // 检查生效时间
  if (payload.nbf && payload.nbf > Math.floor(Date.now() / 1000)) {
    throw new Error('令牌尚未生效');
  }

  return payload;
}

// ===== Token管理 =====

const SECRET = process.env.JWT_SECRET || 'your-256-bit-secret-key-here';
const ACCESS_TOKEN_EXPIRY = '15m';   // 访问令牌15分钟
const REFRESH_TOKEN_EXPIRY = '7d';   // 刷新令牌7天

function generateAccessToken(user) {
  const now = Math.floor(Date.now() / 1000);
  return sign({
    sub: String(user.id),
    username: user.username,
    role: user.role,
    type: 'access',
    iat: now,
    exp: now + 15 * 60,  // 15分钟
    jti: crypto.randomUUID(),
  }, SECRET);
}

function generateRefreshToken(user) {
  const now = Math.floor(Date.now() / 1000);
  return sign({
    sub: String(user.id),
    type: 'refresh',
    iat: now,
    exp: now + 7 * 24 * 60 * 60,  // 7天
    jti: crypto.randomUUID(),
  }, SECRET);
}

// ===== Token黑名单(简化版,生产用Redis) =====
const tokenBlacklist = new Set();

function revokeToken(jti) {
  tokenBlacklist.add(jti);
}

function isTokenRevoked(jti) {
  return tokenBlacklist.has(jti);
}

// ===== 认证中间件 =====

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({
      success: false,
      error: { code: 'AUTH_MISSING', message: '缺少认证令牌' },
    });
  }

  const token = authHeader.substring(7);

  try {
    const payload = verify(token, SECRET);

    // 检查令牌类型
    if (payload.type !== 'access') {
      return res.status(401).json({
        success: false,
        error: { code: 'AUTH_INVALID_TOKEN', message: '无效的访问令牌' },
      });
    }

    // 检查黑名单
    if (isTokenRevoked(payload.jti)) {
      return res.status(401).json({
        success: false,
        error: { code: 'TOKEN_REVOKED', message: '令牌已被撤销' },
      });
    }

    req.user = payload;
    next();
  } catch (err) {
    const message = err.message.includes('过期') ? '令牌已过期' : '令牌无效';
    return res.status(401).json({
      success: false,
      error: { code: 'AUTH_INVALID_TOKEN', message },
    });
  }
}

// 授权中间件
function authorize(...roles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({
        success: false,
        error: { code: 'AUTH_MISSING', message: '未认证' },
      });
    }
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        success: false,
        error: { code: 'FORBIDDEN', message: `需要角色: ${roles.join('/')}` },
      });
    }
    next();
  };
}

// ===== Express应用 =====
const express = require('express');
const app = express();
app.use(express.json());

// 模拟用户数据库
const users = new Map();
users.set(1, { id: 1, username: 'admin', password: hashPassword('admin123'), role: 'admin' });
users.set(2, { id: 2, username: 'editor', password: hashPassword('editor123'), role: 'editor' });

function hashPassword(pwd) {
  return crypto.createHash('sha256').update(pwd + 'salt').digest('hex');
}

// 登录
app.post('/api/auth/login', (req, res) => {
  const { username, password } = req.body;
  const user = [...users.values()].find(u => u.username === username);

  if (!user || user.password !== hashPassword(password)) {
    return res.status(401).json({
      success: false,
      error: { code: 'AUTH_INVALID_CREDENTIAL', message: '用户名或密码错误' },
    });
  }

  const accessToken = generateAccessToken(user);
  const refreshToken = generateRefreshToken(user);

  res.json({
    success: true,
    data: {
      accessToken,
      refreshToken,
      expiresIn: 900,  // 15分钟 = 900秒
      tokenType: 'Bearer',
    },
  });
});

// 刷新令牌
app.post('/api/auth/refresh', (req, res) => {
  const { refreshToken } = req.body;
  if (!refreshToken) {
    return res.status(401).json({
      success: false,
      error: { code: 'AUTH_MISSING', message: '缺少刷新令牌' },
    });
  }

  try {
    const payload = verify(refreshToken, SECRET);
    if (payload.type !== 'refresh') {
      return res.status(401).json({
        success: false,
        error: { code: 'AUTH_INVALID_TOKEN', message: '无效的刷新令牌' },
      });
    }

    const user = users.get(parseInt(payload.sub));
    if (!user) {
      return res.status(401).json({
        success: false,
        error: { code: 'AUTH_INVALID_TOKEN', message: '用户不存在' },
      });
    }

    // 撤销旧刷新令牌
    revokeToken(payload.jti);

    // 生成新令牌对
    const newAccessToken = generateAccessToken(user);
    const newRefreshToken = generateRefreshToken(user);

    res.json({
      success: true,
      data: {
        accessToken: newAccessToken,
        refreshToken: newRefreshToken,
        expiresIn: 900,
      },
    });
  } catch (err) {
    return res.status(401).json({
      success: false,
      error: { code: 'AUTH_INVALID_TOKEN', message: '刷新令牌无效或已过期' },
    });
  }
});

// 登出
app.post('/api/auth/logout', authenticate, (req, res) => {
  revokeToken(req.user.jti);
  res.json({ success: true, data: { message: '已登出' } });
});

// 受保护的资源 - 任何认证用户
app.get('/api/profile', authenticate, (req, res) => {
  const user = users.get(parseInt(req.user.sub));
  res.json({ success: true, data: { id: user.id, username: user.username, role: user.role } });
});

// 受保护的资源 - 仅管理员
app.get('/api/admin/dashboard', authenticate, authorize('admin'), (req, res) => {
  res.json({ success: true, data: { message: '管理员面板', totalUsers: users.size } });
});

app.listen(3000, () => console.log('🔐 JWT认证服务运行在 http://localhost:3000'));

测试JWT认证流程

# 1. 登录获取令牌
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123"}' | jq -r '.data.accessToken')

echo "Token: $TOKEN"

# 2. 用令牌访问受保护资源
curl -s http://localhost:3000/api/profile \
  -H "Authorization: Bearer $TOKEN" | jq .
{
  "success": true,
  "data": {"id":1,"username":"admin","role":"admin"}
}
# 3. 无令牌访问 - 401
curl -s http://localhost:3000/api/profile | jq .

# 4. 管理员访问管理面板
curl -s http://localhost:3000/api/admin/dashboard \
  -H "Authorization: Bearer $TOKEN" | jq .

# 5. 普通用户尝试访问 - 403
EDITOR_TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"editor","password":"editor123"}' | jq -r '.data.accessToken')

curl -s http://localhost:3000/api/admin/dashboard \
  -H "Authorization: Bearer $EDITOR_TOKEN" | jq .

🔄 OAuth2授权框架

OAuth2四种授权模式

授权码模式(Authorization Code)—— 最安全、最常用

1用户点击"使用Google登录",客户端重定向到授权服务器
2用户在授权服务器登录并同意授权
3授权服务器重定向回客户端,携带授权码(code)
4客户端用授权码向授权服务器换取访问令牌
5客户端用访问令牌访问资源服务器
# 步骤1: 重定向到授权端点
GET /oauth/authorize?
  response_type=code&
  client_id=myapp&
  redirect_uri=https://myapp.com/callback&
  scope=read+write&
  state=random_csrf_token

# 步骤3: 回调收到授权码
GET /callback?
  code=AUTHORIZATION_CODE&
  state=random_csrf_token

# 步骤4: 用授权码换令牌
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=https://myapp.com/callback&
client_id=myapp&
client_secret=SECRET

其他三种模式

模式适用场景安全性
隐式模式(Implicit)纯前端SPA(已不推荐)⚠️ 低,令牌暴露在URL
密码模式(Resource Owner Password)高度信任的第一方应用⚠️ 中,需用户直接提供密码
客户端凭证(Client Credentials)服务间调用(无用户参与)✅ 高,无需用户交互
💡 PKCE增强:对于移动端和SPA,使用PKCE(Proof Key for Code Exchange)增强授权码模式的安全性。生成一个随机的code_verifier,将其SHA256哈希作为code_challenge发送,换取令牌时发送原始code_verifier验证。这样即使授权码被截获也无法使用。

🔑 API Key方案

// api-key-auth.js - API Key认证
const crypto = require('crypto');

// 生成API Key
function generateApiKey(prefix = 'sk') {
  const key = crypto.randomBytes(24).toString('hex');
  return `${prefix}_${key}`;
}

// API Key存储(生产环境用数据库)
const apiKeys = new Map();

// 注册API Key
function registerApiKey(userId, permissions = []) {
  const key = generateApiKey();
  const hashed = crypto.createHash('sha256').update(key).digest('hex');
  apiKeys.set(hashed, {
    userId,
    permissions,
    createdAt: new Date().toISOString(),
    lastUsed: null,
    requestCount: 0,
  });
  return key; // 只在创建时返回明文
}

// API Key中间件
function apiKeyAuth(req, res, next) {
  const key = req.headers['x-api-key'] || req.query.api_key;

  if (!key) {
    return res.status(401).json({
      success: false,
      error: { code: 'API_KEY_MISSING', message: '缺少API Key' },
    });
  }

  const hashed = crypto.createHash('sha256').update(key).digest('hex');
  const keyData = apiKeys.get(hashed);

  if (!keyData) {
    return res.status(401).json({
      success: false,
      error: { code: 'API_KEY_INVALID', message: '无效的API Key' },
    });
  }

  // 更新使用统计
  keyData.lastUsed = new Date().toISOString();
  keyData.requestCount++;

  req.apiKey = keyData;
  next();
}

📝 本课小结

  1. 认证=你是谁,授权=你能做什么,必须区分
  2. JWT无状态、易扩展,但不适合需要即时撤销的场景
  3. 双令牌策略:短期AccessToken + 长期RefreshToken
  4. OAuth2授权码模式最安全,适用于第三方授权
  5. API Key适合服务间调用,简单但权限粒度粗
  6. 永远不要在JWT中存储敏感信息——Payload只是Base64编码,不是加密
  7. 使用HTTPS传输令牌,防止中间人攻击

💪 练习

练习1:实现基于角色的API权限矩阵

设计一个权限系统,包含admin/editor/viewer三种角色,实现对不同资源的不同操作权限。

练习2:实现OAuth2授权码模式

用Express实现一个简化版的OAuth2授权服务器,包含/authorize和/token两个端点。

🏆 本课成就:安全守门人