PluginProbe
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel / 1.6.0
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel v1.6.0
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 1.6.0, at includes/Frontend/MagicLogin.php

234 lines 7.8 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, not as text. `sanitize_text_field()` strips percent-encoded octets
60 // and tags, so it would let `/flywp-magic-login%20` and friends match here while WordPress
61 // itself would 404 them — quietly defeating any nginx `location =`, WAF rule or allowlist
62 // a host puts in front of this endpoint.
63 $path = wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
64
65 return $path === self::PATH;
66 }
67
68 /**
69 * Redirect to home.
70 *
71 * @return void
72 */
73 public function redirect_to_home() {
74 wp_safe_redirect( site_url() );
75 exit;
76 }
77
78 /**
79 * Redirect to admin.
80 *
81 * @return void
82 */
83 public function redirect_to_admin() {
84 wp_safe_redirect( admin_url() );
85 exit;
86 }
87
88 /**
89 * Login an user.
90 *
91 * @return void
92 */
93 public function login_user() {
94 if ( ! $this->is_valid_request() ) {
95 return;
96 }
97
98 $token = $this->post_field( 'token' );
99
100 if ( $token === '' ) {
101 $this->refuse( 'missing_token' );
102 }
103
104 $claims = MagicLoginToken::parse( $token, flywp()->get_key(), time() );
105
106 if ( ! $claims ) {
107 $this->refuse( 'invalid_token' );
108 }
109
110 if ( ! $this->spend_token( $claims['jti'], $claims['exp'] ) ) {
111 $this->refuse( 'token_already_used', $claims['sid'] );
112 }
113
114 $user = get_user_by( 'login', $claims['sub'] );
115
116 if ( ! $user instanceof WP_User ) {
117 // Fail closed. This used to fall through to the first administrator on the site,
118 // which turned a wrong username into an administrator session.
119 $this->refuse( 'unknown_user', $claims['sid'] );
120 }
121
122 wp_set_current_user( $user->ID, $user->user_login );
123 wp_set_auth_cookie( $user->ID );
124
125 /**
126 * Fires after magic login has signed a user in.
127 *
128 * @since 1.6.0
129 *
130 * @param int $user_id ID of the user signed in.
131 * @param string $user_login Login name of the user signed in.
132 * @param int $site_id FlyWP site id the token was minted for.
133 */
134 do_action( 'flywp_magic_login_success', $user->ID, $user->user_login, $claims['sid'] );
135
136 $this->redirect_to_admin();
137 }
138
139 /**
140 * Mark a token as spent, refusing a second use of the same one.
141 *
142 * Written before the cookie is issued, so a replay racing the original loses.
143 *
144 * Deliberately an options row rather than a transient. A transient lives in the object cache
145 * when one is installed — which FlyWP installs on both stacks — where it can be evicted under
146 * memory pressure or dropped by a cache flush, and the read-then-write a transient needs is a
147 * race in its own right. The unique index on `option_name` settles both: the insert either
148 * wins or it does not, and it survives a cache flush either way.
149 *
150 * @param string $jti Token identifier.
151 * @param int $exp Token expiry, as a unix timestamp.
152 *
153 * @return bool False when this token has been used already, or the marker could not be stored.
154 */
155 private function spend_token( $jti, $exp ) {
156 global $wpdb;
157
158 $this->forget_spent_tokens();
159
160 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
161 $inserted = $wpdb->query(
162 $wpdb->prepare(
163 "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )",
164 self::SPENT_PREFIX . $jti,
165 (string) $exp
166 )
167 );
168
169 // 0 rows means the marker was already there; false means the write failed. Neither is a
170 // login: a token we cannot prove is unused is a token we refuse.
171 return $inserted === 1;
172 }
173
174 /**
175 * Drop spent-token markers that have outlived the tokens they describe.
176 *
177 * Options carry no expiry of their own, so nothing else would ever collect these.
178 *
179 * @return void
180 */
181 private function forget_spent_tokens() {
182 global $wpdb;
183
184 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
185 $wpdb->query(
186 $wpdb->prepare(
187 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST( option_value AS UNSIGNED ) < %d",
188 $wpdb->esc_like( self::SPENT_PREFIX ) . '%',
189 time() - MagicLoginToken::DEFAULT_SKEW
190 )
191 );
192 }
193
194 /**
195 * Turn away a request, recording why. Never returns.
196 *
197 * @param string $reason Machine-readable reason, for logs and listeners.
198 * @param int|null $site_id FlyWP site id, when the token got far enough to name one.
199 *
200 * @return void
201 */
202 private function refuse( $reason, $site_id = null ) {
203 /**
204 * Fires when magic login turns a request away.
205 *
206 * @since 1.6.0
207 *
208 * @param string $reason Machine-readable reason the request was refused.
209 * @param int|null $site_id FlyWP site id, when the token got far enough to name one.
210 */
211 do_action( 'flywp_magic_login_failed', $reason, $site_id );
212
213 // Behind WP_DEBUG_LOG: this endpoint is unauthenticated, so an unconditional write here is
214 // a disk-fill anyone can drive. Listeners on the action above get every attempt regardless.
215 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
216 error_log( sprintf( 'FlyWP magic login refused (%s) for site %s', $reason, $site_id === null ? 'unknown' : $site_id ) );
217 }
218
219 $this->redirect_to_home();
220 }
221
222 /**
223 * Read a field from the request body.
224 *
225 * @param string $field Field name.
226 *
227 * @return string
228 */
229 private function post_field( $field ) {
230 // phpcs:ignore WordPress.Security.NonceVerification.Missing
231 return isset( $_POST[ $field ] ) ? sanitize_text_field( wp_unslash( $_POST[ $field ] ) ) : '';
232 }
233 }
234