class-wp-otp-public.php 5.06 KB
Newer Older
noplanman's avatar
noplanman committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
<?php
/**
 * The public-facing functionality of the plugin
 *
 * This is basically the input field for the OTP code on the login form.
 *
 * @package    Wp_Otp
 * @subpackage Public
 * @since      0.1.0
 */

namespace Wp_Otp;

use OTPHP\TOTP;
use WP_Error;
use WP_User;

/**
 * The public-facing functionality of the plugin.
 *
 * @since 0.1.0
 */
class Wp_Otp_Public {
	/**
	 * Render the WP-OTP input field on the login form.
	 *
	 * @since 0.1.0
	 */
29
	public function login_form_render(): void {
noplanman's avatar
noplanman committed
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
		/**
		 * Filter for the OTP login form text.
		 *
		 * @since 0.1.0
		 *
		 * @param string $otp_text
		 */
		$otp_text = apply_filters(
			'wp_otp_login_form_text',
			__( 'One Time Password', 'wp-otp' )
		);

		/**
		 * Filter for the OTP login form sub text.
		 *
		 * @since 0.1.0
		 *
		 * @param string $otp_text_sub
		 */
		$otp_text_sub = apply_filters(
			'wp_otp_login_form_text_sub',
			__( 'OTP code from your authenticator app. (Blank if not yet configured)', 'wp-otp' )
		);
		?>
		<p>
			<label for="wp_otp_code"><?php echo $otp_text; ?></label><br/>
			<?php '' !== $otp_text_sub && printf( '<em>%s</em>', $otp_text_sub ); ?>
57
			<input type="text" class="input" name="wp_otp_code" id="wp_otp_code"/>
noplanman's avatar
noplanman committed
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
		</p>
		<?php
	}

	/**
	 * Validation of the user login, to check if the OTP was correct.
	 *
	 * @param WP_User $user The user that's trying to log in.
	 *
	 * @return WP_Error|WP_User
	 */
	public function login_form_validate( $user ) {
		if ( ! $user instanceof WP_User ) {
			return $user;
		}

noplanman's avatar
noplanman committed
74 75 76 77 78 79
		$user_meta_data = Wp_Otp_User_Meta::get_instance( $user->ID );

		$otp = $this->get_otp_if_enabled( $user_meta_data );
		if ( null === $otp ) {
			return $user;
		}
80
		$otp_code = $_POST['wp_otp_code'] ?? '';
noplanman's avatar
noplanman committed
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95

		// If this is a valid OTP code, all good!
		if ( $this->verify_otp( $otp, $otp_code ) ) {
			return $user;
		}

		// Check if a recovery code is being used.
		$recovery_codes = $user_meta_data->get( 'recovery_codes' );
		if ( array_key_exists( $otp_code, array_filter( $recovery_codes ) ) ) {
			// Unset the recovery code that has just been used.
			$recovery_codes[ $otp_code ] = false;
			$user_meta_data->set( 'recovery_codes', $recovery_codes, true );
			return $user;
		}

noplanman's avatar
noplanman committed
96 97 98 99 100 101 102 103 104 105 106 107
		/**
		 * Filter for the OTP login form error text when an invalid code is entered.
		 *
		 * @since 0.1.0
		 *
		 * @param string $otp_invalid_code_text
		 */
		$otp_invalid_code_text = apply_filters(
			'wp_otp_login_form_invalid_code_text',
			__( '<strong>Invalid code!</strong> Please try again.', 'wp-otp' )
		);

noplanman's avatar
noplanman committed
108 109 110 111 112 113 114 115 116 117 118
		return new WP_Error( 'invalid_otp', $otp_invalid_code_text );
	}

	/**
	 * Validation of the user login, to check if the stealth OTP was correct.
	 *
	 * @param string $username The username that's trying to log in.
	 * @param string $password The password being used to log in.
	 *
	 * @return void
	 */
119
	public function login_form_stealth_validate( &$username, &$password ): void {
noplanman's avatar
noplanman committed
120 121 122 123 124
		$user = get_user_by( 'login', $username );
		if ( ! $user ) {
			return;
		}

noplanman's avatar
noplanman committed
125 126
		$user_meta_data = Wp_Otp_User_Meta::get_instance( $user->ID );

noplanman's avatar
noplanman committed
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161
		$otp = $this->get_otp_if_enabled( $user_meta_data );
		if ( null === $otp ) {
			return;
		}

		// Check if no OTP code has been added.
		if ( wp_check_password( $password, $user->user_pass, $user->ID ) ) {
			// To prevent a login without OTP, modify password.
			$password .= '_nootp';
			return;
		}

		// First let's check for a valid OTP code input.
		$otp_code = substr( $password, - 6 );
		$tmp_pass = substr( $password, 0, - 6 );
		if ( wp_check_password( $tmp_pass, $user->user_pass, $user->ID ) && $this->verify_otp( $otp, $otp_code ) ) {
			$password = $tmp_pass;
			return;
		}

		// Then check if it's a recovery code.
		$recovery_codes = $user_meta_data->get( 'recovery_codes' );
		foreach ( array_keys( array_filter( $recovery_codes ) ) as $recovery_code ) {
			$otp_code = substr( $password, - strlen( $recovery_code ) );
			if ( $otp_code !== $recovery_code ) {
				continue;
			}

			$tmp_pass = substr( $password, 0, - strlen( $recovery_code ) );
			if ( wp_check_password( $tmp_pass, $user->user_pass, $user->ID ) ) {
				// Unset the recovery code that has just been used.
				$recovery_codes[ $otp_code ] = false;
				$user_meta_data->set( 'recovery_codes', $recovery_codes, true );
				$password = $tmp_pass;
				return;
noplanman's avatar
noplanman committed
162 163
			}
		}
noplanman's avatar
noplanman committed
164 165 166 167 168
	}

	/**
	 * Get the TOTP object if applicable for this user.
	 *
noplanman's avatar
noplanman committed
169
	 * @since 0.3.0
noplanman's avatar
noplanman committed
170 171 172 173 174
	 *
	 * @param Wp_Otp_User_Meta $user_meta_data
	 *
	 * @return TOTP
	 */
175
	private function get_otp_if_enabled( $user_meta_data ): TOTP {
noplanman's avatar
noplanman committed
176
		if ( $user_meta_data->get( 'enabled' ) && null !== $user_meta_data->get( 'secret' ) ) {
177
			return TOTP::create( $user_meta_data->get( 'secret' ) );
noplanman's avatar
noplanman committed
178 179 180 181 182 183 184 185
		}

		return null;
	}

	/**
	 * Verify the OTP code using the passed TOTP object.
	 *
noplanman's avatar
noplanman committed
186
	 * @since 0.3.0
noplanman's avatar
noplanman committed
187 188 189 190 191 192
	 *
	 * @param TOTP   $otp
	 * @param string $otp_code
	 *
	 * @return bool
	 */
193
	private function verify_otp( $otp, $otp_code ): bool {
noplanman's avatar
noplanman committed
194 195 196 197 198 199 200 201
		/**
		 * Filter for the OTP code expiration window.
		 *
		 * @since 0.1.0
		 *
		 * @param string $otp_window
		 */
		$otp_window = (int) apply_filters( 'wp_otp_code_expiration_window', 2 );
noplanman's avatar
noplanman committed
202

noplanman's avatar
noplanman committed
203
		return $otp->verify( $otp_code, null, $otp_window );
noplanman's avatar
noplanman committed
204 205
	}
}