Skip to content

Commit 32fa946

Browse files
authored
Merge pull request #212 from bcc-code/feature/magic-links
Support magic links
2 parents 4a2736c + 8650587 commit 32fa946

File tree

1 file changed

+151
-0
lines changed

1 file changed

+151
-0
lines changed

plugins/bcc-login/includes/class-bcc-login-visibility.php

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ function __construct( BCC_Login_Settings $settings, BCC_Login_Client $client, BC
6363
add_shortcode( 'get_bcc_group_name', array( $this, 'get_bcc_group_name_by_id' ) );
6464
add_shortcode( 'bcc_my_roles', array( $this, 'bcc_my_roles' ) );
6565
add_shortcode( 'has_bcc_role_with_full_content_access', array( $this, 'has_bcc_role_with_full_content_access' ) );
66+
add_shortcode( 'bcc_magic_link', array( $this, 'bcc_magic_link' ) );
6667

6768
add_action( 'add_meta_boxes', array( $this, 'add_visibility_meta_box_to_attachments' ) );
6869
add_action( 'attachment_updated', array( $this, 'save_visibility_to_attachments' ), 10, 3 );
@@ -176,6 +177,14 @@ function on_template_redirect() {
176177

177178
$visited_url = add_query_arg( $wp->query_vars, home_url( $wp->request ) );
178179

180+
// Include magic link token from URL to the visited URL
181+
$token_name = 'bcc_mt';
182+
$param_token = isset($_GET[$token_name]) ? sanitize_text_field(wp_unslash($_GET[$token_name])) : '';
183+
184+
if ( $param_token ) {
185+
$visited_url = add_query_arg( $token_name, $param_token, $visited_url );
186+
}
187+
179188
$session_is_valid = $this->_client->is_session_valid();
180189

181190
// Initiate new login if session has expired
@@ -222,6 +231,62 @@ function on_template_redirect() {
222231
return;
223232
}
224233

234+
// Magic link access (cookie / token -> redirect)
235+
236+
// 1) If we already have a cookie, verify it and allow
237+
$cookie_name = $token_name . '_' . (int) $post->ID;
238+
$cookie_token = isset($_COOKIE[$cookie_name]) ? (string) $_COOKIE[$cookie_name] : '';
239+
240+
if ($cookie_token) {
241+
$claims = $this->bcc_verify_magic_token($cookie_token);
242+
243+
if ($claims && $claims['post_id'] === (int) $post->ID) {
244+
if ($param_token) {
245+
// Clean URL if token is also present
246+
if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true);
247+
nocache_headers();
248+
249+
wp_safe_redirect(remove_query_arg($token_name));
250+
exit;
251+
}
252+
253+
return; // Allow access without needing the query arg
254+
}
255+
}
256+
257+
// 2) If token is present in URL, verify it, set cookie, then redirect to clean URL
258+
if ($param_token) {
259+
$claims = $this->bcc_verify_magic_token($param_token);
260+
261+
if ($claims && $claims['post_id'] === (int) $post->ID) {
262+
if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true);
263+
nocache_headers();
264+
265+
$exp = (int) $claims['exp'];
266+
$secure = is_ssl();
267+
268+
// PHP 7.3+ supports options array (recommended)
269+
if (PHP_VERSION_ID >= 70300) {
270+
setcookie($cookie_name, $param_token, [
271+
'expires' => $exp,
272+
'path' => '/',
273+
'secure' => $secure,
274+
'httponly' => true,
275+
'samesite' => 'Lax',
276+
]);
277+
} else {
278+
// Fallback (no SameSite support here)
279+
setcookie($cookie_name, $param_token, $exp, '/', '', $secure, true);
280+
}
281+
282+
wp_safe_redirect(remove_query_arg($token_name));
283+
exit;
284+
}
285+
else {
286+
return $this->incorrect_token_for_page();
287+
}
288+
}
289+
225290
if ( !empty($this->_settings->site_groups) ) {
226291
$post_target_groups = get_post_meta($post->ID, 'bcc_groups', false);
227292
$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 = "") {
376441
);
377442
}
378443

