PluginProbe
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel / 0.1
FlyWP Helper – Page Cache, Page Optimization, Emails for FlyWP Server Control Panel v0.1
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
← All changes | includes/Frontend/MagicLogin.php +15 -149 trunk0.1 View file →
@@ -1,45 +1,21 @@
1 1 <?php
2 2
3 3 namespace FlyWP\Frontend;
4 4
5 -use FlyWP\MagicLoginToken;
6 -use WP_User;
7 -
8 5 /**
9 6 * Magic Login.
10 7 *
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 8 * @since 1.0.0
16 9 */
17 10 class MagicLogin {
18 11
19 12 /**
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 13 * Plugin Constructor.
31 14 *
32 15 * @return void
33 16 */
34 17 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 18 add_action( 'setup_theme', [ $this, 'login_user' ] );
43 19 }
44 20
45 21 /**
@@ -47,20 +23,9 @@
47 23 *
48 24 * @return bool
49 25 */
50 26 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;
27 + return isset( $_SERVER['REQUEST_URI'] ) && isset( $_SERVER['REQUEST_METHOD'] ) && $_SERVER['REQUEST_URI'] === '/flywp-magic-login' && $_SERVER['REQUEST_METHOD'] === 'POST';
63 28 }
64 29
65 30 /**
66 31 * Redirect to home.
@@ -91,133 +56,34 @@
91 56 if ( ! $this->is_valid_request() ) {
92 57 return;
93 58 }
94 59
95 - $token = $this->post_field( 'token' );
60 + // phpcs:disable WordPress.Security.NonceVerification.Missing
61 + $api_key = isset( $_POST['api_key'] ) ? sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) : '';
62 + $username = isset( $_POST['username'] ) ? sanitize_text_field( wp_unslash( $_POST['username'] ) ) : '';
63 + // phpcs:enable WordPress.Security.NonceVerification.Missing
96 64
97 - if ( $token === '' ) {
98 - $this->refuse( 'missing_token' );
65 + if ( ! $api_key || ! $username ) {
66 + $this->redirect_to_home();
99 67 }
100 68
101 - $claims = MagicLoginToken::parse( $token, flywp()->get_login_public_key(), time() );
102 -
103 - if ( $claims === null ) {
104 - $this->refuse( 'invalid_token' );
69 + if ( $api_key !== flywp()->get_api_key() ) {
70 + $this->redirect_to_home();
105 71 }
106 72
107 - if ( ! $this->spend_token( $claims['jti'], $claims['exp'] ) ) {
108 - $this->refuse( 'token_already_used', $claims['sid'] );
73 + if ( is_user_logged_in() ) {
74 + $this->redirect_to_admin();
109 75 }
110 76
111 - $user = get_user_by( 'login', $claims['sub'] );
77 + $user = get_user_by( 'login', $username );
112 78
113 - if ( ! $user instanceof WP_User ) {
114 - // Fail closed on an unknown user; never substitute another account.
115 - $this->refuse( 'unknown_user', $claims['sid'] );
79 + if ( ! $user ) {
80 + $this->redirect_to_admin();
116 81 }
117 82
118 83 wp_set_current_user( $user->ID, $user->user_login );
119 84 wp_set_auth_cookie( $user->ID );
120 85
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 -
86 + // redirect to admin
132 87 $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 88 }
223 89 }