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,
+ ];
+ }
}