fix(auth): harden WeChat login and explicit identity switching
This commit is contained in:
@@ -9,6 +9,8 @@ import { createRequire } from 'node:module';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
const require = createRequire(import.meta.url);
|
||||
const createAccounts = require('../lib/accounts');
|
||||
const SkinScore = require('../js/skin-score');
|
||||
const ReportUI = require('../js/report-ui');
|
||||
const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-wechat-test-'));
|
||||
const h5 = fileURLToPath(new URL('../', import.meta.url));
|
||||
@@ -35,12 +37,25 @@ try {
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const base = 'https://skin.example.test:' + server.address().port + '/state/';
|
||||
accounts = createAccounts(path.join(root, 'accounts'), { WECHAT_APP_ID: 'test-app', WECHAT_APP_SECRET: 'test-secret', PUBLIC_BASE_URL: base });
|
||||
let upstreamError = false, openid = 'test-same-wechat-user';
|
||||
global.fetch = async url => {
|
||||
assert.equal(new URL(url).origin, 'https://api.weixin.qq.com');
|
||||
return { ok: true, json: async () => ({ openid: 'test-same-wechat-user' }) };
|
||||
return { ok: true, status: 200, json: async () => upstreamError ? { errcode: 40029 } : { openid } };
|
||||
};
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-proxy-server', '--host-resolver-rules=MAP skin.example.test 127.0.0.1'] });
|
||||
// Fetch in Chromium so its test-host resolver and cookie jar are both used.
|
||||
async function api(page, route, data) {
|
||||
return page.evaluate(async ({ route, data }) => {
|
||||
const response = await fetch('api/' + route, { method: data ? 'POST' : 'GET',
|
||||
headers: data ? { 'Content-Type': 'application/json' } : {}, body: data ? JSON.stringify(data) : undefined });
|
||||
return { status: response.status, data: await response.json() };
|
||||
}, { route, data });
|
||||
}
|
||||
let firstId;
|
||||
const vm = SkinScore.buildViewModel(SkinScore.MOCK_RESULT, SkinScore.MOCK_SIDE, { realAge: 30 });
|
||||
const saved = { id: 'RwechatCrossDevice', created_at: new Date().toISOString(), vm,
|
||||
presentation: ReportUI.build(vm, SkinScore.MOCK_RESULT), photos: [null, null, null],
|
||||
payload: { result: SkinScore.MOCK_RESULT, side: SkinScore.MOCK_SIDE } };
|
||||
for (let device = 0; device < 2; device++) {
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true, userAgent: 'Mozilla/5.0 MicroMessenger/8.0', viewport: { width: 375, height: 726 } });
|
||||
const page = await context.newPage();
|
||||
@@ -56,22 +71,91 @@ try {
|
||||
});
|
||||
await page.goto(base);
|
||||
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
|
||||
let browserId, browserToken;
|
||||
if (device === 0) {
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
browserId = await page.evaluate(() => AccountClient.user().id);
|
||||
browserToken = await page.evaluate(() => localStorage.getItem('juzi.identity.v1'));
|
||||
accounts.storeReport(browserId, { ...saved, id: 'RbrowserOriginal' });
|
||||
await page.click('[data-action="history"]');
|
||||
await page.waitForSelector('[data-report-id="RbrowserOriginal"]');
|
||||
await page.click('[data-action="switch-login"]');
|
||||
assert.equal(await page.locator('#login-consent').isChecked(), false);
|
||||
assert((await page.locator('#login-message').textContent()).includes('报告不会自动合并'));
|
||||
upstreamError = true;
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-wechat"]');
|
||||
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('微信授权未完成'));
|
||||
assert.equal(await page.locator('#s-login.active').count(), 1, 'OAuth failure must not masquerade as browser login success');
|
||||
await page.waitForFunction(() => !location.search.includes('login_error'));
|
||||
upstreamError = false;
|
||||
}
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-wechat"]');
|
||||
// Redirects may preserve WeChat's fragment; it is not sent to the backend.
|
||||
await page.waitForURL(url => url.origin + url.pathname === base && url.searchParams.get('login') === 'success');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
await page.waitForFunction(() => !location.search.includes('login=success'));
|
||||
const user = await page.evaluate(() => AccountClient.user());
|
||||
assert.equal(user.kind, 'wechat');
|
||||
if (firstId) assert.equal(user.id, firstId); else firstId = user.id;
|
||||
if (device === 0) accounts.storeReport(user.id, saved);
|
||||
const cookie = (await context.cookies()).find(c => c.name === 'juzi_session');
|
||||
assert(cookie.secure && cookie.httpOnly && cookie.path === '/state/' && cookie.sameSite === 'Lax');
|
||||
await page.click('[data-action="history"]');
|
||||
await page.waitForFunction(() => document.querySelector('#identity-note').textContent.includes('同一微信账号'));
|
||||
await page.waitForSelector('[data-report-id="RwechatCrossDevice"]');
|
||||
assert.equal(await page.locator('[data-report-id="RbrowserOriginal"]').count(), 0);
|
||||
assert.deepEqual((await api(page, 'reports/' + saved.id)).data.report, JSON.parse(JSON.stringify(saved)));
|
||||
if (device === 0) {
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('juzi.identity.v1')), browserToken);
|
||||
await context.clearCookies();
|
||||
await page.reload();
|
||||
await page.waitForFunction(() => !document.querySelector('#login-message').textContent.includes('检查'));
|
||||
assert.equal(await page.locator('#s-login.active').count(), 1, 'lost WeChat session must not silently restore the browser account');
|
||||
assert.equal((await api(page, 'auth/session')).data.user, null);
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-wechat"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
assert.equal(await page.evaluate(() => AccountClient.user().id), firstId);
|
||||
await page.click('[data-action="history"]');
|
||||
await page.click('[data-action="switch-login"]');
|
||||
await page.check('#login-consent');
|
||||
await page.click('[data-action="login-browser"]');
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
|
||||
await page.click('[data-action="history"]');
|
||||
await page.waitForSelector('[data-report-id="RbrowserOriginal"]');
|
||||
assert.equal(await page.locator('[data-report-id="RwechatCrossDevice"]').count(), 0);
|
||||
assert.equal((await api(page, 'reports/' + saved.id)).status, 404);
|
||||
// An OAuth success URL with a lost cookie must not restore an unrelated browser credential.
|
||||
await context.clearCookies();
|
||||
await page.goto(base + '?login=success');
|
||||
await page.waitForFunction(() => document.querySelector('#login-message').textContent.includes('会话未能保存'));
|
||||
assert.equal((await api(page, 'auth/session')).data.user, null);
|
||||
await page.waitForFunction(() => !location.search.includes('login=success'));
|
||||
await page.reload(); // Ordinary browser restore is still supported.
|
||||
await page.waitForSelector('#s-splash.active');
|
||||
assert.equal(await page.evaluate(() => AccountClient.user().id), browserId);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
}
|
||||
console.log('PASS: HTTPS /state/ login → simulated WeChat → callback with bound Secure cookie → homepage/history; two browser contexts share WeChat identity');
|
||||
// Different WeChat users must never see each other's reports.
|
||||
openid = 'test-different-wechat-user';
|
||||
const stranger = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const strangerPage = await stranger.newPage();
|
||||
await strangerPage.goto(base);
|
||||
const start = await api(strangerPage, 'auth/wechat', { consent: true });
|
||||
const auth = new URL(start.data.url), callback = new URL(auth.searchParams.get('redirect_uri'));
|
||||
callback.search = new URLSearchParams({ code: 'test-code', state: auth.searchParams.get('state') });
|
||||
await strangerPage.goto(callback.href);
|
||||
const strangerUser = (await api(strangerPage, 'auth/session')).data.user;
|
||||
assert(strangerUser && strangerUser.id !== firstId);
|
||||
assert.equal((await api(strangerPage, 'reports/' + saved.id)).status, 404);
|
||||
assert.equal((await api(strangerPage, 'reports')).data.total, 0);
|
||||
await stranger.close();
|
||||
console.log('PASS: HTTPS /state/ simulated WeChat login, Secure cookies, same identity/report across devices, different-user isolation, explicit account switching, preserved browser reports, OAuth failure/missing-cookie handling, no silent browser fallback');
|
||||
} finally {
|
||||
global.fetch = realFetch;
|
||||
if (browser) await browser.close();
|
||||
|
||||
Reference in New Issue
Block a user