Files
wordpress-theme-aksrt/src/Auth/OAuth.php
T
kate522 6db34646ed feat(oauth): add the seven missing providers and open the full aggregate list
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.
2026-11-04 18:00:29 +08:00

528 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package Jio
*/
namespace Jio\Auth;
defined('ABSPATH') || exit;
final class OAuth {
private const BIND_PASS_KIND = 'oauth_bind_pass';
private const BIND_PASS_TTL = 5 * MINUTE_IN_SECONDS;
private static $instance = null;
private OAuthProviderBroker $broker;
private function __construct() {
$this->broker = new OAuthProviderBroker();
}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('init', [$this, 'add_rewrite_rules']);
add_action('init', [$this, 'maybe_flush_rewrite_rules'], 20);
add_action('init', static function (): void {
if (wp_rand(1, 100) === 1) {
OAuthTransactionStore::cleanup();
}
}, 30);
add_action('template_redirect', [$this, 'handle_routes'], 8);
add_action('rest_api_init', [$this, 'register_rest_routes']);
add_action('wp_ajax_jio_oauth_unbind', [$this, 'ajax_unbind']);
}
public function add_rewrite_rules(): void {
add_rewrite_rule('^oauth/start/([a-z0-9_-]+)/?$', 'index.php?jio_oauth=start&jio_oauth_provider=$matches[1]', 'top');
add_rewrite_rule('^oauth/callback/?$', 'index.php?jio_oauth=callback', 'top');
add_rewrite_rule('^oauth/clogin/?$', 'index.php?jio_oauth=clogin', 'top');
add_rewrite_rule('^oauth/clogin/callback/?$', 'index.php?jio_oauth=clogin_callback', 'top');
add_filter('query_vars', static function (array $vars): array {
$vars[] = 'jio_oauth';
$vars[] = 'jio_oauth_provider';
return $vars;
});
}
public function register_rest_routes(): void {
register_rest_route('jio/v1', '/oauth/bind-pass', [
'methods' => 'POST',
'callback' => [$this, 'rest_issue_bind_pass'],
'permission_callback' => static function (): bool {
return is_user_logged_in();
},
]);
}
public function rest_issue_bind_pass(\WP_REST_Request $request): \WP_REST_Response {
$user_id = get_current_user_id();
$nonce = sanitize_text_field((string) $request->get_header('X-WP-Nonce'));
if ($user_id <= 0 || $nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
return new \WP_REST_Response([
'success' => false,
'code' => 'oauth_bind_nonce_invalid',
'message' => __('安全验证失败,请刷新页面重试', 'jio'),
], 403);
}
$payload = $request->get_json_params();
$payload = is_array($payload) ? $payload : [];
$provider = sanitize_key((string) ($payload['provider'] ?? ''));
if (!in_array($provider, OAuthProviderCatalog::enabled_login_keys(), true)) {
return new \WP_REST_Response([
'success' => false,
'code' => 'oauth_bind_provider_invalid',
'message' => __('该第三方登录方式不可用', 'jio'),
], 400);
}
$url = $this->get_auth_url($provider, 'bind');
if ($url === '') {
return new \WP_REST_Response([
'success' => false,
'code' => 'oauth_bind_provider_invalid',
'message' => __('该第三方登录方式不可用', 'jio'),
], 400);
}
$redirect = OAuthUrlPolicy::validated_redirect((string) ($payload['redirect_to'] ?? ''), home_url('/'));
$url = add_query_arg('redirect_to', $redirect, $url);
if (!jio_is_captcha_enabled_for('bind')) {
return new \WP_REST_Response([
'success' => true,
'data' => ['url' => $url],
], 200);
}
$result = jio_require_captcha('bind', $payload);
if ($result !== true) {
return new \WP_REST_Response([
'success' => false,
'code' => is_wp_error($result) ? $result->get_error_code() : 'jio_captcha_failed',
'message' => is_wp_error($result) ? $result->get_error_message() : __('人机验证失败,请重试', 'jio'),
], 400);
}
try {
$pass = OAuthTransactionStore::issue(self::BIND_PASS_KIND, [
'user_id' => $user_id,
'provider' => $provider,
], self::BIND_PASS_TTL, true);
} catch (\Throwable $e) {
return new \WP_REST_Response([
'success' => false,
'code' => 'oauth_bind_pass_issue_failed',
'message' => __('无法创建绑定会话,请稍后重试', 'jio'),
], 500);
}
$url = add_query_arg('bind_pass', $pass, $url);
return new \WP_REST_Response([
'success' => true,
'data' => ['url' => $url],
], 200);
}
public static function validate_bind_pass(string $token, string $provider, int $user_id, bool $consume = true): bool {
if (!jio_is_captcha_enabled_for('bind')) {
return true;
}
if ($token === '' || $user_id <= 0) {
return false;
}
$matches = static function (?array $row) use ($provider, $user_id): bool {
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
return $row !== null
&& (int) ($payload['user_id'] ?? 0) === $user_id
&& hash_equals(sanitize_key($provider), sanitize_key((string) ($payload['provider'] ?? '')));
};
$row = OAuthTransactionStore::peek($token, self::BIND_PASS_KIND, true);
if (!$matches($row)) {
return false;
}
if (!$consume) {
return true;
}
return $matches(OAuthTransactionStore::claim($token, self::BIND_PASS_KIND, true));
}
public function maybe_flush_rewrite_rules(): void {
$version = defined('JIO_VERSION') ? JIO_VERSION . ':oauth-v2' : 'oauth-v2';
if (get_option('jio_oauth_routes_flushed') !== $version) {
flush_rewrite_rules(false);
update_option('jio_oauth_routes_flushed', $version, false);
}
}
public function handle_routes(): void {
$route = sanitize_key((string) get_query_var('jio_oauth'));
if ($route === '') {
return;
}
switch ($route) {
case 'start':
$this->handle_start();
break;
case 'callback':
$this->handle_callback();
break;
case 'clogin':
$this->handle_clogin_start();
break;
case 'clogin_callback':
$this->handle_clogin_callback();
break;
}
}
public function get_auth_url(string $provider, string $action = 'login'): string {
$provider = sanitize_key($provider);
$action = $action === 'bind' ? 'bind' : 'login';
if (!in_array($provider, OAuthProviderCatalog::enabled_login_keys(), true)) {
return '';
}
$redirect = isset($_GET['redirect_to'])
? OAuthUrlPolicy::validated_redirect((string) wp_unslash($_GET['redirect_to']), home_url('/'))
: '';
if ($provider === 'weixingzh') {
$url = function_exists('jio_get_sign_page_url') ? jio_get_sign_page_url('oauth') : home_url('/sign/?tab=oauth');
$url = add_query_arg('oauth_intent', $action, $url);
return $redirect !== '' ? add_query_arg('redirect_to', $redirect, $url) : $url;
}
if (str_starts_with($provider, 'clogin_')) {
$url = add_query_arg([
'type' => substr($provider, 7),
'action' => $action,
], home_url('/oauth/clogin/'));
return $redirect !== '' ? add_query_arg('redirect_to', $redirect, $url) : $url;
}
$url = add_query_arg('action', $action, home_url('/oauth/start/' . $provider . '/'));
return $redirect !== '' ? add_query_arg('redirect_to', $redirect, $url) : $url;
}
public function get_auth_providers_data(): array {
$result = [];
foreach (OAuthProviderCatalog::enabled_login_keys() as $provider) {
$url = $this->get_auth_url($provider);
if ($url === '') {
continue;
}
$result[] = [
'name' => $provider,
'label' => OAuthProviderCatalog::label($provider),
'icon' => function_exists('jio_brand_icon') ? jio_brand_icon(OAuthProviderCatalog::icon_key($provider)) : '',
// Raw, matching get_frontend_bindings_data(): every surface escapes
// once on output. esc_url() is idempotent on its own output, so
// escaping here too was redundant rather than broken.
'url' => $url,
];
}
return $result;
}
/**
* Social login block.
*
* Emits the muted separator plus a .social_loginbar of .social-login-item
* buttons, one per enabled provider. Returns '' when nothing is enabled.
*
* @param string $title Separator label; an empty title drops the separator.
*/
public function render_social_buttons(string $title = ''): string {
$items = $this->get_auth_providers_data();
if ($items === []) {
return '';
}
$large = (bool) \Jio\Settings\Settings::get_value('oauth_button_lg', false);
$html = $title !== ''
? '<p class="social-separator separator muted-3-color em09 mt10">' . esc_html($title) . '</p>'
: '';
$html .= '<div class="social_loginbar">';
foreach ($items as $item) {
$name = sanitize_html_class((string) ($item['name'] ?? ''));
$type = str_starts_with($name, 'clogin_') ? substr($name, 7) : $name;
$label = trim((string) ($item['label'] ?? ''));
$url = (string) ($item['url'] ?? '');
if ($name === '' || $type === '' || $label === '' || $url === '') {
continue;
}
$class = 'social-login-item ' . $type . ($large ? ' button-lg' : ' toggle-radius');
if ($name === 'weixingzh') {
// Reference parity: the WeChat entry opens the QR panel instead of
// navigating, so auth.js routes it to the modal's oauth tab.
$class .= ' qrcode-signin';
}
$html .= '<a rel="nofollow" href="' . esc_url($url) . '" class="' . esc_attr($class) . '"'
. ' data-provider="' . esc_attr($name) . '"'
. ' aria-label="' . esc_attr(sprintf(__('%s登录', 'jio'), $label)) . '"'
. ' title="' . esc_attr(sprintf(__('%s登录', 'jio'), $label)) . '">'
. $item['icon'] // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
. ($large ? '<span>' . esc_html(sprintf(__('%s登录', 'jio'), $label)) . '</span>' : '')
. '</a>';
}
return $html . '</div>';
}
public function get_frontend_bindings_data(int $user_id): array {
$bound = OAuthAccountService::bindings($user_id);
$bindings = [];
foreach ($bound as $provider) {
$bindings[] = $this->binding_row($provider, true);
}
$available = [];
foreach (OAuthProviderCatalog::enabled_login_keys() as $provider) {
if (in_array($provider, $bound, true)) {
continue;
}
$url = $this->get_auth_url($provider, 'bind');
if ($url !== '') {
$row = $this->binding_row($provider, false);
$row['url'] = $url;
$available[] = $row;
}
}
return ['bindings' => $bindings, 'available' => $available];
}
public function ajax_unbind(): void {
check_ajax_referer('jio_oauth_unbind', 'nonce');
$provider = sanitize_key((string) ($_POST['provider'] ?? ''));
$user_id = isset($_POST['userId']) ? (int) $_POST['userId'] : 0;
$current_id = get_current_user_id();
if ($current_id <= 0 || $current_id !== $user_id || !current_user_can('read')) {
wp_send_json_error(['message' => __('无权执行此操作', 'jio')], 403);
}
$result = OAuthAccountService::unbind($current_id, $provider);
if (is_wp_error($result)) {
$status = $result->get_error_code() === 'oauth_last_login_method' ? 409 : 400;
wp_send_json_error(['message' => $result->get_error_message()], $status);
}
wp_send_json_success(['unbound' => true, 'message' => __('解除绑定成功', 'jio')]);
}
private function handle_start(): void {
$provider = sanitize_key((string) get_query_var('jio_oauth_provider'));
$action = isset($_GET['action']) && sanitize_key((string) $_GET['action']) === 'bind' ? 'bind' : 'login';
if (!OAuthProviderCatalog::master_enabled() || !in_array($provider, OAuthProviderCatalog::enabled_login_keys(), true)) {
$this->redirect_error('provider_disabled', $provider, $action);
}
$user_id = get_current_user_id();
if ($action === 'bind' && $user_id <= 0) {
wp_safe_redirect(function_exists('jio_get_login_url') ? jio_get_login_url() : wp_login_url());
exit;
}
if ($action === 'bind') {
$bind_pass = sanitize_text_field((string) ($_GET['bind_pass'] ?? ''));
if (!self::validate_bind_pass($bind_pass, $provider, $user_id, true)) {
$this->redirect_error('bind_verification_required', $provider, $action);
}
}
if (OAuthProviderCatalog::proxy_mode() === 'client') {
$url = OAuthProxyClient::get_instance()->get_proxy_auth_url($provider, $action);
if ($url === '') {
$this->redirect_error('proxy_config', $provider, $action);
}
wp_redirect($url); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
exit;
}
$redirect = isset($_GET['redirect_to']) ? OAuthUrlPolicy::validated_redirect((string) $_GET['redirect_to'], home_url('/')) : home_url('/');
$redirect_uri = home_url('/oauth/callback/');
try {
$state = OAuthTransactionStore::issue('direct', [
'provider' => $provider,
'action' => $action,
'user_id' => $user_id,
'redirect_to' => $redirect,
'redirect_uri' => $redirect_uri,
], 10 * MINUTE_IN_SECONDS);
wp_redirect($this->broker->auth_url($provider, $redirect_uri, $state)); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
exit;
} catch (\Throwable $e) {
$this->redirect_error('provider_unavailable', $provider, $action);
}
}
private function handle_callback(): void {
// 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'] ?? ''));
$action = (string) ($payload['action'] ?? 'login');
if (!$row || !OAuthProviderCatalog::is_direct($provider)) {
$this->redirect_error('state_invalid', $provider, $action);
}
if ($action === 'bind' && (int) ($payload['user_id'] ?? 0) !== get_current_user_id()) {
$this->redirect_error('state_user_mismatch', $provider, $action);
}
if (!empty($input['error'])) {
$this->redirect_error('provider_cancelled', $provider, $action);
}
$code = sanitize_text_field((string) ($input['code'] ?? ''));
if ($code === '') {
$this->redirect_error('code_missing', $provider, $action);
}
try {
$identity = $this->broker->identity($provider, $code, (string) $payload['redirect_uri'], $state);
if (empty($identity['provider_id'])) {
throw new \RuntimeException('missing_provider_id');
}
if ($action === 'bind') {
$result = OAuthAccountService::bind(get_current_user_id(), $provider, $identity);
if (is_wp_error($result)) {
throw new \RuntimeException($result->get_error_code());
}
$this->redirect_success('bind', $provider, function_exists('jio_get_user_center_url') ? jio_get_user_center_url('settings') : home_url('/'));
}
$this->complete_identity_login($provider, $identity, (string) ($payload['redirect_to'] ?? home_url('/')));
} catch (\Throwable $e) {
$this->redirect_error($e->getMessage() === 'oauth_binding_conflict' ? 'binding_conflict' : 'callback_failed', $provider, $action);
}
}
private function handle_clogin_start(): void {
$config = OAuthProviderCatalog::clogin_settings();
$type = sanitize_key((string) ($_GET['type'] ?? ''));
$action = sanitize_key((string) ($_GET['action'] ?? '')) === 'bind' ? 'bind' : 'login';
if (!$config['enabled'] || !in_array($type, $config['types'], true)) {
$this->redirect_error('provider_disabled', 'clogin_' . $type, $action);
}
if ($action === 'bind' && !is_user_logged_in()) {
wp_safe_redirect(function_exists('jio_get_login_url') ? jio_get_login_url() : wp_login_url());
exit;
}
if ($action === 'bind') {
$bind_pass = sanitize_text_field((string) ($_GET['bind_pass'] ?? ''));
if (!self::validate_bind_pass($bind_pass, 'clogin_' . $type, get_current_user_id(), true)) {
$this->redirect_error('bind_verification_required', 'clogin_' . $type, $action);
}
}
$redirect = isset($_GET['redirect_to']) ? OAuthUrlPolicy::validated_redirect((string) $_GET['redirect_to'], home_url('/')) : home_url('/');
try {
$state = OAuthTransactionStore::issue('clogin', [
'type' => $type,
'action' => $action,
'user_id' => get_current_user_id(),
'redirect_to' => $redirect,
], 10 * MINUTE_IN_SECONDS);
$client = new CLoginClient([
'apiurl' => $config['url'],
'appid' => $config['id'],
'appkey' => $config['key'],
'callback' => home_url('/oauth/clogin/callback/'),
]);
$response = $client->login($type, $state);
if (!empty($response['code']) || empty($response['url'])) {
throw new \RuntimeException('clogin_unavailable');
}
wp_redirect((string) $response['url']); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
exit;
} catch (\Throwable $e) {
$this->redirect_error('provider_unavailable', 'clogin_' . $type, $action);
}
}
private function handle_clogin_callback(): void {
$state = sanitize_text_field((string) ($_GET['state'] ?? ''));
$row = $state !== '' ? OAuthTransactionStore::claim($state, 'clogin') : null;
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
$type = sanitize_key((string) ($payload['type'] ?? ''));
$provider = 'clogin_' . $type;
$action = (string) ($payload['action'] ?? 'login');
if (!$row || !in_array($type, OAuthProviderCatalog::clogin_settings()['types'], true)) {
$this->redirect_error('state_invalid', $provider, $action);
}
if ($action === 'bind' && (int) ($payload['user_id'] ?? 0) !== get_current_user_id()) {
$this->redirect_error('state_user_mismatch', $provider, $action);
}
$code = sanitize_text_field((string) ($_GET['code'] ?? ''));
if ($code === '') {
$this->redirect_error('code_missing', $provider, $action);
}
$config = OAuthProviderCatalog::clogin_settings();
$client = new CLoginClient(['apiurl' => $config['url'], 'appid' => $config['id'], 'appkey' => $config['key']]);
$info = $client->callback($code);
$returned_type = $client->from_api_type((string) ($info['type'] ?? ''));
if ((isset($info['code']) && (int) $info['code'] !== 0) || $returned_type !== $type || empty($info['social_uid'])) {
$this->redirect_error('callback_failed', $provider, $action);
}
$identity = [
'provider' => $provider,
'provider_id' => (string) $info['social_uid'],
'username' => sanitize_text_field((string) ($info['nickname'] ?? '')),
'name' => sanitize_text_field((string) ($info['nickname'] ?? '')),
'email' => '',
'email_verified' => false,
'avatar_url' => esc_url_raw((string) ($info['faceimg'] ?? '')),
];
if ($action === 'bind') {
$result = OAuthAccountService::bind(get_current_user_id(), $provider, $identity);
if (is_wp_error($result)) {
$this->redirect_error('binding_conflict', $provider, $action);
}
$this->redirect_success('bind', $provider, function_exists('jio_get_user_center_url') ? jio_get_user_center_url('settings') : home_url('/'));
}
$this->complete_identity_login($provider, $identity, (string) ($payload['redirect_to'] ?? home_url('/')));
}
private function complete_identity_login(string $provider, array $identity, string $redirect): void {
$user = OAuthAccountService::resolve($provider, $identity);
if (is_wp_error($user) && $user->get_error_code() === 'oauth_no_match') {
$obk = OAuthTransactionStore::issue('obk', [
'provider' => $provider,
'provider_id' => (string) ($identity['provider_id'] ?? ''),
'user_data' => $identity,
'redirect_to' => OAuthUrlPolicy::validated_redirect($redirect, home_url('/')),
], 15 * MINUTE_IN_SECONDS);
$url = function_exists('jio_get_bind_url') ? jio_get_bind_url($obk) : home_url('/sign/?tab=bind&obk=' . rawurlencode($obk));
wp_safe_redirect($url);
exit;
}
if (!$user instanceof \WP_User) {
$this->redirect_error('account_match_failed', $provider, 'login');
}
OAuthAccountService::complete_login($user, true);
$this->redirect_success('login', $provider, OAuthUrlPolicy::validated_redirect($redirect, home_url('/')));
}
private function binding_row(string $provider, bool $bound): array {
return [
'key' => $provider,
'name' => OAuthProviderCatalog::label($provider),
'icon' => function_exists('jio_brand_icon') ? jio_brand_icon(OAuthProviderCatalog::icon_key($provider)) : '',
'is_bound' => $bound,
'url' => '',
];
}
private function redirect_success(string $action, string $provider, string $target): void {
wp_safe_redirect(add_query_arg([
'oauth_action' => $action,
'oauth_provider' => $provider,
], $target));
exit;
}
private function redirect_error(string $code, string $provider = '', string $action = 'login'): void {
$target = $action === 'bind' && is_user_logged_in() && function_exists('jio_get_user_center_url')
? jio_get_user_center_url('settings')
: (function_exists('jio_get_login_url') ? jio_get_login_url() : wp_login_url());
wp_safe_redirect(add_query_arg([
'oauth_action' => 'error',
'oauth_provider' => sanitize_key($provider),
'oauth_error' => sanitize_key($code),
], $target));
exit;
}
}