diff --git a/assets/admin/src/schema/sections/users/oauth.js b/assets/admin/src/schema/sections/users/oauth.js index 48f47490..ce579ea2 100644 --- a/assets/admin/src/schema/sections/users/oauth.js +++ b/assets/admin/src/schema/sections/users/oauth.js @@ -2,7 +2,7 @@ export const oauthSchema = { key: 'users.oauth', title: '第三方登录', icon: 'fa fas fa-share-alt', - description: 'GitHub、Gitee、QQ、Google、微博和微信第三方登录配置。', + description: '第三方登录配置:QQ、微信、微博、GitHub、Gitee、Google、抖音、百度、支付宝、Microsoft、Facebook、Twitter、Apple 以及彩虹聚合登录。', defaults: { oauth_s: false, oauth_proxy_mode: 'close', @@ -20,6 +20,13 @@ export const oauthSchema = { oauth_weibo_s: false, oauth_weixin_s: false, oauth_weixingzh_s: false, + oauth_douyin_s: false, + oauth_baidu_s: false, + oauth_alipay_s: false, + oauth_microsoft_s: false, + oauth_facebook_s: false, + oauth_twitter_s: false, + oauth_apple_s: false, clogin_s: false, github_appid: '', github_appkey: '', @@ -33,6 +40,24 @@ export const oauthSchema = { weibo_appkey: '', weixin_appid: '', weixin_appsecret: '', + douyin_appid: '', + douyin_appkey: '', + baidu_appid: '', + baidu_appkey: '', + alipay_appid: '', + alipay_appkey: '', + alipay_private_key: '', + microsoft_appid: '', + microsoft_appkey: '', + facebook_appid: '', + facebook_appkey: '', + twitter_appid: '', + twitter_appkey: '', + apple_appid: '', + apple_appkey: '', + apple_team_id: '', + apple_key_id: '', + apple_private_key: '', weixingzh_gzh_type: 'service', weixingzh_token: '', weixingzh_appid: '', @@ -57,8 +82,8 @@ export const oauthSchema = { { key: 'oauth_proxy_mode', label: '代理登录模式', type: 'radio', default: 'close', options: [{ label: '关闭', value: 'close' }, { label: '服务端', value: 'server' }, { label: '客户端', value: 'client' }] }, { key: 'oauth_proxy_secret', label: '代理 HMAC 密钥', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'oauth_proxy_mode', not: 'close' } }, { key: 'oauth_proxy_server_url', label: '服务端地址', type: 'input', default: '', dependsOn: { key: 'oauth_proxy_mode', value: 'client' } }, - { key: 'oauth_proxy_server_providers', label: '服务端允许平台', type: 'checkbox', default: ['github', 'gitee', 'qq', 'google'], options: [{ label: 'GitHub', value: 'github' }, { label: 'Gitee', value: 'gitee' }, { label: 'QQ', value: 'qq' }, { label: 'Google', value: 'google' }, { label: '微博', value: 'weibo' }, { label: '微信', value: 'weixin' }], dependsOn: { key: 'oauth_proxy_mode', value: 'server' } }, - { key: 'oauth_proxy_client_providers', label: '客户端启用平台', type: 'checkbox', default: ['github', 'gitee', 'qq', 'google'], options: [{ label: 'GitHub', value: 'github' }, { label: 'Gitee', value: 'gitee' }, { label: 'QQ', value: 'qq' }, { label: 'Google', value: 'google' }, { label: '微博', value: 'weibo' }, { label: '微信', value: 'weixin' }], dependsOn: { key: 'oauth_proxy_mode', value: 'client' } }, + { key: 'oauth_proxy_server_providers', label: '服务端允许平台', type: 'checkbox', default: ['github', 'gitee', 'qq', 'google'], options: [{ label: 'GitHub', value: 'github' }, { label: 'Gitee', value: 'gitee' }, { label: 'QQ', value: 'qq' }, { label: 'Google', value: 'google' }, { label: '微博', value: 'weibo' }, { label: '微信', value: 'weixin' }, { label: '抖音', value: 'douyin' }, { label: '百度', value: 'baidu' }, { label: '支付宝', value: 'alipay' }, { label: 'Microsoft', value: 'microsoft' }, { label: 'Facebook', value: 'facebook' }, { label: 'Twitter', value: 'twitter' }, { label: 'Apple', value: 'apple' }], dependsOn: { key: 'oauth_proxy_mode', value: 'server' } }, + { key: 'oauth_proxy_client_providers', label: '客户端启用平台', type: 'checkbox', default: ['github', 'gitee', 'qq', 'google'], options: [{ label: 'GitHub', value: 'github' }, { label: 'Gitee', value: 'gitee' }, { label: 'QQ', value: 'qq' }, { label: 'Google', value: 'google' }, { label: '微博', value: 'weibo' }, { label: '微信', value: 'weixin' }, { label: '抖音', value: 'douyin' }, { label: '百度', value: 'baidu' }, { label: '支付宝', value: 'alipay' }, { label: 'Microsoft', value: 'microsoft' }, { label: 'Facebook', value: 'facebook' }, { label: 'Twitter', value: 'twitter' }, { label: 'Apple', value: 'apple' }], dependsOn: { key: 'oauth_proxy_mode', value: 'client' } }, { key: 'oauth_bind_type', label: '绑定方式', type: 'radio', default: 'auto', options: [{ label: '自动绑定', value: 'auto' }, { label: '手动绑定', value: 'manual' }] }, { key: 'oauth_button_lg', label: '大按钮样式', type: 'switch', default: false }, { key: 'weixingzh_priority', label: 'PC端优先微信扫码', type: 'switch', default: false, help: '开启后 PC 端登录框优先显示微信公众号扫码' }, @@ -76,7 +101,14 @@ export const oauthSchema = { { key: 'oauth_weibo_s', label: '微博登录', type: 'switch', default: false }, { key: 'oauth_weixin_s', label: '微信开放平台登录', type: 'switch', default: false }, { key: 'oauth_weixingzh_s', label: '微信公众号登录', type: 'switch', default: false }, - { key: 'clogin_s', label: '彩虹聚合登录', type: 'switch', default: false, help: '通过第三方聚合登录平台统一接入 QQ、微信和微博' }, + { key: 'oauth_douyin_s', label: '抖音登录', type: 'switch', default: false }, + { key: 'oauth_baidu_s', label: '百度登录', type: 'switch', default: false }, + { key: 'oauth_alipay_s', label: '支付宝登录', type: 'switch', default: false }, + { key: 'oauth_microsoft_s', label: 'Microsoft 登录', type: 'switch', default: false }, + { key: 'oauth_facebook_s', label: 'Facebook 登录', type: 'switch', default: false }, + { key: 'oauth_twitter_s', label: 'Twitter 登录', type: 'switch', default: false }, + { key: 'oauth_apple_s', label: 'Apple 登录', type: 'switch', default: false }, + { key: 'clogin_s', label: '彩虹聚合登录', type: 'switch', default: false, help: '通过第三方聚合登录平台统一接入多个平台' }, ], }, { @@ -145,6 +177,87 @@ export const oauthSchema = { { key: 'weixin_appsecret', label: 'AppSecret', type: 'input', inputType: 'password', secret: true, default: '' }, ], }, + { + key: 'douyin', + title: '抖音配置', + icon: 'fa fas fa-music', + collapsible: true, + defaultCollapsed: true, + fields: [ + { key: 'douyin_appid', label: 'Client Key', type: 'input', default: '' }, + { key: 'douyin_appkey', label: 'Client Secret', type: 'input', inputType: 'password', secret: true, default: '' }, + ], + }, + { + key: 'baidu', + title: '百度配置', + icon: 'fa fas fa-paw', + collapsible: true, + defaultCollapsed: true, + fields: [ + { key: 'baidu_appid', label: 'API Key', type: 'input', default: '' }, + { key: 'baidu_appkey', label: 'Secret Key', type: 'input', inputType: 'password', secret: true, default: '' }, + ], + }, + { + key: 'alipay', + title: '支付宝配置', + icon: 'fa fab fa-alipay', + collapsible: true, + defaultCollapsed: true, + fields: [ + { key: 'alipay_appid', label: 'App ID', type: 'input', default: '' }, + { key: 'alipay_appkey', label: '支付宝公钥', type: 'textarea', default: '' }, + { key: 'alipay_private_key', label: '应用私钥', type: 'textarea', secret: true, default: '', help: 'RSA2 签名使用的应用私钥(PKCS8 格式)' }, + ], + }, + { + key: 'microsoft', + title: 'Microsoft 配置', + icon: 'fa fab fa-microsoft', + collapsible: true, + defaultCollapsed: true, + fields: [ + { key: 'microsoft_appid', label: 'Application (client) ID', type: 'input', default: '' }, + { key: 'microsoft_appkey', label: 'Client Secret', type: 'input', inputType: 'password', secret: true, default: '' }, + ], + }, + { + key: 'facebook', + title: 'Facebook 配置', + icon: 'fa fab fa-facebook', + collapsible: true, + defaultCollapsed: true, + fields: [ + { key: 'facebook_appid', label: 'App ID', type: 'input', default: '' }, + { key: 'facebook_appkey', label: 'App Secret', type: 'input', inputType: 'password', secret: true, default: '' }, + ], + }, + { + key: 'twitter', + title: 'Twitter 配置', + icon: 'fa fab fa-twitter', + collapsible: true, + defaultCollapsed: true, + fields: [ + { key: 'twitter_appid', label: 'Client ID', type: 'input', default: '' }, + { key: 'twitter_appkey', label: 'Client Secret', type: 'input', inputType: 'password', secret: true, default: '' }, + ], + }, + { + key: 'apple', + title: 'Apple 配置', + icon: 'fa fab fa-apple', + collapsible: true, + defaultCollapsed: true, + fields: [ + { key: 'apple_appid', label: 'Service ID', type: 'input', default: '' }, + { key: 'apple_appkey', label: '备用 Secret', type: 'input', inputType: 'password', secret: true, default: '' }, + { key: 'apple_team_id', label: 'Team ID', type: 'input', default: '' }, + { key: 'apple_key_id', label: 'Key ID', type: 'input', default: '' }, + { key: 'apple_private_key', label: '私钥(.p8)', type: 'textarea', secret: true, default: '', help: '用于生成 client_secret 的 ES256 私钥' }, + ], + }, { key: 'weixingzh', title: '微信公众号配置', @@ -177,7 +290,20 @@ export const oauthSchema = { { key: 'clogin_oauth_s', label: '启用平台', type: 'checkbox', default: ['qq', 'weixin', 'weibo'], options: [ { label: 'QQ', value: 'qq' }, { label: '微信', value: 'weixin' }, + { label: '支付宝', value: 'alipay' }, { label: '微博', value: 'weibo' }, + { label: '抖音', value: 'douyin' }, + { label: '百度', value: 'baidu' }, + { label: 'GitHub', value: 'github' }, + { label: 'Gitee', value: 'gitee' }, + { label: '钉钉', value: 'dingtalk' }, + { label: '华为', value: 'huawei' }, + { label: '小米', value: 'xiaomi' }, + { label: 'Google', value: 'google' }, + { label: 'Microsoft', value: 'microsoft' }, + { label: 'Facebook', value: 'facebook' }, + { label: 'Twitter', value: 'twitter' }, + { label: 'Apple', value: 'apple' }, ] }, ], }, diff --git a/inc/helpers/jio-icons.php b/inc/helpers/jio-icons.php index a623af6e..9a3ccadc 100644 --- a/inc/helpers/jio-icons.php +++ b/inc/helpers/jio-icons.php @@ -59,7 +59,6 @@ function jio_brand_icon_map(): array { 'github' => 'fa fab fa-github', 'google' => 'jiosvg-google-color', 'x' => 'fa fab fa-twitter', - 'twitter' => 'fa fab fa-twitter', 'email' => 'fa fas fa-envelope', 'envelope' => 'fa fas fa-envelope', 'wechat' => 'fa fab fa-weixin', @@ -70,7 +69,6 @@ function jio_brand_icon_map(): array { 'discord' => 'fa fab fa-discord', 'bilibili' => 'fa fas fa-tv', 'linkedin' => 'fa fab fa-linkedin', - 'facebook' => 'fa fab fa-facebook-f', 'youtube' => 'fa fab fa-youtube', 'instagram' => 'fa fab fa-instagram', 'web' => 'fa fas fa-globe', @@ -78,6 +76,15 @@ function jio_brand_icon_map(): array { 'gitee' => 'jiosvg-gitee', 'baidu' => 'fa fas fa-paw', 'alipay' => 'fa fab fa-alipay', + 'douyin' => 'jiosvg-douyin', + // FA5 Free 没有 Microsoft 品牌字形,参考主题同样用 Windows 标。 + 'microsoft' => 'fa fab fa-windows', + 'apple' => 'jiosvg-apple', + 'dingtalk' => 'jiosvg-dingtalk', + 'huawei' => 'jiosvg-huawei', + 'xiaomi' => 'jiosvg-xiaomi', + 'facebook' => 'jiosvg-facebook-color', + 'twitter' => 'jiosvg-twitter-color', ]; return $map; @@ -115,7 +122,6 @@ function jio_normalize_icon_key(string $name): string { $aliases = [ 'weixin' => 'wechat', 'weixingzh' => 'wechat', - 'twitter' => 'x', 'envelope' => 'email', 'globe' => 'web', ]; diff --git a/src/Admin/Schema/SchemaUsers.php b/src/Admin/Schema/SchemaUsers.php index 6b53182c..e46ee55f 100644 --- a/src/Admin/Schema/SchemaUsers.php +++ b/src/Admin/Schema/SchemaUsers.php @@ -327,6 +327,13 @@ class SchemaUsers { ['label' => 'Google', 'value' => 'google'], ['label' => '微博', 'value' => 'weibo'], ['label' => '微信', 'value' => 'weixin'], + ['label' => '抖音', 'value' => 'douyin'], + ['label' => '百度', 'value' => 'baidu'], + ['label' => '支付宝', 'value' => 'alipay'], + ['label' => 'Microsoft', 'value' => 'microsoft'], + ['label' => 'Facebook', 'value' => 'facebook'], + ['label' => 'Twitter', 'value' => 'twitter'], + ['label' => 'Apple', 'value' => 'apple'], ], 'dependsOn' => ['key' => 'oauth_proxy_mode', 'value' => 'server']], ['key' => 'oauth_proxy_client_providers', 'label' => '客户端启用平台', 'type' => 'checkbox', 'default' => ['github', 'gitee', 'qq', 'google'], 'options' => [ ['label' => 'GitHub', 'value' => 'github'], @@ -335,6 +342,13 @@ class SchemaUsers { ['label' => 'Google', 'value' => 'google'], ['label' => '微博', 'value' => 'weibo'], ['label' => '微信', 'value' => 'weixin'], + ['label' => '抖音', 'value' => 'douyin'], + ['label' => '百度', 'value' => 'baidu'], + ['label' => '支付宝', 'value' => 'alipay'], + ['label' => 'Microsoft', 'value' => 'microsoft'], + ['label' => 'Facebook', 'value' => 'facebook'], + ['label' => 'Twitter', 'value' => 'twitter'], + ['label' => 'Apple', 'value' => 'apple'], ], 'dependsOn' => ['key' => 'oauth_proxy_mode', 'value' => 'client']], ['key' => 'oauth_bind_type', 'label' => '绑定方式', 'type' => 'radio', 'default' => 'auto', 'options' => [ ['label' => '自动绑定', 'value' => 'auto'], @@ -351,6 +365,13 @@ class SchemaUsers { ['key' => 'oauth_weibo_s', 'label' => '微博登录', 'type' => 'switch', 'default' => false], ['key' => 'oauth_weixin_s', 'label' => '微信开放平台登录', 'type' => 'switch', 'default' => false], ['key' => 'oauth_weixingzh_s', 'label' => '微信公众号登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_douyin_s', 'label' => '抖音登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_baidu_s', 'label' => '百度登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_alipay_s', 'label' => '支付宝登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_microsoft_s', 'label' => 'Microsoft 登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_facebook_s', 'label' => 'Facebook 登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_twitter_s', 'label' => 'Twitter 登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_apple_s', 'label' => 'Apple 登录', 'type' => 'switch', 'default' => false], ['key' => 'clogin_s', 'label' => '彩虹聚合登录', 'type' => 'switch', 'default' => false, 'help' => '通过第三方聚合登录平台统一接入 QQ、微信和微博'], ]], ['key' => 'github', 'title' => 'GitHub配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [ @@ -377,6 +398,38 @@ class SchemaUsers { ['key' => 'weixin_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''], ['key' => 'weixin_appsecret', 'label' => 'AppSecret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''], ]], + ['key' => 'douyin', 'title' => '抖音配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [ + ['key' => 'douyin_appid', 'label' => 'Client Key', 'type' => 'input', 'default' => ''], + ['key' => 'douyin_appkey', 'label' => 'Client Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''], + ]], + ['key' => 'baidu', 'title' => '百度配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [ + ['key' => 'baidu_appid', 'label' => 'API Key', 'type' => 'input', 'default' => ''], + ['key' => 'baidu_appkey', 'label' => 'Secret Key', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''], + ]], + ['key' => 'alipay', 'title' => '支付宝配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [ + ['key' => 'alipay_appid', 'label' => 'App ID', 'type' => 'input', 'default' => ''], + ['key' => 'alipay_appkey', 'label' => '支付宝公钥', 'type' => 'textarea', 'default' => ''], + ['key' => 'alipay_private_key', 'label' => '应用私钥', 'type' => 'textarea', 'secret' => true, 'default' => '', 'help' => 'RSA2 签名使用的应用私钥(PKCS8 格式)'], + ]], + ['key' => 'microsoft', 'title' => 'Microsoft 配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [ + ['key' => 'microsoft_appid', 'label' => 'Application (client) ID', 'type' => 'input', 'default' => ''], + ['key' => 'microsoft_appkey', 'label' => 'Client Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''], + ]], + ['key' => 'facebook', 'title' => 'Facebook 配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [ + ['key' => 'facebook_appid', 'label' => 'App ID', 'type' => 'input', 'default' => ''], + ['key' => 'facebook_appkey', 'label' => 'App Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''], + ]], + ['key' => 'twitter', 'title' => 'Twitter 配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [ + ['key' => 'twitter_appid', 'label' => 'Client ID', 'type' => 'input', 'default' => ''], + ['key' => 'twitter_appkey', 'label' => 'Client Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''], + ]], + ['key' => 'apple', 'title' => 'Apple 配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [ + ['key' => 'apple_appid', 'label' => 'Service ID', 'type' => 'input', 'default' => ''], + ['key' => 'apple_appkey', 'label' => '备用 Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''], + ['key' => 'apple_team_id', 'label' => 'Team ID', 'type' => 'input', 'default' => ''], + ['key' => 'apple_key_id', 'label' => 'Key ID', 'type' => 'input', 'default' => ''], + ['key' => 'apple_private_key', 'label' => '私钥(.p8)', 'type' => 'textarea', 'secret' => true, 'default' => '', 'help' => '用于生成 client_secret 的 ES256 私钥'], + ]], ['key' => 'weixingzh', 'title' => '微信公众号配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [ ['key' => 'weixingzh_gzh_type', 'label' => '公众号类型', 'type' => 'radio', 'default' => 'service', 'options' => [ ['label' => '服务号', 'value' => 'service'], @@ -398,7 +451,20 @@ class SchemaUsers { ['key' => 'clogin_oauth_s', 'label' => '启用平台', 'type' => 'checkbox', 'default' => ['qq', 'weixin', 'weibo'], 'options' => [ ['label' => 'QQ', 'value' => 'qq'], ['label' => '微信', 'value' => 'weixin'], + ['label' => '支付宝', 'value' => 'alipay'], ['label' => '微博', 'value' => 'weibo'], + ['label' => '抖音', 'value' => 'douyin'], + ['label' => '百度', 'value' => 'baidu'], + ['label' => 'GitHub', 'value' => 'github'], + ['label' => 'Gitee', 'value' => 'gitee'], + ['label' => '钉钉', 'value' => 'dingtalk'], + ['label' => '华为', 'value' => 'huawei'], + ['label' => '小米', 'value' => 'xiaomi'], + ['label' => 'Google', 'value' => 'google'], + ['label' => 'Microsoft', 'value' => 'microsoft'], + ['label' => 'Facebook', 'value' => 'facebook'], + ['label' => 'Twitter', 'value' => 'twitter'], + ['label' => 'Apple', 'value' => 'apple'], ]], ]], ], diff --git a/src/Auth/CLoginClient.php b/src/Auth/CLoginClient.php index 90a579f3..f45cfb04 100644 --- a/src/Auth/CLoginClient.php +++ b/src/Auth/CLoginClient.php @@ -29,7 +29,7 @@ final class CLoginClient { public function login(string $type, string $state): array { $type = sanitize_key($type); - if (!in_array($type, ['qq', 'weixin', 'weibo'], true) || $state === '') { + if (!in_array($type, OAuthProviderCatalog::CLOGIN_TYPES, true) || $state === '') { return ['code' => 1, 'msg' => __('聚合登录参数无效', 'jio')]; } return $this->request([ diff --git a/src/Auth/OAuth.php b/src/Auth/OAuth.php index fa536d98..7ac5e77f 100644 --- a/src/Auth/OAuth.php +++ b/src/Auth/OAuth.php @@ -352,7 +352,9 @@ final class OAuth { } private function handle_callback(): void { - $state = sanitize_text_field((string) ($_GET['state'] ?? '')); + // Apple 使用 response_mode=form_post,state/code 走 POST 而不是 query。 + $input = $_SERVER['REQUEST_METHOD'] === 'POST' ? $_POST : $_GET; + $state = sanitize_text_field((string) ($input['state'] ?? '')); $row = $state !== '' ? OAuthTransactionStore::claim($state, 'direct') : null; $payload = is_array($row['payload'] ?? null) ? $row['payload'] : []; $provider = sanitize_key((string) ($payload['provider'] ?? '')); @@ -363,10 +365,10 @@ final class OAuth { if ($action === 'bind' && (int) ($payload['user_id'] ?? 0) !== get_current_user_id()) { $this->redirect_error('state_user_mismatch', $provider, $action); } - if (!empty($_GET['error'])) { + if (!empty($input['error'])) { $this->redirect_error('provider_cancelled', $provider, $action); } - $code = sanitize_text_field((string) ($_GET['code'] ?? '')); + $code = sanitize_text_field((string) ($input['code'] ?? '')); if ($code === '') { $this->redirect_error('code_missing', $provider, $action); } diff --git a/src/Auth/OAuthProviderBroker.php b/src/Auth/OAuthProviderBroker.php index 5ecdf833..13e288a0 100644 --- a/src/Auth/OAuthProviderBroker.php +++ b/src/Auth/OAuthProviderBroker.php @@ -23,6 +23,9 @@ final class OAuthProviderBroker { 'state' => $state, ]); } + if (OAuthProviderClient::handles($provider)) { + return OAuthProviderClient::auth_url($provider, $config, $redirect_uri, $state); + } $oauth = $this->client($provider, $config, $redirect_uri); $scope = $provider === 'github' ? 'read:user user:email' : null; return (string) $oauth->getAuthUrl($redirect_uri, $state, $scope); @@ -36,6 +39,9 @@ final class OAuthProviderBroker { if ($provider === 'google') { return $this->google_identity($config, $code, $redirect_uri); } + if (OAuthProviderClient::handles($provider)) { + return OAuthProviderClient::identity($provider, $config, $code, $redirect_uri, $state); + } $oauth = $this->client($provider, $config, $redirect_uri); $oauth->getAccessToken($state, $code, $state); if ($provider === 'qq') { diff --git a/src/Auth/OAuthProviderCatalog.php b/src/Auth/OAuthProviderCatalog.php index cf8ae413..3f9400b2 100644 --- a/src/Auth/OAuthProviderCatalog.php +++ b/src/Auth/OAuthProviderCatalog.php @@ -42,9 +42,54 @@ final class OAuthProviderCatalog { 'id_key' => 'weixin_appid', 'secret_key' => 'weixin_appsecret', ], + 'douyin' => [ + 'label' => '抖音', + 'id_key' => 'douyin_appid', + 'secret_key' => 'douyin_appkey', + ], + 'baidu' => [ + 'label' => '百度', + 'id_key' => 'baidu_appid', + 'secret_key' => 'baidu_appkey', + ], + 'alipay' => [ + 'label' => '支付宝', + 'id_key' => 'alipay_appid', + 'secret_key' => 'alipay_appkey', + 'extra_keys' => ['private_key' => 'alipay_private_key'], + ], + 'microsoft' => [ + 'label' => 'Microsoft', + 'id_key' => 'microsoft_appid', + 'secret_key' => 'microsoft_appkey', + ], + 'facebook' => [ + 'label' => 'Facebook', + 'id_key' => 'facebook_appid', + 'secret_key' => 'facebook_appkey', + ], + 'twitter' => [ + 'label' => 'Twitter', + 'id_key' => 'twitter_appid', + 'secret_key' => 'twitter_appkey', + ], + 'apple' => [ + 'label' => 'Apple', + 'id_key' => 'apple_appid', + 'secret_key' => 'apple_appkey', + 'extra_keys' => [ + 'team_id' => 'apple_team_id', + 'key_id' => 'apple_key_id', + 'private_key' => 'apple_private_key', + ], + ], ]; - private const CLOGIN_TYPES = ['qq', 'weixin', 'weibo']; + /** 彩虹聚合登录支持的平台,与参考主题一致。 */ + public const CLOGIN_TYPES = [ + 'qq', 'weixin', 'alipay', 'weibo', 'douyin', 'baidu', 'github', 'gitee', + 'dingtalk', 'huawei', 'xiaomi', 'google', 'microsoft', 'facebook', 'twitter', 'apple', + ]; public static function direct_keys(): array { return array_keys(self::DIRECT); @@ -101,13 +146,17 @@ final class OAuthProviderCatalog { $definition = self::DIRECT[$provider]; $client_id = trim((string) ($settings[$definition['id_key']] ?? '')); $client_secret = trim((string) ($settings[$definition['secret_key']] ?? '')); + $extra = []; + foreach ((array) ($definition['extra_keys'] ?? []) as $field => $setting_key) { + $extra[$field] = trim((string) ($settings[$setting_key] ?? '')); + } return [ 'provider' => $provider, 'enabled' => self::master_enabled() && !empty($settings['oauth_' . $provider . '_s']), 'client_id' => $client_id, 'client_secret' => $client_secret, 'configured' => $client_id !== '' && $client_secret !== '', - ]; + ] + $extra; } public static function enabled_direct_keys(): array { diff --git a/src/Auth/OAuthProviderClient.php b/src/Auth/OAuthProviderClient.php new file mode 100644 index 00000000..daa3a978 --- /dev/null +++ b/src/Auth/OAuthProviderClient.php @@ -0,0 +1,436 @@ + $config provider_config() output plus extra secrets. + * @param string $redirect_uri Callback URL registered with the provider. + * @param string $state Opaque CSRF token. + */ + public static function auth_url(string $provider, array $config, string $redirect_uri, string $state): string { + if (in_array($provider, self::SDK, true)) { + return self::sdk_client($provider, $config, $redirect_uri)->getAuthUrl($redirect_uri, $state); + } + switch ($provider) { + case 'douyin': + return 'https://open.douyin.com/platform/oauth/connect?' . http_build_query([ + 'client_key' => $config['client_id'], + 'redirect_uri' => $redirect_uri, + 'response_type' => 'code', + 'scope' => 'user_info', + 'state' => $state, + ]); + case 'microsoft': + return 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?' . http_build_query([ + 'client_id' => $config['client_id'], + 'redirect_uri' => $redirect_uri, + 'response_type' => 'code', + 'response_mode' => 'query', + 'scope' => 'openid profile email User.Read', + 'state' => $state, + ]); + case 'facebook': + return 'https://www.facebook.com/v21.0/dialog/oauth?' . http_build_query([ + 'client_id' => $config['client_id'], + 'redirect_uri' => $redirect_uri, + 'response_type' => 'code', + 'scope' => 'public_profile,email', + 'state' => $state, + ]); + case 'twitter': + // Twitter 的 PKCE code_challenge 需要随 state 一起保存,回调时再校验。 + $verifier = self::twitter_verifier($state); + $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + return 'https://twitter.com/i/oauth2/authorize?' . http_build_query([ + 'client_id' => $config['client_id'], + 'redirect_uri' => $redirect_uri, + 'response_type' => 'code', + 'scope' => 'users.read tweet.read offline.access', + 'state' => $state, + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + ]); + case 'apple': + return 'https://appleid.apple.com/auth/authorize?' . http_build_query([ + 'client_id' => $config['client_id'], + 'redirect_uri' => $redirect_uri, + 'response_type' => 'code', + // Apple 只支持 form_post 回调,服务端需要读 $_POST。 + 'response_mode' => 'form_post', + 'scope' => 'name email', + 'state' => $state, + ]); + } + throw new \RuntimeException(__('不支持的第三方登录平台', 'jio')); + } + + /** + * Exchange the callback code for a normalized identity. + * + * @param array $config provider_config() output plus extra secrets. + */ + public static function identity(string $provider, array $config, string $code, string $redirect_uri, string $state): array { + if (in_array($provider, self::SDK, true)) { + return self::sdk_identity($provider, $config, $code, $redirect_uri, $state); + } + switch ($provider) { + case 'douyin': + return self::douyin_identity($config, $code); + case 'microsoft': + return self::microsoft_identity($config, $code, $redirect_uri); + case 'facebook': + return self::facebook_identity($config, $code, $redirect_uri); + case 'twitter': + return self::twitter_identity($config, $code, $redirect_uri, $state); + case 'apple': + return self::apple_identity($config, $code, $redirect_uri); + } + throw new \RuntimeException(__('不支持的第三方登录平台', 'jio')); + } + + // ---------------------------------------------------------------- SDK bridge + + /** @param array $config */ + private static function sdk_client(string $provider, array $config, string $redirect_uri) { + switch ($provider) { + case 'baidu': + $client = new \Yurun\OAuthLogin\Baidu\OAuth2($config['client_id'], $config['client_secret'], $redirect_uri); + break; + case 'alipay': + $client = new \Yurun\OAuthLogin\Alipay\OAuth2($config['client_id'], $config['client_secret'], $redirect_uri); + // 支付宝要求应用私钥;client_secret 位存的是支付宝公钥。 + if (!empty($config['private_key'])) { + $client->appPrivateKey = (string) $config['private_key']; + } + break; + default: + throw new \RuntimeException(__('不支持的第三方登录平台', 'jio')); + } + return $client; + } + + /** @param array $config */ + private static function sdk_identity(string $provider, array $config, string $code, string $redirect_uri, string $state): array { + $client = self::sdk_client($provider, $config, $redirect_uri); + $client->getAccessToken($state, $code, $state); + $info = (array) $client->getUserInfo(); + if ($provider === 'baidu') { + return [ + 'provider' => 'baidu', + 'provider_id' => (string) ($info['userid'] ?? $info['uid'] ?? ''), + 'username' => (string) ($info['uname'] ?? ''), + 'name' => (string) ($info['uname'] ?? ''), + 'email' => sanitize_email((string) ($info['email'] ?? '')), + 'email_verified' => false, + 'avatar_url' => str_replace('tb.himg.baidu.com', 'himg.bdimg.com', (string) ($info['portrait'] ?? '')), + ]; + } + // 支付宝:user_id 为唯一标识,昵称在 user_info.nick_name。 + $user_info = (array) ($info['user_info'] ?? $info); + return [ + 'provider' => 'alipay', + 'provider_id' => (string) ($info['user_id'] ?? $user_info['user_id'] ?? ''), + 'username' => (string) ($user_info['nick_name'] ?? ''), + 'name' => (string) ($user_info['nick_name'] ?? ''), + 'email' => '', + 'email_verified' => false, + 'avatar_url' => (string) ($user_info['avatar'] ?? ''), + ]; + } + + // ------------------------------------------------------------------- Douyin + + /** @param array $config */ + private static function douyin_identity(array $config, string $code): array { + $token = wp_remote_post('https://open.douyin.com/oauth/access_token/', [ + 'timeout' => 20, + 'body' => [ + 'client_key' => $config['client_id'], + 'client_secret' => $config['client_secret'], + 'code' => $code, + 'grant_type' => 'authorization_code', + ], + ]); + $data = self::json_body($token, __('获取抖音授权令牌失败', 'jio')); + $payload = (array) ($data['data'] ?? []); + if (empty($payload['access_token']) || empty($payload['open_id'])) { + throw new \RuntimeException(__('获取抖音授权令牌失败', 'jio')); + } + + $profile = wp_remote_post('https://open.douyin.com/oauth/userinfo/', [ + 'timeout' => 20, + 'body' => [ + 'access_token' => $payload['access_token'], + 'open_id' => $payload['open_id'], + ], + ]); + $info = self::json_body($profile, __('获取抖音用户信息失败', 'jio')); + $user = (array) ($info['data'] ?? []); + + return [ + 'provider' => 'douyin', + 'provider_id' => (string) $payload['open_id'], + 'username' => (string) ($user['nickname'] ?? ''), + 'name' => (string) ($user['nickname'] ?? ''), + 'email' => '', + 'email_verified' => false, + 'avatar_url' => (string) ($user['avatar_larger'] ?? $user['avatar_300x300'] ?? $user['avatar_168x168'] ?? ''), + ]; + } + + // ---------------------------------------------------------------- Microsoft + + /** @param array $config */ + private static function microsoft_identity(array $config, string $code, string $redirect_uri): array { + $token = wp_remote_post('https://login.microsoftonline.com/common/oauth2/v2.0/token', [ + 'timeout' => 20, + 'body' => [ + 'client_id' => $config['client_id'], + 'client_secret' => $config['client_secret'], + 'code' => $code, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $redirect_uri, + 'scope' => 'openid profile email User.Read', + ], + ]); + $data = self::json_body($token, __('获取 Microsoft 授权令牌失败', 'jio')); + if (empty($data['access_token'])) { + throw new \RuntimeException(__('获取 Microsoft 授权令牌失败', 'jio')); + } + + $profile = wp_remote_get('https://graph.microsoft.com/oidc/userinfo', [ + 'timeout' => 20, + 'headers' => ['Authorization' => 'Bearer ' . $data['access_token']], + ]); + $info = self::json_body($profile, __('获取 Microsoft 用户信息失败', 'jio')); + + $email = sanitize_email((string) ($info['email'] ?? $info['preferred_username'] ?? '')); + return [ + 'provider' => 'microsoft', + 'provider_id' => (string) ($info['sub'] ?? ''), + 'username' => (string) ($info['email'] ?? $info['preferred_username'] ?? ''), + 'name' => (string) ($info['name'] ?? ''), + 'email' => $email, + 'email_verified' => $email !== '' && !empty($info['email']), + 'avatar_url' => '', + ]; + } + + // ----------------------------------------------------------------- Facebook + + /** @param array $config */ + private static function facebook_identity(array $config, string $code, string $redirect_uri): array { + $token = wp_remote_get('https://graph.facebook.com/v21.0/oauth/access_token?' . http_build_query([ + 'client_id' => $config['client_id'], + 'client_secret' => $config['client_secret'], + 'redirect_uri' => $redirect_uri, + 'code' => $code, + ]), ['timeout' => 20]); + $data = self::json_body($token, __('获取 Facebook 授权令牌失败', 'jio')); + if (empty($data['access_token'])) { + throw new \RuntimeException(__('获取 Facebook 授权令牌失败', 'jio')); + } + + $profile = wp_remote_get('https://graph.facebook.com/v21.0/me?' . http_build_query([ + 'fields' => 'id,name,email,picture.type(large)', + 'access_token' => $data['access_token'], + ]), ['timeout' => 20]); + $info = self::json_body($profile, __('获取 Facebook 用户信息失败', 'jio')); + + // Facebook 头像地址不带协议,补 https 以适配页面协议。 + $avatar = (string) ($info['picture']['data']['url'] ?? ''); + $email = sanitize_email((string) ($info['email'] ?? '')); + return [ + 'provider' => 'facebook', + 'provider_id' => (string) ($info['id'] ?? ''), + 'username' => $email !== '' ? $email : (string) ($info['name'] ?? ''), + 'name' => (string) ($info['name'] ?? ''), + 'email' => $email, + 'email_verified' => $email !== '', + 'avatar_url' => $avatar !== '' ? self::https_url($avatar) : '', + ]; + } + + // ------------------------------------------------------------------ Twitter + + /** @param array $config */ + private static function twitter_identity(array $config, string $code, string $redirect_uri, string $state): array { + $verifier = self::twitter_verifier($state); + $token = wp_remote_post('https://api.twitter.com/2/oauth2/token', [ + 'timeout' => 20, + 'headers' => ['Authorization' => 'Basic ' . base64_encode($config['client_id'] . ':' . $config['client_secret'])], + 'body' => [ + 'code' => $code, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $redirect_uri, + 'code_verifier' => $verifier, + ], + ]); + $data = self::json_body($token, __('获取 Twitter 授权令牌失败', 'jio')); + if (empty($data['access_token'])) { + throw new \RuntimeException(__('获取 Twitter 授权令牌失败', 'jio')); + } + + $profile = wp_remote_get('https://api.twitter.com/2/users/me?' . http_build_query([ + 'user.fields' => 'id,name,username,profile_image_url', + ]), [ + 'timeout' => 20, + 'headers' => ['Authorization' => 'Bearer ' . $data['access_token']], + ]); + $info = (array) (self::json_body($profile, __('获取 Twitter 用户信息失败', 'jio'))['data'] ?? []); + + return [ + 'provider' => 'twitter', + 'provider_id' => (string) ($info['id'] ?? ''), + 'username' => (string) ($info['username'] ?? ''), + 'name' => (string) ($info['name'] ?? ''), + 'email' => '', + 'email_verified' => false, + 'avatar_url' => (string) ($info['profile_image_url'] ?? ''), + ]; + } + + /** Derive the PKCE verifier from the state so it never needs extra storage. */ + private static function twitter_verifier(string $state): string { + return rtrim(strtr(base64_encode(hash('sha256', 'jio-twitter|' . $state, true)), '+/', '-_'), '='); + } + + // -------------------------------------------------------------------- Apple + + /** @param array $config */ + private static function apple_identity(array $config, string $code, string $redirect_uri): array { + $secret = self::apple_client_secret($config); + $token = wp_remote_post('https://appleid.apple.com/auth/token', [ + 'timeout' => 20, + 'body' => [ + 'client_id' => $config['client_id'], + 'client_secret' => $secret, + 'code' => $code, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $redirect_uri, + ], + ]); + $data = self::json_body($token, __('获取 Apple 授权令牌失败', 'jio')); + if (empty($data['id_token'])) { + throw new \RuntimeException(__('获取 Apple 授权令牌失败', 'jio')); + } + + $claims = self::jwt_claims((string) $data['id_token']); + if (!is_array($claims) || empty($claims['sub'])) { + throw new \RuntimeException(__('解析 Apple 用户信息失败', 'jio')); + } + + $email = sanitize_email((string) ($claims['email'] ?? '')); + return [ + 'provider' => 'apple', + 'provider_id' => (string) $claims['sub'], + 'username' => $email !== '' ? $email : (string) $claims['sub'], + 'name' => '', + 'email' => $email, + 'email_verified' => !empty($claims['email_verified']) || !empty($claims['is_private_email']) === false && $email !== '', + 'avatar_url' => '', + ]; + } + + /** + * Build the ES256 client secret Apple requires (a short-lived JWT). + * + * @param array $config + */ + private static function apple_client_secret(array $config): string { + $team = (string) ($config['team_id'] ?? ''); + $key = (string) ($config['key_id'] ?? ''); + $pem = (string) ($config['private_key'] ?? ''); + if ($pem === '') { + // 未配置密钥时退化为普通 client_secret,仍然遵循同一签名流程。 + return (string) $config['client_secret']; + } + $now = time(); + $header = self::b64url(wp_json_encode(['alg' => 'ES256', 'kid' => $key])); + $payload = self::b64url(wp_json_encode([ + 'iss' => $team, + 'iat' => $now, + 'exp' => $now + 3600, + 'aud' => 'https://appleid.apple.com', + 'sub' => (string) $config['client_id'], + ])); + $signing = $header . '.' . $payload; + $key_id = openssl_pkey_get_private($pem); + if ($key_id === false) { + throw new \RuntimeException(__('Apple 私钥格式无效', 'jio')); + } + $signature = ''; + openssl_sign($signing, $signature, $key_id, OPENSSL_ALGO_SHA256); + return $signing . '.' . self::b64url($signature); + } + + /** Decode the payload of a JWT without verifying it (the token came over TLS). */ + private static function jwt_claims(string $token): ?array { + $parts = explode('.', $token); + if (count($parts) < 2) { + return null; + } + $payload = json_decode((string) base64_decode(strtr($parts[1], '-_', '+/'), true), true); + return is_array($payload) ? $payload : null; + } + + private static function b64url(string $value): string { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + // ------------------------------------------------------------------ helpers + + /** + * Decode a JSON response, raising a translated message on transport errors. + * + * @param array|\WP_Error $response + * @return array + */ + private static function json_body($response, string $message): array { + if (is_wp_error($response)) { + throw new \RuntimeException($response->get_error_message()); + } + if ((int) wp_remote_retrieve_response_code($response) !== 200) { + throw new \RuntimeException($message); + } + $data = json_decode((string) wp_remote_retrieve_body($response), true); + if (!is_array($data)) { + throw new \RuntimeException($message); + } + if (!empty($data['error'])) { + $detail = $data['error_description'] ?? $data['error']['message'] ?? $data['error']; + throw new \RuntimeException(is_string($detail) ? $detail : $message); + } + return $data; + } + + private static function https_url(string $url): string { + return (string) preg_replace('#^http://#i', 'https://', $url); + } +} diff --git a/tests/widget-system/oauth-provider-client-harness.php b/tests/widget-system/oauth-provider-client-harness.php new file mode 100644 index 00000000..6236cc0d --- /dev/null +++ b/tests/widget-system/oauth-provider-client-harness.php @@ -0,0 +1,133 @@ +message; } +} + +/** Canned responses keyed by URL. */ +final class HttpStub { + public static array $responses = []; + + public static function respond(string $url, int $code, array $body): void { + self::$responses[$url] = ['code' => $code, 'body' => wp_json_encode($body)]; + } +} + +function wp_remote_get(string $url, array $args = []) { + $GLOBALS['http_calls'][] = ['GET', $url, $args]; + return HttpStub::$responses[$url] ?? new WP_Error('no_stub_for_' . $url); +} + +function wp_remote_post(string $url, array $args = []) { + $GLOBALS['http_calls'][] = ['POST', $url, $args]; + return HttpStub::$responses[$url] ?? new WP_Error('no_stub_for_' . $url); +} + +function wp_remote_retrieve_response_code($response): int { return (int) ($response['code'] ?? 0); } +function wp_remote_retrieve_body($response): string { return (string) ($response['body'] ?? ''); } +function base64url_decode(string $value): string { return (string) base64_decode(strtr($value, '-_', '+/'), true); } + +require dirname(__DIR__, 2) . '/src/Auth/OAuthProviderClient.php'; + +$config = [ + 'client_id' => 'cid', + 'client_secret' => 'secret', + 'private_key' => '', + 'team_id' => 'TEAM', + 'key_id' => 'KEY', +]; + +$redirect = 'https://example.test/oauth/callback/'; +$state = 'state-abc123'; +$code = 'authcode'; + +$out = []; + +/** Auth URL assertions. */ +foreach (['douyin', 'microsoft', 'facebook', 'twitter', 'apple'] as $provider) { + $url = \Jio\Auth\OAuthProviderClient::auth_url($provider, $config, $redirect, $state); + $query = []; + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + $out['auth'][$provider] = [ + 'url' => $url, + 'has_redirect' => ($query['redirect_uri'] ?? '') === $redirect, + 'has_state' => ($query['state'] ?? '') === $state, + 'challenge' => $query['code_challenge'] ?? '', + ]; +} + +/** Identity normalisation, one canned response set per provider. */ +HttpStub::respond('https://open.douyin.com/oauth/access_token/', 200, [ + 'data' => ['access_token' => 'dytoken', 'open_id' => 'dy-open-id'], +]); +HttpStub::respond('https://open.douyin.com/oauth/userinfo/', 200, [ + 'data' => ['nickname' => '抖音用户', 'avatar_larger' => 'https://p3.douyinpic.com/a.jpg'], +]); +$out['identity']['douyin'] = \Jio\Auth\OAuthProviderClient::identity('douyin', $config, $code, $redirect, $state); + +HttpStub::respond('https://login.microsoftonline.com/common/oauth2/v2.0/token', 200, [ + 'access_token' => 'mstoken', +]); +HttpStub::respond('https://graph.microsoft.com/oidc/userinfo', 200, [ + 'sub' => 'ms-sub', 'email' => 'ms@example.test', 'name' => 'MS User', +]); +$out['identity']['microsoft'] = \Jio\Auth\OAuthProviderClient::identity('microsoft', $config, $code, $redirect, $state); + +HttpStub::respond('https://graph.facebook.com/v21.0/oauth/access_token?' . http_build_query([ + 'client_id' => 'cid', + 'client_secret' => 'secret', + 'redirect_uri' => $redirect, + 'code' => $code, +]), 200, ['access_token' => 'fbtoken']); +HttpStub::respond('https://graph.facebook.com/v21.0/me?' . http_build_query([ + 'fields' => 'id,name,email,picture.type(large)', + 'access_token' => 'fbtoken', +]), 200, [ + 'id' => 'fb-id', 'name' => 'FB User', 'email' => 'fb@example.test', + 'picture' => ['data' => ['url' => 'http://graph.facebook.com/pic.jpg']], +]); +$out['identity']['facebook'] = \Jio\Auth\OAuthProviderClient::identity('facebook', $config, $code, $redirect, $state); + +HttpStub::respond('https://api.twitter.com/2/oauth2/token', 200, ['access_token' => 'twtoken']); +HttpStub::respond('https://api.twitter.com/2/users/me?' . http_build_query([ + 'user.fields' => 'id,name,username,profile_image_url', +]), 200, [ + 'data' => [ + 'id' => 'tw-id', 'name' => 'TW User', 'username' => 'twuser', + 'profile_image_url' => 'https://pbs.twimg.com/p.jpg', + ], +]); +$out['identity']['twitter'] = \Jio\Auth\OAuthProviderClient::identity('twitter', $config, $code, $redirect, $state); + +/** Apple: build a fake id_token payload (no signature verification needed here). */ +$apple_payload = rtrim(strtr(base64_encode((string) wp_json_encode([ + 'sub' => 'apple-sub', 'email' => 'apple@privaterelay.appleid.com', 'email_verified' => 'true', +])), '+/', '-_'), '='); +HttpStub::respond('https://appleid.apple.com/auth/token', 200, [ + 'id_token' => 'eyJhbGciOiJub25lIn0.' . $apple_payload . '.', +]); +$out['identity']['apple'] = \Jio\Auth\OAuthProviderClient::identity('apple', $config, $code, $redirect, $state); + +/** Error path: a provider returning an error payload must throw, not leak it. */ +HttpStub::respond('https://open.douyin.com/oauth/access_token/', 200, ['error' => 'invalid_code']); +try { + \Jio\Auth\OAuthProviderClient::identity('douyin', $config, 'bad', $redirect, $state); + $out['error_path'] = 'NO_THROW'; +} catch (\RuntimeException $e) { + $out['error_path'] = 'threw: ' . $e->getMessage(); +} + +echo json_encode($out, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/tests/widget-system/oauth-provider-client.test.cjs b/tests/widget-system/oauth-provider-client.test.cjs new file mode 100644 index 00000000..53430cfe --- /dev/null +++ b/tests/widget-system/oauth-provider-client.test.cjs @@ -0,0 +1,77 @@ +/** + * 新增 OAuth provider 客户端契约。 + * + * 覆盖参考主题有、而打包 SDK 未提供的五个平台:抖音、Microsoft、 + * Facebook、Twitter、Apple。断言授权 URL 的关键参数、身份归一化结果, + * 以及错误响应必须抛出而不是静默返回空身份。 + */ +'use strict'; + +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '..', '..'); + +function runHarness() { + const result = spawnSync( + process.env.PHP_BINARY || 'php', + [path.join(__dirname, 'oauth-provider-client-harness.php')], + { cwd: root, encoding: 'utf8' }, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + return JSON.parse(result.stdout); +} + +test('新 provider 的授权 URL 携带 redirect/state(Twitter 额外带 PKCE)', () => { + const { auth } = runHarness(); + + for (const provider of ['douyin', 'microsoft', 'facebook', 'twitter', 'apple']) { + assert.equal(auth[provider].has_redirect, true, provider + ' redirect_uri'); + assert.equal(auth[provider].has_state, true, provider + ' state'); + } + + // 只有 Twitter 走 PKCE,其余平台不带 code_challenge。 + assert.match(auth.twitter.challenge, /^[A-Za-z0-9_-]{20,}$/); + for (const provider of ['douyin', 'microsoft', 'facebook', 'apple']) { + assert.equal(auth[provider].challenge, '', provider + ' should not send PKCE'); + } + + // Apple 必须使用 form_post,否则服务端收不到 code。 + assert.match(auth.apple.url, /appleid\.apple\.com\/auth\/authorize/); +}); + +test('各 provider 的身份归一化产出统一字段', () => { + const { identity } = runHarness(); + const required = ['provider', 'provider_id', 'username', 'name', 'email', 'email_verified', 'avatar_url']; + + for (const [provider, row] of Object.entries(identity)) { + for (const key of required) { + assert.ok(key in row, `${provider} missing ${key}`); + } + assert.ok(row.provider_id !== '', `${provider} provider_id must not be empty`); + assert.equal(row.provider, provider); + } + + assert.equal(identity.douyin.provider_id, 'dy-open-id'); + assert.equal(identity.douyin.name, '抖音用户'); + assert.match(identity.douyin.avatar_url, /^https:/); + + assert.equal(identity.microsoft.email, 'ms@example.test'); + assert.equal(identity.microsoft.email_verified, true); + + // Facebook 头像原始地址是 http://,必须升级到 https。 + assert.match(identity.facebook.avatar_url, /^https:\/\//); + + assert.equal(identity.twitter.username, 'twuser'); + assert.equal(identity.twitter.email, '', 'Twitter 不返回邮箱'); + + // Apple 的邮箱是私密转发地址,仍可用于自动绑定。 + assert.match(identity.apple.email, /@privaterelay\.appleid\.com$/); +}); + +test('provider 返回错误时抛出异常而不是产出空身份', () => { + const { error_path: errorPath } = runHarness(); + assert.match(errorPath, /^threw: /); +}); diff --git a/tests/widget-system/oauth-security-contract.test.cjs b/tests/widget-system/oauth-security-contract.test.cjs index dfd69d26..d4bf8994 100644 --- a/tests/widget-system/oauth-security-contract.test.cjs +++ b/tests/widget-system/oauth-security-contract.test.cjs @@ -33,19 +33,20 @@ 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 absent from the OAuth surface', () => { - for (const needle of [ - 'oauth_baidu_s', - 'oauth_alipay_s', - 'get_baidu_user_data', - 'get_alipay_user_data', - 'OAuthLogin\\Baidu', - 'OAuthLogin\\Alipay', - "'baidu' => [", - "'alipay' => [", - ]) { - assert.equal(oauthScope.includes(needle), false, `unexpected OAuth reference: ${needle}`) +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', () => { @@ -71,10 +72,19 @@ test('OAuth uses one-time transactions and no PHP sessions', () => { test('provider catalog is the canonical provider list', () => { const catalog = read('src/Auth/OAuthProviderCatalog.php') - for (const provider of ['github', 'gitee', 'qq', 'google', 'weibo', 'weixin']) { - assert.match(catalog, new RegExp(`'${provider}'\\s*=>`)) + 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) } - assert.match(catalog, /CLOGIN_TYPES = \['qq', 'weixin', 'weibo'\]/) + + // 聚合登录平台与参考主题一致(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/) })