3. 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端口(客户端应用)。
验证路径:
- 浏览器访问
http://localhost:3001,点击“使用银行账号登录授权”链接,页面将跳转到授权服务器的授权确认页; - 点击“允许”按钮,浏览器将重定向回客户端回调地址,并携带授权码;
- 客户端自动使用授权码向令牌端点发起POST请求,换取访问令牌;
- 客户端携带访问令牌请求
/api/account,页面显示账户信息JSON数据。
判定标准:最终页面显示账户信息(用户alice、账号ACC-10086、余额5280.50),且页面中展示的访问令牌为64位十六进制字符串,即表示授权码模式全流程验证通过。若在授权确认页点击“拒绝”,回调地址将携带 error=access_denied,客户端页面显示“授权失败”,此路径同样验证正确。