Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 78
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Login
0.00% covered (danger)
0.00%
0 / 78
0.00% covered (danger)
0.00%
0 / 3
380
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 process
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
156
 maybe_redirect
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2/**
3 * The actual logging in functionality of the plugin.
4 *
5 * Distinct from login UI.
6 *
7 * @link       https://BrianHenry.ie
8 * @since      1.0.0
9 *
10 * @package    brianhenryie/bh-wp-autologin-urls
11 */
12
13namespace BrianHenryIE\WP_Autologin_URLs\WP_Includes;
14
15use BrianHenryIE\WP_Autologin_URLs\API_Interface;
16use BrianHenryIE\WP_Autologin_URLs\API\Integrations\User_Finder_Factory;
17use BrianHenryIE\WP_Autologin_URLs\Settings_Interface;
18use BrianHenryIE\WP_Autologin_URLs\WooCommerce\Checkout;
19use Psr\Log\LoggerAwareTrait;
20use Psr\Log\LoggerInterface;
21use WP_User;
22
23/**
24 * The actual logging-in functionality of the plugin.
25 */
26class Login {
27
28    use LoggerAwareTrait;
29
30    const MAX_BAD_LOGIN_ATTEMPTS       = 5;
31    const MAX_BAD_LOGIN_PERIOD_SECONDS = 60 * 60 * 24; // Aka DAY_IN_SECONDS.
32
33    /**
34     * Not in use?
35     *
36     * @var Settings_Interface
37     */
38    protected Settings_Interface $settings;
39
40    /**
41     * Core API methods for verifying autologin querystring.
42     *
43     * @var API_Interface
44     */
45    protected API_Interface $api;
46
47    /**
48     * This plugin can parse URLs for MailPoet, The Newsletter Plugin, and Klaviyo, and AutologinUrls own
49     * querystring parameter. The factory returns valid User_Finders for each.
50     *
51     * @var User_Finder_Factory
52     */
53    protected User_Finder_Factory $user_finder_factory;
54
55    /**
56     * Initialize the class and set its properties.
57     *
58     * @param API_Interface        $api The core plugin functions.
59     * @param Settings_Interface   $settings The plugin's settings.
60     * @param LoggerInterface      $logger The logger instance.
61     * @param ?User_Finder_Factory $user_finder_factory Factory to return a class that can determine the user from the URL.
62     *
63     * @since   1.0.0
64     */
65    public function __construct( API_Interface $api, Settings_Interface $settings, LoggerInterface $logger, ?User_Finder_Factory $user_finder_factory = null ) {
66        $this->setLogger( $logger );
67
68        $this->settings = $settings;
69        $this->api      = $api;
70
71        $this->user_finder_factory = $user_finder_factory ?? new User_Finder_Factory( $this->api, $this->settings, $this->logger );
72    }
73
74    /**
75     * The primary handler of the plugin, that reads the request querystring and checks it for autologin parameters.
76     *
77     * @hooked determine_current_user
78     *
79     * @param int|bool $user_id The already determined user ID, or false if none.
80     * @return int|bool
81     */
82    public function process( $user_id ) {
83
84        remove_action( 'determine_current_user', array( $this, 'process' ), 30 );
85
86        // If we're logged in already, or there's no querystring to parse, just return.
87        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
88        if ( $user_id || empty( $_GET ) ) {
89            return $user_id;
90        }
91
92        // Check for bots.
93        // Use the null coalescing operator to ensure $user_agent is always a string.
94        // This prevents passing null to strpos, which is deprecated in newer PHP versions.
95        $user_agent = filter_input( INPUT_SERVER, 'HTTP_USER_AGENT' ) ?? '';
96        $bot        = false !== strpos( $user_agent, 'bot' );
97        if ( $bot ) {
98            return $user_id;
99        }
100
101        // Maybe use a cookie to only use an autologin URL once every x minutes.
102
103        // Checks does the querystring contain an autologin parameter.
104        $user_finder = $this->user_finder_factory->get_user_finder();
105
106        if ( is_null( $user_finder ) ) {
107            // No querystring was present, this was not an attempt to log in.
108            return $user_id;
109        }
110
111        $user_array = $user_finder->get_wp_user_array();
112
113        if ( isset( $user_array['wp_user'] ) && $user_array['wp_user'] instanceof WP_User ) {
114            $this->logger->debug( "Found `wp_user:{$user_array['wp_user']->ID}`." );
115            $wp_user = $user_array['wp_user'];
116            $user_id = $wp_user->ID;
117        } elseif ( ! empty( $user_array['user_data'] ) ) {
118            // If no WP_User account was found, but other user data was found that could be used for WooCommerce, prepopulate the checkout fields.
119            $this->logger->debug( 'No wp_user found, preloading WooCommerce fields.', $user_array );
120            $prefill_checkout_fields = function () use ( $user_array ) {
121                $woocommerce_checkout = new Checkout( $this->logger );
122                $woocommerce_checkout->prefill_checkout_fields( $user_array['user_data'] );
123            };
124            if ( did_action( 'woocommerce_init' ) ) {
125                $prefill_checkout_fields();
126            } else {
127                add_action( 'woocommerce_init', $prefill_checkout_fields );
128            }
129            return $user_id;
130        } else {
131            $this->logger->debug( 'Could not find wp_user or user data using request URL.' );
132            return $user_id;
133        }
134
135        $ip_address = $this->api->get_ip_address();
136
137        if ( empty( $ip_address ) ) {
138            // This would be empty during cron jobs and WP CLI.
139            return $user_id;
140        }
141
142        // Log each attempt to log in, prevent too many attempts by any one IP.
143        if ( ! $this->api->should_allow_login_attempt( "ip:{$ip_address}" ) ) {
144            return $user_id;
145        }
146
147        // Rate limit too many failed attempts at logging in the one user.
148        if ( ! $this->api->should_allow_login_attempt( "wp_user:{$wp_user->ID}" ) ) {
149            return $user_id;
150        }
151
152        /**
153         * Although cookies will be set in a moment, they won't be available in the `$_COOKIE` array,
154         * and they need to be to log into wp-admin via the autologin URL.
155         *
156         * @see wp-admin/admin.php
157         * @see auth_redirect()
158         * @see wp_parse_auth_cookie()
159         */
160        add_action(
161            'set_auth_cookie',
162            function ( $auth_cookie ) {
163                global $_COOKIE;
164                $_COOKIE[ AUTH_COOKIE ]        = $auth_cookie;
165                $_COOKIE[ SECURE_AUTH_COOKIE ] = $auth_cookie;
166            }
167        );
168
169        add_action(
170            'set_logged_in_cookie',
171            function ( $logged_in_cookie ) {
172                global $_COOKIE;
173                $_COOKIE[ LOGGED_IN_COOKIE ] = $logged_in_cookie;
174            }
175        );
176
177        // @see https://developer.wordpress.org/reference/functions/wp_set_current_user/
178        wp_set_current_user( $wp_user->ID, $wp_user->user_login );
179        wp_set_auth_cookie( $wp_user->ID );
180        add_action(
181            'init',
182            function () use ( $wp_user ) {
183                do_action( 'wp_login', $wp_user->user_login, $wp_user );
184            }
185        );
186
187        $this->logger->info( "User wp_user:{$wp_user->ID} logged in via {$user_array['source']}." );
188
189        $this->maybe_redirect();
190
191        return $user_id;
192    }
193
194    /**
195     * If the request is for wp-login.php, we should redirect to home or to the specified redirect_to url.
196     */
197    protected function maybe_redirect(): void {
198
199        if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
200            // Cron, WP CLI.
201            return;
202        }
203
204        $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
205
206        // Check is the requested URL wp-login.php. Otherwise we don't want to redirect.
207        $wp_login_endpoint = str_replace( get_site_url(), '', wp_login_url() );
208        if ( ! stristr( $request_uri, $wp_login_endpoint ) ) {
209            return;
210        }
211
212        // Check we're on wp-login.php?redirect_to=...
213        // We won't have a nonce here if the link is from an email.
214        // phpcs:disable WordPress.Security.NonceVerification.Recommended
215        if ( isset( $_GET['redirect_to'] ) ) {
216
217            $url = filter_var( wp_unslash( $_GET['redirect_to'] ), FILTER_SANITIZE_STRING );
218            if ( false === $url ) {
219                return;
220            }
221            $redirect_to = urldecode( $url );
222
223        } else {
224            // TODO: There's a filter determining what the destination URL should be when logging in a user.
225            $redirect_to = get_site_url();
226        }
227
228        if ( wp_safe_redirect( $redirect_to ) ) {
229            exit();
230        }
231    }
232}