| @@ -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 | |