Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions plugins/bcc-login/includes/class-bcc-login-visibility.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -376,6 +441,22 @@ private function not_allowed_to_view_page($visited_url = "") {
);
}

private function incorrect_token_for_page() {
wp_die(
sprintf(
'%s<br><br>%s<br><br><a href="%s">%s</a>',
__( '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
*/
Expand Down Expand Up @@ -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,
];
}
}
Loading