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