The theme shipped six direct providers while the reference carries fourteen, and its aggregate-login channel offered three of sixteen platforms. OAuthProviderClient now owns the providers the bundled SDK does not cover. Douyin, Microsoft, Facebook, Twitter and Apple talk to their documented OAuth2 endpoints directly; Baidu and Alipay reuse yurunsoft, which already ships both (Alipay needs its application private key, so provider_config learned an extra_keys map to surface it). The broker dispatches through handles(), so the existing start/callback routes stay provider-agnostic. Three provider quirks needed handling rather than a plain copy: Apple returns the callback as response_mode=form_post, and handle_callback only read $_GET, so it now reads $_POST when the request is a POST — otherwise every Apple login would fail with code_missing. Its client secret is built as an ES256 JWT from Team ID / Key ID / .p8 rather than a static string. Twitter requires PKCE. The verifier is derived from the state token, so it travels with the existing transaction row and needs no extra storage. Facebook hands back protocol-less avatar URLs, which are upgraded to https the same way the OAuth avatar resolver does. Settings gained matching toggles and credential groups on both sides — the PHP schema and the Vue schema — and the aggregate platform list went from three to sixteen, with CLoginClient's hard-coded allowlist replaced by the catalog constant. Brand icons were extended so each new provider resolves to its sprite; facebook and twitter moved to their colour sprites, and the `twitter -> x` alias was dropped since it bypassed the new mapping. The oauth-security contract previously asserted that Baidu and Alipay must not appear on the OAuth surface. That was the old intent, so the assertion is replaced with a positive one, keeping the separate check that the Alipay payment gateway still exists. Verification: a new harness stubs the HTTP layer and drives all five custom clients — authorization URLs carry redirect/state (Twitter also a PKCE challenge), identities normalize to the shared shape, Facebook's avatar is upgraded to https, Apple's private-relay email survives, and an error payload throws instead of yielding an empty identity. 871 widget/navigation tests pass and the PSR-4 audit reports 0 errors. Not covered: no live handshake happened — every provider needs real AppID and secret pairs, and Apple/Douyin require developer-account review before the full round trip can be exercised.
210 lines
8.7 KiB
JavaScript
210 lines
8.7 KiB
JavaScript
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const test = require('node:test')
|
|
const vm = require('node:vm')
|
|
const { execFileSync } = require('node:child_process')
|
|
|
|
const root = path.resolve(__dirname, '..', '..')
|
|
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
|
|
const walkFiles = (dir, extensions) => {
|
|
const result = []
|
|
for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
|
|
const relative = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) result.push(...walkFiles(relative, extensions))
|
|
else if (extensions.some((ext) => entry.name.endsWith(ext))) result.push(relative)
|
|
}
|
|
return result
|
|
}
|
|
|
|
const oauthScope = [
|
|
'src/Auth/OAuth.php',
|
|
'src/Auth/OAuthProviderCatalog.php',
|
|
'src/Auth/OAuthProviderBroker.php',
|
|
'src/Auth/OAuthAccountService.php',
|
|
'src/Auth/OAuthProxyClient.php',
|
|
'src/Auth/OAuthProxyServer.php',
|
|
'src/Auth/CLoginClient.php',
|
|
'src/Admin/Schema/SchemaUsers.php',
|
|
'assets/admin/src/schema/sections/users/oauth.js',
|
|
].map(read).join('\n')
|
|
|
|
test('OAuth PHP behavior harness passes', () => {
|
|
const output = execFileSync('php', [path.join(root, 'tests/widget-system/oauth-security-harness.php')], { encoding: 'utf8' })
|
|
assert.match(output, /OAuth security harness passed/)
|
|
})
|
|
test('Baidu and Alipay are wired into the OAuth surface', () => {
|
|
const catalog = read('src/Auth/OAuthProviderCatalog.php')
|
|
const client = read('src/Auth/OAuthProviderClient.php')
|
|
const schema = read('src/Admin/Schema/SchemaUsers.php')
|
|
|
|
for (const provider of ['baidu', 'alipay']) {
|
|
assert.match(catalog, new RegExp(`'${provider}'\\s*=>`), provider)
|
|
assert.match(schema, new RegExp(`oauth_${provider}_s`), provider)
|
|
}
|
|
// 复用打包的 yurun SDK,而不是自己重写签名。
|
|
assert.match(client, /Yurun\\OAuthLogin\\Baidu\\OAuth2/)
|
|
assert.match(client, /Yurun\\OAuthLogin\\Alipay\\OAuth2/)
|
|
// 支付宝的支付网关仍在,两者互不影响。
|
|
assert.match(read('src/Payment/Gateways/AlipayGateway.php'), /class AlipayGateway/)
|
|
})
|
|
|
|
test('Alipay payment remains available', () => {
|
|
const functions = read('functions.php')
|
|
const paymentSchema = read('src/Admin/Schema/SchemaPayment.php')
|
|
const gateway = read('src/Payment/Gateways/AlipayGateway.php')
|
|
assert.match(functions, /AlipayGateway/)
|
|
assert.match(paymentSchema, /alipay_appid/)
|
|
assert.match(paymentSchema, /alipay_privatekey/)
|
|
assert.match(gateway, /class AlipayGateway/)
|
|
})
|
|
|
|
test('OAuth uses one-time transactions and no PHP sessions', () => {
|
|
const auth = fs.readdirSync(path.join(root, 'src', 'Auth'))
|
|
.filter((name) => name.endsWith('.php'))
|
|
.map((name) => read(path.join('src', 'Auth', name)))
|
|
.join('\n')
|
|
assert.match(auth, /OAuthTransactionStore::issue/)
|
|
assert.match(auth, /OAuthTransactionStore::claim/)
|
|
assert.equal(auth.includes('session_start('), false)
|
|
assert.equal(auth.includes('$_SESSION'), false)
|
|
})
|
|
|
|
test('provider catalog is the canonical provider list', () => {
|
|
const catalog = read('src/Auth/OAuthProviderCatalog.php')
|
|
const direct = ['github', 'gitee', 'qq', 'google', 'weibo', 'weixin', 'douyin', 'baidu', 'alipay', 'microsoft', 'facebook', 'twitter', 'apple']
|
|
for (const provider of direct) {
|
|
assert.match(catalog, new RegExp(`'${provider}'\\s*=>`), provider)
|
|
}
|
|
|
|
// 聚合登录平台与参考主题一致(16 种)。
|
|
const block = catalog.slice(catalog.indexOf('CLOGIN_TYPES = ['), catalog.indexOf('];', catalog.indexOf('CLOGIN_TYPES = [')))
|
|
const types = [...block.matchAll(/'([a-z]+)'/g)].map((m) => m[1])
|
|
assert.equal(types.length, 16, types.join(','))
|
|
for (const type of ['dingtalk', 'huawei', 'xiaomi', 'alipay', 'douyin', 'apple', 'microsoft']) {
|
|
assert.ok(types.includes(type), type)
|
|
}
|
|
|
|
assert.match(catalog, /oauth_s/)
|
|
assert.match(catalog, /oauth_bind_type/)
|
|
})
|
|
|
|
test('verified email is required for email auto-linking', () => {
|
|
const account = read('src/Auth/OAuthAccountService.php')
|
|
const broker = read('src/Auth/OAuthProviderBroker.php')
|
|
assert.match(account, /!empty\(\$identity\['email_verified'\]\)/)
|
|
assert.match(broker, /primary.*verified/s)
|
|
assert.match(broker, /email_verified.*Google|Google.*email_verified/s)
|
|
})
|
|
|
|
test('OAuth secrets are marked and masked by the schema provider', () => {
|
|
const phpSchema = read('src/Admin/Schema/SchemaUsers.php')
|
|
const jsSchema = read('assets/admin/src/schema/sections/users/oauth.js')
|
|
const provider = read('src/Admin/SchemaProvider.php')
|
|
for (const key of [
|
|
'oauth_proxy_secret',
|
|
'github_appkey',
|
|
'gitee_appkey',
|
|
'qq_appkey',
|
|
'google_client_secret',
|
|
'weibo_appkey',
|
|
'weixin_appsecret',
|
|
'weixingzh_token',
|
|
'weixingzh_appsecret',
|
|
'clogin_key',
|
|
]) {
|
|
assert.match(phpSchema, new RegExp(`${key}[^\\n]+secret`))
|
|
assert.match(jsSchema, new RegExp(`${key}[^\\n]+secret`))
|
|
}
|
|
assert.match(provider, /\*\*\*/)
|
|
assert.match(provider, /!empty\(\$field\['secret'\]\)/)
|
|
})
|
|
|
|
test('PHP and JavaScript OAuth schema field keys stay in parity', () => {
|
|
const php = read('src/Admin/Schema/SchemaUsers.php')
|
|
const js = read('assets/admin/src/schema/sections/users/oauth.js')
|
|
const start = php.indexOf("'oauth' => [")
|
|
const phpSection = php.slice(start, php.indexOf("'sms' => [", start))
|
|
const phpKeys = [...phpSection.matchAll(/\['key' => '([^']+)', 'label'/g)].map((match) => match[1])
|
|
const jsKeys = [...js.matchAll(/\{ key: '([^']+)', label:/g)].map((match) => match[1])
|
|
assert.deepEqual([...new Set(phpKeys)].sort(), [...new Set(jsKeys)].sort())
|
|
})
|
|
test('OAuth bind starts require a one-time captcha bind pass', () => {
|
|
const oauth = read('src/Auth/OAuth.php')
|
|
const weixingzh = read('src/Auth/WeixinGzh.php')
|
|
const bindings = read('inc/helpers/user-center-renderers/settings-bindings.php')
|
|
assert.match(oauth, /\/oauth\/bind-pass/)
|
|
assert.match(oauth, /BIND_PASS_KIND = 'oauth_bind_pass'/)
|
|
assert.match(oauth, /OAuthTransactionStore::issue\(self::BIND_PASS_KIND/)
|
|
assert.match(oauth, /OAuthTransactionStore::claim\(\$token, self::BIND_PASS_KIND, true\)/)
|
|
assert.match(oauth, /validate_bind_pass\(\$bind_pass, \$provider, \$user_id, true\)/)
|
|
assert.match(oauth, /validate_bind_pass\(\$bind_pass, 'clogin_' \. \$type, get_current_user_id\(\), true\)/)
|
|
assert.match(weixingzh, /OAuth::validate_bind_pass\(\$bind_pass, 'weixingzh'/)
|
|
assert.match(bindings, /jio_render_captcha\('bind'\)/)
|
|
})
|
|
|
|
test('OAuth result handler maps safe errors and preserves unrelated URL state', () => {
|
|
const notices = []
|
|
let replaced = ''
|
|
const listeners = {}
|
|
const location = new URL('https://site.test/sign/?oauth_action=error&oauth_provider=github&oauth_error=%3Cscript%3E&keep=1#section')
|
|
const documentElement = { dataset: {}, contains: () => true }
|
|
const document = {
|
|
readyState: 'complete',
|
|
documentElement,
|
|
visibilityState: 'visible',
|
|
querySelectorAll: () => [],
|
|
querySelector: () => null,
|
|
addEventListener: (name, fn) => { listeners[name] = fn },
|
|
activeElement: null,
|
|
body: { classList: { add() {}, remove() {} } },
|
|
}
|
|
let authInit = null
|
|
const jQueryApi = {
|
|
off() { return jQueryApi },
|
|
on(_name, fn) { authInit = fn; return jQueryApi },
|
|
}
|
|
const jQueryStub = () => jQueryApi
|
|
const window = {
|
|
jioData: {},
|
|
URL,
|
|
location,
|
|
history: { replaceState: (_state, _title, value) => { replaced = value } },
|
|
notyf: (message, type) => notices.push({ message, type }),
|
|
matchMedia: () => ({ matches: false }),
|
|
setTimeout,
|
|
clearTimeout,
|
|
setInterval,
|
|
clearInterval,
|
|
localStorage: { getItem: () => null, setItem() {} },
|
|
}
|
|
vm.runInNewContext(read('assets/js/auth.js'), {
|
|
window,
|
|
document,
|
|
URL,
|
|
FormData: class {},
|
|
fetch: () => Promise.reject(new Error('unexpected fetch')),
|
|
console,
|
|
CustomEvent: class {},
|
|
AbortController,
|
|
Date,
|
|
Math,
|
|
Promise,
|
|
jQuery: jQueryStub,
|
|
})
|
|
if (authInit) authInit()
|
|
assert.deepEqual(notices, [{ message: '第三方授权失败,请重试', type: 'danger' }])
|
|
assert.equal(replaced, '/sign/?keep=1#section')
|
|
assert.equal(replaced.includes('script'), false)
|
|
})
|
|
test('obsolete OAuth admin routes and components are removed', () => {
|
|
const controller = read('src/Admin/UserController.php')
|
|
const usersApi = read('assets/admin/src/api/modules/users.js')
|
|
assert.equal(controller.includes("'/settings/oauth'"), false)
|
|
assert.equal(controller.includes("'/settings/oauth-proxy'"), false)
|
|
assert.equal(usersApi.includes('getOAuthSettings'), false)
|
|
assert.equal(usersApi.includes('saveOAuthSettings'), false)
|
|
assert.equal(fs.existsSync(path.join(root, 'assets/admin/src/components/OAuthProviderForm.vue')), false)
|
|
assert.equal(fs.existsSync(path.join(root, 'assets/admin/src/pages/users/UsersSectionPage.vue')), false)
|
|
})
|