PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.11.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.11.0
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
← All changes | Modules/Auth/AuthHelper.php +111 -32 2.7.72.11.0 View file →
@@ -45,10 +45,20 @@
45 45 __('<strong>Error:</strong> This email address is already registered. Please login or try resetting your password.', 'fluent-community')
46 46 );
47 47 }
48 48
49 + /**
50 + * MemberPress rejects every `register_post` while its "Disable WordPress registration form"
51 + * option is on (default on). That option targets wp-login.php, not the community portal, which has its own registration gate.
52 + */
53 + $hadMeprBlocker = remove_action('register_post', 'MeprUsersCtrl::maybe_disable_wp_registration_form', 10);
54 +
49 55 do_action('register_post', $sanitized_user_login, $user_email, $errors); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
50 56
57 + if ($hadMeprBlocker) {
58 + add_action('register_post', 'MeprUsersCtrl::maybe_disable_wp_registration_form', 10, 3);
59 + }
60 +
51 61 if ($errors->has_errors()) {
52 62 return $errors;
53 63 }
54 64
@@ -128,17 +138,90 @@
128 138
129 139 return $user;
130 140 }
131 141
142 + /**
143 + * The slug this plugin is known by inside FluentAuth.
144 + */
145 + const FLUENT_AUTH_HOST = 'fluent-community';
146 +
147 + /**
148 + * Whether FluentAuth's login stack is usable on this screen.
149 + *
150 + * Note the order this has to be asked in: adoptFluentAuth() is what makes the answer
151 + * yes on a site whose shortcode setting is off, so the auth screen adopts first and
152 + * asks second. Everywhere else - a plugin wondering whether the portal is running
153 + * FluentAuth's forms - the question stands on its own.
154 + */
132 155 public static function isFluentAuthAvailable()
133 156 {
134 - if (defined('FLUENT_AUTH_VERSION') && FLUENT_AUTH_VERSION) {
135 - return (new \FluentAuth\App\Hooks\Handlers\CustomAuthHandler())->isEnabled();
157 + if (!defined('FLUENT_AUTH_VERSION') || !FLUENT_AUTH_VERSION) {
158 + return false;
136 159 }
137 160
138 - return false;
161 + return (new \FluentAuth\App\Hooks\Handlers\CustomAuthHandler())->isEnabled();
139 162 }
140 163
164 + /**
165 + * Tells FluentAuth this plugin exists, so it recognises the admin-ajax posts our
166 + * auth screen makes later. Cheap enough to run on every request, which is what it
167 + * has to do - the form post is a request of its own.
168 + *
169 + * @return void
170 + */
171 + public static function registerWithFluentAuth()
172 + {
173 + if (!self::hasFluentAuthBridge()) {
174 + return;
175 + }
176 +
177 + \FluentAuth\App\Services\LoginBridge::register(
178 + self::FLUENT_AUTH_HOST,
179 + 'is_fcom_auth',
180 + /*
181 + * Our own login endpoint. Unreachable while the portal renders FluentAuth's
182 + * form, but a site that filters the adoption back off falls through to it,
183 + * and this is what keeps the second factor arriving as a form there rather
184 + * than as the error message handleUserLogin() would otherwise print.
185 + */
186 + ['fcom_user_login_form']
187 + );
188 + }
189 +
190 + /**
191 + * Hands the screen being rendered to FluentAuth: its shortcodes render here even
192 + * where the site has the front end forms switched off, its assets load, and its
193 + * endpoints answer the posts this screen's forms make.
194 + *
195 + * @return bool whether FluentAuth took it
196 + */
197 + public static function adoptFluentAuth()
198 + {
199 + if (!self::hasFluentAuthBridge()) {
200 + return false;
201 + }
202 +
203 + \FluentAuth\App\Services\LoginBridge::adopt([
204 + 'host' => self::FLUENT_AUTH_HOST
205 + ]);
206 +
207 + return true;
208 + }
209 +
210 + /**
211 + * @return bool whether the installed FluentAuth is new enough to be adopted
212 + */
213 + private static function hasFluentAuthBridge()
214 + {
215 + // FluentAuth's autoloader requires the mapped file unconditionally, so probing
216 + // for a class an older release does not ship is a fatal error, not a false.
217 + return defined('FLUENT_AUTH_VERSION')
218 + && FLUENT_AUTH_VERSION
219 + && defined('FLUENT_AUTH_PLUGIN_PATH')
220 + && file_exists(FLUENT_AUTH_PLUGIN_PATH . 'app/Services/LoginBridge.php')
221 + && class_exists('\FluentAuth\App\Services\LoginBridge');
222 + }
223 +
141 224 public static function getTermsText()
142 225 {
143 226 $policyUrl = apply_filters('fluent_community/terms_policy_url', get_privacy_policy_url());
144 227
@@ -240,9 +323,11 @@
240 323 }
241 324
242 325 public static function isPasswordConfRequired()
243 326 {
244 - return apply_filters('fluent_community/autg/password_confirmation', true);
327 + $isRequired = apply_filters_deprecated('fluent_community/autg/password_confirmation', [true], '2.7.8', 'fluent_community/auth/password_confirmation');
328 +
329 + return apply_filters('fluent_community/auth/password_confirmation', $isRequired);
245 330 }
246 331
247 332 public static function isRegistrationEnabled()
248 333 {
@@ -273,23 +358,19 @@
273 358 } catch (\Exception $e) {
274 359 $verifcationCode = str_pad((string) wp_rand(100123, 900987), 6, '0', STR_PAD_LEFT);
275 360 }
276 361
277 - // Hash the code
362 + // Keep the code hash server-side, keyed by an opaque challenge id. The client only ever
363 + // receives the id, never the password verifier, so the code cannot be recovered offline.
278 364 $codeHash = wp_hash_password($verifcationCode);
279 -
280 - // Create a token with the email and code hash
281 - $data = [
365 + $signedToken = 'fcs_' . wp_generate_password(40, false);
366 + set_transient('fcom_signup_2fa_' . $signedToken, [
282 367 'email' => $formData['email'],
283 368 'code_hash' => $codeHash,
284 - 'expires' => time() + 600 // 10 minutes expiry
285 - ];
286 - $token = base64_encode(json_encode($data));
369 + 'expires' => time() + 600, // 10 minutes expiry
370 + 'attempts' => 0,
371 + ], 600);
287 372
288 - // Sign the token
289 - $signature = hash_hmac('sha256', $token, SECURE_AUTH_KEY);
290 - $signedToken = $token . '.' . $signature;
291 -
292 373 /* translators: %s is replaced by the title of the site */
293 374 $mailSubject = apply_filters("fluent_community/auth/signup_verification_mail_subject", sprintf(__('Your registration verification code for %s', 'fluent-community'), Arr::get($generalSettings, 'site_title')));
294 375
295 376 $pStart = '<p style="font-family: Arial, sans-serif; font-size: 16px; font-weight: normal; margin: 0; margin-bottom: 16px;">';
@@ -369,34 +450,21 @@
369 450 }
370 451
371 452 public static function validateVerificationCode($code, $verificationToken, $formData)
372 453 {
373 - if (!is_string($verificationToken) || strpos($verificationToken, '.') === false) {
454 + if (!is_string($verificationToken) || $verificationToken === '') {
374 455 return new \WP_Error('invalid_token', __('Invalid verification token. Please try again', 'fluent-community'));
375 456 }
376 457
377 - list($data, $signature) = explode('.', $verificationToken, 2);
378 - if (!$data || !$signature) {
379 - return new \WP_Error('invalid_token', __('Invalid verification token. Please try again', 'fluent-community'));
380 - }
458 + $transientKey = 'fcom_signup_2fa_' . $verificationToken;
459 + $data = get_transient($transientKey);
381 460
382 - $expectedSignature = hash_hmac('sha256', $data, SECURE_AUTH_KEY);
383 -
384 - if (!hash_equals($expectedSignature, $signature)) {
385 - return new \WP_Error('invalid_token', __('Invalid verification token. Please try again', 'fluent-community'));
386 - }
387 -
388 - $decodedData = base64_decode($data, true); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
389 - if ($decodedData === false) {
390 - return new \WP_Error('invalid_token', __('Invalid verification token. Please try again', 'fluent-community'));
391 - }
392 -
393 - $data = json_decode($decodedData, true);
394 461 if (!is_array($data) || empty($data['expires']) || empty($data['email']) || empty($data['code_hash'])) {
395 462 return new \WP_Error('invalid_token', __('Invalid verification token. Please try again', 'fluent-community'));
396 463 }
397 464
398 465 if ((int)$data['expires'] < time()) {
466 + delete_transient($transientKey);
399 467 return new \WP_Error('expired_token', __('Verification token has expired. Please try again.', 'fluent-community'));
400 468 }
401 469
402 470 if (!isset($formData['email']) || $data['email'] !== $formData['email']) {
@@ -402,11 +470,22 @@
402 470 if (!isset($formData['email']) || $data['email'] !== $formData['email']) {
403 471 return new \WP_Error('invalid_email', __('Invalid email address. Please try again', 'fluent-community'));
404 472 }
405 473
474 + // Cap online guesses per challenge: after too many wrong codes the challenge is burned.
475 + if ((int) Arr::get($data, 'attempts', 0) >= 10) {
476 + delete_transient($transientKey);
477 + return new \WP_Error('too_many_attempts', __('Too many invalid attempts. Please try again', 'fluent-community'));
478 + }
479 +
406 480 if (!wp_check_password($code, $data['code_hash'])) {
481 + $data['attempts'] = (int) Arr::get($data, 'attempts', 0) + 1;
482 + set_transient($transientKey, $data, max(1, (int) $data['expires'] - time()));
407 483 return new \WP_Error('invalid_code', __('Invalid verification code. Please try again', 'fluent-community'));
408 484 }
485 +
486 + // Single-use: consume the challenge on success.
487 + delete_transient($transientKey);
409 488
410 489 return true;
411 490 }
412 491