forked from wangziqi/gongxue-base
feat: add web oauth login adapters
This commit is contained in:
@@ -79,6 +79,7 @@ let serverLogs = '';
|
||||
let legacyDisabledServer = null;
|
||||
let legacyDisabledServerLogs = '';
|
||||
let fakeWechatServer = null;
|
||||
let fakeQqServer = null;
|
||||
let fakeWechatPayServer = null;
|
||||
|
||||
function buildUrl(path, query = {}) {
|
||||
@@ -268,22 +269,58 @@ async function startFakeWechatServer() {
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
});
|
||||
|
||||
if (url.pathname !== '/sns/jscode2session') {
|
||||
if (!['/sns/jscode2session', '/sns/oauth2/access_token', '/sns/userinfo'].includes(url.pathname)) {
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ errcode: 404, errmsg: 'not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
url.searchParams.get('appid') !== 'wx-smoke-appid' ||
|
||||
url.searchParams.get('secret') !== 'wechat-app-secret-smoke' ||
|
||||
url.searchParams.get('grant_type') !== 'authorization_code'
|
||||
) {
|
||||
if (url.pathname === '/sns/userinfo') {
|
||||
const accessToken = url.searchParams.get('access_token') || '';
|
||||
const openid = url.searchParams.get('openid') || '';
|
||||
if (!accessToken.startsWith('wechat-web-token-') || !openid.startsWith('wechat-web-openid-')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ errcode: 40003, errmsg: 'invalid openid' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
openid,
|
||||
nickname: '微信网页烟测学生',
|
||||
headimgurl: 'https://example.test/wechat-web-avatar.png',
|
||||
unionid: 'unionid-smoke-user',
|
||||
privilege: [],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const appId = url.searchParams.get('appid');
|
||||
const secret = url.searchParams.get('secret');
|
||||
const grantType = url.searchParams.get('grant_type');
|
||||
if (appId !== 'wx-smoke-appid' || secret !== 'wechat-app-secret-smoke' || grantType !== 'authorization_code') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ errcode: 40013, errmsg: 'invalid appid or secret' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/sns/oauth2/access_token') {
|
||||
const webCode = url.searchParams.get('code') || 'unknown';
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
access_token: `wechat-web-token-${webCode}`,
|
||||
expires_in: 7200,
|
||||
refresh_token: `wechat-web-refresh-${webCode}`,
|
||||
openid: `wechat-web-openid-${webCode}`,
|
||||
scope: 'snsapi_login',
|
||||
unionid: 'unionid-smoke-user',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const jsCode = url.searchParams.get('js_code') || 'unknown';
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
@@ -302,6 +339,91 @@ async function startFakeWechatServer() {
|
||||
|
||||
return {
|
||||
endpoint: `${baseUrl}/sns/jscode2session`,
|
||||
webTokenEndpoint: `${baseUrl}/sns/oauth2/access_token`,
|
||||
webUserInfoEndpoint: `${baseUrl}/sns/userinfo`,
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
async function startFakeQqServer() {
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const requests = [];
|
||||
const tokenToOpenId = new Map();
|
||||
fakeQqServer = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
requests.push({
|
||||
method: req.method,
|
||||
pathname: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
});
|
||||
|
||||
if (url.pathname === '/oauth2.0/token') {
|
||||
if (
|
||||
url.searchParams.get('client_id') !== 'qq-smoke-appid' ||
|
||||
url.searchParams.get('client_secret') !== 'qq-client-secret-smoke' ||
|
||||
url.searchParams.get('grant_type') !== 'authorization_code' ||
|
||||
url.searchParams.get('redirect_uri') !== 'https://h5.example.test/auth/qq/callback'
|
||||
) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 100010, error_description: 'invalid client' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const code = url.searchParams.get('code') || 'unknown';
|
||||
const accessToken = `qq-token-${code}`;
|
||||
tokenToOpenId.set(accessToken, `qq-openid-${code}`);
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end(`access_token=${encodeURIComponent(accessToken)}&expires_in=7776000&refresh_token=qq-refresh-${encodeURIComponent(code)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/oauth2.0/me') {
|
||||
const accessToken = url.searchParams.get('access_token') || '';
|
||||
const openid = tokenToOpenId.get(accessToken);
|
||||
if (!openid) {
|
||||
res.writeHead(200, { 'content-type': 'application/javascript' });
|
||||
res.end('callback( {"error":100016,"error_description":"access token check failed"} );');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/javascript' });
|
||||
res.end(`callback( {"client_id":"qq-smoke-appid","openid":"${openid}"} );`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/user/get_user_info') {
|
||||
const accessToken = url.searchParams.get('access_token') || '';
|
||||
const openid = url.searchParams.get('openid') || '';
|
||||
if (!tokenToOpenId.has(accessToken) || tokenToOpenId.get(accessToken) !== openid) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ret: 100001, msg: 'invalid token' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
ret: 0,
|
||||
msg: '',
|
||||
nickname: 'QQ烟测学生',
|
||||
figureurl_qq_2: 'https://example.test/qq-avatar.png',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ret: 404, msg: 'not found' }));
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
fakeQqServer.once('error', reject);
|
||||
fakeQqServer.listen(port, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
return {
|
||||
tokenEndpoint: `${baseUrl}/oauth2.0/token`,
|
||||
openIdEndpoint: `${baseUrl}/oauth2.0/me`,
|
||||
userInfoEndpoint: `${baseUrl}/user/get_user_info`,
|
||||
requests,
|
||||
};
|
||||
}
|
||||
@@ -664,6 +786,7 @@ async function testPhoneBinding() {
|
||||
const phoneSuffix = String(Date.now()).slice(-6);
|
||||
const oldPhone = `13920${phoneSuffix}`;
|
||||
const newPhone = `13921${phoneSuffix}`;
|
||||
const wrongPurposePhone = `13922${phoneSuffix}`;
|
||||
const login = await loginBySms(oldPhone);
|
||||
const authHeaders = { authorization: `Bearer ${login.session.token}` };
|
||||
|
||||
@@ -697,12 +820,12 @@ async function testPhoneBinding() {
|
||||
});
|
||||
assert.equal(conflict.code, 'PHONE_ALREADY_BOUND', 'binding a phone owned by another account should be rejected');
|
||||
|
||||
const wrongPurposeCode = await sendMockSmsCode('13800000022', 'login');
|
||||
const wrongPurposeCode = await sendMockSmsCode(wrongPurposePhone, 'login');
|
||||
const wrongPurpose = await request('/api/auth/phone/bind', {
|
||||
userId: false,
|
||||
headers: authHeaders,
|
||||
method: 'POST',
|
||||
body: { phone: '13800000022', code: wrongPurposeCode, purpose: 'login' },
|
||||
body: { phone: wrongPurposePhone, code: wrongPurposeCode, purpose: 'login' },
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(wrongPurpose.code, 'PHONE_BIND_PURPOSE_REQUIRED', 'phone bind endpoint must reject login SMS codes');
|
||||
@@ -824,6 +947,10 @@ function stopServer() {
|
||||
fakeWechatServer.close();
|
||||
fakeWechatServer = null;
|
||||
}
|
||||
if (fakeQqServer) {
|
||||
fakeQqServer.close();
|
||||
fakeQqServer = null;
|
||||
}
|
||||
if (fakeWechatPayServer) {
|
||||
fakeWechatPayServer.close();
|
||||
fakeWechatPayServer = null;
|
||||
@@ -3921,6 +4048,7 @@ async function testPublicQuestionBankAdoption() {
|
||||
|
||||
async function testTenantAdminOps() {
|
||||
const fakeWechat = await startFakeWechatServer();
|
||||
const fakeQq = await startFakeQqServer();
|
||||
const denied = await request('/api/tenant-admin/branding', {
|
||||
method: 'PUT',
|
||||
body: { brandName: '学生不能改品牌' },
|
||||
@@ -4046,6 +4174,84 @@ async function testTenantAdminOps() {
|
||||
});
|
||||
assert.equal(miniappMe.user?.id, miniappLogin.user.id, 'wechat session should work with auth/me');
|
||||
|
||||
const wechatWebProvider = await request('/api/tenant-admin/auth-providers', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
provider: 'wechat-web',
|
||||
displayName: '微信网页登录',
|
||||
status: 'testing',
|
||||
configPublic: {
|
||||
appId: 'wx-smoke-appid',
|
||||
endpoint: fakeWechat.webTokenEndpoint,
|
||||
userInfoEndpoint: fakeWechat.webUserInfoEndpoint,
|
||||
},
|
||||
secret: {
|
||||
secretValue: 'wechat-app-secret-smoke',
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(wechatWebProvider.item?.provider, 'wechat-web', 'tenant admin should upsert WeChat web provider');
|
||||
assert.equal(wechatWebProvider.item?.configPublic?.secretRef, 'app_private.tenant_secrets:oauth:wechat-web', 'WeChat web provider should expose only secretRef');
|
||||
|
||||
const wechatWebLogin = await request('/api/auth/oauth/wechat', {
|
||||
userId: false,
|
||||
method: 'POST',
|
||||
body: {
|
||||
code: 'web-code-001',
|
||||
lang: 'zh_CN',
|
||||
},
|
||||
});
|
||||
assert.equal(wechatWebLogin.provider, 'wechat_web', 'wechat web login should return canonical provider');
|
||||
assert.equal(wechatWebLogin.user?.id, miniappLogin.user.id, 'wechat web login should merge with miniapp account by unionId');
|
||||
assert.ok(wechatWebLogin.session?.token?.startsWith('tk_'), 'wechat web login should issue API session token');
|
||||
assert.equal(wechatWebLogin.identity?.openId, 'wechat-web-openid-web-code-001', 'wechat web login should expose openId');
|
||||
assert.equal(wechatWebLogin.identity?.unionId, 'unionid-smoke-user', 'wechat web login should expose unionId');
|
||||
assert.ok(fakeWechat.requests.some(item => item.pathname === '/sns/oauth2/access_token'), 'wechat web login should exchange code server-side');
|
||||
assert.ok(fakeWechat.requests.some(item => item.pathname === '/sns/userinfo'), 'wechat web login should fetch userinfo server-side');
|
||||
assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-web-token-web-code-001'), 'wechat web response must not leak access_token');
|
||||
assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-app-secret-smoke'), 'wechat web response must not leak app secret');
|
||||
|
||||
const qqProvider = await request('/api/tenant-admin/auth-providers', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
provider: 'qq-oauth',
|
||||
displayName: 'QQ网页登录',
|
||||
status: 'testing',
|
||||
configPublic: {
|
||||
appId: 'qq-smoke-appid',
|
||||
redirectUri: 'https://h5.example.test/auth/qq/callback',
|
||||
endpoint: fakeQq.tokenEndpoint,
|
||||
openIdEndpoint: fakeQq.openIdEndpoint,
|
||||
userInfoEndpoint: fakeQq.userInfoEndpoint,
|
||||
},
|
||||
secret: {
|
||||
secretValue: 'qq-client-secret-smoke',
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(qqProvider.item?.provider, 'qq-oauth', 'tenant admin should upsert QQ OAuth provider');
|
||||
assert.equal(qqProvider.item?.configPublic?.secretRef, 'app_private.tenant_secrets:oauth:qq-oauth', 'QQ provider should expose only secretRef');
|
||||
|
||||
const qqLogin = await request('/api/auth/oauth/qq', {
|
||||
userId: false,
|
||||
method: 'POST',
|
||||
body: {
|
||||
code: 'qq-code-001',
|
||||
redirectUri: 'https://h5.example.test/auth/qq/callback',
|
||||
},
|
||||
});
|
||||
assert.equal(qqLogin.provider, 'qq', 'qq login should return canonical provider');
|
||||
assert.ok(qqLogin.user?.id, 'qq login should create or resolve user');
|
||||
assert.ok(qqLogin.session?.token?.startsWith('tk_'), 'qq login should issue API session token');
|
||||
assert.equal(qqLogin.identity?.openId, 'qq-openid-qq-code-001', 'qq login should expose openId');
|
||||
assert.ok(fakeQq.requests.some(item => item.pathname === '/oauth2.0/token'), 'qq login should exchange code server-side');
|
||||
assert.ok(fakeQq.requests.some(item => item.pathname === '/oauth2.0/me'), 'qq login should fetch openid server-side');
|
||||
assert.ok(fakeQq.requests.some(item => item.pathname === '/user/get_user_info'), 'qq login should fetch userinfo server-side');
|
||||
assert.ok(!JSON.stringify(qqLogin).includes('qq-token-qq-code-001'), 'qq response must not leak access_token');
|
||||
assert.ok(!JSON.stringify(qqLogin).includes('qq-client-secret-smoke'), 'qq response must not leak client secret');
|
||||
|
||||
const paymentAccount = await request('/api/tenant-admin/payment-accounts', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
|
||||
Reference in New Issue
Block a user