This commit is contained in:
gr
2026-11-04 14:16:38 +08:00
parent b7c3a962dd
commit 6a2b387edc
258 changed files with 1559 additions and 53 deletions
+38 -13
View File
@@ -1,4 +1,5 @@
import { t } from '#/locales/t';
import { createSHA256 } from 'hash-wasm';
/** Browser proof of the initiating tab. Identity is exchanged by our server. */
export const GROUP_TRANSACTION_KEY = 'kefu:group-login:v1';
const TTL = 5 * 60 * 1000;
@@ -9,44 +10,50 @@ export interface GroupConfig {
client_id?: string;
enabled: boolean;
entry?: string;
http_callback_allowed?: boolean;
redirect_uri?: string;
}
function safeUrl(value: unknown): URL {
type CheckedGroupConfig = GroupConfig & Required<Pick<GroupConfig,
'authorize_url' | 'client_id' | 'entry' | 'redirect_uri'>>;
function safeUrl(value: unknown, approvedHttpOrigin?: string): URL {
if (typeof value !== 'string' || !value || value.length > 2048 || /[\s\u0000-\u001F\u007F\\%?#]/.test(value)) {
throw new Error(t('集团登录地址配置无效'));
}
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
const loopback = hostname === 'localhost' || hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(hostname);
if (url.username || url.password || url.search || url.hash ||
!(url.protocol === 'https:' || (url.protocol === 'http:' && loopback)) ||
if (url.username || url.password || /^[a-z]+:\/\/[^/]*@/i.test(value) || url.search || url.hash ||
!(url.protocol === 'https:' || (url.protocol === 'http:' && (loopback || url.origin === approvedHttpOrigin))) ||
/(?:^|\/)\.{1,2}(?:\/|$)/.test(value.slice(value.indexOf('://') + 3))) {
throw new Error(t('集团登录地址配置无效'));
}
return url;
}
export function validateGroupConfig(config: GroupConfig, origin: string): GroupConfig & Required<Omit<GroupConfig, 'enabled'>> {
export function validateGroupConfig(config: GroupConfig, origin: string): CheckedGroupConfig {
if (!config || config.enabled !== true || config.client_id !== 'kefu') {
throw new Error(t('集团登录尚未启用'));
}
const entry = safeUrl(config.entry);
const authorize = safeUrl(config.authorize_url);
const callback = safeUrl(config.redirect_uri);
const callback = safeUrl(config.redirect_uri, config.http_callback_allowed === true ? origin : undefined);
const expectedAuthorize = `${entry.href.replace(/\/$/, '')}/sso/authorize`;
if (authorize.href !== expectedAuthorize || callback.origin !== origin ||
callback.pathname !== '/auth/group-login') {
throw new Error(t('集团登录地址配置无效'));
}
return config as GroupConfig & Required<Omit<GroupConfig, 'enabled'>>;
return config as CheckedGroupConfig;
}
/** Reach the configured callback origin before storing its tab-bound PKCE proof. */
export function groupLoginEntry(config: GroupConfig, origin: string): string {
const callback = safeUrl(config?.redirect_uri);
const approvedHttpOrigin = config?.http_callback_allowed === true && typeof config.redirect_uri === 'string'
? new URL(config.redirect_uri).origin : undefined;
const callback = safeUrl(config?.redirect_uri, approvedHttpOrigin);
validateGroupConfig(config, callback.origin);
const current = safeUrl(origin);
const current = safeUrl(origin, approvedHttpOrigin);
if (current.href !== `${current.origin}/`) throw new Error(t('集团登录地址配置无效'));
return callback.origin === current.origin ? '/auth/group-login' : callback.href;
}
@@ -59,13 +66,25 @@ export async function beginGroupLogin(config: GroupConfig, origin = window.locat
storage: Storage = sessionStorage, webCrypto: Crypto = crypto, now = Date.now()): Promise<string> {
storage.removeItem(GROUP_TRANSACTION_KEY);
const checked = validateGroupConfig(config, origin);
if (!webCrypto?.subtle || !webCrypto.getRandomValues) {
if (!webCrypto?.getRandomValues) {
throw new Error(t('请使用支持安全登录的浏览器和 HTTPS 地址'));
}
const state = base64url(webCrypto.getRandomValues(new Uint8Array(32)));
const verifier = base64url(webCrypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(new Uint8Array(await webCrypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));
storage.setItem(GROUP_TRANSACTION_KEY, JSON.stringify({ state, verifier, created: now }));
const input = new TextEncoder().encode(verifier);
let digest: Uint8Array;
if (webCrypto.subtle) {
digest = new Uint8Array(await webCrypto.subtle.digest('SHA-256', input));
} else {
// HTTP still has secure randomness; the existing WASM hasher supplies S256.
const hasher = await createSHA256();
hasher.init();
hasher.update(input);
digest = hasher.digest('binary');
}
const challenge = base64url(digest);
storage.setItem(GROUP_TRANSACTION_KEY, JSON.stringify({ state, verifier, created: now,
callback: checked.redirect_uri, httpCallbackAllowed: checked.http_callback_allowed === true }));
const target = new URL(checked.authorize_url);
target.search = new URLSearchParams({ client_id: 'kefu', redirect_uri: checked.redirect_uri,
state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
@@ -77,7 +96,8 @@ export function consumeGroupCallback(href: string, storage: Storage = sessionSto
storage.removeItem(GROUP_TRANSACTION_KEY);
const url = new URL(href);
const keys = [...url.searchParams.keys()];
if (url.hash || keys.length !== 2 || keys.some(key => !['code', 'state'].includes(key)) ||
if (url.username || url.password || /^[a-z]+:\/\/[^/]*@/i.test(href) || url.hash ||
keys.length !== 2 || keys.some(key => !['code', 'state'].includes(key)) ||
url.searchParams.getAll('code').length !== 1 || url.searchParams.getAll('state').length !== 1) {
throw new Error(t('集团登录回调无效,请重新登录'));
}
@@ -86,12 +106,17 @@ export function consumeGroupCallback(href: string, storage: Storage = sessionSto
if (!/^[A-Za-z0-9_-]{32,256}$/.test(code) || !SAFE_TOKEN.test(state) || !raw) {
throw new Error(t('集团登录已过期,请重新登录'));
}
let transaction: { created?: unknown; state?: unknown; verifier?: unknown };
let transaction: { callback?: unknown; created?: unknown; httpCallbackAllowed?: unknown;
state?: unknown; verifier?: unknown };
try { transaction = JSON.parse(raw); } catch { throw new Error(t('集团登录已过期,请重新登录')); }
if (!transaction || typeof transaction.created !== 'number' || !Number.isFinite(transaction.created) ||
now < transaction.created || now - transaction.created > TTL || transaction.state !== state ||
typeof transaction.verifier !== 'string' || !SAFE_TOKEN.test(transaction.verifier)) {
throw new Error(t('集团登录已过期,请重新登录'));
}
const callback = safeUrl(transaction.callback, transaction.httpCallbackAllowed === true ? url.origin : undefined);
if (callback.href !== `${url.origin}${url.pathname}` || callback.pathname !== '/auth/group-login') {
throw new Error(t('集团登录回调无效,请重新登录'));
}
return { code, code_verifier: transaction.verifier };
}
@@ -7,9 +7,10 @@ import vm from 'node:vm';
const require = createRequire(import.meta.url);
const ts = require(process.env.KEFU_SSO_TYPESCRIPT || 'typescript');
const hashWasm = require(process.env.KEFU_SSO_HASH_WASM || 'hash-wasm');
function loadTs(relative, dependencies = {}) {
dependencies = { '#/locales/t': { t: key => key }, ...dependencies };
dependencies = { '#/locales/t': { t: key => key }, 'hash-wasm': hashWasm, ...dependencies };
const source = fs.readFileSync(new URL(relative, import.meta.url), 'utf8');
const result = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.CommonJS, esModuleInterop: true }, reportDiagnostics: true });
@@ -26,6 +27,10 @@ const config = { enabled: true, client_id: 'kefu', entry: 'http://127.0.0.1:5173
authorize_url: 'http://127.0.0.1:5173/sso/authorize',
redirect_uri: 'http://127.0.0.1:5666/auth/group-login' };
const origin = 'http://127.0.0.1:5666';
const publicOrigin = 'http://im.zhenyangtang.cn:8369';
const publicConfig = { enabled: true, client_id: 'kefu', entry: 'https://x.zhenyangtang.com.cn',
authorize_url: 'https://x.zhenyangtang.com.cn/sso/authorize',
redirect_uri: `${publicOrigin}/auth/group-login`, http_callback_allowed: true };
const now = 1_000_000;
const code = 'C'.repeat(43);
@@ -60,7 +65,9 @@ test('authorization uses random state, S256 PKCE and a fixed client/callback', a
assert.match(pending.state, /^[A-Za-z0-9_-]{43}$/);
assert.match(pending.verifier, /^[A-Za-z0-9_-]{43}$/);
assert.notEqual(pending.state, pending.verifier);
assert.deepEqual(Object.keys(pending).sort(), ['created', 'state', 'verifier']);
assert.deepEqual(Object.keys(pending).sort(), ['callback', 'created', 'httpCallbackAllowed', 'state', 'verifier']);
assert.equal(pending.callback, config.redirect_uri);
assert.equal(pending.httpCallbackAllowed, false);
assert.equal(target.origin, 'http://127.0.0.1:5173');
assert.equal(target.pathname, '/sso/authorize');
assert.deepEqual([...target.searchParams.keys()].sort(),
@@ -106,6 +113,79 @@ test('entry/callback validation rejects insecure and attacker controlled configu
}
});
test('approved HTTP callback keeps its exact port and cannot approve HTTP Group URLs', async () => {
assert.equal(sso.groupLoginEntry(publicConfig, publicOrigin), '/auth/group-login');
assert.equal(sso.groupLoginEntry(publicConfig, 'https://other.example'), publicConfig.redirect_uri);
assert.equal(sso.validateGroupConfig(publicConfig, publicOrigin).redirect_uri, publicConfig.redirect_uri);
for (const changedOrigin of ['http://im.zhenyangtang.cn', 'http://im.zhenyangtang.cn:8370',
'http://im.zhenyangtang.cn.evil:8369']) {
assert.throws(() => sso.validateGroupConfig(publicConfig, changedOrigin));
assert.throws(() => sso.groupLoginEntry(publicConfig, changedOrigin));
await assert.rejects(sso.beginGroupLogin(publicConfig, changedOrigin, storage(), webcrypto, now));
}
for (const http_callback_allowed of [false, undefined, 'true', 1]) {
assert.throws(() => sso.validateGroupConfig({ ...publicConfig, http_callback_allowed }, publicOrigin));
assert.throws(() => sso.groupLoginEntry({ ...publicConfig, http_callback_allowed }, publicOrigin));
}
for (const changed of [
{ redirect_uri: `http://user@im.zhenyangtang.cn:8369/auth/group-login` },
{ redirect_uri: `http://@im.zhenyangtang.cn:8369/auth/group-login` },
{ redirect_uri: `${publicOrigin}/other` },
{ entry: 'http://x.zhenyangtang.com.cn', authorize_url: 'http://x.zhenyangtang.com.cn/sso/authorize' },
{ authorize_url: 'http://x.zhenyangtang.com.cn/sso/authorize' },
]) {
assert.throws(() => sso.validateGroupConfig({ ...publicConfig, ...changed }, publicOrigin));
assert.throws(() => sso.groupLoginEntry({ ...publicConfig, ...changed }, publicOrigin));
}
});
test('HTTP without subtle uses real hash-wasm for the known S256 vector and a port-bound callback', async () => {
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
let randomCalls = 0;
const httpCrypto = { getRandomValues(bytes) {
assert.equal(bytes.length, 32);
bytes.set(++randomCalls === 2 ? Buffer.from(verifier, 'base64url') : new Uint8Array(32).fill(17));
return bytes;
} };
const state = storage();
const target = new URL(await sso.beginGroupLogin(publicConfig, publicOrigin, state, httpCrypto, now));
assert.equal(randomCalls, 2);
assert.equal(target.origin, publicConfig.entry);
assert.equal(target.searchParams.get('redirect_uri'), `${publicOrigin}/auth/group-login`);
assert.equal(target.searchParams.get('code_challenge_method'), 'S256');
assert.equal(target.searchParams.get('code_challenge'), 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
assert.notEqual(target.searchParams.get('code_challenge'), verifier);
const pending = JSON.parse(state.getItem(sso.GROUP_TRANSACTION_KEY));
assert.equal(pending.verifier, verifier);
assert.equal(pending.callback, publicConfig.redirect_uri);
assert.equal(pending.httpCallbackAllowed, true);
const href = `${publicConfig.redirect_uri}?code=${code}&state=${pending.state}`;
assert.equal(sso.consumeGroupCallback(href, state, now).code_verifier, verifier);
assert.equal(state.values.size, 0);
});
test('HTTP crypto keeps secure randomness and rejects callbacks from another origin or path', async () => {
const httpCrypto = { getRandomValues: webcrypto.getRandomValues.bind(webcrypto) };
for (const prefix of ['http://im.zhenyangtang.cn/auth/group-login',
'http://im.zhenyangtang.cn:8370/auth/group-login', 'https://im.zhenyangtang.cn:8369/auth/group-login',
'http://im.zhenyangtang.cn.evil:8369/auth/group-login', `${publicOrigin}/other`,
'http://user@im.zhenyangtang.cn:8369/auth/group-login',
'http://@im.zhenyangtang.cn:8369/auth/group-login']) {
const state = storage();
const target = new URL(await sso.beginGroupLogin(publicConfig, publicOrigin, state, httpCrypto, now));
const pending = JSON.parse(state.getItem(sso.GROUP_TRANSACTION_KEY));
assert.notEqual(pending.state, pending.verifier);
const digest = Buffer.from(await webcrypto.subtle.digest('SHA-256', new TextEncoder().encode(pending.verifier))).toString('base64url');
assert.equal(target.searchParams.get('code_challenge'), digest);
assert.throws(() => sso.consumeGroupCallback(`${prefix}?code=${code}&state=${pending.state}`, state, now));
assert.equal(state.values.size, 0);
}
const state = storage();
state.setItem(sso.GROUP_TRANSACTION_KEY, 'previous');
await assert.rejects(sso.beginGroupLogin(publicConfig, publicOrigin, state, { subtle: webcrypto.subtle }, now));
assert.equal(state.values.size, 0);
});
test('starting another login clears the old transaction', async () => {
const { state, pending } = await transaction();
await sso.beginGroupLogin(config, origin, state, webcrypto, now);