PluginProbe
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel / trunk
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel vtrunk
1.7.1 1.7.0 1.6.0 1.5.2 trunk 0.1 0.2.0 0.2.1 0.3 0.3.1 0.3.2 0.3.3 0.3.4 0.4 0.4.1 0.4.2 0.4.3 1.0 1.1 1.2 1.3.1 1.4.0 1.4.1 1.5.0 1.5.1 All 26 releases
flywp / includes / Frontend / MagicLogin.php

MagicLogin.php in FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel trunk, at includes/Frontend/MagicLogin.php

224 lines 7.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FlyWP\Frontend;
4
5 use FlyWP\MagicLoginToken;
6 use WP_User;
7
8 /**
9 * Magic Login.
10 *
11 * Signs a user in from a signed, single-use token minted by the FlyWP control plane. The token
12 * names the user it is good for, so the request cannot choose one; an unknown user is refused
13 * rather than substituted for an administrator.
14 *
15 * @since 1.0.0
16 */
17 class MagicLogin {
18
19 /**
20 * Path this handler answers on.
21 */
22 const PATH = '/flywp-magic-login';
23
24 /**
25 * Option prefix recording tokens that have already been spent.
26 */
27 const SPENT_PREFIX = 'flywp_ml_used_';
28
29 /**
30 * Plugin Constructor.
31 *
32 * @return void
33 */
34 public function __construct() {
35 // `setup_theme` deliberately, and it must stay that way. WordPress includes the active
36 // theme's functions.php *after* this hook and before `init`, so running any later means a
37 // theme with a fatal, an early redirect, or stray output past its closing tag takes magic
38 // login down with it — and getting into wp-admin to fix exactly that is what this is for.
39 //
40 // Moving later buys nothing for security plugins either: `plugins_loaded` fires before
41 // `setup_theme`, so they have already loaded and can hook here too.
42 add_action( 'setup_theme', [ $this, 'login_user' ] );
43 }
44
45 /**
46 * Check if the request is valid.
47 *
48 * @return bool
49 */
50 private function is_valid_request() {
51 if ( ! isset( $_SERVER['REQUEST_URI'], $_SERVER['REQUEST_METHOD'] ) ) {
52 return false;
53 }
54
55 if ( wp_unslash( $_SERVER['REQUEST_METHOD'] ) !== 'POST' ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
56 return false;
57 }
58
59 // Compared as a path. Do not swap in `sanitize_text_field()`, which alters the value.
60 $path = wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
61
62 return $path === self::PATH;
63 }
64
65 /**
66 * Redirect to home.
67 *
68 * @return void
69 */
70 public function redirect_to_home() {
71 wp_safe_redirect( site_url() );
72 exit;
73 }
74
75 /**
76 * Redirect to admin.
77 *
78 * @return void
79 */
80 public function redirect_to_admin() {
81 wp_safe_redirect( admin_url() );
82 exit;
83 }
84
85 /**
86 * Login an user.
87 *
88 * @return void
89 */
90 public function login_user() {
91 if ( ! $this->is_valid_request() ) {
92 return;
93 }
94
95 $token = $this->post_field( 'token' );
96
97 if ( $token === '' ) {
98 $this->refuse( 'missing_token' );
99 }
100
101 $claims = MagicLoginToken::parse( $token, flywp()->get_login_public_key(), time() );
102
103 if ( $claims === null ) {
104 $this->refuse( 'invalid_token' );
105 }
106
107 if ( ! $this->spend_token( $claims['jti'], $claims['exp'] ) ) {
108 $this->refuse( 'token_already_used', $claims['sid'] );
109 }
110
111 $user = get_user_by( 'login', $claims['sub'] );
112
113 if ( ! $user instanceof WP_User ) {
114 // Fail closed on an unknown user; never substitute another account.
115 $this->refuse( 'unknown_user', $claims['sid'] );
116 }
117
118 wp_set_current_user( $user->ID, $user->user_login );
119 wp_set_auth_cookie( $user->ID );
120
121 /**
122 * Fires after magic login has signed a user in.
123 *
124 * @since 1.6.0
125 *
126 * @param int $user_id ID of the user signed in.
127 * @param string $user_login Login name of the user signed in.
128 * @param int $site_id FlyWP site id the token was minted for.
129 */
130 do_action( 'flywp_magic_login_success', $user->ID, $user->user_login, $claims['sid'] );
131
132 $this->redirect_to_admin();
133 }
134
135 /**
136 * Mark a token as spent, refusing a second use of the same one.
137 *
138 * Written before the cookie is issued. Uses an options row rather than a transient so the
139 * unique index on `option_name` decides the outcome; keep it that way.
140 *
141 * @param string $jti Token identifier.
142 * @param int $exp Token expiry, as a unix timestamp.
143 *
144 * @return bool False when this token has been used already, or the marker could not be stored.
145 */
146 private function spend_token( $jti, $exp ) {
147 global $wpdb;
148
149 $this->forget_spent_tokens();
150
151 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
152 $inserted = $wpdb->query(
153 $wpdb->prepare(
154 "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )",
155 self::SPENT_PREFIX . $jti,
156 (string) $exp
157 )
158 );
159
160 // 0 rows means the marker was already there; false means the write failed. Neither is a
161 // login: a token we cannot prove is unused is a token we refuse.
162 return $inserted === 1;
163 }
164
165 /**
166 * Drop spent-token markers that have outlived the tokens they describe.
167 *
168 * Options carry no expiry of their own, so nothing else would ever collect these.
169 *
170 * @return void
171 */
172 private function forget_spent_tokens() {
173 global $wpdb;
174
175 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
176 $wpdb->query(
177 $wpdb->prepare(
178 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST( option_value AS UNSIGNED ) < %d",
179 $wpdb->esc_like( self::SPENT_PREFIX ) . '%',
180 time() - MagicLoginToken::DEFAULT_SKEW
181 )
182 );
183 }
184
185 /**
186 * Turn away a request, recording why. Never returns.
187 *
188 * @param string $reason Machine-readable reason, for logs and listeners.
189 * @param int|null $site_id FlyWP site id, when the token got far enough to name one.
190 *
191 * @return void
192 */
193 private function refuse( $reason, $site_id = null ) {
194 /**
195 * Fires when magic login turns a request away.
196 *
197 * @since 1.6.0
198 *
199 * @param string $reason Machine-readable reason the request was refused.
200 * @param int|null $site_id FlyWP site id, when the token got far enough to name one.
201 */
202 do_action( 'flywp_magic_login_failed', $reason, $site_id );
203
204 // Behind WP_DEBUG_LOG. Listeners on the action above get every attempt regardless.
205 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
206 error_log( sprintf( 'FlyWP magic login refused (%s) for site %s', $reason, $site_id === null ? 'unknown' : $site_id ) );
207 }
208
209 $this->redirect_to_home();
210 }
211
212 /**
213 * Read a field from the request body.
214 *
215 * @param string $field Field name.
216 *
217 * @return string
218 */
219 private function post_field( $field ) {
220 // phpcs:ignore WordPress.Security.NonceVerification.Missing
221 return isset( $_POST[ $field ] ) ? sanitize_text_field( wp_unslash( $_POST[ $field ] ) ) : '';
222 }
223 }
224