has_cap( $cap ) ) { $privileged = true; break; } } if ( $privileged && ! Helper::get_setting( 'magic_login_allow_admins', false ) ) { return false; } $roles = array_filter( (array) Helper::get_setting( 'magic_login_roles', [] ) ); if ( $roles && ! array_intersect( (array) $user->roles, $roles ) ) { return false; } // Let account-approval / spam plugins that veto sign-in have their say. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound if ( is_wp_error( apply_filters( 'wp_authenticate_user', $user, '' ) ) ) { return false; } /** * Whether a user may sign in with a magic link. * * @param bool $allowed * @param \WP_User $user */ return (bool) apply_filters( 'gemsecurity/magic_login/user_allowed', true, $user ); } /* ------------------------------------------------------------------ */ /* Tokens */ /* ------------------------------------------------------------------ */ /** * Create a fresh token for a user, replacing any earlier one. * * @param int $user_id * @return string The plain token — only ever sent by email. */ private function issue_token( $user_id ) { $token = bin2hex( random_bytes( 32 ) ); update_user_meta( $user_id, self::META_HASH, hash( 'sha256', $token ) ); update_user_meta( $user_id, self::META_EXPIRES, time() + self::expiry_minutes() * MINUTE_IN_SECONDS ); return $token; } /** Whether the token matches the user's live, unexpired link. Consumes nothing. */ private function token_valid( $user, $token ) { if ( ! $user instanceof \WP_User || ! preg_match( '/^[a-f0-9]{64}$/', $token ) ) { return false; } $stored = (string) get_user_meta( $user->ID, self::META_HASH, true ); $expires = (int) get_user_meta( $user->ID, self::META_EXPIRES, true ); return '' !== $stored && $expires >= time() && hash_equals( $stored, hash( 'sha256', $token ) ); } /** * Use the token up. delete_user_meta() only removes the row while it still * holds this exact hash, so of two simultaneous confirmations one wins. */ private function consume_token( $user, $token ) { if ( ! $this->token_valid( $user, $token ) ) { return false; } $consumed = delete_user_meta( $user->ID, self::META_HASH, hash( 'sha256', $token ) ); delete_user_meta( $user->ID, self::META_EXPIRES ); return (bool) $consumed; } /** Remove stale links; run daily from the plugin's cron. */ public static function cleanup() { $ids = get_users( [ 'fields' => 'ID', 'number' => 500, 'meta_query' => [ [ 'key' => self::META_EXPIRES, 'value' => time(), 'compare' => '<', 'type' => 'NUMERIC' ] ], // phpcs:ignore WordPress.DB.SlowDBQuery ] ); foreach ( $ids as $id ) { delete_user_meta( $id, self::META_HASH ); delete_user_meta( $id, self::META_EXPIRES ); } } /* ------------------------------------------------------------------ */ /* Rate limits */ /* ------------------------------------------------------------------ */ /** Count this request against the caller's IP. False once over the limit. */ private function take_ip_slot() { // Store, not a transient: a broken object cache must not disable the limit. $key = 'magic_ip_' . md5( Helper::get_ip_address() ); $count = (int) Store::get( $key ); if ( $count >= self::MAX_REQUESTS_PER_IP ) { return false; } Store::set( $key, $count + 1, self::LIMIT_WINDOW ); return true; } /** Count an email against the account. False once its budget is spent. */ private function take_user_slot( $user ) { $now = time(); $sends = array_filter( (array) get_user_meta( $user->ID, self::META_SENDS, true ), static function ( $t ) use ( $now ) { return (int) $t > $now - self::LIMIT_WINDOW; } ); if ( count( $sends ) >= self::MAX_SENDS_PER_USER ) { return false; } $sends[] = $now; update_user_meta( $user->ID, self::META_SENDS, array_values( $sends ) ); return true; } /* ------------------------------------------------------------------ */ /* Request handling (wp-login.php?action=gemsec_magic) */ /* ------------------------------------------------------------------ */ public function handle() { nocache_headers(); // Keep the token out of referrers. login_header() adds core's own // referrer , which would override the header; the last // wins, so ours is printed after it. header( 'Referrer-Policy: no-referrer' ); add_action( 'login_head', [ __CLASS__, 'head_meta' ], 99 ); // A magic link carries its own token, which stands in for a nonce; the // request form below is nonce-protected. // phpcs:disable WordPress.Security.NonceVerification $key = isset( $_REQUEST['gemsec_magic_key'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['gemsec_magic_key'] ) ) : ''; $uid = isset( $_REQUEST['gemsec_magic_uid'] ) ? absint( $_REQUEST['gemsec_magic_uid'] ) : 0; $redirect = isset( $_REQUEST['redirect_to'] ) ? wp_validate_redirect( esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ), '' ) : ''; $post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD']; // phpcs:enable if ( '' !== $key ) { $user = $uid ? get_user_by( 'id', $uid ) : false; if ( ! $user || ! $this->token_valid( $user, $key ) ) { Logger::log( 'magic_login_failed', 'A magic login link was opened that is invalid, expired or already used.', Logger::SEVERITY_WARNING, [ 'user_id' => $user ? $user->ID : 0 ] ); $this->render_request_form( $redirect, '', __( 'This sign-in link is invalid, has expired, or was already used. Request a new one below.', 'gemsecurity' ) ); exit; } if ( ! $post ) { $this->render_confirm( $user, $key, $redirect ); exit; } $this->complete( $user, $key, $redirect ); exit; } if ( $post ) { $this->handle_request( $redirect ); exit; } $this->render_request_form( $redirect ); exit; } /** Someone asked for a link. The answer never says whether the account exists. */ private function handle_request( $redirect ) { $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified on the next line. if ( ! wp_verify_nonce( $nonce, 'gemsec_magic_request' ) ) { $this->render_request_form( $redirect, '', __( 'The page expired. Please try again.', 'gemsecurity' ) ); return; } if ( ! $this->take_ip_slot() ) { Logger::log( 'magic_login_throttled', 'Magic login requests from one address were throttled.', Logger::SEVERITY_WARNING, [ 'user_id' => 0 ] ); $this->render_request_form( $redirect, '', __( 'Too many requests. Please wait a few minutes and try again.', 'gemsecurity' ) ); return; } $login = isset( $_POST['gemsec_magic_login'] ) ? sanitize_text_field( wp_unslash( $_POST['gemsec_magic_login'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above. $user = '' === $login ? false : ( is_email( $login ) ? get_user_by( 'email', $login ) : get_user_by( 'login', $login ) ); if ( $user && self::user_allowed( $user ) && $this->take_user_slot( $user ) ) { $this->send_link( $user, $redirect ); } $this->render_request_form( $redirect, __( 'If that account can sign in this way, a sign-in link is on its way to the email address on file. It works once and expires soon.', 'gemsecurity' ), '', false ); } private function send_link( $user, $redirect ) { $token = $this->issue_token( $user->ID ); $minutes = self::expiry_minutes(); $url = add_query_arg( array_filter( [ 'action' => self::ACTION, 'gemsec_magic_uid' => $user->ID, 'gemsec_magic_key' => $token, 'redirect_to' => $redirect, ] ), wp_login_url() ); Mailer::send( $user->user_email, '🔗', /* translators: %s: site name. */ sprintf( __( 'Your sign-in link for %s', 'gemsecurity' ), Mailer::site_name() ), [ [ 'type' => 'paragraph', 'text' => sprintf( /* translators: %d: minutes the link is valid. */ __( 'Use the button below to sign in. The link works once and expires in %d minutes.', 'gemsecurity' ), $minutes ), ], [ 'type' => 'button', 'url' => $url, 'label' => __( 'Sign in', 'gemsecurity' ), ], [ 'type' => 'paragraph', 'text' => __( 'If you did not ask for this link, ignore this email — nobody can sign in without it.', 'gemsecurity' ), ], ] ); Logger::log( 'magic_login_sent', sprintf( 'Magic login link sent to "%s".', $user->user_login ), Logger::SEVERITY_INFO, [ 'user_id' => $user->ID ] ); } /** The link was confirmed: sign in, via the second-factor prompt where required. */ private function complete( $user, $key, $redirect ) { // The policy may have changed since the link was sent. $confirm = isset( $_POST['gemsec_magic_confirm'] ) ? sanitize_text_field( wp_unslash( $_POST['gemsec_magic_confirm'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked against its SameSite cookie. if ( ! Helper::confirm_check( 'magic_confirm', $confirm ) ) { $this->render_confirm( $user, $key, $redirect ); return; } if ( ! self::user_allowed( $user ) || is_wp_error( Helper::login_gate( $user ) ) || ! $this->consume_token( $user, $key ) ) { Logger::log( 'magic_login_failed', sprintf( 'Magic login for "%s" was refused.', $user->user_login ), Logger::SEVERITY_WARNING, [ 'user_id' => $user->ID ] ); $this->render_request_form( '', '', __( 'This sign-in link is invalid, has expired, or was already used. Request a new one below.', 'gemsecurity' ) ); return; } $remember = ! empty( $_POST['rememberme'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the one-time token authorises this request. if ( '' === $redirect ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound $redirect = apply_filters( 'login_redirect', admin_url(), '', $user ); } if ( Helper::get_setting( 'magic_login_require_2fa', true ) && TwoFactor::user_needs_2fa( $user ) ) { TwoFactor::challenge_and_exit( $user, $remember, $redirect ); } Logger::log( 'magic_login', sprintf( '%s signed in with a magic link.', $user->user_login ), Logger::SEVERITY_NOTICE, [ 'user_id' => $user->ID ] ); wp_set_current_user( $user->ID ); wp_set_auth_cookie( $user->ID, $remember ); // Fire wp_login so login alerts / session stamping apply — this is WP's // own hook, not one of ours. do_action( 'wp_login', $user->user_login, $user ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound wp_safe_redirect( $redirect ); exit; } public static function head_meta() { echo '' . "\n"; } /* ------------------------------------------------------------------ */ /* Pages */ /* ------------------------------------------------------------------ */ private function form_url() { return add_query_arg( 'action', self::ACTION, wp_login_url() ); } private function render_request_form( $redirect = '', $notice = '', $error = '', $show_form = true ) { login_header( __( 'Email me a sign-in link', 'gemsecurity' ), $notice ? '
' : '', $error ? new \WP_Error( 'gemsec_magic', $error ) : null ); if ( $show_form ) : ?> ' . esc_html__( '← Back to login', 'gemsecurity' ) . ''; login_footer(); } /** The page a link opens on. Signing in needs this POST, not the GET itself. */ private function render_confirm( $user, $key, $redirect ) { $confirm = Helper::confirm_issue( 'magic_confirm' ); login_header( __( 'Confirm sign-in', 'gemsecurity' ) ); ?> self::ACTION, 'redirect_to' => $redirect ] ), wp_login_url() ); return '' . esc_html__( 'Email me a sign-in link', 'gemsecurity' ) . '
'; } /** wp-login.php — printed inside the form, then moved below the Log In button. */ public function render_link() { echo $this->link_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built with escaping above. wp_print_inline_script_tag( 'document.addEventListener("DOMContentLoaded",function(){var f=document.getElementById("loginform"),m=document.querySelector("#loginform .gemsec-magic");if(f&&m&&f.parentNode){f.parentNode.insertBefore(m,f.nextSibling);}});' ); } /** wp_login_form() / WooCommerce — appended to the returned markup. */ public function append_link( $html ) { return $html . $this->link_html(); } public function styles() { wp_add_inline_style( 'login', self::css() ); } private static function css() { return '.gemsec-magic{clear:both;margin:16px 0 0;text-align:center;}' . '.gemsec-magic__btn{display:block;box-sizing:border-box;width:100%;padding:8px 14px;border:1px solid #2271b1;border-radius:3px;' . 'color:#2271b1;background:#fff;text-decoration:none;font-weight:600;}' . '.gemsec-magic__btn:hover{background:#2271b1;color:#fff;}'; } }