PluginProbe ʕ •ᴥ•ʔ
WP 2FA – Two-factor authentication for WordPress / 2.9.0
WP 2FA – Two-factor authentication for WordPress v2.9.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
assets 11 months ago class-authentication.php 11 months ago class-login.php 11 months ago class-open-ssl.php 11 months ago class-reset-password.php 11 months ago index.php 11 months ago
class-authentication.php
570 lines
1 <?php
2 /**
3 * Responsible for WP2FA user's authentication.
4 *
5 * @package wp2fa
6 * @subpackage authentication
7 * @copyright 2025 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\BaconQrCode\Writer;
27 use WP2FA\Admin\Methods\Traits\Login_Attempts;
28 use WP2FA_Vendor\BaconQrCode\Renderer\ImageRenderer;
29 use WP2FA_Vendor\BaconQrCode\Renderer\Image\SvgImageBackEnd;
30 use WP2FA_Vendor\BaconQrCode\Renderer\RendererStyle\RendererStyle;
31
32 if ( ! class_exists( '\WP2FA\Authenticator\Authentication' ) ) {
33 /**
34 * Authenticator class
35 */
36 class Authentication {
37 use Login_Attempts;
38
39 const DEFAULT_KEY_BIT_SIZE = 160;
40 const DEFAULT_CRYPTO = 'sha1';
41 const DEFAULT_DIGIT_COUNT = 6;
42 const DEFAULT_TIME_STEP_SEC = 30;
43 const DEFAULT_TIME_STEP_ALLOWANCE = 4;
44
45 /**
46 * Holds the name of the meta key for the allowed login attempts
47 *
48 * @var string
49 *
50 * @since 2.0.0
51 */
52 private static $logging_attempts_meta_key = WP_2FA_PREFIX . 'email-login-attempts';
53
54 /**
55 * The login attempts class
56 *
57 * @var \WP2FA\Admin\Controllers\Login_Attempts
58 *
59 * @since 2.0.0
60 */
61 private static $login_attempts = null;
62
63 /**
64 * String with the base32 characters
65 *
66 * @var string
67 */
68 private static $base_32_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
69
70 /**
71 * String with the decrypted key
72 *
73 * @var string
74 */
75 private static $decrypted_key = '';
76
77 /**
78 * Generate QR code
79 *
80 * @param string $name Username.
81 * @param string $key Auth key.
82 * @param string $title Site title.
83 * @return string QR code URL.
84 */
85 public static function get_google_qr_code( $name, $key, $title = null ) {
86 // Encode to support spaces, question marks and other characters.
87 $name = rawurlencode( $name );
88
89 self::decrypt_key_if_needed( $key );
90
91 $target_url = ( 'otpauth://totp/' . $name . '?secret=' . $key );
92 if ( isset( $title ) ) {
93 $target_url .= ( '&issuer=' . rawurlencode( $title ) );
94 }
95
96 $renderer = new ImageRenderer(
97 new RendererStyle( 400 ),
98 new SvgImageBackEnd()
99 );
100 $writer = new Writer( $renderer );
101
102 $result = $writer->writeString( $target_url );
103
104 return 'data:image/svg+xml;base64,' . base64_encode( $result ); // phpcs:ignore
105 }
106
107 /**
108 * Generates key
109 *
110 * @param int $bitsize Number of bits to use for key.
111 *
112 * @return string $bitsize long string composed of available base32 chars.
113 */
114 public static function generate_key( $bitsize = self::DEFAULT_KEY_BIT_SIZE ) {
115 $bytes = ceil( $bitsize / 8 );
116 $secret = wp_generate_password( $bytes, true, true );
117
118 $secret = Open_SSL::encrypt( self::base32_encode( $secret ) );
119
120 if ( Open_SSL::is_ssl_available() ) {
121 $secret = Open_SSL::SECRET_KEY_PREFIX . $secret;
122 }
123
124 return $secret;
125 }
126
127 /**
128 * Generates salt for the site
129 *
130 * @return string
131 *
132 * @since 2.4.0
133 *
134 * @throws \RuntimeException - throw exception if the generated string has unexpected characters or not a string.
135 */
136 public static function generate_salt(): string {
137 $secret = \wp_generate_password( 64, true, true );
138
139 if ( ! is_string( $secret ) || strlen( $secret ) !== 64 ) {
140 throw new \RuntimeException( 'Could not generate secret key.' );
141 }
142
143 return base64_encode( $secret );
144 }
145
146 /**
147 * Returns a base32 encoded string.
148 *
149 * @param string $string String to be encoded using base32.
150 *
151 * @return string base32 encoded string without padding.
152 */
153 public static function base32_encode( $string ) {
154 if ( empty( $string ) ) {
155 return '';
156 }
157
158 $binary_string = '';
159
160 foreach ( str_split( $string ) as $character ) {
161 $binary_string .= str_pad( base_convert( (string) ord( $character ), 10, 2 ), 8, '0', STR_PAD_LEFT );
162 }
163
164 $five_bit_sections = str_split( $binary_string, 5 );
165 $base32_string = '';
166
167 foreach ( $five_bit_sections as $five_bit_section ) {
168 $base32_string .= self::$base_32_chars[ base_convert( str_pad( $five_bit_section, 5, '0' ), 2, 10 ) ];
169 }
170
171 return $base32_string;
172 }
173
174 /**
175 * Clears the value of the decrypted key
176 *
177 * @return void
178 *
179 * @since 2.6.0
180 */
181 public static function clear_decrypted_key() {
182 self::$decrypted_key = '';
183 }
184
185 /**
186 * Check if the TOTP secret key has a proper format.
187 *
188 * @param string $key TOTP secret key.
189 *
190 * @return boolean
191 */
192 public static function is_valid_key( $key ) {
193 self::decrypt_key_if_needed( $key );
194
195 $check = sprintf( '/^[%s]+$/', self::$base_32_chars );
196
197 if ( 1 === preg_match( $check, $key ) ) {
198 return true;
199 }
200
201 return false;
202 }
203
204 /**
205 * Checks if a given code is valid for a given key, allowing for a certain amount of time drift
206 *
207 * @param string $key The share secret key to use.
208 * @param string $authcode The code to test.
209 *
210 * @return bool Whether the code is valid within the time frame
211 */
212 public static function is_valid_authcode( $key, $authcode ) {
213
214 self::decrypt_key_if_needed( $key );
215 /**
216 * That allows to change the amount of thick for decrypting the key.
217 *
218 * @param bool - Default at this point is true - no method is selected.
219 *
220 * @since 2.0.0
221 */
222 $max_ticks = apply_filters( WP_2FA_PREFIX . 'totp_time_step_allowance', self::DEFAULT_TIME_STEP_ALLOWANCE );
223
224 // Array of all ticks to allow, sorted using absolute value to test closest match first.
225 $ticks = range( - $max_ticks, $max_ticks );
226 usort( $ticks, array( __CLASS__, 'abssort' ) );
227
228 $time = time() / self::DEFAULT_TIME_STEP_SEC;
229 foreach ( $ticks as $offset ) {
230 $log_time = $time + $offset;
231 $calculdated = (string) self::calc_totp( $key, $log_time );
232 if ( hash_equals( $calculdated, $authcode ) ) {
233 return true;
234 }
235 }
236 return false;
237 }
238
239 /**
240 * Calculate a valid code given the shared secret key
241 *
242 * @param string $key The shared secret key to use for calculating code.
243 * @param mixed $step_count The time step used to calculate the code, which is the floor of time() divided by step size.
244 * @param int $digits The number of digits in the returned code.
245 * @param string $hash The hash used to calculate the code.
246 * @param int $time_step The size of the time step.
247 *
248 * @return string The totp code
249 */
250 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 ) {
251
252 $secret = self::base32_decode( $key );
253
254 if ( false === $step_count ) {
255 $step_count = floor( time() / $time_step );
256 }
257
258 $timestamp = self::pack64( $step_count );
259
260 $hash = hash_hmac( $hash, $timestamp, $secret, true );
261
262 $offset = ord( $hash[19] ) & 0xf;
263
264 $code = (
265 ( ( ord( $hash[ $offset + 0 ] ) & 0x7f ) << 24 ) |
266 ( ( ord( $hash[ $offset + 1 ] ) & 0xff ) << 16 ) |
267 ( ( ord( $hash[ $offset + 2 ] ) & 0xff ) << 8 ) |
268 ( ord( $hash[ $offset + 3 ] ) & 0xff )
269 ) % pow( 10, $digits );
270
271 return str_pad( (string) $code, $digits, '0', STR_PAD_LEFT );
272 }
273
274 /**
275 * Decode a base32 string and return a binary representation
276 *
277 * @param string $base32_string The base 32 string to decode.
278 *
279 * @throws \InvalidArgumentException If string contains non-base32 characters.
280 *
281 * @return string Binary representation of decoded string
282 */
283 public static function base32_decode( $base32_string ) {
284
285 $base32_string = strtoupper( $base32_string );
286
287 if ( ! preg_match( '/^[' . self::$base_32_chars . ']+$/', $base32_string ) ) {
288 throw new \InvalidArgumentException( 'Invalid characters in the base32 string.' );
289 }
290
291 $l = strlen( $base32_string );
292 $n = 0;
293 $j = 0;
294 $binary = '';
295
296 for ( $i = 0; $i < $l; $i++ ) {
297
298 $n = $n << 5; // Move buffer left by 5 to make room.
299 $n = $n + strpos( self::$base_32_chars, $base32_string[ $i ] ); // Add value into buffer.
300 $j += 5; // Keep track of number of bits in buffer.
301
302 if ( $j >= 8 ) {
303 $j -= 8;
304 $binary .= chr( ( $n & ( 0xFF << $j ) ) >> $j );
305 }
306 }
307
308 return $binary;
309 }
310
311 /**
312 * Used with usort to sort an array by distance from 0
313 *
314 * @param int $a First array element.
315 * @param int $b Second array element.
316 *
317 * @return int -1, 0, or 1 as needed by usort
318 */
319 private static function abssort( $a, $b ) {
320 $a = abs( $a );
321 $b = abs( $b );
322 if ( $a === $b ) {
323 return 0;
324 }
325 return ( $a < $b ) ? -1 : 1;
326 }
327
328 /**
329 * Pack stuff
330 *
331 * @param string $value The value to be packed.
332 *
333 * @return string Binary packed string.
334 */
335 public static function pack64( $value ) {
336 // 64bit mode (PHP_INT_SIZE == 8).
337 if ( PHP_INT_SIZE >= 8 ) {
338 // If we're on PHP 5.6.3+ we can use the new 64bit pack functionality.
339 if ( version_compare( PHP_VERSION, '5.6.3', '>=' ) && PHP_INT_SIZE >= 8 ) {
340 return pack( 'J', $value );
341 }
342 $highmap = 0xffffffff << 32;
343 $higher = ( $value & $highmap ) >> 32;
344 } else {
345 /*
346 * 32bit PHP can't shift 32 bits like that, so we have to assume 0 for the higher
347 * and not pack anything beyond it's limits.
348 */
349 $higher = 0;
350 }
351
352 $lowmap = 0xffffffff;
353 $lower = $value & $lowmap;
354
355 return pack( 'NN', $higher, $lower );
356 }
357
358 /**
359 * Generate a random eight-digit string to send out as an auth code.
360 *
361 * @since 0.1-dev
362 *
363 * @param int $length The code length.
364 * @param string|array $chars Valid auth code characters.
365 * @return string
366 */
367 public static function get_code( $length = 6, $chars = '1234567890' ) {
368 $code = '';
369 if ( is_array( $chars ) ) {
370 $chars = implode( '', $chars );
371 }
372 for ( $i = 0; $i < $length; $i++ ) {
373 $code .= substr( $chars, wp_rand( 0, strlen( $chars ) - 1 ), 1 );
374 }
375 return $code;
376 }
377
378 /**
379 * Generate the user token.
380 *
381 * @since 0.1-dev
382 *
383 * @param int $user_id User ID.
384 * @return string
385 */
386 public static function generate_token( $user_id ) {
387 $token = self::get_code();
388
389 User_Helper::set_email_token_for_user( \wp_hash( $token ), $user_id );
390 return $token;
391 }
392
393 /**
394 * Validate the user token.
395 *
396 * @since 0.1-dev
397 *
398 * @param \WP_User $user User ID.
399 * @param string $token User token.
400 *
401 * @return boolean
402 */
403 public static function validate_token( $user, $token ) {
404 $user_id = $user->ID;
405 $hashed_token = self::get_user_token( $user_id );
406
407 // Bail if token is empty or it doesn't match.
408 if ( empty( $hashed_token ) || ! hash_equals( wp_hash( $token ), $hashed_token ) ) {
409 self::increase_login_attempts( $user );
410 return false;
411 }
412
413
414 // Ensure that the token can't be re-used.
415 self::delete_token( $user_id );
416 self::clear_login_attempts( $user );
417
418 \delete_transient( 'wp_2fa_code_login_' . $user_id );
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 * @throws \RuntimeException - The key could not be decrypted.
551 */
552 public static function decrypt_key_if_needed( string &$key ): string {
553 if ( '' === trim( self::$decrypted_key ) ) {
554 if ( Open_SSL::is_ssl_available() && strpos( $key, Open_SSL::SECRET_KEY_PREFIX ) === 0 ) {
555 $decrypted = Open_SSL::decrypt( substr( $key, strlen( Open_SSL::SECRET_KEY_PREFIX ) ) );
556 if ( false !== $decrypted ) {
557 self::$decrypted_key = $decrypted;
558 } else {
559 throw new \RuntimeException( 'Failed to decrypt the key.' );
560 }
561 } else {
562 self::$decrypted_key = $key;
563 }
564 }
565
566 return ( $key = self::$decrypted_key ); // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found
567 }
568 }
569 }
570