444+
private function incorrect_token_for_page() {
445+
wp_die(
446+
sprintf(
447+
'%s<br><br>%s<br><br><a href="%s">%s</a>',
448+
__( 'Sorry, the token for the magic link is either expired or incorrect.', 'bcc-login' ),
449+
__( 'Make sure you are using the correct link or ask for a new one.', 'bcc-login' ),
450+
site_url(),
451+
__( 'Go to the front page', 'bcc-login' )
452+
),
453+
__( 'Unauthorized' ),
454+
array(
455+
'response' => 401,
456+
)
457+
);
458+
}
459+
379460
/**
380461
* Determines whether authentication should be skipped for this action
381462
*/
@@ -1238,4 +1319,74 @@ public static function sanitize_sent_notifications_meta( $value, $meta_key, $obj
12381319
// Reindex
12391320
return array_values( $out );
12401321
}
1322+
1323+
/**
1324+
* Magic token functions
1325+
*/
1326+
1327+
public function bcc_magic_link() {
1328+
$post_id = get_the_ID();
1329+
if (!$post_id) return;
1330+
1331+
// Generate token valid for 60 days
1332+
$token = $this->bcc_make_magic_token($post_id, 60 * DAY_IN_SECONDS);
1333+
1334+
$url = add_query_arg(
1335+
['bcc_mt' => $token],
1336+
get_permalink($post_id)
1337+
);
1338+
1339+
return $url;
1340+
}
1341+
1342+
private function bcc_base64url_encode(string $bin): string {
1343+
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
1344+
}
1345+
1346+
private function bcc_base64url_decode(string $str): string|false {
1347+
$pad = strlen($str) % 4;
1348+
if ($pad) $str .= str_repeat('=', 4 - $pad);
1349+
$out = base64_decode(strtr($str, '-_', '+/'), true);
1350+
return $out === false ? false : $out;
1351+
}
1352+
1353+
private function bcc_make_magic_token(int $post_id, int $ttl_seconds = 900): string {
1354+
$exp = time() + $ttl_seconds;
1355+
$nonce = bin2hex(random_bytes(16)); // prevent deterministic tokens
1356+
1357+
// v1|postId|exp|nonce
1358+
$payload = implode('|', ['v1', (string)$post_id, (string)$exp, $nonce]);
1359+
1360+
// Uses keys/salts from wp-config.php (+ DB secret) via wp_salt
1361+
$secret = wp_salt('secure_auth');
1362+
$sig = hash_hmac('sha256', $payload, $secret);
1363+
1364+
return $this->bcc_base64url_encode($payload . '|' . $sig);
1365+
}
1366+
1367+
private function bcc_verify_magic_token(string $token): array|false {
1368+
$raw = $this->bcc_base64url_decode($token);
1369+
if ($raw === false) return false;
1370+
1371+
$parts = explode('|', $raw);
1372+
// v1|postId|exp|nonce|sig => 5 parts
1373+
if (count($parts) !== 5) return false;
1374+
1375+
[$v, $post_id, $exp, $nonce, $sig] = $parts;
1376+
if ($v !== 'v1') return false;
1377+
1378+
if (!ctype_digit($post_id) || !ctype_digit($exp)) return false;
1379+
if ((int)$exp < time()) return false;
1380+
1381+
$payload = implode('|', [$v, $post_id, $exp, $nonce]);
1382+
$secret = wp_salt('secure_auth');
1383+
$calc = hash_hmac('sha256', $payload, $secret);
1384+
1385+
if (!hash_equals($calc, $sig)) return false;
1386+
1387+
return [
1388+
'post_id' => (int)$post_id,
1389+
'exp' => (int)$exp,
1390+
];
1391+
}
12411392
}

0 commit comments

Comments
 (0)