Last active
August 10, 2026 19:46
-
-
Save gomasy/5f01c52b4bece379585969c42221b8b9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Plugin Name: Cloudflare Access SSO | |
| * Description: Cloudflare Access の JWT を検証し、認証済みメールアドレスに対応する WordPress ユーザーへ自動ログインします。 | |
| * Version: 2.0.0 | |
| * Requires PHP: 7.2 | |
| * | |
| * 設置場所: wp-content/mu-plugins/cf-access-sso.php | |
| * | |
| * --- 必須 --- | |
| * define('CF_ACCESS_TEAM_DOMAIN', 'https://example.cloudflareaccess.com'); | |
| * define('CF_ACCESS_AUD', '4714c1358e65fe4b...'); // 文字列または配列 | |
| * | |
| * --- 任意 --- | |
| * define('CF_ACCESS_AUTO_CREATE', false); // 未登録メールのユーザーを自動作成 | |
| * define('CF_ACCESS_DEFAULT_ROLE', 'subscriber'); // 自動作成時のロール | |
| * define('CF_ACCESS_ENFORCE_SESSION', true); // Access セッション消失時に WP も強制ログアウト | |
| * define('CF_ACCESS_HEADER_ONLY', false); // JWT 検証を省きヘッダーのみ信頼(非推奨) | |
| * define('CF_ACCESS_LOGOUT_URL', '...'); // 既定: <サイト>/cdn-cgi/access/logout | |
| * define('CF_ACCESS_DEBUG', false); // error_log への診断出力(WP_DEBUG と併用) | |
| * | |
| */ | |
| if (!defined('ABSPATH')) { | |
| exit; | |
| } | |
| /* ========================================================================= | |
| * 共通ユーティリティ | |
| * ====================================================================== */ | |
| final class CF_Access_Util { | |
| /** | |
| * base64url をデコードする。不正な入力は空文字を返す。 | |
| * | |
| * @param string $input | |
| * @return string | |
| */ | |
| public static function b64url_decode($input) { | |
| if (!is_string($input) || $input === '') { | |
| return ''; | |
| } | |
| $decoded = base64_decode(strtr($input, '-_', '+/'), true); | |
| return $decoded === false ? '' : $decoded; | |
| } | |
| /** | |
| * 診断ログ。トークンや Cookie の値は決して渡さないこと。 | |
| * | |
| * @param string $message | |
| */ | |
| public static function log($message) { | |
| if (CF_Access_Config::debug()) { | |
| error_log('[cf-access-sso] ' . $message); | |
| } | |
| } | |
| } | |
| /* ========================================================================= | |
| * 設定 | |
| * ====================================================================== */ | |
| final class CF_Access_Config { | |
| /** @var array 正規化済みの値をリクエスト内で使い回す */ | |
| private static $cache = []; | |
| /** | |
| * チームドメイン。スキーム省略や http 指定は https に正規化する。 | |
| * | |
| * @return string|null | |
| */ | |
| public static function team_domain() { | |
| if (array_key_exists('team_domain', self::$cache)) { | |
| return self::$cache['team_domain']; | |
| } | |
| $value = null; | |
| if (defined('CF_ACCESS_TEAM_DOMAIN') && is_string(CF_ACCESS_TEAM_DOMAIN)) { | |
| $domain = untrailingslashit(trim(CF_ACCESS_TEAM_DOMAIN)); | |
| if ($domain !== '') { | |
| $value = 'https://' . preg_replace('#^https?://#i', '', $domain); | |
| } | |
| } | |
| return self::$cache['team_domain'] = $value; | |
| } | |
| /** | |
| * 許可する Application Audience (AUD) の一覧。 | |
| * | |
| * @return string[] | |
| */ | |
| public static function audiences() { | |
| if (array_key_exists('audiences', self::$cache)) { | |
| return self::$cache['audiences']; | |
| } | |
| $out = []; | |
| if (defined('CF_ACCESS_AUD')) { | |
| $list = is_array(CF_ACCESS_AUD) ? CF_ACCESS_AUD : [CF_ACCESS_AUD]; | |
| foreach ($list as $aud) { | |
| if (is_string($aud) && $aud !== '') { | |
| $out[] = $aud; | |
| } | |
| } | |
| } | |
| return self::$cache['audiences'] = $out; | |
| } | |
| /** | |
| * JWT 検証に必要な設定が揃っているか。 | |
| * | |
| * @return bool | |
| */ | |
| public static function is_configured() { | |
| return self::team_domain() !== null && self::audiences() !== []; | |
| } | |
| /** @return bool */ | |
| public static function header_only() { | |
| return self::flag('CF_ACCESS_HEADER_ONLY', false); | |
| } | |
| /** @return bool */ | |
| public static function enforce_session() { | |
| return self::flag('CF_ACCESS_ENFORCE_SESSION', true); | |
| } | |
| /** @return bool */ | |
| public static function auto_create() { | |
| return self::flag('CF_ACCESS_AUTO_CREATE', false); | |
| } | |
| /** @return bool */ | |
| public static function debug() { | |
| return self::flag('WP_DEBUG', false) && self::flag('CF_ACCESS_DEBUG', false); | |
| } | |
| /** @return string */ | |
| public static function default_role() { | |
| if (defined('CF_ACCESS_DEFAULT_ROLE') && is_string(CF_ACCESS_DEFAULT_ROLE) | |
| && CF_ACCESS_DEFAULT_ROLE !== '') { | |
| return CF_ACCESS_DEFAULT_ROLE; | |
| } | |
| return 'subscriber'; | |
| } | |
| /** | |
| * ログアウト時の遷移先。null なら WordPress 標準の挙動に委ねる。 | |
| * | |
| * @return string|null | |
| */ | |
| public static function logout_url() { | |
| if (!defined('CF_ACCESS_LOGOUT_URL')) { | |
| // 既定値はサイト自身のホスト。ここでしかサイト側の Cookie は削除できない。 | |
| return home_url('/cdn-cgi/access/logout'); | |
| } | |
| $url = CF_ACCESS_LOGOUT_URL; | |
| if (!is_string($url) || $url === '') { | |
| return null; | |
| } | |
| return esc_url_raw($url); | |
| } | |
| /** | |
| * @param string $constant | |
| * @param bool $default | |
| * @return bool | |
| */ | |
| private static function flag($constant, $default) { | |
| return defined($constant) ? (bool) constant($constant) : (bool) $default; | |
| } | |
| } | |
| /* ========================================================================= | |
| * 公開鍵の取得 | |
| * ====================================================================== */ | |
| final class CF_Access_Keys { | |
| /** 取得済みの鍵(transient) */ | |
| const CACHE_KEY = 'cf_access_sso_certs'; | |
| const CACHE_TTL = 3600; | |
| /** 取得失敗時に使う、最後に成功した鍵(option) */ | |
| const BACKUP_KEY = 'cf_access_sso_certs_backup'; | |
| /** 外部リクエストの連打を防ぐロック(transient) */ | |
| const LOCK_KEY = 'cf_access_sso_certs_lock'; | |
| const LOCK_TTL = 300; | |
| /** | |
| * kid に対応する公開鍵 PEM を返す。未知の kid なら一度だけ再取得を試みる。 | |
| * | |
| * @param string $kid | |
| * @return string|null | |
| */ | |
| public static function pem_for($kid) { | |
| $keys = self::all(); | |
| if (isset($keys[$kid])) { | |
| return $keys[$kid]; | |
| } | |
| // 鍵ローテーション直後の可能性。ロックがあるため連打にはならない。 | |
| $keys = self::all(true); | |
| return isset($keys[$kid]) ? $keys[$kid] : null; | |
| } | |
| /** | |
| * @param bool $force キャッシュを無視して取得を試みる | |
| * @return array<string,string> kid => PEM | |
| */ | |
| private static function all($force = false) { | |
| if (!$force) { | |
| $cached = get_transient(self::CACHE_KEY); | |
| if (is_array($cached) && $cached) { | |
| return $cached; | |
| } | |
| } | |
| // 存在しない kid を送り続けることで外部リクエストを誘発する攻撃を防ぐ | |
| if (get_transient(self::LOCK_KEY)) { | |
| return self::backup(); | |
| } | |
| set_transient(self::LOCK_KEY, 1, self::LOCK_TTL); | |
| $fetched = self::fetch(); | |
| if (!$fetched) { | |
| // Cloudflare 側の一時障害で全員をログアウトさせないための保険 | |
| CF_Access_Util::log('key fetch failed; falling back to last known good keys'); | |
| return self::backup(); | |
| } | |
| set_transient(self::CACHE_KEY, $fetched, self::CACHE_TTL); | |
| if (self::backup() !== $fetched) { | |
| update_option(self::BACKUP_KEY, $fetched, false); | |
| } | |
| return $fetched; | |
| } | |
| /** | |
| * /cdn-cgi/access/certs から鍵を取得する。 | |
| * public_certs(PEM)を優先し、無ければ keys(JWKS)から組み立てる。 | |
| * | |
| * @return array<string,string> | |
| */ | |
| private static function fetch() { | |
| $domain = CF_Access_Config::team_domain(); | |
| if (!$domain) { | |
| return []; | |
| } | |
| $res = wp_remote_get($domain . '/cdn-cgi/access/certs', ['timeout' => 5]); | |
| if (is_wp_error($res) || wp_remote_retrieve_response_code($res) !== 200) { | |
| return []; | |
| } | |
| $body = json_decode(wp_remote_retrieve_body($res), true); | |
| if (!is_array($body)) { | |
| return []; | |
| } | |
| $keys = []; | |
| if (!empty($body['public_certs']) && is_array($body['public_certs'])) { | |
| foreach ($body['public_certs'] as $entry) { | |
| if (is_array($entry) && !empty($entry['kid']) && !empty($entry['cert']) | |
| && is_string($entry['kid']) && is_string($entry['cert'])) { | |
| $keys[$entry['kid']] = $entry['cert']; | |
| } | |
| } | |
| } | |
| if (!$keys && !empty($body['keys']) && is_array($body['keys'])) { | |
| foreach ($body['keys'] as $jwk) { | |
| if (!is_array($jwk) || empty($jwk['kid']) || !is_string($jwk['kid'])) { | |
| continue; | |
| } | |
| $pem = self::jwk_to_pem($jwk); | |
| if ($pem) { | |
| $keys[$jwk['kid']] = $pem; | |
| } | |
| } | |
| } | |
| return $keys; | |
| } | |
| /** | |
| * @return array<string,string> | |
| */ | |
| private static function backup() { | |
| $backup = get_option(self::BACKUP_KEY); | |
| return is_array($backup) ? $backup : []; | |
| } | |
| /** | |
| * RSA の JWK を PEM 形式の SubjectPublicKeyInfo に変換する。 | |
| * | |
| * @param array $jwk | |
| * @return string|null | |
| */ | |
| private static function jwk_to_pem(array $jwk) { | |
| if (empty($jwk['kty']) || $jwk['kty'] !== 'RSA' || empty($jwk['n']) || empty($jwk['e'])) { | |
| return null; | |
| } | |
| $modulus = CF_Access_Util::b64url_decode($jwk['n']); | |
| $exponent = CF_Access_Util::b64url_decode($jwk['e']); | |
| if ($modulus === '' || $exponent === '') { | |
| return null; | |
| } | |
| // RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER } | |
| $rsa_key = self::der(0x30, self::der_integer($modulus) . self::der_integer($exponent)); | |
| // AlgorithmIdentifier ::= SEQUENCE { OID rsaEncryption, NULL } | |
| $algorithm = self::der( | |
| 0x30, | |
| self::der(0x06, "\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01") . self::der(0x05, '') | |
| ); | |
| // SubjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING } | |
| $spki = self::der(0x30, $algorithm . self::der(0x03, "\x00" . $rsa_key)); | |
| return "-----BEGIN PUBLIC KEY-----\n" | |
| . chunk_split(base64_encode($spki), 64, "\n") | |
| . "-----END PUBLIC KEY-----\n"; | |
| } | |
| /** | |
| * @param int $tag | |
| * @param string $value | |
| * @return string | |
| */ | |
| private static function der($tag, $value) { | |
| return chr($tag) . self::der_length(strlen($value)) . $value; | |
| } | |
| /** | |
| * @param int $length | |
| * @return string | |
| */ | |
| private static function der_length($length) { | |
| if ($length < 0x80) { | |
| return chr($length); | |
| } | |
| $bytes = ''; | |
| while ($length > 0) { | |
| $bytes = chr($length & 0xff) . $bytes; | |
| $length >>= 8; | |
| } | |
| return chr(0x80 | strlen($bytes)) . $bytes; | |
| } | |
| /** | |
| * DER の INTEGER は符号付きなので、最上位ビットが立つ場合は 0x00 を前置する。 | |
| * | |
| * @param string $binary | |
| * @return string | |
| */ | |
| private static function der_integer($binary) { | |
| $binary = ltrim($binary, "\x00"); | |
| if ($binary === '') { | |
| $binary = "\x00"; | |
| } | |
| if (ord($binary[0]) & 0x80) { | |
| $binary = "\x00" . $binary; | |
| } | |
| return self::der(0x02, $binary); | |
| } | |
| } | |
| /* ========================================================================= | |
| * Access が認証した識別情報(値オブジェクト) | |
| * ====================================================================== */ | |
| final class CF_Access_Identity { | |
| /** @var string */ | |
| private $email; | |
| /** @var int|null JWT の exp。ヘッダーのみのモードでは null */ | |
| private $expires_at; | |
| public function __construct($email, $expires_at = null) { | |
| $this->email = $email; | |
| $this->expires_at = $expires_at; | |
| } | |
| /** @return string */ | |
| public function email() { | |
| return $this->email; | |
| } | |
| /** @return int|null */ | |
| public function expires_at() { | |
| return $this->expires_at; | |
| } | |
| } | |
| /* ========================================================================= | |
| * トークンの取得と検証 | |
| * ====================================================================== */ | |
| final class CF_Access_Token { | |
| /** 時刻ずれの許容秒数 */ | |
| const LEEWAY = 60; | |
| /** @var CF_Access_Identity|false|null false = 該当なし、null = 未評価 */ | |
| private static $current = null; | |
| /** | |
| * このリクエストの識別情報。検証に失敗した場合は null。 | |
| * | |
| * @return CF_Access_Identity|null | |
| */ | |
| public static function current() { | |
| if (self::$current !== null) { | |
| return self::$current ?: null; | |
| } | |
| self::$current = self::resolve() ?: false; | |
| return self::$current ?: null; | |
| } | |
| /** | |
| * @return CF_Access_Identity|null | |
| */ | |
| private static function resolve() { | |
| if (CF_Access_Config::header_only()) { | |
| return self::from_header(); | |
| } | |
| if (!CF_Access_Config::is_configured()) { | |
| CF_Access_Util::log('CF_ACCESS_TEAM_DOMAIN / CF_ACCESS_AUD is not configured'); | |
| return null; | |
| } | |
| $token = self::raw_token(); | |
| if (!$token) { | |
| return null; | |
| } | |
| $payload = self::verify($token); | |
| if (!$payload) { | |
| return null; | |
| } | |
| // Service Token(非対話認証)は email を持たないためログインさせない | |
| if (empty($payload['email']) || !is_string($payload['email'])) { | |
| CF_Access_Util::log('token has no email claim (service token?)'); | |
| return null; | |
| } | |
| $email = sanitize_email($payload['email']); | |
| if (!is_email($email)) { | |
| return null; | |
| } | |
| return new CF_Access_Identity($email, isset($payload['exp']) ? (int) $payload['exp'] : null); | |
| } | |
| /** | |
| * 非推奨モード。オリジンへの直接到達経路が存在しない場合のみ成立する。 | |
| * | |
| * @return CF_Access_Identity|null | |
| */ | |
| private static function from_header() { | |
| if (empty($_SERVER['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'])) { | |
| return null; | |
| } | |
| $email = sanitize_email(wp_unslash($_SERVER['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'])); | |
| return is_email($email) ? new CF_Access_Identity($email) : null; | |
| } | |
| /** | |
| * ヘッダーを優先し、無ければ Cookie から取る。 | |
| * ヘッダーは Access を通過したリクエストにしか付かないため、 | |
| * アプリのパス外(/wp-admin/ など)では Cookie が唯一の経路になる。 | |
| * | |
| * @return string|null | |
| */ | |
| private static function raw_token() { | |
| if (!empty($_SERVER['HTTP_CF_ACCESS_JWT_ASSERTION'])) { | |
| return trim(wp_unslash($_SERVER['HTTP_CF_ACCESS_JWT_ASSERTION'])); | |
| } | |
| if (!empty($_COOKIE['CF_Authorization'])) { | |
| return trim(wp_unslash($_COOKIE['CF_Authorization'])); | |
| } | |
| return null; | |
| } | |
| /** | |
| * RS256 署名・iss・aud・exp/nbf を検証してペイロードを返す。 | |
| * | |
| * @param string $token | |
| * @return array|null | |
| */ | |
| private static function verify($token) { | |
| $parts = explode('.', $token); | |
| if (count($parts) !== 3) { | |
| return null; | |
| } | |
| list($b64_header, $b64_payload, $b64_signature) = $parts; | |
| $header = json_decode(CF_Access_Util::b64url_decode($b64_header), true); | |
| $payload = json_decode(CF_Access_Util::b64url_decode($b64_payload), true); | |
| $signature = CF_Access_Util::b64url_decode($b64_signature); | |
| if (!is_array($header) || !is_array($payload) || $signature === '') { | |
| return null; | |
| } | |
| if (!self::verify_signature($header, $b64_header . '.' . $b64_payload, $signature)) { | |
| return null; | |
| } | |
| if (!self::verify_time($payload)) { | |
| return null; | |
| } | |
| if (!self::verify_issuer($payload)) { | |
| return null; | |
| } | |
| if (!self::verify_audience($payload)) { | |
| return null; | |
| } | |
| return $payload; | |
| } | |
| /** | |
| * @param array $header | |
| * @param string $signed_input | |
| * @param string $signature | |
| * @return bool | |
| */ | |
| private static function verify_signature(array $header, $signed_input, $signature) { | |
| // alg を固定する。none や HS256 への差し替えを許すと署名検証が無意味になる。 | |
| if (!isset($header['alg']) || $header['alg'] !== 'RS256') { | |
| CF_Access_Util::log('unexpected alg'); | |
| return false; | |
| } | |
| if (empty($header['kid']) || !is_string($header['kid'])) { | |
| return false; | |
| } | |
| $pem = CF_Access_Keys::pem_for($header['kid']); | |
| if (!$pem) { | |
| CF_Access_Util::log('no public key for kid'); | |
| return false; | |
| } | |
| $public_key = openssl_pkey_get_public($pem); | |
| if ($public_key === false) { | |
| CF_Access_Util::log('failed to parse public key'); | |
| return false; | |
| } | |
| if (openssl_verify($signed_input, $signature, $public_key, OPENSSL_ALGO_SHA256) !== 1) { | |
| CF_Access_Util::log('signature verification failed'); | |
| return false; | |
| } | |
| return true; | |
| } | |
| /** | |
| * @param array $payload | |
| * @return bool | |
| */ | |
| private static function verify_time(array $payload) { | |
| $now = time(); | |
| if (!isset($payload['exp']) || ($now - self::LEEWAY) >= (int) $payload['exp']) { | |
| CF_Access_Util::log('token expired'); | |
| return false; | |
| } | |
| if (isset($payload['nbf']) && ($now + self::LEEWAY) < (int) $payload['nbf']) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| /** | |
| * @param array $payload | |
| * @return bool | |
| */ | |
| private static function verify_issuer(array $payload) { | |
| if (empty($payload['iss']) || !is_string($payload['iss'])) { | |
| return false; | |
| } | |
| if (!hash_equals(CF_Access_Config::team_domain(), untrailingslashit($payload['iss']))) { | |
| CF_Access_Util::log('issuer mismatch'); | |
| return false; | |
| } | |
| return true; | |
| } | |
| /** | |
| * @param array $payload | |
| * @return bool | |
| */ | |
| private static function verify_audience(array $payload) { | |
| $actual_list = isset($payload['aud']) ? (array) $payload['aud'] : []; | |
| foreach ($actual_list as $actual) { | |
| if (!is_string($actual)) { | |
| continue; | |
| } | |
| foreach (CF_Access_Config::audiences() as $expected) { | |
| if (hash_equals($expected, $actual)) { | |
| return true; | |
| } | |
| } | |
| } | |
| CF_Access_Util::log('audience mismatch (check CF_ACCESS_AUD)'); | |
| return false; | |
| } | |
| } | |
| /* ========================================================================= | |
| * WordPress 統合 | |
| * ====================================================================== */ | |
| final class CF_Access_SSO { | |
| /** wp-login.php で素通しするアクション */ | |
| const PASSTHROUGH_ACTIONS = ['logout', 'postpass', 'confirmaction']; | |
| /** @var int|false|null 解決済みユーザーID */ | |
| private static $user_id = null; | |
| public static function boot() { | |
| // 認証 Cookie の発行。auth_redirect() より前である必要があるため plugins_loaded。 | |
| add_action('plugins_loaded', [__CLASS__, 'establish_session'], 1); | |
| // wp-login.php は reauth=1 で Cookie を消してから login_init を撃つので張り直す。 | |
| add_action('login_init', [__CLASS__, 'login_init'], 1); | |
| // WordPress の Cookie 操作は $_COOKIE を更新しない。同一リクエスト内の | |
| // auth_redirect() や nonce 生成と食い違わないよう、発行・削除の両方を反映させる。 | |
| add_action('set_auth_cookie', [__CLASS__, 'remember_auth_cookie'], 10, 5); | |
| add_action('set_logged_in_cookie', [__CLASS__, 'remember_logged_in_cookie'], 10, 1); | |
| add_action('clear_auth_cookie', [__CLASS__, 'forget_cookies'], 10, 0); | |
| // WordPress のセッションが Access のセッションより長生きしないようにする。 | |
| add_filter('auth_cookie_expiration', [__CLASS__, 'auth_cookie_expiration'], 99, 3); | |
| add_filter('logout_redirect', [__CLASS__, 'logout_redirect'], 10, 3); | |
| add_filter('allowed_redirect_hosts', [__CLASS__, 'allowed_redirect_hosts'], 10, 1); | |
| } | |
| /* ---------------------------------------------------------------- */ | |
| /* セッション */ | |
| /* ---------------------------------------------------------------- */ | |
| /** | |
| * Access の識別情報に合わせて WordPress のセッションを同期する。 | |
| */ | |
| public static function establish_session() { | |
| if (self::is_non_browser_context() || headers_sent()) { | |
| return; | |
| } | |
| $user_id = self::user_id(); | |
| if ($user_id) { | |
| self::issue_cookie($user_id); | |
| return; | |
| } | |
| // Access のセッションが無い。WordPress 側も終了させる。 | |
| // wp_clear_auth_cookie() ではなく wp_logout() を使う。前者は Cookie を消すだけで | |
| // サーバー側のセッショントークンが user_meta に残り、再利用できてしまう。 | |
| if (CF_Access_Config::enforce_session() && wp_validate_auth_cookie('', 'logged_in')) { | |
| CF_Access_Util::log('no Access identity; terminating WordPress session'); | |
| wp_logout(); | |
| } | |
| } | |
| /** | |
| * 必要であれば認証 Cookie を発行し、カレントユーザーを確定する。 | |
| * | |
| * @param int $user_id | |
| */ | |
| private static function issue_cookie($user_id) { | |
| $current = wp_validate_auth_cookie('', 'logged_in'); | |
| if ($current === $user_id) { | |
| return; // 既に有効な Cookie がある | |
| } | |
| if ($current) { | |
| wp_clear_auth_cookie(); // 別ユーザーの Cookie が残っている | |
| } | |
| wp_set_auth_cookie($user_id, false); | |
| wp_set_current_user($user_id); | |
| CF_Access_Util::log(sprintf('issued auth cookie for user #%d', $user_id)); | |
| } | |
| /** | |
| * ログイン画面。reauth=1 で消された Cookie を張り直し、目的地へ送る。 | |
| */ | |
| public static function login_init() { | |
| $action = isset($_REQUEST['action']) ? sanitize_key(wp_unslash($_REQUEST['action'])) : ''; | |
| // ログアウト・投稿パスワード・プライバシー確認リンクは WordPress 本来の処理に任せる | |
| if (in_array($action, self::PASSTHROUGH_ACTIONS, true)) { | |
| return; | |
| } | |
| $user_id = self::user_id(); | |
| if (!$user_id) { | |
| wp_die( | |
| esc_html__('このサイトへのログインは Cloudflare Access 経由でのみ可能です。', 'cf-access-sso'), | |
| esc_html__('Forbidden', 'cf-access-sso'), | |
| ['response' => 403] | |
| ); | |
| } | |
| if (!headers_sent()) { | |
| self::issue_cookie($user_id); | |
| } | |
| // セッション切れモーダル(iframe 内)はリダイレクトさせず、そのまま描画させる | |
| if (isset($_REQUEST['interim-login'])) { | |
| return; | |
| } | |
| wp_safe_redirect(self::post_login_redirect()); | |
| exit; | |
| } | |
| /** | |
| * ログイン後の遷移先。wp-login.php 自身が指定された場合はループするため除外する。 | |
| * | |
| * @return string | |
| */ | |
| private static function post_login_redirect() { | |
| $fallback = admin_url(); | |
| if (empty($_REQUEST['redirect_to'])) { | |
| return $fallback; | |
| } | |
| $target = wp_unslash($_REQUEST['redirect_to']); | |
| if (!is_string($target) || $target === '') { | |
| return $fallback; | |
| } | |
| $path = (string) wp_parse_url($target, PHP_URL_PATH); | |
| return strpos($path, 'wp-login.php') !== false ? $fallback : $target; | |
| } | |
| /** | |
| * WP-CLI・cron など、Cookie を扱う意味がない実行文脈かどうか。 | |
| * | |
| * @return bool | |
| */ | |
| private static function is_non_browser_context() { | |
| return (defined('WP_CLI') && WP_CLI) | |
| || (defined('DOING_CRON') && DOING_CRON) | |
| || (defined('WP_INSTALLING') && WP_INSTALLING); | |
| } | |
| /* ---------------------------------------------------------------- */ | |
| /* Cookie の整合性 */ | |
| /* ---------------------------------------------------------------- */ | |
| public static function remember_auth_cookie($cookie, $expire, $expiration, $user_id, $scheme) { | |
| $name = ($scheme === 'secure_auth') ? SECURE_AUTH_COOKIE : AUTH_COOKIE; | |
| $_COOKIE[$name] = $cookie; | |
| } | |
| public static function remember_logged_in_cookie($cookie) { | |
| $_COOKIE[LOGGED_IN_COOKIE] = $cookie; | |
| } | |
| /** | |
| * 削除を同一リクエストへ反映させる。これが無いと直後の auth_redirect() が | |
| * 古い $_COOKIE で認証を通し、ログアウトが1リクエスト分遅れる。 | |
| * wp_destroy_current_session() はこのフックより前に走るため取り違えは起きない。 | |
| */ | |
| public static function forget_cookies() { | |
| foreach ([AUTH_COOKIE, SECURE_AUTH_COOKIE, LOGGED_IN_COOKIE] as $name) { | |
| unset($_COOKIE[$name]); | |
| } | |
| } | |
| /** | |
| * WordPress の Cookie 有効期限を JWT の exp までに切り詰める。 | |
| */ | |
| public static function auth_cookie_expiration($length, $user_id, $remember) { | |
| $identity = CF_Access_Token::current(); | |
| if (!$identity || !$identity->expires_at()) { | |
| return $length; | |
| } | |
| $remaining = $identity->expires_at() - time(); | |
| return ($remaining > CF_Access_Token::LEEWAY && $remaining < $length) ? $remaining : $length; | |
| } | |
| /* ---------------------------------------------------------------- */ | |
| /* ユーザー解決 */ | |
| /* ---------------------------------------------------------------- */ | |
| /** | |
| * @return int|false | |
| */ | |
| private static function user_id() { | |
| if (self::$user_id !== null) { | |
| return self::$user_id; | |
| } | |
| $identity = CF_Access_Token::current(); | |
| self::$user_id = $identity ? self::resolve_user($identity->email()) : false; | |
| return self::$user_id; | |
| } | |
| /** | |
| * @param string $email | |
| * @return int|false | |
| */ | |
| private static function resolve_user($email) { | |
| $user = get_user_by('email', $email); | |
| if ($user instanceof WP_User) { | |
| return (int) $user->ID; | |
| } | |
| if (!CF_Access_Config::auto_create()) { | |
| CF_Access_Util::log('authenticated but no matching WordPress user'); | |
| return false; | |
| } | |
| $login = sanitize_user($email, true); | |
| if ($login === '' || username_exists($login)) { | |
| $login .= '-' . wp_generate_password(4, false, false); | |
| } | |
| $user_id = wp_insert_user([ | |
| 'user_login' => $login, | |
| 'user_email' => $email, | |
| 'user_pass' => wp_generate_password(64, true, true), | |
| 'role' => CF_Access_Config::default_role(), | |
| ]); | |
| if (is_wp_error($user_id)) { | |
| CF_Access_Util::log('user creation failed: ' . $user_id->get_error_code()); | |
| return false; | |
| } | |
| return (int) $user_id; | |
| } | |
| /* ---------------------------------------------------------------- */ | |
| /* ログアウト */ | |
| /* ---------------------------------------------------------------- */ | |
| public static function logout_redirect($redirect_to, $requested_redirect_to = '', $user = null) { | |
| $url = CF_Access_Config::logout_url(); | |
| return $url ? $url : $redirect_to; | |
| } | |
| /** | |
| * logout_redirect の戻り値は wp_safe_redirect() に渡されるため、 | |
| * 別ホスト(IdP のログアウト等)を経由する場合は許可リストへの追加が必須。 | |
| */ | |
| public static function allowed_redirect_hosts($hosts) { | |
| $url = CF_Access_Config::logout_url(); | |
| if (!$url) { | |
| return $hosts; | |
| } | |
| $host = wp_parse_url($url, PHP_URL_HOST); | |
| if ($host) { | |
| $hosts[] = $host; | |
| } | |
| return $hosts; | |
| } | |
| } | |
| CF_Access_SSO::boot(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment