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