class-wp-otp-admin.php 13.1 KB
Newer Older
noplanman's avatar
noplanman committed
1 2 3 4 5 6 7 8 9 10 11
<?php
/**
 * The admin-specific functionality of the plugin
 *
 * @package    Wp_Otp
 * @subpackage Admin
 * @since      0.1.0
 */

namespace Wp_Otp;

noplanman's avatar
noplanman committed
12 13
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
noplanman's avatar
noplanman committed
14
use OTPHP\TOTP;
15
use ParagonIE\ConstantTime\Base32;
16
use Throwable;
noplanman's avatar
noplanman committed
17 18 19 20 21 22 23 24
use WP_User;

/**
 * The admin-specific functionality of the plugin.
 *
 * @since 0.1.0
 */
class Wp_Otp_Admin {
25 26 27 28 29 30 31 32

	/**
	 * Register the stylesheets for the admin area.
	 *
	 * @since 1.0.0
	 *
	 * @param string $hook Page on which this hook is called.
	 */
noplanman's avatar
noplanman committed
33
	public function enqueue_styles( string $hook ): void {
34
		if ( 'profile.php' === $hook ) {
35
			wp_enqueue_style( WP_OTP_SLUG . '-admin', plugin_dir_url( __FILE__ ) . 'css/wp-otp-admin.css', [], WP_OTP_VERSION );
36 37 38 39 40 41 42 43 44 45
		}
	}

	/**
	 * Register the scripts for the admin area.
	 *
	 * @since 1.0.0
	 *
	 * @param string $hook Page on which this hook is called.
	 */
noplanman's avatar
noplanman committed
46
	public function enqueue_scripts( string $hook ): void {
47 48 49
		if ( 'profile.php' === $hook ) {
			$handle = WP_OTP_SLUG . '-admin';

50
			wp_enqueue_script( $handle, plugin_dir_url( __FILE__ ) . 'js/wp-otp-admin.js', [ 'jquery' ], WP_OTP_VERSION, true );
51
			wp_localize_script( $handle, 'wp_otp', [
52 53
				'confirm_reconfigure'        => __( 'Are you sure you want to reconfigure WP-OTP?', 'wp-otp' ),
				'confirm_new_recovery_codes' => __( 'Are you sure you want to regenerate your recovery codes?', 'wp-otp' ),
54 55 56 57
			] );
		}
	}

noplanman's avatar
noplanman committed
58 59 60 61 62
	/**
	 * Check and save the OTP data when saving the user profile.
	 *
	 * @since 0.1.0
	 *
63
	 * @param int $user_id WordPress User ID.
noplanman's avatar
noplanman committed
64 65 66
	 *
	 * @return void
	 */
noplanman's avatar
noplanman committed
67
	public function user_profile_updated( int $user_id ): void {
noplanman's avatar
noplanman committed
68 69 70 71
		if ( ! current_user_can( 'edit_user', $user_id ) ) {
			return;
		}

72
		check_admin_referer( 'wp_otp_nonce', 'wp_otp_nonce' );
73

noplanman's avatar
noplanman committed
74 75 76 77
		$user = get_userdata( $user_id );

		$user_meta_data = Wp_Otp_User_Meta::get_instance();

78 79
		// Get the secret.
		$secret = $user_meta_data->get( 'secret', $this->get_random_secret() );
noplanman's avatar
noplanman committed
80

81 82
		$otp = TOTP::create( $secret );
		$otp->setLabel( $user->user_login );
noplanman's avatar
noplanman committed
83

noplanman's avatar
noplanman committed
84
		$otp_code = sanitize_key( $_POST['wp-otp-code'] ?? '' );
85
		if ( $otp_code && ! $user_meta_data->get( 'enabled', false ) ) {
86
			/** Filter documented in class-wp-otp-public.php */
noplanman's avatar
noplanman committed
87 88
			$otp_window = (int) apply_filters( 'wp_otp_code_expiration_window', 2 );

89 90
			if ( $otp->verify( $otp_code, null, $otp_window ) ) {
				$otp_recovery_codes = $this->get_random_recovery_codes();
91
				$user_meta_data->set_all( [
92 93 94
					'enabled'        => true,
					'recovery_codes' => $otp_recovery_codes,
					'notice'         => [
noplanman's avatar
noplanman committed
95 96 97
						'type'     => 'success',
						'messages' => [
							'<strong>' . __( 'WP-OTP configured successfully!', 'wp-otp' ) . '</strong>',
98
							__( 'If you change your phone or do not have access to the OTP Authenticator app you can use the following codes as One Time Passwords on your login screen and then reconfigure WP-OTP.', 'wp-otp' ),
99 100
							'<br>' . __( 'Keep these codes secret!', 'wp-otp' ),
							implode( '<br>', array_keys( $otp_recovery_codes ) ),
noplanman's avatar
noplanman committed
101 102 103 104
						],
					],
				] );
			} else {
105 106
				Wp_Otp_User_Meta::clear();
				$user_meta_data->set_all( [
noplanman's avatar
noplanman committed
107 108 109 110 111 112 113 114 115 116
					'notice' => [
						'type'     => 'error',
						'messages' => [
							'<strong>' . __( 'WP-OTP configuration failed.', 'wp-otp' ) . '</strong>',
							__( 'The One Time Password entered was invalid! Please try again.', 'wp-otp' ),
						],
					],
				] );
			}

117
			$user_meta_data->set( 'secret', $secret, true );
noplanman's avatar
noplanman committed
118 119 120 121 122 123 124 125
		}
	}

