PluginProbe
DoLogin Security / trunk
DoLogin Security vtrunk
5.0.10 4.8.3 trunk 1.0 1.1 1.1.1 1.2 1.2.1 1.2.2 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 1.6 All 64 releases
dologin / src / twofa.cls.php

twofa.cls.php in DoLogin Security trunk, at src/twofa.cls.php

404 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * 2FA class
4 *
5 * @since 3.5
6 * @package dologin
7 */
8
9 namespace dologin;
10
11 defined( 'WPINC' ) || exit;
12
13 class TwoFA extends Instance {
14
15 private $_dry_run = false;
16
17 /**
18 * Register replay-marker lifecycle cleanup.
19 */
20 public function init() {
21 add_action( 'added_user_meta', array( $this, 'clear_replay_marker_for_meta' ), 10, 3 );
22 add_action( 'deleted_user_meta', array( $this, 'clear_replay_marker_for_meta' ), 10, 3 );
23 add_action( 'deleted_user', array( $this, 'delete_replay_markers' ), 10, 1 );
24 add_action( 'remove_user_from_blog', array( $this, 'delete_replay_marker' ), 10, 1 );
25 }
26
27 /**
28 * Delete replay state when a user's TOTP secret changes.
29 */
30 public function clear_replay_marker_for_meta( $meta_ids, $user_id, $meta_key ) {
31 if ( '2fa' === $meta_key ) {
32 $this->delete_replay_markers( $user_id );
33 }
34 }
35
36 /**
37 * Delete one user's replay markers from every site where they are a member.
38 */
39 public function delete_replay_markers( $user_id ) {
40 $user_id = (int) $user_id;
41 if ( $user_id < 1 ) {
42 return;
43 }
44 $this->delete_replay_marker( $user_id );
45 if ( ! is_multisite() || ! function_exists( 'get_blogs_of_user' ) ) {
46 return;
47 }
48
49 $current_blog_id = (int) get_current_blog_id();
50 foreach ( (array) get_blogs_of_user( $user_id ) as $blog ) {
51 $blog_id = is_object( $blog ) && isset( $blog->userblog_id ) ? (int) $blog->userblog_id : 0;
52 if ( $blog_id < 1 || $current_blog_id === $blog_id ) {
53 continue;
54 }
55 switch_to_blog( $blog_id );
56 $this->delete_replay_marker( $user_id );
57 restore_current_blog();
58 }
59 }
60
61 /**
62 * Delete one user's replay marker from the current site.
63 */
64 public function delete_replay_marker( $user_id ) {
65 $user_id = (int) $user_id;
66 if ( $user_id > 0 ) {
67 delete_option( self::replay_option_name( $user_id ) );
68 }
69 }
70
71 /**
72 * Maybe save user 2fa status
73 *
74 * @since 3.5
75 */
76 public function maybe_save_2fa() {
77 if ( empty( $_POST['dologin-2fa-code'] ) || ! is_string( $_POST['dologin-2fa-code'] ) ) {
78 return;
79 }
80 if ( empty( $_POST['dologin-2fa-secret'] ) || ! is_string( $_POST['dologin-2fa-secret'] ) ) {
81 return;
82 }
83 check_admin_referer( 'dologin-set2fa' );
84
85 $secret = sanitize_text_field( wp_unslash( $_POST['dologin-2fa-secret'] ) );
86 $code = sanitize_text_field( wp_unslash( $_POST['dologin-2fa-code'] ) );
87
88 $lib = new lib\Two_FA_Lib();
89 if ( ! $lib->verifyCode( $secret, $code, 1 ) ) {
90 GUI::error( __( 'Code verification failed!', 'dologin' ) );
91 return;
92 }
93
94 // Set user's 2FA secret
95 if ( $this->current_status() ) {
96 GUI::error( __( 'You have set your 2FA secret before!', 'dologin' ) );
97 return;
98 }
99
100 $uid = get_current_user_id();
101 $sealed = Secret::seal( 'totp-user-secret', $secret );
102 if ( ! $sealed || false === update_user_meta( $uid, '2fa', $sealed ) ) {
103 GUI::error( __( 'Failed to encrypt the 2FA secret. The secret was not saved.', 'dologin' ) );
104 return;
105 }
106
107 GUI::succeed( __( 'Congratulations! Your 2FA is successfully enabled!', 'dologin' ) );
108
109 wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );
110 exit;
111 }
112
113 /**
114 * Show GUI notice if missing 2fa in user profile
115 *
116 * @since 3.5
117 */
118 public function gui_notice() {
119 $current_user_2fa = $this->current_status();
120 if ( ! $current_user_2fa && Conf::val( '2fa' ) ) {
121 $installer = new Installer();
122 if ( ! $installer->dash_notifier_is_plugin_active( 'doqrcode' ) ) {
123 $install_link = Util::build_url( Router::ACTION_INSTALLER, Installer::TYPE_INSTALL_3RD, false, null, array( 'plugin' => 'doqrcode' ) );
124 $desc = __( 'You need to install the following plugin to enable 2FA', 'dologin' ) . ': <a href="' . esc_url( $install_link ) . '">WordPress QR Code generator (click to install)</a>';
125 GUI::error( '<h2>' . DOLOGIN_LOGO . __( 'Dologin Notice', 'dologin' ) . '</h2>' . $desc );
126 return;
127 }
128
129 $lib = new lib\Two_FA_Lib();
130 $secret = $lib->createSecret();
131 $qrcode = do_shortcode( "[qrcode size='8' margin='3']" . $lib->getQRCodeGoogleUrl( get_bloginfo( 'name' ), $secret ) . '[/qrcode]' );
132 $form = '<form action="' . esc_url( menu_page_url( 'dologin', false ) ) . '" method="post"><input type="hidden" name="dologin-2fa-secret" value="' . esc_attr( $secret ) . '" />'
133 . wp_nonce_field( 'dologin-set2fa', '_wpnonce', true, false )
134 . esc_html__( 'Code', 'dologin' )
135 . ': <input type="text" name="dologin-2fa-code" />'
136 . get_submit_button( __( 'Enable 2FA', 'dologin' ) )
137 . '</form>';
138
139 $desc = __( 'Please scan this barcode w/ your phone 2FA app (e.g. KeyLockr or Google Authenticator) and type the code in 2FA app below.', 'dologin' );
140 if ( Conf::val( '2fa_force' ) ) {
141 $desc .= '<br/><span style="color:red;">' . __( 'You need to setup your 2FA before enabling this setting to avoid yourself being blocked from next time login.', 'dologin' ) . '</span>';
142 }
143
144 GUI::error( '<h2>' . DOLOGIN_LOGO . __( 'Dologin Notice', 'dologin' ) . '</h2>' . $desc . '<br />' . $qrcode . $form );
145 }
146 }
147
148 /**
149 * Return current usre's 2fa status
150 *
151 * @since 3.5
152 */
153 public function current_status() {
154 $uid = get_current_user_id();
155 $code = get_user_meta( $uid, '2fa', true );
156 return (bool) $code;
157 }
158
159 /**
160 * Check if is dry run or not
161 *
162 * @since 3.5
163 */
164 public static function is_dry_run() {
165 return self::cls()->_dry_run;
166 }
167
168 /**
169 * Verify code after u+p authenticated
170 *
171 * @since 3.5
172 *
173 * @param mixed $user WP_User or WP_Error from earlier authenticate filters.
174 * @param string $username Submitted username.
175 * @param string $password Submitted password.
176 * @return mixed
177 */
178 public function authenticate( $user, $username, $password ) {
179 defined( 'debug' ) && debug( 'auth' );
180
181 if ( $this->_dry_run ) {
182 defined( 'debug' ) && debug( 'bypassed due to dryrun' );
183 return $user;
184 }
185
186 if ( empty( $username ) || empty( $password ) ) {
187 defined( 'debug' ) && debug( 'bypassed due to lack of u/p' );
188 return $user;
189 }
190
191 if ( is_wp_error( $user ) ) {
192 defined( 'debug' ) && debug( 'bypassed due to is_wp_error already' );
193 return $user;
194 }
195
196 // If 2fa is optional and the user doesn't have phone set, bypass.
197 $code = $this->user_secret( $user->ID );
198 if ( null === $code ) {
199 defined( 'debug' ) && debug( 'no 2fa set' );
200 if ( ! Conf::val( '2fa_force' ) ) {
201 defined( 'debug' ) && debug( 'bypassed due to no force_2fa check' );
202 return $user;
203 }
204
205 $error = new \WP_Error();
206 $error->add( 'not_2fa_set_user', Lang::msg( 'not_2fa_set_user' ) );
207 ! defined( 'DOLOGIN_ERR' ) && define( 'DOLOGIN_ERR', true );
208 return $error;
209 }
210 if ( false === $code ) {
211 $error = new \WP_Error();
212 $error->add( 'twofa_secret_unavailable', __( 'The stored 2FA secret cannot be decrypted. Restore the WordPress authentication salts or reset this user\'s 2FA secret.', 'dologin' ) );
213 ! defined( 'DOLOGIN_ERR' ) && define( 'DOLOGIN_ERR', true );
214 return $error;
215 }
216
217 $error = new \WP_Error();
218
219 // Validate dynamic code.
220 // phpcs:ignore WordPress.Security.NonceVerification.Missing
221 if ( empty( $_POST['dologin-two_factor_code'] ) || ! is_string( $_POST['dologin-two_factor_code'] ) ) {
222 $error->add( 'dynamic_code_missing', Lang::msg( 'dynamic_code_missing' ) );
223 ! defined( 'DOLOGIN_ERR' ) && define( 'DOLOGIN_ERR', true );
224 defined( 'debug' ) && debug( '❌ 2fa missing' );
225 return $error;
226 }
227
228 $lib = new lib\Two_FA_Lib();
229 // phpcs:ignore WordPress.Security.NonceVerification.Missing
230 $submitted_code = sanitize_text_field( wp_unslash( $_POST['dologin-two_factor_code'] ) );
231 $matched_slice = $lib->findValidTimeSlice( $code, $submitted_code, 1 );
232 if ( false === $matched_slice ) {
233 $error->add( 'dynamic_code_wrong', Lang::msg( 'dynamic_code_wrong' ) );
234 ! defined( 'DOLOGIN_ERR' ) && define( 'DOLOGIN_ERR', true );
235 defined( 'debug' ) && debug( '❌ 2fa wrong' );
236 return $error;
237 }
238
239 // Remember the secret fingerprint and latest time slice to prevent TOTP replay within the valid window.
240 $fingerprint = hash( 'sha256', (string) $code );
241 if ( ! $this->consume_time_slice( $user->ID, $fingerprint, $matched_slice ) ) {
242 $error->add( 'dynamic_code_wrong', Lang::msg( 'dynamic_code_wrong' ) );
243 ! defined( 'DOLOGIN_ERR' ) && define( 'DOLOGIN_ERR', true );
244 defined( 'debug' ) && debug( '❌ 2fa replayed' );
245 return $error;
246 }
247
248 defined( 'debug' ) && debug( '�
249 auth successfully' );
250
251 return $user;
252 }
253
254 /**
255 * Atomically consume a TOTP time slice with an option-value compare-and-swap.
256 */
257 private function consume_time_slice( $user_id, $fingerprint, $time_slice ) {
258 global $wpdb;
259
260 $option_name = self::replay_option_name( $user_id );
261 $new_value = $fingerprint . ':' . (int) $time_slice;
262 for ( $attempt = 0; $attempt < 2; $attempt++ ) {
263 $current = (string) get_option( $option_name, '' );
264 $parts = explode( ':', $current, 2 );
265 if ( 2 === count( $parts ) && hash_equals( $fingerprint, $parts[0] ) && (int) $time_slice <= (int) $parts[1] ) {
266 return false;
267 }
268 if ( '' === $current ) {
269 if ( add_option( $option_name, $new_value, '', false ) ) {
270 return true;
271 }
272 wp_cache_delete( $option_name, 'options' );
273 continue;
274 }
275
276 $q = "UPDATE `$wpdb->options` SET option_value = %s WHERE option_name = %s AND option_value = %s";
277 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- Per-user replay markers require an atomic compare-and-swap.
278 $updated = $wpdb->query( $wpdb->prepare( $q, $new_value, $option_name, $current ) );
279 wp_cache_delete( $option_name, 'options' );
280 if ( 1 === $updated ) {
281 return true;
282 }
283 }
284
285 return false;
286 }
287
288 /**
289 * Build one user's current-site replay option name.
290 */
291 private static function replay_option_name( $user_id ) {
292 return 'dologin.2fa.last.' . (int) $user_id;
293 }
294
295 /**
296 * Read and, when necessary, migrate one user's encrypted TOTP secret.
297 *
298 * @return string|null|false Plain secret, null when missing, or false when unavailable.
299 */
300 private function user_secret( $user_id ) {
301 $stored = get_user_meta( (int) $user_id, '2fa', true );
302 if ( ! is_string( $stored ) || '' === $stored ) {
303 return null;
304 }
305 if ( Secret::is_sealed( $stored ) ) {
306 $plain = Secret::open( 'totp-user-secret', $stored );
307 return is_string( $plain ) && '' !== $plain ? $plain : false;
308 }
309
310 $sealed = Secret::seal( 'totp-user-secret', $stored );
311 if ( ! $sealed || false === update_user_meta( (int) $user_id, '2fa', $sealed, $stored ) ) {
312 $latest = get_user_meta( (int) $user_id, '2fa', true );
313 if ( is_string( $latest ) && Secret::is_sealed( $latest ) ) {
314 $plain = Secret::open( 'totp-user-secret', $latest );
315 return is_string( $plain ) && '' !== $plain ? $plain : false;
316 }
317 return false;
318 }
319 return $stored;
320 }
321
322 /**
323 * Check if has enabled 2fa or not
324 *
325 * @since 3.5
326 */
327 public function check() {
328 if ( ! Conf::val( '2fa' ) ) {
329 return REST::ok( array( 'bypassed' => 1 ) );
330 }
331
332 if ( $this->cls( 'Auth' )->is_ip_denied() ) {
333 return REST::err( __( 'This IP is not allowed to login.', 'dologin' ) );
334 }
335 if ( $this->cls( 'Auth' )->is_rate_limited() ) {
336 return REST::err( Lang::msg( 'max_retries_hit' ) );
337 }
338
339 $field_u = 'log';
340 $field_p = 'pwd';
341 // phpcs:ignore WordPress.Security.NonceVerification.Missing
342 if ( isset( $_POST['woocommerce-login-nonce'] ) ) {
343 $field_u = 'username';
344 $field_p = 'password';
345 }
346
347 // phpcs:ignore WordPress.Security.NonceVerification.Missing
348 if ( empty( $_POST[ $field_u ] ) || ! is_string( $_POST[ $field_u ] ) || empty( $_POST[ $field_p ] ) || ! is_string( $_POST[ $field_p ] ) ) {
349 return REST::err( Lang::msg( 'empty_u_p' ) );
350 }
351
352 // Password contents must not be sanitized, but WordPress-added slashes must be removed.
353 // phpcs:ignore WordPress.Security.NonceVerification.Missing
354 $username = wp_unslash( $_POST[ $field_u ] );
355 // phpcs:ignore WordPress.Security.NonceVerification.Missing
356 $password = wp_unslash( $_POST[ $field_p ] );
357
358 // Verify u & p first.
359 $this->_dry_run = true;
360 $user = wp_authenticate( $username, $password );
361 $this->_dry_run = false;
362 if ( is_wp_error( $user ) ) {
363 if ( $this->is_credential_error( $user ) ) {
364 // wp_authenticate() already fired wp_login_failed, so do not count it again.
365 return REST::err( Lang::msg( 'auth_failed' ) );
366 }
367 return REST::err( $user->get_error_message() );
368 }
369
370 // Search if the user has enabled 2fa or not.
371 $twofa = $this->user_secret( $user->ID );
372
373 if ( null === $twofa ) {
374 if ( ! Conf::val( '2fa_force' ) ) {
375 defined( 'debug' ) && debug( 'bypassed due to no 2fa set' );
376 return REST::ok( array( 'bypassed' => 1 ) );
377 }
378 return REST::err( Lang::msg( 'not_2fa_set_user' ) );
379 }
380 if ( false === $twofa ) {
381 return REST::err( __( 'The stored 2FA secret cannot be decrypted. Restore the WordPress authentication salts or reset this user\'s 2FA secret.', 'dologin' ) );
382 }
383
384 return REST::ok( array( 'info' => __( 'Please provide the code from your 2FA app', 'dologin' ) ) );
385 }
386
387 /**
388 * Whether a wp_authenticate() error should be counted as a login failure.
389 */
390 private function is_credential_error( $error ) {
391 $credential_codes = array(
392 'incorrect_password',
393 'invalid_email',
394 'invalid_username',
395 );
396 foreach ( $credential_codes as $code ) {
397 if ( $error->get_error_message( $code ) ) {
398 return true;
399 }
400 }
401 return false;
402 }
403 }
404