PluginProbe ʕ •ᴥ•ʔ
WP 2FA – Two-factor authentication for WordPress / 4.0.0
WP 2FA – Two-factor authentication for WordPress v4.0.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 1 day ago class-authentication.php 1 day ago class-login.php 1 day ago class-open-ssl.php 1 day ago class-reset-password.php 1 day ago index.php 1 day ago
class-authentication.php
572 lines
1 <?php
2 /**
3 * Responsible for WP2FA user's authentication.
4 *
5 * @package wp2fa
6 * @subpackage authentication
7 * @copyright 2026 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 );
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 * @since 3.1.0
213 */
214 public static function is_valid_authcode( $key, $authcode ) {
215
216 self::decrypt_key_if_needed( $key );
217 /**
218 * That allows to change the amount of thick for decrypting the key.
219 *
220 * @param bool - Default at this point is true - no method is selected.
221 *
222 * @since 2.0.0
223 */
224 $max_ticks = apply_filters( WP_2FA_PREFIX . 'totp_time_step_allowance', self::DEFAULT_TIME_STEP_ALLOWANCE );
225
226 // Array of all ticks to allow, sorted using absolute value to test closest match first.
227 $ticks = range( - $max_ticks, $max_ticks );
228 usort( $ticks, array( __CLASS__, 'abssort' ) );
229
230 $time = time() / self::DEFAULT_TIME_STEP_SEC;
231 foreach ( $ticks as $offset ) {
232 $log_time = $time + $offset;
233 $calculdated = (string) self::calc_totp( $key, $log_time );
234 if ( hash_equals( $calculdated, (string) $authcode ) ) {
235 return true;
236 }
237 }
238 return false;
239 }
240
241 /**
242 * Calculate a valid code given the shared secret key
243 *
244 * @param string $key The shared secret key to use for calculating code.
245 * @param mixed $step_count The time step used to calculate the code, which is the floor of time() divided by step size.
246 * @param int $digits The number of digits in the returned code.
247 * @param string $hash The hash used to calculate the code.
248 * @param int $time_step The size of the time step.
249 *
250 * @return string The totp code
251 */
252 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 ) {
253
254 $secret = self::base32_decode( $key );
255
256 if ( false === $step_count ) {
257 $step_count = floor( time() / $time_step );
258 }
259
260 $timestamp = self::pack64( $step_count );
261
262 $hash = hash_hmac( $hash, $timestamp, $secret, true );
263
264 $offset = ord( $hash[19] ) & 0xf;
265
266 $code = (
267 ( ( ord( $hash[ $offset + 0 ] ) & 0x7f ) << 24 ) |
268 ( ( ord( $hash[ $offset + 1 ] ) & 0xff ) << 16 ) |
269 ( ( ord( $hash[ $offset + 2 ] ) & 0xff ) << 8 ) |
270 ( ord( $hash[ $offset + 3 ] ) & 0xff )
271 ) % pow( 10, $digits );
272
273 return str_pad( (string) $code, $digits, '0', STR_PAD_LEFT );
274 }
275
276 /**
277 * Decode a base32 string and return a binary representation
278 *
279 * @param string $base32_string The base 32 string to decode.
280 *
281 * @throws \InvalidArgumentException If string contains non-base32 characters.
282 *
283 * @return string Binary representation of decoded string
284 */
285 public static function base32_decode( $base32_string ) {
286
287 $base32_string = strtoupper( $base32_string );
288
289 if ( ! preg_match( '/^[' . self::$base_32_chars . ']+$/', $base32_string ) ) {
290 throw new \InvalidArgumentException( 'Invalid characters in the base32 string.' );
291 }
292
293 $l = strlen( $base32_string );
294 $n = 0;
295 $j = 0;
296 $binary = '';
297
298 for ( $i = 0; $i < $l; $i++ ) {
299
300 $n = $n << 5; // Move buffer left by 5 to make room.
301 $n = $n + strpos( self::$base_32_chars, $base32_string[ $i ] ); // Add value into buffer.
302 $j += 5; // Keep track of number of bits in buffer.
303
304 if ( $j >= 8 ) {
305 $j -= 8;
306 $binary .= chr( ( $n & ( 0xFF << $j ) ) >> $j );
307 }
308 }
309
310 return $binary;
311 }
312
313 /**
314 * Used with usort to sort an array by distance from 0
315 *
316 * @param int $a First array element.
317 * @param int $b Second array element.
318 *
319 * @return int -1, 0, or 1 as needed by usort
320 */
321 private static function abssort( $a, $b ) {
322 $a = abs( $a );
323 $b = abs( $b );
324 if ( $a === $b ) {
325 return 0;
326 }
327 return ( $a < $b ) ? -1 : 1;
328 }
329
330 /**
331 * Pack stuff
332 *
333 * @param string $value The value to be packed.
334 *
335 * @return string Binary packed string.
336 */
337 public static function pack64( $value ) {
338 // 64bit mode (PHP_INT_SIZE == 8).
339 if ( PHP_INT_SIZE >= 8 ) {
340 // If we're on PHP 5.6.3+ we can use the new 64bit pack functionality.
341 if ( version_compare( PHP_VERSION, '5.6.3', '>=' ) && PHP_INT_SIZE >= 8 ) {
342 return pack( 'J', $value );
343 }
344 $highmap = 0xffffffff << 32;
345 $higher = ( $value & $highmap ) >> 32;
346 } else {
347 /*
348 * 32bit PHP can't shift 32 bits like that, so we have to assume 0 for the higher
349 * and not pack anything beyond it's limits.
350 */
351 $higher = 0;
352 }
353
354 $lowmap = 0xffffffff;
355 $lower = $value & $lowmap;
356
357 return pack( 'NN', $higher, $lower );
358 }
359
360 /**
361 * Generate a random eight-digit string to send out as an auth code.
362 *
363 * @since 0.1-dev
364 *
365 * @param int $length The code length.
366 * @param string|array $chars Valid auth code characters.
367 * @return string
368 */
369 public static function get_code( $length = 6, $chars = '1234567890' ) {
370 $code = '';
371 if ( is_array( $chars ) ) {
372 $chars = implode( '', $chars );
373 }
374 for ( $i = 0; $i < $length; $i++ ) {
375 $code .= substr( $chars, wp_rand( 0, strlen( $chars ) - 1 ), 1 );
376 }
377 return $code;
378 }
379
380 /**
381 * Generate the user token.
382 *
383 * @since 0.1-dev
384 *
385 * @param int $user_id User ID.
386 * @return string
387 */
388 public static function generate_token( $user_id ) {
389 $token = self::get_code();
390
391 User_Helper::set_email_token_for_user( \wp_hash( $token ), $user_id );
392 return $token;
393 }
394
395 /**
396 * Validate the user token.
397 *
398 * @since 0.1-dev
399 *
400 * @param \WP_User $user User ID.
401 * @param string $token User token.
402 *
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
409 // Bail if token is empty or it doesn't match.
410 if ( empty( $hashed_token ) || ! hash_equals( wp_hash( $token ), $hashed_token ) ) {
411 self::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::clear_login_attempts( $user );
419
420 \delete_transient( 'wp_2fa_code_login_' . $user_id );
421
422 return true;
423 }
424
425 /**
426 * Delete the user token.
427 *
428 * @since 0.1-dev
429 *
430 * @param int $user_id User ID.
431 */
432 public static function delete_token( $user_id ) {
433 User_Helper::remove_email_token_for_user( $user_id );
434 }
435
436 /**
437 * Check if user has a valid token already.
438 *
439 * @param int $user_id User ID.
440 * @return boolean If user has a valid email token.
441 */
442 public static function user_has_token( $user_id ) {
443 $hashed_token = self::get_user_token( $user_id );
444 if ( ! empty( $hashed_token ) ) {
445 return true;
446 } else {
447 return false;
448 }
449 }
450
451 /**
452 * Get the authentication token for the user.
453 *
454 * @param int $user_id User ID.
455 *
456 * @return string|boolean User token or `false` if no token found.
457 */
458 public static function get_user_token( $user_id ) {
459
460
461 $hashed_token = User_Helper::get_email_token_for_user( $user_id );
462
463 if ( ! empty( $hashed_token ) && is_string( $hashed_token ) ) {
464 return $hashed_token;
465 }
466
467 return false;
468 }
469
470 /**
471 * Returns list of all the auth apps and their properties
472 *
473 * @return array
474 */
475 public static function get_apps(): array {
476 return array(
477 'authy' => array(
478 'logo' => 'authy-logo.png',
479 'hash' => 'authy',
480 'name' => 'Authy',
481 ),
482 'google' => array(
483 'logo' => 'google-logo.png',
484 'hash' => 'google',
485 'name' => 'Google Authenticator',
486 ),
487 'microsoft' => array(
488 'logo' => 'microsoft-logo.png',
489 'hash' => 'microsoft',
490 'name' => 'Microsoft Authenticator',
491 ),
492 'duo' => array(
493 'logo' => 'duo-logo.png',
494 'hash' => 'duo',
495 'name' => 'Duo Security',
496 ),
497 'lastpass' => array(
498 'logo' => 'lastpass-logo.png',
499 'hash' => 'lastpass',
500 'name' => 'LastPass',
501 ),
502 'freeotp' => array(
503 'logo' => 'free-otp-logo.png',
504 'hash' => 'freeotp',
505 'name' => 'FreeOTP',
506 ),
507 'okta' => array(
508 'logo' => 'okta-logo.png',
509 'hash' => 'okta',
510 'name' => 'Okta',
511 ),
512 );
513 }
514
515 /**
516 * Getter for the base32 character set
517 *
518 * @return string
519 *
520 * @since 2.0.0
521 */
522 public static function get_base32_characters(): string {
523 return self::$base_32_chars;
524 }
525
526 /**
527 * Validates base32 encoded string
528 *
529 * @param string $text = The text to be validated.
530 *
531 * @return boolean
532 *
533 * @since 2.0.0
534 */
535 public static function validate_base32_string( string $text ): bool {
536 if ( ! preg_match( '/^[' . self::$base_32_chars . ']+$/', $text, $match ) ) {
537 return false;
538 }
539
540 return true;
541 }
542
543 /**
544 * Checks the given key and decrypts it if necessarily
545 *
546 * @param string $key - The key to check.
547 *
548 * @return string
549 *
550 * @since 2.0.0
551 *
552 * @throws \RuntimeException - The key could not be decrypted.
553 */
554 public static function decrypt_key_if_needed( string &$key ): string {
555 if ( '' === trim( self::$decrypted_key ) ) {
556 if ( Open_SSL::is_ssl_available() && strpos( $key, Open_SSL::SECRET_KEY_PREFIX ) === 0 ) {
557 $decrypted = Open_SSL::decrypt( substr( $key, strlen( Open_SSL::SECRET_KEY_PREFIX ) ) );
558 if ( false !== $decrypted ) {
559 self::$decrypted_key = $decrypted;
560 } else {
561 throw new \RuntimeException( 'Failed to decrypt the key.' );
562 }
563 } else {
564 self::$decrypted_key = $key;
565 }
566 }
567
568 return ( $key = self::$decrypted_key ); // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found
569 }
570 }
571 }
572