class-wp-otp-public.php 5.47 KB
Newer Older
noplanman's avatar
noplanman committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14
<?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;
15
use OTPHP\TOTPInterface;
noplanman's avatar
noplanman committed
16 17 18 19 20 21 22 23 24 25 26 27 28 29
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
	 */
30
	public function login_form_render(): void {
noplanman's avatar
noplanman committed
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
		/**
		 * 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>
noplanman's avatar
noplanman committed
56
			<label for="wp-otp-code"><?php echo wp_kses_data( $otp_text ); ?></label><br/>
57
			<?php '' !== $otp_text_sub && print wp_kses_data( sprintf( '<em>%s</em>', $otp_text_sub ) ); ?>
noplanman's avatar
noplanman committed
58
			<input type="text" class="input" name="wp-otp-code" id="wp-otp-code"/>
noplanman's avatar
noplanman committed
59 60 61 62 63 64 65
		</p>
		<?php
	}

	/**
	 * Validation of the user login, to check if the OTP was correct.
	 *
noplanman's avatar
noplanman committed
66
	 * @param null|WP_User|WP_Error $user The user that's trying to log in.
noplanman's avatar
noplanman committed
67 68 69 70 71 72 73 74
	 *
	 * @return WP_Error|WP_User
	 */
	public function login_form_validate( $user ) {
		if ( ! $user instanceof WP_User ) {
			return $user;
		}

noplanman's avatar
noplanman committed
75 76 77 78 79 80
		$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;
		}
81 82 83

		// We can safely ignore the PHPCS error here, as this gets handled by WP.
		// phpcs:ignore WordPress.Security.NonceVerification.Missing
noplanman's avatar
noplanman committed
84
		$otp_code = sanitize_key( $_POST['wp-otp-code'] ?? '' );
noplanman's avatar
noplanman committed
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99

		// 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
100 101 102 103 104 105 106 107 108 109 110 111
		/**
		 * 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
112 113 114 115 116 117
		return new WP_Error( 'invalid_otp', $otp_invalid_code_text );
	}

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

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

noplanman's avatar
noplanman committed
131 132 133 134 135 136 137 138 139 140 141 142 143
		$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.
144 145
		$otp_code = substr( $password, -6 );
		$tmp_pass = substr( $password, 0, -6 );
noplanman's avatar
noplanman committed
146
		if ( wp_check_password( $tmp_pass, $user->user_pass, $user->ID ) && $this->verify_otp( $otp, $otp_code ) ) {
noplanman's avatar
noplanman committed
147
			$password = (string) $tmp_pass;
noplanman's avatar
noplanman committed
148 149 150 151 152 153
			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 ) {
154
			$otp_code = substr( $password, -strlen( $recovery_code ) );
noplanman's avatar
noplanman committed
155 156 157 158
			if ( $otp_code !== $recovery_code ) {
				continue;
			}

159
			$tmp_pass = substr( $password, 0, -strlen( $recovery_code ) );
noplanman's avatar
noplanman committed
160 161 162 163
			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 );
noplanman's avatar
noplanman committed
164
				$password = (string) $tmp_pass;
noplanman's avatar
noplanman committed
165
				return;
noplanman's avatar
noplanman committed
166 167
			}
		}
noplanman's avatar
noplanman committed
168 169 170 171 172
	}

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

		return null;
	}

	/**
	 * Verify the OTP code using the passed TOTP object.
	 *
noplanman's avatar
noplanman committed
190
	 * @since 0.3.0
noplanman's avatar
noplanman committed
191
	 *
192 193
	 * @param TOTPInterface $otp      OTP object.
	 * @param string        $otp_code OTP code to be verified.
noplanman's avatar
noplanman committed
194 195 196
	 *
	 * @return bool
	 */
noplanman's avatar
noplanman committed
197
	private function verify_otp( TOTPInterface $otp, string $otp_code ): bool {
noplanman's avatar
noplanman committed
198 199 200 201 202 203 204 205
		/**
		 * 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
206

noplanman's avatar
noplanman committed
207
		return $otp->verify( $otp_code, null, $otp_window );
noplanman's avatar
noplanman committed
208 209
	}
}