3. OAuth 2.0授权码模式接入

OAuth 2.0授权码模式接入 学习卡片

任务说明

本案例要求为数字银行开放平台实现一个标准的OAuth 2.0授权码模式(Authorization Code Flow)接入示例。你需要完成一个最小可运行的授权服务器与客户端演示程序,使第三方应用能够通过用户授权获取访问令牌,并携带令牌调用受保护的API资源。

核心实现

以下代码使用Node.js原生HTTP模块实现,不依赖第三方OAuth库,完整展示授权码模式的四个核心步骤:授权请求、用户授权确认、令牌交换、受保护资源访问。为便于演示,用户登录态使用内存中的固定会话模拟。


// oauth_demo.js
const http = require('http');
const crypto = require('crypto');
const url = require('url');

// ---------- 内存数据存储 ----------
const clients = {
  'client-app-001': {
    secret: 'demo-secret-key',
    redirectUri: 'http://localhost:3001/callback'
  }
};

const users = {
  'alice': { password: 'pass123', name: 'Alice' }
};

// 授权码与令牌存储(生产环境应使用持久化存储)
const authCodes = new Map();   // code -> { clientId, redirectUri, user, expiresAt }
const accessTokens = new Map(); //  -> { clientId, user, expiresAt }

// ---------- 工具函数 ----------
function generateToken(len = 32) {
  return crypto.randomBytes(len).toString('hex');
}

function sendJson(res, statusCode, data) {
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(data));
}

function sendHtml(res, statusCode, html) {
  res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8' });
  res.end(html);
}

// ---------- 授权服务器 (端口3000) ----------
const authServer = http.createServer((req, res) => {
  const parsedUrl = url.parse(req.url, true);
  const path = parsedUrl.pathname;

  // 1. 授权端点:GET /authorize
  if (path === '/authorize' && req.method === 'GET') {
    const { client_id, redirect_uri, response_type, state } = parsedUrl.query;

    // 验证客户端与参数
    const client = clients[client_id];
    if (!client || client.redirectUri !== redirect_uri || response_type !== 'code') {
      sendJson(res, 400, { error: 'invalid_request' });
      return;
    }

    // 模拟用户登录检查(实际应跳转登录页)
    // 此处直接展示授权确认页
    const authPage = `
      <html><body>
        <h3>授权确认</h3>
        <p>客户端 ${client_id} 请求访问你的数据</p>
        <form method="POST" action="/approve">
          <input type="hidden" name="client_id" value="${client_id}">
          <input type="hidden" name="redirect_uri" value="${redirect_uri}">
          <input type="hidden" name="state" value="${state || ''}">
          <input type="hidden" name="user" value="alice">
          <button type="submit" name="decision" value="allow">允许</button>
          <button type="submit" name="decision" value="deny">拒绝</button>
        </form>
      </body></html>`;
    sendHtml(res, 200, authPage);
    return;
  }

  // 2. 用户授权确认:POST /approve
  if (path === '/approve' && req.method === 'POST') {
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      const params = new URLSearchParams(body);
      const { client_id, redirect_uri, state, user, decision } = Object.fromEntries(params);

      if (decision !== 'allow') {
        res.writeHead(302, { Location: `${redirect_uri}?error=access_denied&state=${state || ''}` });
        res.end();
        return;
      }

      // 生成授权码(有效期5分钟)
      const code = generateToken(16);
      authCodes.set(code, {
        clientId: client_id,
        redirectUri: redirect_uri,
        user: user,
        expiresAt: Date.now() + 5 * 60 * 1000
      });

      // 重定向回客户端
      res.writeHead(302, { Location: `${redirect_uri}?code=${code}&state=${state || ''}` });
      res.end();
    });
    return;
  }

  // 3. 令牌端点:POST /
  if (path === '/' && req.method === 'POST') {
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      const params = new URLSearchParams(body);
      const grantType = params.get('grant_type');
      const code = params.get('code');
      const clientId = params.get('client_id');
      const clientSecret = params.get('client_secret');
      const redirectUri = params.get('redirect_uri');

      // 验证客户端凭证
      const client = clients[clientId];
      if (!client || client.secret !== clientSecret) {
        sendJson(res, 401, { error: 'invalid_client' });
        return;
      }

      // 验证授权码
      const authCode = authCodes.get(code);
      if (!authCode || authCode.clientId !== clientId || authCode.redirectUri !== redirectUri) {
        sendJson(res, 400, { error: 'invalid_grant' });
        return;
      }
      if (Date.now() > authCode.expiresAt) {
        authCodes.delete(code);
        sendJson(res, 400, { error: 'invalid_grant', description: 'code expired' });
        return;
      }

      // 授权码一次性使用
      authCodes.delete(code);

      // 生成访问令牌(有效期2小时)
      const accessToken = generateToken();
      accessTokens.set(accessToken, {
        clientId: clientId,
        user: authCode.user,
        expiresAt: Date.now() + 2 * 60 * 60 * 1000
      });

      sendJson(res, 200, {
        access_: accessToken,
        _type: 'Bearer',
        expires_in: 7200,
        scope: 'read'
      });
    });
    return;
  }

  // 4. 受保护资源:GET /api/account
  if (path === '/api/account' && req.method === 'GET') {
    const authHeader = req.headers.authorization || '';
    const  = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;

    if (!) {
      sendJson(res, 401, { error: 'unauthorized', description: 'missing ' });
      return;
    }

    const tokenData = accessTokens.get();
    if (!tokenData || Date.now() > tokenData.expiresAt) {
      sendJson(res, 401, { error: 'invalid_' });
      return;
    }

    // 返回用户账户信息(演示数据)
    sendJson(res, 200, {
      user: tokenData.user,
      account_id: 'ACC-10086',
      balance: 5280.50,
      currency: 'CNY'
    });
    return;
  }

  sendJson(res, 404, { error: 'not_found' });
});

