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