更新
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user