diff --git a/plugins/bcc-login/includes/class-bcc-login-visibility.php b/plugins/bcc-login/includes/class-bcc-login-visibility.php index 562408b..c5f8452 100644 --- a/plugins/bcc-login/includes/class-bcc-login-visibility.php +++ b/plugins/bcc-login/includes/class-bcc-login-visibility.php @@ -63,6 +63,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 ); @@ -176,6 +177,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 @@ -222,6 +231,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); @@ -376,6 +441,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 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' ) + ), + __( 'Unauthorized' ), + array( + 'response' => 401, + ) + ); + } + /** * Determines whether authentication should be skipped for this action */ @@ -1238,4 +1319,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_mt' => $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, + ]; + } }