	/**
	 * Check if the OTP is being deleted and reconfigured.
	 *
	 * @since 0.1.0
	 */
126
	public function admin_init(): void {
127 128
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( 'yes' === ( sanitize_key( $_GET['wp-otp-reconfigure'] ?? '' ) ) ) {
129
			Wp_Otp_User_Meta::clear();
noplanman's avatar
noplanman committed
130
			wp_safe_redirect( get_edit_profile_url() . '#wp-otp' );
noplanman's avatar
noplanman committed
131 132
			exit;
		}
133

134 135
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( 'yes' === ( sanitize_key( $_GET['wp-otp-new-recovery-codes'] ?? '' ) ) ) {
136 137 138 139 140 141 142 143 144 145 146 147 148 149
			$otp_recovery_codes = $this->get_random_recovery_codes();
			Wp_Otp_User_Meta::get_instance()->set_all( [
				'recovery_codes' => $otp_recovery_codes,
				'notice'         => [
					'type'     => 'success',
					'messages' => [
						'<strong>' . __( 'WP-OTP recovery codes regenerated!', 'wp-otp' ) . '</strong>',
						__( 'Here are your new recovery codes.', 'wp-otp' ),
						'<br>' . __( 'Keep these codes secret!', 'wp-otp' ),
						implode( '<br>', array_keys( $otp_recovery_codes ) ),
					],
				],
			], true );

150
			wp_safe_redirect( get_edit_profile_url() );
151 152
			exit;
		}
noplanman's avatar
noplanman committed
153 154
	}

155 156 157 158 159 160 161 162 163 164 165 166
	/**
	 * Get a set of random recovery codes.
	 *
	 * Returns an array in the format [ 'code_1' => true, ...,'code_n' => true ]
	 *
	 * @since 0.1.0
	 *
	 * @param null|int $codes_count_override  Override the filter and default for the codes count.
	 * @param null|int $codes_length_override Override the filter and default for the codes length.
	 *
	 * @return array
	 */
167
	public function get_random_recovery_codes( $codes_count_override = null, $codes_length_override = null ): array {
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188
		/**
		 * Filter for the number of random recovery codes to generate (between 1 and 20).
		 *
		 * @since 0.1.0
		 *
		 * @param int $codes_count
		 */
		$codes_count = $codes_count_override ?: (int) apply_filters( 'wp_otp_recovery_codes_count', 5 );
		$codes_count = min( max( 1, $codes_count ), 20 );

		/**
		 * Filter for the length of the random recovery codes to generate (between 8 and 64).
		 *
		 * @since 0.1.0
		 *
		 * @param int $codes_length
		 */
		$codes_length = $codes_length_override ?: (int) apply_filters( 'wp_otp_recovery_codes_length', 16 );
		$codes_length = min( max( 8, $codes_length ), 64 );

		$codes = [];
189
		// phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found
190
		while ( count( $codes ) < $codes_count ) {
191
			$code = $this->get_random_hash( $codes_length );
192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208
			if ( ! array_key_exists( $code, $codes ) ) {
				$codes[ $code ] = true;
			}
		}

		return $codes;
	}

