The previous commit added five direct clients (Douyin, Microsoft, Facebook, Twitter, Apple) written against each platform's own endpoints. Maintaining five bespoke OAuth implementations — including Apple's ES256 client secret and Twitter's PKCE — is maintenance the bundled yurunsoft SDK does not ask for, and none of them could be verified without production credentials. Only providers yurunsoft/yurun-oauth-login actually implements stay on the direct surface: GitHub, Gitee, QQ, Google, Weibo, Weixin, Baidu and Alipay. Baidu and Alipay move into OAuthProviderBroker's existing client() switch and normalize() map rather than living in a separate class, so there is no new layer to reason about. OAuthProviderClient, its harness and its contract test are deleted outright (-809 lines). The Apple form_post handling added to handle_callback() is reverted too — without Apple there is no POST callback to read. The aggregate-login channel keeps its 16 platforms, matching the reference; that list describes what the aggregation provider offers, not a local implementation. Brand icons for those names stay for the same reason. Verification: 869 widget/navigation tests pass, the PSR-4 audit reports 0 errors, and the catalog constant, PHP schema and Vue schema now list the same 16 aggregate platforms.
223 lines
9.4 KiB
JavaScript
223 lines
9.4 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 through the bundled SDK', () => {
|
|
const catalog = read('src/Auth/OAuthProviderCatalog.php')
|
|
const broker = read('src/Auth/OAuthProviderBroker.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(broker, new RegExp(`Yurun\\\\OAuthLogin\\\\${provider[0].toUpperCase()}${provider.slice(1)}\\\\OAuth2`), provider)
|
|
}
|
|
// 支付宝的支付网关仍在,两者互不影响。
|
|
assert.match(read('src/Payment/Gateways/AlipayGateway.php'), /class AlipayGateway/)
|
|
})
|
|
|
|
test('未提供自建客户端的平台不进入 OAuth 表面', () => {
|
|
const catalog = read('src/Auth/OAuthProviderCatalog.php')
|
|
const schema = read('src/Admin/Schema/SchemaUsers.php')
|
|
const jsSchema = read('assets/admin/src/schema/sections/users/oauth.js')
|
|
|
|
// 抖音 / Microsoft / Facebook / Twitter / Apple 只存在于聚合渠道,不再是直连平台。
|
|
for (const provider of ['douyin', 'microsoft', 'facebook', 'twitter', 'apple']) {
|
|
assert.doesNotMatch(catalog, new RegExp(`'${provider}'\\s*=>\\s*\\[`), provider)
|
|
assert.doesNotMatch(schema, new RegExp(`oauth_${provider}_s`), provider)
|
|
assert.doesNotMatch(jsSchema, new RegExp(`oauth_${provider}_s`), provider)
|
|
}
|
|
assert.equal(fs.existsSync(path.join(root, 'src/Auth/OAuthProviderClient.php')), false)
|
|
})
|
|
|
|
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', 'baidu', 'alipay']
|
|
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)
|
|
})
|