From 8f5518b293ec225741542b453af30db869e49861 Mon Sep 17 00:00:00 2001 From: Simon Costea Date: Tue, 13 Jan 2026 16:39:43 +0200 Subject: [PATCH 1/2] Support magic links --- .../includes/class-bcc-login-visibility.php | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/plugins/bcc-login/includes/class-bcc-login-visibility.php b/plugins/bcc-login/includes/class-bcc-login-visibility.php index 397e925..7bdeedd 100644 --- a/plugins/bcc-login/includes/class-bcc-login-visibility.php +++ b/plugins/bcc-login/includes/class-bcc-login-visibility.php @@ -61,6 +61,7 @@ function __construct( BCC_Login_Settings $settings, BCC_Login_Client $client, BC add_shortcode( 'get_bcc_group_name', array( $this, 'get_bcc_group_name_by_id' ) ); add_shortcode( 'bcc_my_roles', array( $this, 'bcc_my_roles' ) ); add_shortcode( 'has_bcc_role_with_full_content_access', array( $this, 'has_bcc_role_with_full_content_access' ) ); + add_shortcode( 'bcc_magic_link', array( $this, 'bcc_magic_link' ) ); add_action( 'add_meta_boxes', array( $this, 'add_visibility_meta_box_to_attachments' ) ); add_action( 'attachment_updated', array( $this, 'save_visibility_to_attachments' ), 10, 3 ); @@ -196,6 +197,57 @@ function on_template_redirect() { } $post = $post_id ? get_post($post_id) : get_post(); + + // Magic link access (cookie / token -> redirect) + if ($post) { + $cookie_name = 'bcc_ml_' . (int) $post->ID; + + // 1) If we already have a cookie, verify it and allow + $cookie_token = isset($_COOKIE[$cookie_name]) ? (string) $_COOKIE[$cookie_name] : ''; + + if ($cookie_token) { + $claims = $this->bcc_verify_magic_token($cookie_token); + + if ($claims && $claims['post_id'] === (int) $post->ID) { + return; // Allow access without needing the query arg + } + } + + // 2) If token is present in URL, verify it, set cookie, then redirect to clean URL + $token = isset($_GET['bcc_token']) ? sanitize_text_field(wp_unslash($_GET['bcc_token'])) : ''; + if ($token) { + $claims = $this->bcc_verify_magic_token($token); + + if ($claims && $claims['post_id'] === (int) $post->ID) { + if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true); + nocache_headers(); + + $exp = (int) $claims['exp']; + $secure = is_ssl(); + + // PHP 7.3+ supports options array (recommended) + if (PHP_VERSION_ID >= 70300) { + setcookie($cookie_name, $token, [ + 'expires' => $exp, + 'path' => '/', + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + } else { + // Fallback (no SameSite support here) + setcookie($cookie_name, $token, $exp, '/', '', $secure, true); + } + + wp_safe_redirect(); + exit; + } + else { + return $this->incorrect_token_for_page(); + } + } + } + $level = $this->_client->get_current_user_level(); $visibility = (int)$this->_settings->default_visibility; @@ -374,6 +426,22 @@ private function not_allowed_to_view_page($visited_url = "") { ); } + private function incorrect_token_for_page() { + wp_die( + sprintf( + '%s

%s

%s', + __( 'Sorry, the token either expired or is incorrect.', 'bcc-login' ), + __( 'Make sure you are using the correct url or ask to get a new one.', 'bcc-login' ), + site_url(), + __( 'Go to the front page', 'bcc-login' ) + ), + __( 'Unauthorized' ), + array( + 'response' => 401, + ) + ); + } + /** * Determines whether authentication should be skipped for this action */ @@ -1227,4 +1295,74 @@ public static function sanitize_sent_notifications_meta( $value, $meta_key, $obj // Reindex return array_values( $out ); } + + /** + * Magic token functions + */ + + public function bcc_magic_link() { + $post_id = get_the_ID(); + if (!$post_id) return; + + // Generate token valid for 60 days + $token = $this->bcc_make_magic_token($post_id, 60 * DAY_IN_SECONDS); + + $url = add_query_arg( + ['bcc_token' => $token], + get_permalink($post_id) + ); + + return $url; + } + + private function bcc_base64url_encode(string $bin): string { + return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); + } + + private function bcc_base64url_decode(string $str): string|false { + $pad = strlen($str) % 4; + if ($pad) $str .= str_repeat('=', 4 - $pad); + $out = base64_decode(strtr($str, '-_', '+/'), true); + return $out === false ? false : $out; + } + + private function bcc_make_magic_token(int $post_id, int $ttl_seconds = 900): string { + $exp = time() + $ttl_seconds; + $nonce = bin2hex(random_bytes(16)); // prevent deterministic tokens + + // v1|postId|exp|nonce + $payload = implode('|', ['v1', (string)$post_id, (string)$exp, $nonce]); + + // Uses keys/salts from wp-config.php (+ DB secret) via wp_salt + $secret = wp_salt('secure_auth'); + $sig = hash_hmac('sha256', $payload, $secret); + + return $this->bcc_base64url_encode($payload . '|' . $sig); + } + + private function bcc_verify_magic_token(string $token): array|false { + $raw = $this->bcc_base64url_decode($token); + if ($raw === false) return false; + + $parts = explode('|', $raw); + // v1|postId|exp|nonce|sig => 5 parts + if (count($parts) !== 5) return false; + + [$v, $post_id, $exp, $nonce, $sig] = $parts; + if ($v !== 'v1') return false; + + if (!ctype_digit($post_id) || !ctype_digit($exp)) return false; + if ((int)$exp < time()) return false; + + $payload = implode('|', [$v, $post_id, $exp, $nonce]); + $secret = wp_salt('secure_auth'); + $calc = hash_hmac('sha256', $payload, $secret); + + if (!hash_equals($calc, $sig)) return false; + + return [ + 'post_id' => (int)$post_id, + 'exp' => (int)$exp, + ]; + } } From 425bb0369ab437ff703b90c4c5835af95da39d58 Mon Sep 17 00:00:00 2001 From: Simon Costea Date: Wed, 14 Jan 2026 16:09:38 +0200 Subject: [PATCH 2/2] Magic link fixes & adjustments - ask for login before checking magic link token - rename magic link token param - support for not valid sessions - improve expired or incorrect message --- .../includes/class-bcc-login-visibility.php | 121 ++++++++++-------- 1 file changed, 67 insertions(+), 54 deletions(-) diff --git a/plugins/bcc-login/includes/class-bcc-login-visibility.php b/plugins/bcc-login/includes/class-bcc-login-visibility.php index 7bdeedd..0f4ba0c 100644 --- a/plugins/bcc-login/includes/class-bcc-login-visibility.php +++ b/plugins/bcc-login/includes/class-bcc-login-visibility.php @@ -175,6 +175,14 @@ function on_template_redirect() { $visited_url = add_query_arg( $wp->query_vars, home_url( $wp->request ) ); + // Include magic link token from URL to the visited URL + $token_name = 'bcc_mt'; + $param_token = isset($_GET[$token_name]) ? sanitize_text_field(wp_unslash($_GET[$token_name])) : ''; + + if ( $param_token ) { + $visited_url = add_query_arg( $token_name, $param_token, $visited_url ); + } + $session_is_valid = $this->_client->is_session_valid(); // Initiate new login if session has expired @@ -197,57 +205,6 @@ function on_template_redirect() { } $post = $post_id ? get_post($post_id) : get_post(); - - // Magic link access (cookie / token -> redirect) - if ($post) { - $cookie_name = 'bcc_ml_' . (int) $post->ID; - - // 1) If we already have a cookie, verify it and allow - $cookie_token = isset($_COOKIE[$cookie_name]) ? (string) $_COOKIE[$cookie_name] : ''; - - if ($cookie_token) { - $claims = $this->bcc_verify_magic_token($cookie_token); - - if ($claims && $claims['post_id'] === (int) $post->ID) { - return; // Allow access without needing the query arg - } - } - - // 2) If token is present in URL, verify it, set cookie, then redirect to clean URL - $token = isset($_GET['bcc_token']) ? sanitize_text_field(wp_unslash($_GET['bcc_token'])) : ''; - if ($token) { - $claims = $this->bcc_verify_magic_token($token); - - if ($claims && $claims['post_id'] === (int) $post->ID) { - if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true); - nocache_headers(); - - $exp = (int) $claims['exp']; - $secure = is_ssl(); - - // PHP 7.3+ supports options array (recommended) - if (PHP_VERSION_ID >= 70300) { - setcookie($cookie_name, $token, [ - 'expires' => $exp, - 'path' => '/', - 'secure' => $secure, - 'httponly' => true, - 'samesite' => 'Lax', - ]); - } else { - // Fallback (no SameSite support here) - setcookie($cookie_name, $token, $exp, '/', '', $secure, true); - } - - wp_safe_redirect(); - exit; - } - else { - return $this->incorrect_token_for_page(); - } - } - } - $level = $this->_client->get_current_user_level(); $visibility = (int)$this->_settings->default_visibility; @@ -272,6 +229,62 @@ function on_template_redirect() { return; } + // Magic link access (cookie / token -> redirect) + + // 1) If we already have a cookie, verify it and allow + $cookie_name = $token_name . '_' . (int) $post->ID; + $cookie_token = isset($_COOKIE[$cookie_name]) ? (string) $_COOKIE[$cookie_name] : ''; + + if ($cookie_token) { + $claims = $this->bcc_verify_magic_token($cookie_token); + + if ($claims && $claims['post_id'] === (int) $post->ID) { + if ($param_token) { + // Clean URL if token is also present + if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true); + nocache_headers(); + + wp_safe_redirect(remove_query_arg($token_name)); + exit; + } + + return; // Allow access without needing the query arg + } + } + + // 2) If token is present in URL, verify it, set cookie, then redirect to clean URL + if ($param_token) { + $claims = $this->bcc_verify_magic_token($param_token); + + if ($claims && $claims['post_id'] === (int) $post->ID) { + if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true); + nocache_headers(); + + $exp = (int) $claims['exp']; + $secure = is_ssl(); + + // PHP 7.3+ supports options array (recommended) + if (PHP_VERSION_ID >= 70300) { + setcookie($cookie_name, $param_token, [ + 'expires' => $exp, + 'path' => '/', + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + } else { + // Fallback (no SameSite support here) + setcookie($cookie_name, $param_token, $exp, '/', '', $secure, true); + } + + wp_safe_redirect(remove_query_arg($token_name)); + exit; + } + else { + return $this->incorrect_token_for_page(); + } + } + if ( !empty($this->_settings->site_groups) ) { $post_target_groups = get_post_meta($post->ID, 'bcc_groups', false); $post_visibility_groups = get_post_meta($post->ID, 'bcc_visibility_groups', false); @@ -430,8 +443,8 @@ private function incorrect_token_for_page() { wp_die( sprintf( '%s

%s

%s', - __( 'Sorry, the token either expired or is incorrect.', 'bcc-login' ), - __( 'Make sure you are using the correct url or ask to get a new one.', 'bcc-login' ), + __( 'Sorry, the token for the magic link is either expired or incorrect.', 'bcc-login' ), + __( 'Make sure you are using the correct link or ask for a new one.', 'bcc-login' ), site_url(), __( 'Go to the front page', 'bcc-login' ) ), @@ -1308,7 +1321,7 @@ public function bcc_magic_link() { $token = $this->bcc_make_magic_token($post_id, 60 * DAY_IN_SECONDS); $url = add_query_arg( - ['bcc_token' => $token], + ['bcc_mt' => $token], get_permalink($post_id) );