PluginProbe ʕ •ᴥ•ʔ
WP 2FA – Two-factor authentication for WordPress / 2.4.2
WP 2FA – Two-factor authentication for WordPress v2.4.2
1.7.1 2.0.0 2.0.1 2.1.0 2.2.0 2.2.1 2.3.0 2.4.0 2.4.1 2.4.2 2.5.0 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.7.0 2.8.0 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0 3.0.1 3.1.0 3.1.1 3.1.1.2 trunk 1.2.0 1.3.0 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.2 1.6.0 1.6.1 1.6.2 1.7.0
wp-2fa / includes / classes / Authenticator / class-authentication.php
wp-2fa / includes / classes / Authenticator Last commit date
class-authentication.php 3 years ago class-backup-codes.php 3 years ago class-backupcodes.php 3 years ago class-login.php 2 years ago class-open-ssl.php 3 years ago index.php 5 years ago
class-authentication.php
636 lines
1 <?php
2 /**
3 * Responsible for WP2FA user's authentication.
4 *
5 * @package wp2fa
6 * @subpackage authentication
7 * @copyright 2023 WP White Security
8 * @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
9 * @link https://wordpress.org/plugins/wp-2fa/
10 */
11
12 /**
13 * Class for handling general authentication tasks.
14 *
15 * @since 0.1-dev
16 *
17 * @package WP2FA
18 */
19
20 namespace WP2FA\Authenticator;
21
22 use WP2FA\Authenticator\Open_SSL;
23 use WP2FA\Admin\Helpers\User_Helper;
24 use WP2FA_Vendor\Endroid\QrCode\QrCode;
25 use WP2FA\Admin\Controllers\Login_Attempts;
26 use WP2FA_Vendor\Endroid\QrCode\Writer\SvgWriter;
27
28 /**
29 * Authenticator class
30 */
31 class Authentication {
32
33 const DEFAULT_KEY_BIT_SIZE = 160;
34 const DEFAULT_CRYPTO = 'sha1';
35 const DEFAULT_DIGIT_COUNT = 6;
36 const DEFAULT_TIME_STEP_SEC = 30;
37 const DEFAULT_TIME_STEP_ALLOWANCE = 4;
38
39 /**
40 * Holds the name of the meta key for the allowed login attempts
41 *
42 * @var string
43 *
44 * @since 2.0.0
45 */
46 private static $login_num_meta_key = WP_2FA_PREFIX . 'email-login-attempts';
47
48 /**
49 * The login attempts class
50 *
51 * @var \WP2FA\Admin\Controllers\Login_Attempts
52 *
53 * @since 2.0.0
54 */
55 private static $login_attempts = null;
56
57 /**
58 * String with the base32 characters
59 *
60 * @var string
61 */
62 private static $base_32_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
63
64 /**
65 * String with the decrypted key
66 *
67 * @var string
68 */
69 private static $decrypted_key = '';
70
71 /**
72 * Generate QR code
73 *
74 * @param string $name Username.
75 * @param string $key Auth key.
76 * @param string $title Site title.
77 * @return string QR code URL.
78 */
79 public static function get_google_qr_code( $name, $key, $title = null ) {
80 // Encode to support spaces, question marks and other characters.
81 $name = rawurlencode( $name );
82
83 self::decrypt_key_if_needed( $key );
84
85 $target_url = ( 'otpauth://totp/' . $name . '?secret=' . $key );
86 if ( isset( $title ) ) {
87 $target_url .= ( '&issuer=' . rawurlencode( $title ) );
88 }
89
90 $qr = new QrCode( $target_url );
91 $qr->setWriterOptions( array( 'exclude_xml_declaration' => true ) );
92 $writer = new SvgWriter();
93 $result = $writer->writeString( $qr );
94
95 return 'data:image/svg+xml;base64,' . base64_encode( $result ); // phpcs:ignore
96 }
97
98 /**
99 * Generates key
100 *
101 * @param int $bitsize Nume of bits to use for key.
102 *
103 * @return string $bitsize long string composed of available base32 chars.
104 */
105 public static function generate_key( $bitsize = self::DEFAULT_KEY_BIT_SIZE ) {
106 $bytes = ceil( $bitsize / 8 );
107 $secret = wp_generate_password( $bytes, true, true );
108
109 $secret = Open_SSL::encrypt( self::base32_encode( $secret ) );
110
111 if ( Open_SSL::is_ssl_available() ) {
112 $secret = Open_SSL::SECRET_KEY_PREFIX . $secret;
113 }
114
115 return $secret;
116 }
117
118 /**
119 * Generates salt for the site
120 *
121 * @return string
122 *
123 * @since 2.4.0
124 *
125 * @throws \RuntimeException - throw exception if the generated string has unexpected characters or not a string.
126 */
127 public static function generate_salt(): string {
128 $secret = \wp_generate_password( 64, true, true );
129
130 if ( ! is_string( $secret ) || strlen( $secret ) !== 64 ) {
131 throw new \RuntimeException( 'Could not generate secret key.' );
132 }
133
134 return base64_encode( $secret );
135 }
136
137 /**
138 * Returns a base32 encoded string.
139 *
140 * @param string $string String to be encoded using base32.
141 *
142 * @return string base32 encoded string without padding.
143 */
144 public static function base32_encode( $string ) {
145 if ( empty( $string ) ) {
146 return '';
147 }
148
149 $binary_string = '';
150
151 foreach ( str_split( $string ) as $character ) {
152 $binary_string .= str_pad( base_convert( ord( $character ), 10, 2 ), 8, '0', STR_PAD_LEFT );
153 }
154
155 $five_bit_sections = str_split( $binary_string, 5 );
156 $base32_string = '';
157
158 foreach ( $five_bit_sections as $five_bit_section ) {
159 $base32_string .= self::$base_32_chars[ base_convert( str_pad( $five_bit_section, 5, '0' ), 2, 10 ) ];
160 }
161
162 return $base32_string;
163 }
164
165 /**
166 * Get the TOTP secret key for a user.
167 *
168 * @param int $user_id User ID.
169 *
170 * @return string
171 */
172 public static function get_user_totp_key( $user_id ) {
173
174 $key = (string) User_Helper::get_user_totp_key( $user_id );
175
176 $test = $key;
177
178 if ( Open_SSL::is_ssl_available() && false !== \strpos( $key, 'ssl_' ) ) {
179
180 /**
181 * Old key detected - convert.
182 */
183 $key = Open_SSL::decrypt_legacy( substr( $key, 4 ) );
184
185 User_Helper::remove_user_totp_key();
186
187 $secret = Open_SSL::encrypt( $key );
188
189 if ( Open_SSL::is_ssl_available() ) {
190 $secret = Open_SSL::SECRET_KEY_PREFIX . $secret;
191 }
192
193 User_Helper::set_user_totp_key( $key, $user_id );
194
195 $test = $key = (string) User_Helper::get_user_totp_key( $user_id ); // phpcs:ignore
196 }
197
198 // We've tried tried to use WP core functionality, but that doesn't work - lets update.
199 if ( Open_SSL::is_ssl_available() && false !== \strpos( $key, 'wps_' ) ) {
200
201 /**
202 * Old key detected - convert.
203 */
204 $key = Open_SSL::decrypt_wps( substr( $key, 4 ) );
205
206 User_Helper::remove_user_totp_key();
207
208 $secret = Open_SSL::encrypt( $key );
209
210 if ( Open_SSL::is_ssl_available() ) {
211 $secret = Open_SSL::SECRET_KEY_PREFIX . $secret;
212 }
213
214 User_Helper::set_user_totp_key( $key, $user_id );
215
216 $test = $key = (string) User_Helper::get_user_totp_key( $user_id ); // phpcs:ignore
217 }
218
219 self::decrypt_key_if_needed( $test );
220
221 if ( ! self::is_valid_key( $test ) ) {
222 $key = self::generate_key();
223 User_Helper::set_user_totp_key( $key, $user_id );
224 self::$decrypted_key = '';
225 }
226
227 return $key;
228 }
229
230 /**
231 * Check if the TOTP secret key has a proper format.
232 *
233 * @param string $key TOTP secret key.
234 *
235 * @return boolean
236 */
237 public static function is_valid_key( $key ) {
238 self::decrypt_key_if_needed( $key );
239
240 $check = sprintf( '/^[%s]+$/', self::$base_32_chars );
241
242 if ( 1 === preg_match( $check, $key ) ) {
243 return true;
244 }
245
246 return false;
247 }
248
249 /**
250 * Checks if a given code is valid for a given key, allowing for a certain amount of time drift
251 *
252 * @param string $key The share secret key to use.
253 * @param string $authcode The code to test.
254 *
255 * @return bool Whether the code is valid within the time frame
256 */
257 public static function is_valid_authcode( $key, $authcode ) {
258
259 self::decrypt_key_if_needed( $key );
260 /**
261 * That allows to change the amount of thick for decrypting the key.
262 *
263 * @param bool - Default at this point is true - no method is selected.
264 *
265 * @since 2.0.0
266 */
267 $max_ticks = apply_filters( WP_2FA_PREFIX . 'totp_time_step_allowance', self::DEFAULT_TIME_STEP_ALLOWANCE );
268
269 // Array of all ticks to allow, sorted using absolute value to test closest match first.
270 $ticks = range( - $max_ticks, $max_ticks );
271 usort( $ticks, array( __CLASS__, 'abssort' ) );
272
273 $time = time() / self::DEFAULT_TIME_STEP_SEC;
274 foreach ( $ticks as $offset ) {
275 $log_time = $time + $offset;
276 $calculdated = (string) self::calc_totp( $key, $log_time );
277 if ( $calculdated === $authcode ) {
278 return true;
279 }
280 }
281 return false;
282 }
283
284 /**
285 * Calculate a valid code given the shared secret key
286 *
287 * @param string $key The shared secret key to use for calculating code.
288 * @param mixed $step_count The time step used to calculate the code, which is the floor of time() divided by step size.
289 * @param int $digits The number of digits in the returned code.
290 * @param string $hash The hash used to calculate the code.
291 * @param int $time_step The size of the time step.
292 *
293 * @return string The totp code
294 */
295 public static function calc_totp( $key, $step_count = false, $digits = self::DEFAULT_DIGIT_COUNT, $hash = self::DEFAULT_CRYPTO, $time_step = self::DEFAULT_TIME_STEP_SEC ) {
296
297 $secret = self::base32_decode( $key );
298
299 if ( false === $step_count ) {
300 $step_count = floor( time() / $time_step );
301 }
302
303 $timestamp = self::pack64( $step_count );
304
305 $hash = hash_hmac( $hash, $timestamp, $secret, true );
306
307 $offset = ord( $hash[19] ) & 0xf;
308
309 $code = (
310 ( ( ord( $hash[ $offset + 0 ] ) & 0x7f ) << 24 ) |
311 ( ( ord( $hash[ $offset + 1 ] ) & 0xff ) << 16 ) |
312 ( ( ord( $hash[ $offset + 2 ] ) & 0xff ) << 8 ) |
313 ( ord( $hash[ $offset + 3 ] ) & 0xff )
314 ) % pow( 10, $digits );
315
316 return str_pad( $code, $digits, '0', STR_PAD_LEFT );
317 }
318
319 /**
320 * Decode a base32 string and return a binary representation
321 *
322 * @param string $base32_string The base 32 string to decode.
323 *
324 * @throws \Exception If string contains non-base32 characters.
325 *
326 * @return string Binary representation of decoded string
327 */
328 public static function base32_decode( $base32_string ) {
329
330 $base32_string = strtoupper( $base32_string );
331
332 if ( ! preg_match( '/^[' . self::$base_32_chars . ']+$/', $base32_string, $match ) ) {
333 throw new \Exception( 'Invalid characters in the base32 string.' );
334 }
335
336 $l = strlen( $base32_string );
337 $n = 0;
338 $j = 0;
339 $binary = '';
340
341 for ( $i = 0; $i < $l; $i++ ) {
342
343 $n = $n << 5; // Move buffer left by 5 to make room.
344 $n = $n + strpos( self::$base_32_chars, $base32_string[ $i ] ); // Add value into buffer.
345 $j += 5; // Keep track of number of bits in buffer.
346
347 if ( $j >= 8 ) {
348 $j -= 8;
349 $binary .= chr( ( $n & ( 0xFF << $j ) ) >> $j );
350 }
351 }
352
353 return $binary;
354 }
355
356 /**
357 * Used with usort to sort an array by distance from 0
358 *
359 * @param int $a First array element.
360 * @param int $b Second array element.
361 *
362 * @return int -1, 0, or 1 as needed by usort
363 */
364 private static function abssort( $a, $b ) {
365 $a = abs( $a );
366 $b = abs( $b );
367 if ( $a === $b ) {
368 return 0;
369 }
370 return ( $a < $b ) ? -1 : 1;
371 }
372
373 /**
374 * Pack stuff
375 *
376 * @param string $value The value to be packed.
377 *
378 * @return string Binary packed string.
379 */
380 public static function pack64( $value ) {
381 // 64bit mode (PHP_INT_SIZE == 8).
382 if ( PHP_INT_SIZE >= 8 ) {
383 // If we're on PHP 5.6.3+ we can use the new 64bit pack functionality.
384 if ( version_compare( PHP_VERSION, '5.6.3', '>=' ) && PHP_INT_SIZE >= 8 ) {
385 return pack( 'J', $value );
386 }
387 $highmap = 0xffffffff << 32;
388 $higher = ( $value & $highmap ) >> 32;
389 } else {
390 /*
391 * 32bit PHP can't shift 32 bits like that, so we have to assume 0 for the higher
392 * and not pack anything beyond it's limits.
393 */
394 $higher = 0;
395 }
396
397 $lowmap = 0xffffffff;
398 $lower = $value & $lowmap;
399
400 return pack( 'NN', $higher, $lower );
401 }
402
403 /**
404 * Generate a random eight-digit string to send out as an auth code.
405 *
406 * @since 0.1-dev
407 *
408 * @param int $length The code length.
409 * @param string|array $chars Valid auth code characters.
410 * @return string
411 */
412 public static function get_code( $length = 8, $chars = '1234567890' ) {
413 $code = '';
414 if ( is_array( $chars ) ) {
415 $chars = implode( '', $chars );
416 }
417 for ( $i = 0; $i < $length; $i++ ) {
418 $code .= substr( $chars, wp_rand( 0, strlen( $chars ) - 1 ), 1 );
419 }
420 return $code;
421 }
422
423 /**
424 * Generate the user token.
425 *
426 * @since 0.1-dev
427 *
428 * @param int $user_id User ID.
429 * @return string
430 */
431 public static function generate_token( $user_id ) {
432 $token = self::get_code();
433
434 User_Helper::set_email_token_for_user( \wp_hash( $token ), $user_id );
435 return $token;
436 }
437
438 /**
439 * Validate the user token.
440 *
441 * @since 0.1-dev
442 *
443 * @param \WP_User $user User ID.
444 * @param string $token User token.
445 *
446 * @return boolean
447 */
448 public static function validate_token( $user, $token ) {
449 $user_id = $user->ID;
450 $hashed_token = self::get_user_token( $user_id );
451 // Bail if token is empty or it doesn't match.
452 // This code is here just because people have no idea what is the difference between preaching and real life.
453 if ( empty( $hashed_token ) || ( ! hash_equals( wp_hash( $token ), $hashed_token ) ) ) {
454 self::get_login_attempts_instance()->increase_login_attempts( $user );
455 return false;
456 }
457
458
459 // Ensure that the token can't be re-used.
460 self::delete_token( $user_id );
461 self::get_login_attempts_instance()->clear_login_attempts( $user );
462
463 \delete_transient( 'wp2fa_code_login_' . $user_id );
464
465 return true;
466 }
467
468 /**
469 * Delete the user token.
470 *
471 * @since 0.1-dev
472 *
473 * @param int $user_id User ID.
474 */
475 public static function delete_token( $user_id ) {
476 User_Helper::remove_email_token_for_user( $user_id );
477 }
478
479 /**
480 * Check if user has a valid token already.
481 *
482 * @param int $user_id User ID.
483 * @return boolean If user has a valid email token.
484 */
485 public static function user_has_token( $user_id ) {
486 $hashed_token = self::get_user_token( $user_id );
487 if ( ! empty( $hashed_token ) ) {
488 return true;
489 } else {
490 return false;
491 }
492 }
493
494 /**
495 * Get the authentication token for the user.
496 *
497 * @param int $user_id User ID.
498 *
499 * @return string|boolean User token or `false` if no token found.
500 */
501 public static function get_user_token( $user_id ) {
502
503
504 $hashed_token = User_Helper::get_email_token_for_user( $user_id );
505
506 if ( ! empty( $hashed_token ) && is_string( $hashed_token ) ) {
507 return $hashed_token;
508 }
509
510 return false;
511 }
512
513 /**
514 * Returns list of all the auth apps and their properties
515 *
516 * @return array
517 */
518 public static function get_apps(): array {
519 return array(
520 'authy' => array(
521 'logo' => 'authy-logo.png',
522 'hash' => 'authy',
523 'name' => 'Authy',
524 ),
525 'google' => array(
526 'logo' => 'google-logo.png',
527 'hash' => 'google',
528 'name' => 'Google Authenticator',
529 ),
530 'microsoft' => array(
531 'logo' => 'microsoft-logo.png',
532 'hash' => 'microsoft',
533 'name' => 'Microsoft Authenticator',
534 ),
535 'duo' => array(
536 'logo' => 'duo-logo.png',
537 'hash' => 'duo',
538 'name' => 'Duo Security',
539 ),
540 'lastpass' => array(
541 'logo' => 'lastpass-logo.png',
542 'hash' => 'lastpass',
543 'name' => 'LastPass',
544 ),
545 'freeotp' => array(
546 'logo' => 'free-otp-logo.png',
547 'hash' => 'freeotp',
548 'name' => 'FreeOTP',
549 ),
550 'okta' => array(
551 'logo' => 'okta-logo.png',
552 'hash' => 'okta',
553 'name' => 'Okta',
554 ),
555 );
556 }
557
558 /**
559 * Getter for the base32 character set
560 *
561 * @return string
562 *
563 * @since 2.0.0
564 */
565 public static function get_base32_characters(): string {
566 return self::$base_32_chars;
567 }
568
569 /**
570 * Validates base32 encoded string
571 *
572 * @param string $text = The text to be validated.
573 *
574 * @return boolean
575 *
576 * @since 2.0.0
577 */
578 public static function validate_base32_string( string $text ): bool {
579 if ( ! preg_match( '/^[' . self::$base_32_chars . ']+$/', $text, $match ) ) {
580 return false;
581 }
582
583 return true;
584 }
585
586 /**
587 * Checks the given key and decrypts it if necessarily
588 *
589 * @param string $key - The key to check.
590 *
591 * @return string
592 *
593 * @since 2.0.0
594 */
595 public static function decrypt_key_if_needed( string &$key ): string {
596 if ( '' === trim( self::$decrypted_key ) ) {
597 if ( Open_SSL::is_ssl_available() && false !== \strpos( $key, Open_SSL::SECRET_KEY_PREFIX ) ) {
598 $key = self::$decrypted_key = Open_SSL::decrypt( substr( $key, 4 ) ); // phpcs:ignore
599 } else {
600 self::$decrypted_key = $key;
601 }
602 }
603
604 return ( $key = self::$decrypted_key ); // phpcs:ignore
605 }
606
607 /**
608 * Returns instance of the LoginAttempts class
609 *
610 * @return \WP2FA\Admin\Controllers\Login_Attempts
611 *
612 * @since 2.0.0
613 */
614 public static function get_login_attempts_instance() {
615 if ( null === self::$login_attempts ) {
616
617 self::$login_attempts = new Login_Attempts( self::$login_num_meta_key );
618
619 }
620 return self::$login_attempts;
621 }
622
623 /**
624 * Checks the number of login attempts
625 *
626 * @param \WP_User $user - The user we have to check for.
627 *
628 * @return boolean
629 *
630 * @since 2.0.0
631 */
632 public static function check_number_of_attempts( \WP_User $user ):bool {
633 return self::get_login_attempts_instance()->check_number_of_attempts( $user );
634 }
635 }
636