// ---------- 客户端应用 (端口3001) ----------
const clientApp = http.createServer((req, res) => {
  const parsedUrl = url.parse(req.url, true);
  const path = parsedUrl.pathname;

  // 首页:发起授权请求
  if (path === '/') {
    const authUrl = 'http://localhost:3000/authorize' +
      '?client_id=client-app-001' +
      '&redirect_uri=' + encodeURIComponent('http://localhost:3001/callback') +
      '&response_type=code' +
      '&state=xyz123';
    sendHtml(res, 200, `
      <html><body>
        <h3>第三方应用演示</h3>
        <a href="#" aria-disabled="true" data-keke-missing-local-link="1" onclick="return false">使用银行账号登录授权</a>
      </body></html>`);
    return;
  }

  // 回调端点:接收授权码并交换令牌
  if (path === '/callback') {
    const { code, state, error } = parsedUrl.query;

    if (error) {
      sendHtml(res, 200, `<p>授权失败: ${error}</p>`);
      return;
    }
    if (state !== 'xyz123') {
      sendHtml(res, 400, '<p>state校验失败</p>');
      return;
    }

    // 使用授权码请求令牌
    const postData = new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      client_id: 'client-app-001',
      client_secret: 'demo-secret-key',
      redirect_uri: 'http://localhost:3001/callback'
    }).toString();

    const tokenReq = http.request({
      hostname: 'localhost',
      port: 3000,
      path: '/',
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Content-Length': Buffer.byteLength(postData)
      }
    }, (tokenRes) => {
      let data = '';
      tokenRes.on('data', chunk => data += chunk);
      tokenRes.on('end', () => {
        const tokenData = JSON.parse(data);

        if (tokenData.access_) {
          // 携带令牌访问受保护资源
          const apiReq = http.request({
            hostname: 'localhost',
            port: 3000,
            path: '/api/account',
            method: 'GET',
            headers: { 'Authorization': `Bearer ${tokenData.access_}` }
          }, (apiRes) => {
            let apiData = '';
            apiRes.on('data', chunk => apiData += chunk);
            apiRes.on('end', () => {
              sendHtml(res, 200, `
                <h3>授权成功,账户信息:</h3>
                <pre>${JSON.stringify(JSON.parse(apiData), null, 2)}</pre>
                <p>访问令牌: ${tokenData.access_}</p>`);
            });
          });
          apiReq.end();
        } else {
          sendHtml(res, 400, `<p>令牌获取失败: ${JSON.stringify(tokenData)}</p>`);
        }
      });
    });
    tokenReq.on('error', (e) => {
      sendHtml(res, 500, `<p>请求错误: ${e.message}</p>`);
    });
    tokenReq.write(postData);
    tokenReq.end();
    return;
  }

  sendHtml(res, 404, '<p>页面不存在</p>');
});

// ---------- 启动服务 ----------
authServer.listen(3000, () => {
  console.log('授权服务器运行于 http://localhost:3000');
});
clientApp.listen(3001, () => {
  console.log('客户端应用运行于 http://localhost:3001');
});

运行与验证

启动方式:将上述代码保存为 oauth_demo.js,在终端执行 node oauth_demo.js。两个服务将分别监听3000端口(授权服务器)和3001端口(客户端应用)。

验证路径:

  1. 浏览器访问 http://localhost:3001,点击“使用银行账号登录授权”链接,页面将跳转到授权服务器的授权确认页;
  2. 点击“允许”按钮,浏览器将重定向回客户端回调地址,并携带授权码;
  3. 客户端自动使用授权码向令牌端点发起POST请求,换取访问令牌;
  4. 客户端携带访问令牌请求 /api/account,页面显示账户信息JSON数据。

判定标准:最终页面显示账户信息(用户alice、账号ACC-10086、余额5280.50),且页面中展示的访问令牌为64位十六进制字符串,即表示授权码模式全流程验证通过。若在授权确认页点击“拒绝”,回调地址将携带 error=access_denied,客户端页面显示“授权失败”,此路径同样验证正确。

OAuth 2.0 授权码模式接入时序 客户端应用 localhost:3001 授权服务器 localhost:3000 资源服务器 /api/account 1. GET /authorize?client_id=... (浏览器重定向) 显示授权确认页 2. POST /approve (允许) 3. 302 → /callback?code=...&state=xyz123 4. POST / (code+client_secret) 5. 200 OK {access_, expires_in} 6. GET /api/account (Bearer ) 7. 200 OK {user, account_id, balance} 授权流程 令牌交换 资源访问 state参数用于防止CSRF,授权码一次性且5分钟有效