	/**
	 * Get a new random OTP secret.
	 *
	 * @since 0.1.0
	 *
	 * @param null|int $secret_length_override Override the filter and default for the codes count.
	 *
	 * @return string
	 */
209
	public function get_random_secret( $secret_length_override = null ): string {
210 211 212 213 214 215 216
		/**
		 * Filter for the length of the secret to be generated (between 8 and 64).
		 *
		 * @since 0.1.0
		 *
		 * @param int $secret_length
		 */
217
		$secret_length = $secret_length_override ?: (int) apply_filters( 'wp_otp_secret_length', 16 );
218 219
		$secret_length = min( max( 8, $secret_length ), 64 );

220 221 222 223 224 225
		return $this->get_random_hash( $secret_length );
	}

	/**
	 * Get a random hash string of up to 100 characters.
	 *
noplanman's avatar
noplanman committed
226
	 * @since 0.5.0
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243
	 *
	 * @param int $length Length of the random hash (max 100).
	 *
	 * @return string
	 */
	public function get_random_hash( $length = 0 ): string {
		try {
			$random_hash = Base32::encode( random_bytes( 64 ) );
		} catch ( Throwable $e ) {
			$random_hash = Base32::encode( md5( microtime( true ) ) . md5( microtime( true ) ) );
		}

		if ( $length <= 0 ) {
			return substr( $random_hash, 0, 100 );
		}

		return substr( $random_hash, 0, min( $length, 100 ) );
244 245
	}

noplanman's avatar
noplanman committed
246 247 248 249 250
	/**
	 * Render the WP-OTP section on the user's profile edit screen.
	 *
	 * @since 0.1.0
	 *
251
	 * @param WP_User $user WordPress User Object.
noplanman's avatar
noplanman committed
252
	 */
noplanman's avatar
noplanman committed
253
	public function user_profile_render( WP_User $user ): void {
noplanman's avatar
noplanman committed
254 255
		$user_meta_data = Wp_Otp_User_Meta::get_instance();

256 257 258
		// Get and save the secret.
		$secret = $user_meta_data->get( 'secret', $this->get_random_secret() );
		$user_meta_data->set( 'secret', $secret, true );
noplanman's avatar
noplanman committed
259

260 261
		$otp = TOTP::create( $secret );
		$otp->setLabel( $user->user_login );
noplanman's avatar
noplanman committed
262

263 264
		// Issuer isn't allowed to have any colon.
		$otp->setIssuer( str_replace( [ ':', '%3a', '%3A' ], '', get_bloginfo( 'name' ) ) );
noplanman's avatar
noplanman committed
265

noplanman's avatar
noplanman committed
266
		$qr_code_provisioning_uri_default = 'https://api.qrserver.com/v1/create-qr-code/?data={PROVISIONING_URI}&qzone=2';
noplanman's avatar
noplanman committed
267 268 269 270 271 272 273
		/**
		 * Filter for the OTP QR code provisioning URI.
		 *
		 * Set a custom QR code provisioning URI which has a data placeholder of {PROVISIONING_URI}.
		 *
		 * @since 0.1.0
		 *
274
		 * @param string $qr_code_provisioning_uri
noplanman's avatar
noplanman committed
275
		 */
noplanman's avatar
noplanman committed
276 277 278 279 280 281 282 283 284
		$qr_code_provisioning_uri = apply_filters( 'wp_otp_qr_code_provisioning_uri', $qr_code_provisioning_uri_default );

		// If no custom provisioning URI is set, opt for internal QR code processing, if possible.
		if ( $qr_code_provisioning_uri === $qr_code_provisioning_uri_default ) {
			try {
				$qr_code_options     = new QROptions( [ 'quietzoneSize' => 2 ] );
				$qr_code             = new QRCode( $qr_code_options );
				$otp_qr_code_raw_uri = $otp->getProvisioningUri();
				$otp_qr_code_img_uri = $qr_code->render( $otp_qr_code_raw_uri );
285 286
			} catch ( Throwable $e ) {
				$otp_qr_code_img_uri = null;
noplanman's avatar
noplanman committed
287 288
			}
		}
noplanman's avatar
noplanman committed
289

290 291 292 293
		if ( ! isset( $otp_qr_code_img_uri ) ) {
			$otp_qr_code_img_uri = $otp->getQrCodeUri( $qr_code_provisioning_uri, '{PROVISIONING_URI}' );
		}

294
		$otp_enabled = $user_meta_data->get( 'enabled' );
noplanman's avatar
noplanman committed
295 296

		$otp_apps = [
297 298 299 300 301 302 303
			[
				'name'           => 'Aegis Authenticator',
				'uri'            => 'https://getaegis.app/',
				'uri_play_store' => 'https://play.google.com/store/apps/details?id=com.beemdevelopment.aegis',
				'uri_f_droid'    => 'https://f-droid.org/en/packages/com.beemdevelopment.aegis',
				'uri_logo'       => plugins_url( 'images/aegis.png', __FILE__ ),
			],
noplanman's avatar
noplanman committed
304
			[
noplanman's avatar
noplanman committed
305 306 307 308 309
				'name'           => 'andOTP',
				'uri'            => 'https://github.com/andOTP/andOTP',
				'uri_play_store' => 'https://play.google.com/store/apps/details?id=org.shadowice.flocke.andotp',
				'uri_f_droid'    => 'https://f-droid.org/packages/org.shadowice.flocke.andotp',
				'uri_logo'       => plugins_url( 'images/andotp.png', __FILE__ ),
noplanman's avatar
noplanman committed
310
			],
311 312 313 314 315 316
			[
				'name'        => 'OneTimePass',
				'uri'         => 'https://github.com/OneTimePass/OneTimePass',
				'uri_f_droid' => 'https://f-droid.org/en/packages/com.github.onetimepass',
				'uri_logo'    => plugins_url( 'images/onetimepass.png', __FILE__ ),
			],
noplanman's avatar
noplanman committed
317
			[
noplanman's avatar
noplanman committed
318 319 320 321 322 323 324 325 326 327 328 329 330
				'name'           => 'FreeOTP+',
				'uri'            => 'https://github.com/helloworld1/FreeOTPPlus',
				'uri_play_store' => 'https://play.google.com/store/apps/details?id=org.liberty.android.freeotpplus',
				'uri_f_droid'    => 'https://f-droid.org/en/packages/org.liberty.android.freeotpplus',
				'uri_logo'       => plugins_url( 'images/freeotpplus.png', __FILE__ ),
			],
			[
				'name'           => 'OTP Authenticator',
				'uri'            => 'https://www.swiss-safelab.com/en-us/products/otpauthenticator.aspx',
				'uri_app_store'  => 'https://itunes.apple.com/us/app/otp-authenticator/id915359210',
				'uri_play_store' => 'https://www.swiss-safelab.com/de-de/community/downloadcenter.aspx?Command=Core_Download&EntryId=684',
				'uri_logo'       => plugins_url( 'images/otp-authenticator.png', __FILE__ ),
			],
noplanman's avatar
noplanman committed
331 332 333
		];

		$app_providers = [
334 335 336 337 338 339 340 341 342 343 344 345
			'f_droid'    => [
				'name'     => 'F-Droid',
				'uri_logo' => plugins_url( 'images/f-droid.png', __FILE__ ),
			],
			'play_store' => [
				'name'     => 'Play Store',
				'uri_logo' => plugins_url( 'images/play-store.png', __FILE__ ),
			],
			'app_store'  => [
				'name'     => 'App Store',
				'uri_logo' => plugins_url( 'images/app-store.png', __FILE__ ),
			],
noplanman's avatar
noplanman committed
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360
		];

		include __DIR__ . '/partials/wp-otp-profile-display.php';
	}

