271 lines
9.0 KiB
PHP
271 lines
9.0 KiB
PHP
<?php
|
|
/**
|
|
* @package AKSRT
|
|
*/
|
|
|
|
namespace AKSRT\Auth;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
final class OAuthProxyClient {
|
|
|
|
private static $instance = null;
|
|
|
|
public static function get_instance(): self {
|
|
if (null === self::$instance) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
private function __construct() {}
|
|
|
|
public function init(): void {
|
|
add_action('init', [$this, 'add_rewrite_rules']);
|
|
add_action('init', [$this, 'maybe_flush_rewrite_rules'], 20);
|
|
add_action('template_redirect', [$this, 'handle_proxy_callback'], 5);
|
|
}
|
|
|
|
public function add_rewrite_rules(): void {
|
|
add_rewrite_rule('^oauth/proxy/callback/?$', 'index.php?aksrt_proxy_callback=1', 'top');
|
|
add_filter('query_vars', static function (array $vars): array {
|
|
$vars[] = 'aksrt_proxy_callback';
|
|
return $vars;
|
|
});
|
|
}
|
|
|
|
public function maybe_flush_rewrite_rules(): void {
|
|
$version = defined('AKSRT_VERSION') ? AKSRT_VERSION . ':oauth-proxy-client-v2' : 'oauth-proxy-client-v2';
|
|
if (get_option('aksrt_oauth_proxy_client_routes_flushed') !== $version) {
|
|
flush_rewrite_rules(false);
|
|
update_option('aksrt_oauth_proxy_client_routes_flushed', $version, false);
|
|
}
|
|
}
|
|
|
|
public function get_proxy_auth_url(string $provider, string $action = 'login'): string {
|
|
$provider = sanitize_key($provider);
|
|
$action = $action === 'bind' ? 'bind' : 'login';
|
|
$server_url = OAuthProxyProtocol::server_base_url(OAuthProviderCatalog::proxy_server_url());
|
|
$secret = OAuthProviderCatalog::proxy_secret();
|
|
|
|
if (
|
|
!OAuthProviderCatalog::master_enabled()
|
|
|| OAuthProviderCatalog::proxy_mode() !== 'client'
|
|
|| !OAuthProviderCatalog::is_direct($provider)
|
|
|| !in_array($provider, OAuthProviderCatalog::proxy_client_providers(), true)
|
|
|| $server_url === ''
|
|
|| $secret === ''
|
|
) {
|
|
return '';
|
|
}
|
|
|
|
$user_id = get_current_user_id();
|
|
if ($action === 'bind' && $user_id <= 0) {
|
|
return '';
|
|
}
|
|
|
|
$redirect_to = isset($_GET['redirect_to'])
|
|
? OAuthUrlPolicy::validated_redirect((string) wp_unslash($_GET['redirect_to']), home_url('/'))
|
|
: home_url('/');
|
|
$callback = OAuthProxyProtocol::callback_url(home_url('/oauth/proxy/callback/'));
|
|
if ($callback === '') {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
$request_id = OAuthTransactionStore::issue('proxy_client', [
|
|
'request_id' => '',
|
|
'provider' => $provider,
|
|
'action' => $action,
|
|
'user_id' => $user_id,
|
|
'redirect_to' => $redirect_to,
|
|
'callback' => $callback,
|
|
'issuer' => OAuthProxyProtocol::issuer($server_url),
|
|
], OAuthProxyProtocol::REPLAY_TTL, true);
|
|
$updated = OAuthTransactionStore::update($request_id, 'proxy_client', static function (array $row) use ($request_id): array {
|
|
$row['payload']['request_id'] = $request_id;
|
|
return $row;
|
|
}, true);
|
|
if (!$updated) {
|
|
OAuthTransactionStore::claim($request_id, 'proxy_client', true);
|
|
return '';
|
|
}
|
|
} catch (\Throwable $e) {
|
|
return '';
|
|
}
|
|
|
|
$params = [
|
|
'v' => OAuthProxyProtocol::VERSION,
|
|
'request_id' => $request_id,
|
|
'provider' => $provider,
|
|
'action' => $action,
|
|
'agent_back_url' => $callback,
|
|
'ts' => (string) time(),
|
|
];
|
|
$params['sign'] = OAuthProxyProtocol::sign_request($params, $secret);
|
|
|
|
return $server_url . '/oauth/proxy/' . rawurlencode($provider) . '/?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
|
}
|
|
|
|
public function handle_proxy_callback(): void {
|
|
if (!get_query_var('aksrt_proxy_callback')) {
|
|
return;
|
|
}
|
|
|
|
$fields = OAuthProxyProtocol::callback_fields($_GET);
|
|
$signature = sanitize_text_field((string) wp_unslash($_GET['sign'] ?? ''));
|
|
$secret = OAuthProviderCatalog::proxy_secret();
|
|
$request_id = sanitize_text_field($fields['request_id']);
|
|
$provider = sanitize_key($fields['provider']);
|
|
$action = $fields['action'] === 'bind' ? 'bind' : 'login';
|
|
|
|
if (
|
|
OAuthProviderCatalog::proxy_mode() !== 'client'
|
|
|| $fields['v'] !== OAuthProxyProtocol::VERSION
|
|
|| !OAuthProxyProtocol::token_valid($request_id)
|
|
|| !OAuthProxyProtocol::token_valid($fields['jti'])
|
|
|| !OAuthProxyProtocol::timestamp_valid($fields['ts'])
|
|
|| !OAuthProxyProtocol::verify_callback($fields, $secret, $signature)
|
|
) {
|
|
$this->redirect_error('proxy_response_invalid', $provider, $action);
|
|
}
|
|
|
|
$row = OAuthTransactionStore::peek($request_id, 'proxy_client', true);
|
|
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
|
|
$expected_provider = sanitize_key((string) ($payload['provider'] ?? ''));
|
|
$expected_action = (string) ($payload['action'] ?? 'login') === 'bind' ? 'bind' : 'login';
|
|
$expected_issuer = (string) ($payload['issuer'] ?? '');
|
|
$issuer = OAuthProxyProtocol::issuer($fields['issuer']);
|
|
|
|
if (
|
|
!$row
|
|
|| !hash_equals((string) ($payload['request_id'] ?? ''), $request_id)
|
|
|| !hash_equals($expected_provider, $provider)
|
|
|| !hash_equals($expected_action, $action)
|
|
|| $issuer === ''
|
|
|| !hash_equals($expected_issuer, $issuer)
|
|
|| !OAuthProviderCatalog::is_direct($provider)
|
|
|| !in_array($provider, OAuthProviderCatalog::proxy_client_providers(), true)
|
|
) {
|
|
$this->redirect_error('proxy_response_mismatch', $expected_provider ?: $provider, $expected_action);
|
|
}
|
|
|
|
if ($action === 'bind' && (int) ($payload['user_id'] ?? 0) !== get_current_user_id()) {
|
|
$this->redirect_error('state_user_mismatch', $provider, $action);
|
|
}
|
|
|
|
$jti_scope = 'proxy_client_jti:' . $issuer . ':' . $fields['jti'];
|
|
$jti_lock = OAuthTransactionStore::acquire_lock($jti_scope, OAuthProxyProtocol::REPLAY_TTL);
|
|
if ($jti_lock === false) {
|
|
$this->redirect_error('proxy_replayed', $provider, $action);
|
|
}
|
|
|
|
|
|
$claimed = OAuthTransactionStore::claim($request_id, 'proxy_client', true);
|
|
if (!$claimed) {
|
|
$this->redirect_error('proxy_replayed', $provider, $action);
|
|
}
|
|
|
|
if ($fields['error'] !== '') {
|
|
$this->redirect_error($this->public_error($fields['error']), $provider, $action);
|
|
}
|
|
|
|
$identity = $this->identity($provider, $fields);
|
|
if ($identity['provider_id'] === '') {
|
|
$this->redirect_error('proxy_identity_invalid', $provider, $action);
|
|
}
|
|
|
|
if ($action === 'bind') {
|
|
$result = OAuthAccountService::bind(get_current_user_id(), $provider, $identity);
|
|
if (is_wp_error($result)) {
|
|
$this->redirect_error(
|
|
$result->get_error_code() === 'oauth_binding_conflict' ? 'binding_conflict' : 'binding_failed',
|
|
$provider,
|
|
$action
|
|
);
|
|
}
|
|
$this->redirect_success('bind', $provider, $this->settings_url());
|
|
}
|
|
|
|
$user = OAuthAccountService::resolve($provider, $identity);
|
|
if (is_wp_error($user) && $user->get_error_code() === 'oauth_no_match') {
|
|
try {
|
|
$obk = OAuthTransactionStore::issue('obk', [
|
|
'provider' => $provider,
|
|
'provider_id' => $identity['provider_id'],
|
|
'user_data' => $identity,
|
|
'redirect_to' => OAuthUrlPolicy::validated_redirect((string) ($payload['redirect_to'] ?? ''), home_url('/')),
|
|
], 15 * MINUTE_IN_SECONDS, true);
|
|
} catch (\Throwable $e) {
|
|
$this->redirect_error('account_match_failed', $provider, $action);
|
|
}
|
|
$bind_url = function_exists('aksrt_get_bind_url')
|
|
? aksrt_get_bind_url($obk)
|
|
: home_url('/sign/?tab=bind&obk=' . rawurlencode($obk));
|
|
wp_safe_redirect($bind_url);
|
|
exit;
|
|
}
|
|
if (!$user instanceof \WP_User) {
|
|
$this->redirect_error('account_match_failed', $provider, $action);
|
|
}
|
|
|
|
OAuthAccountService::complete_login($user, true);
|
|
$this->redirect_success(
|
|
'login',
|
|
$provider,
|
|
OAuthUrlPolicy::validated_redirect((string) ($payload['redirect_to'] ?? ''), home_url('/'))
|
|
);
|
|
}
|
|
|
|
private function identity(string $provider, array $fields): array {
|
|
return [
|
|
'provider' => $provider,
|
|
'provider_id' => sanitize_text_field($fields['provider_id']),
|
|
'username' => sanitize_text_field($fields['username']),
|
|
'name' => sanitize_text_field($fields['name']),
|
|
'email' => sanitize_email($fields['email']),
|
|
'email_verified' => $fields['email_verified'] === '1',
|
|
'avatar_url' => esc_url_raw($fields['avatar_url']),
|
|
'url' => esc_url_raw($fields['url']),
|
|
'bio' => sanitize_text_field($fields['bio']),
|
|
'locale' => sanitize_text_field($fields['locale']),
|
|
];
|
|
}
|
|
|
|
private function public_error(string $error): string {
|
|
$error = sanitize_key($error);
|
|
$allowed = [
|
|
'provider_cancelled',
|
|
'code_missing',
|
|
'provider_unavailable',
|
|
'identity_invalid',
|
|
'callback_failed',
|
|
];
|
|
return in_array($error, $allowed, true) ? $error : 'callback_failed';
|
|
}
|
|
|
|
private function settings_url(): string {
|
|
return function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('settings') : home_url('/');
|
|
}
|
|
|
|
private function redirect_success(string $action, string $provider, string $target): void {
|
|
wp_safe_redirect(add_query_arg([
|
|
'oauth_action' => $action,
|
|
'oauth_provider' => sanitize_key($provider),
|
|
], $target));
|
|
exit;
|
|
}
|
|
|
|
private function redirect_error(string $code, string $provider = '', string $action = 'login'): void {
|
|
$target = $action === 'bind' && is_user_logged_in()
|
|
? $this->settings_url()
|
|
: (function_exists('aksrt_get_login_url') ? aksrt_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;
|
|
}
|
|
}
|