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