	/**
	 * Show the user a notification.
	 *
	 * @since 0.1.0
	 *
	 * @param array  $messages List of messages to be displayed.
	 * @param string $type     Type of notification to show (notice (default), success, error).
	 *
	 * @return void
	 */
361
	public function show_user_notification( array $messages, $type = 'notice' ): void {
362
		if ( empty( $messages ) ) {
noplanman's avatar
noplanman committed
363 364 365 366 367 368 369 370 371 372 373
			return;
		}

		$classes = [
			'notice'  => 'update-nag',
			'success' => 'updated',
			'error'   => 'error',
		];
		$class   = $classes[ array_key_exists( $type, $classes ) ? $type : 'notice' ];
		?>
		<div id="message" class="<?php echo esc_attr( $class ); ?>">
374
			<p><?php echo implode( '<br>', $messages ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></p>
noplanman's avatar
noplanman committed
375 376 377 378 379 380 381 382 383 384 385
		</div>
		<?php
	}

	/**
	 * Display any saved admin notices.
	 *
	 * These notices are saved to the user meta and get cleared after showing.
	 *
	 * @since 0.1.0
	 */
386
	public function admin_notices(): void {
noplanman's avatar
noplanman committed
387 388
		$user_meta_data = Wp_Otp_User_Meta::get_instance();

389 390 391 392 393
		if ( $user_meta_data->get( 'enabled' ) ) {
			$recovery_codes       = array_filter( $user_meta_data->get( 'recovery_codes' ) );
			$recovery_codes_count = count( $recovery_codes );
			if ( $recovery_codes_count < 3 ) {
				$this->show_user_notification( [
394
					'<strong>' . esc_html__( 'Important', 'wp-otp' ) . '</strong>',
395
					sprintf(
396
						_n( // phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment
397 398 399 400 401 402 403 404 405
							'You have %d WP-OTP recovery code left. You should generate new ones.',
							'You have %d WP-OTP recovery codes left. You should generate new ones.',
							$recovery_codes_count,
							'wp-otp'
						),
						$recovery_codes_count
					),
					sprintf(
						'<a href="%1$s" class="button">%2$s</a>',
406 407
						esc_url( add_query_arg( 'wp-otp-new-recovery-codes', 'yes', get_edit_profile_url() ) ),
						esc_html_x( 'Regenerate', 'Link to regenerate the WP-OTP recovery codes', 'wp-otp' )
408 409 410
					),
				], 'error' );
			}
noplanman's avatar
noplanman committed
411 412
		}

413 414
		$notice = $user_meta_data->get( 'notice' );
		if ( $notice ) {
noplanman's avatar
noplanman committed
415 416 417 418 419 420
			$this->show_user_notification(
				(array) $notice['messages'],
				$notice['type']
			);

			// Remove any notices from the user meta.
421
			$user_meta_data->set( 'notice', null, true );
noplanman's avatar
noplanman committed
422 423 424
		}
	}
}