@@ -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