PluginProbe
Authorizer / 3.13.0
Authorizer v3.13.0
3.15.3 3.15.2 3.15.1 3.15.0 3.14.3 3.14.4 3.14.2 3.14.1 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.9.0 2.9.1 2.9.10 2.9.11 2.9.12 2.9.13 2.9.2 2.9.3 2.9.6 All 126 releases
← All changes | src/authorizer/class-authentication.php +1582 -151 2.9.13.13.0 View file →
@@ -16,11 +16,18 @@
16 16 /**
17 17 * Implements the authentication (is user who they say they are?) features of
18 18 * the plugin.
19 19 */
20 -class Authentication extends Static_Instance {
20 +class Authentication extends Singleton {
21 21
22 22 /**
23 + * Tracks the external service used by the user currently logging out.
24 + *
25 + * @var string
26 + */
27 + private static $authenticated_by = '';
28 +
29 + /**
23 30 * Authenticate against an external service.
24 31 *
25 32 * Filter: authenticate
26 33 *
@@ -59,8 +66,9 @@
59 66 // do this to preserve any content they created, but here we should
60 67 // pretend they don't exist).
61 68 if ( $unauthenticated_user_is_blocked ) {
62 69 remove_filter( 'authenticate', 'wp_authenticate_username_password', 20, 3 );
70 + remove_filter( 'authenticate', 'wp_authenticate_email_password', 20, 3 );
63 71 return new \WP_Error( 'empty_password', __( '<strong>ERROR</strong>: Incorrect username or password.', 'authorizer' ) );
64 72 }
65 73
66 74 // Grab plugin settings.
@@ -68,19 +76,19 @@
68 76 $auth_settings = $options->get_all( Helper::SINGLE_CONTEXT, 'allow override' );
69 77
70 78 // Make sure $last_attempt (time) and $num_attempts are positive integers.
71 79 // Note: this addresses resetting them if either is unset from above.
72 - $last_attempt = abs( intval( $last_attempt ) );
73 - $num_attempts = abs( intval( $num_attempts ) );
80 + $last_attempt = absint( $last_attempt );
81 + $num_attempts = absint( $num_attempts );
74 82
75 83 // Create semantic lockout variables.
76 84 $lockouts = $auth_settings['advanced_lockouts'];
77 85 $time_since_last_fail = time() - $last_attempt;
78 - $reset_duration = $lockouts['reset_duration'] * 60; // minutes to seconds.
79 - $num_attempts_long_lockout = $lockouts['attempts_1'] + $lockouts['attempts_2'];
80 - $num_attempts_short_lockout = $lockouts['attempts_1'];
81 - $seconds_remaining_long_lockout = $lockouts['duration_2'] * 60 - $time_since_last_fail;
82 - $seconds_remaining_short_lockout = $lockouts['duration_1'] * 60 - $time_since_last_fail;
86 + $reset_duration = absint( $lockouts['reset_duration'] ) * 60; // minutes to seconds.
87 + $num_attempts_long_lockout = absint( $lockouts['attempts_1'] ) + absint( $lockouts['attempts_2'] );
88 + $num_attempts_short_lockout = absint( $lockouts['attempts_1'] );
89 + $seconds_remaining_long_lockout = absint( $lockouts['duration_2'] ) * 60 - $time_since_last_fail;
90 + $seconds_remaining_short_lockout = absint( $lockouts['duration_1'] ) * 60 - $time_since_last_fail;
83 91
84 92 // Check if we need to institute a lockout delay.
85 93 if ( $is_login_attempt && $time_since_last_fail > $reset_duration ) {
86 94 // Enough time has passed since the last invalid attempt and
@@ -92,8 +100,9 @@
92 100 // Note: set the error code to 'empty_password' so it doesn't
93 101 // trigger the wp_login_failed hook, which would continue to
94 102 // increment the failed attempt count.
95 103 remove_filter( 'authenticate', 'wp_authenticate_username_password', 20, 3 );
104 + remove_filter( 'authenticate', 'wp_authenticate_email_password', 20, 3 );
96 105 return new \WP_Error(
97 106 'empty_password',
98 107 sprintf(
99 108 /* TRANSLATORS: 1: username 2: duration of lockout in seconds 3: duration of lockout as a phrase 4: lost password URL */
@@ -109,8 +118,9 @@
109 118 // Note: set the error code to 'empty_password' so it doesn't
110 119 // trigger the wp_login_failed hook, which would continue to
111 120 // increment the failed attempt count.
112 121 remove_filter( 'authenticate', 'wp_authenticate_username_password', 20, 3 );
122 + remove_filter( 'authenticate', 'wp_authenticate_email_password', 20, 3 );
113 123 return new \WP_Error(
114 124 'empty_password',
115 125 sprintf(
116 126 /* TRANSLATORS: 1: username 2: duration of lockout in seconds 3: duration of lockout as a phrase 4: lost password URL */
@@ -127,8 +137,44 @@
127 137 $externally_authenticated_emails = array();
128 138 $authenticated_by = '';
129 139 $result = null;
130 140
141 + // Try OAuth2 authentication if it's enabled and we don't have a
142 + // successful login yet.
143 + if (
144 + '1' === $auth_settings['oauth2'] &&
145 + 0 === count( $externally_authenticated_emails ) &&
146 + ! is_wp_error( $result )
147 + ) {
148 + $result = $this->custom_authenticate_oauth2( $auth_settings );
149 + if ( ! is_null( $result ) && ! is_wp_error( $result ) ) {
150 + if ( is_array( $result['email'] ) ) {
151 + $externally_authenticated_emails = $result['email'];
152 + } else {
153 + $externally_authenticated_emails[] = $result['email'];
154 + }
155 + $authenticated_by = $result['authenticated_by'];
156 + }
157 + }
158 +
159 + // Try OIDC authentication if it's enabled and we don't have a
160 + // successful login yet.
161 + if (
162 + '1' === $auth_settings['oidc'] &&
163 + 0 === count( $externally_authenticated_emails ) &&
164 + ! is_wp_error( $result )
165 + ) {
166 + $result = $this->custom_authenticate_oidc( $auth_settings );
167 + if ( ! is_null( $result ) && ! is_wp_error( $result ) ) {
168 + if ( is_array( $result['email'] ) ) {
169 + $externally_authenticated_emails = $result['email'];
170 + } else {
171 + $externally_authenticated_emails[] = $result['email'];
172 + }
173 + $authenticated_by = $result['authenticated_by'];
174 + }
175 + }
176 +
131 177 // Try Google authentication if it's enabled and we don't have a
132 178 // successful login yet.
133 179 if (
134 180 '1' === $auth_settings['google'] &&
@@ -181,11 +227,57 @@
181 227 $authenticated_by = $result['authenticated_by'];
182 228 }
183 229 }
184 230
185 - // Skip to WordPress authentication if we don't have an externally
186 - // authenticated user.
231 + // If we don't have an externally authenticated user, either skip to
232 + // WordPress authentication (if WordPress logins are enabled), or return
233 + // an error (if WordPress logins are disabled and at least one external
234 + // service is enabled).
187 235 if ( count( array_filter( $externally_authenticated_emails ) ) < 1 ) {
236 + if (
237 + array_key_exists( 'advanced_disable_wp_login', $auth_settings ) &&
238 + '1' === $auth_settings['advanced_disable_wp_login'] &&
239 + (
240 + '1' === $auth_settings['oauth2'] ||
241 + '1' === $auth_settings['oidc'] ||
242 + '1' === $auth_settings['google'] ||
243 + '1' === $auth_settings['cas'] ||
244 + '1' === $auth_settings['ldap']
245 + )
246 + ) {
247 + // Edge case: if WordPress logins are disabled but the username/email
248 + // attempting to login has been added to the list of users allowed to
249 + // bypass disabled logins, then allow the login (proceed to WordPress
250 + // authentication).
251 + if ( ! empty( $auth_settings['advanced_disable_wp_login_bypass_usernames'] ) ) {
252 + $bypass_usernames = explode( "\n", str_replace( "\r", '', $auth_settings['advanced_disable_wp_login_bypass_usernames'] ) );
253 + $bypass_users = get_users( array(
254 + 'login__in' => $bypass_usernames,
255 + 'count_total' => false,
256 + ) );
257 + foreach ( $bypass_users as $bypass_user ) {
258 + if ( $bypass_user->user_login === $username || $bypass_user->user_email === $username ) {
259 + return null;
260 + }
261 + }
262 + }
263 +
264 + remove_filter( 'authenticate', 'wp_authenticate_username_password', 20, 3 );
265 + remove_filter( 'authenticate', 'wp_authenticate_email_password', 20, 3 );
266 +
267 + $error = new \WP_Error();
268 +
269 + if ( empty( $username ) ) {
270 + $error->add( 'empty_username', __( '<strong>ERROR</strong>: The username field is empty.', 'authorizer' ) );
271 + }
272 +
273 + if ( empty( $password ) ) {
274 + $error->add( 'empty_password', __( '<strong>ERROR</strong>: The password field is empty.', 'authorizer' ) );
275 + }
276 +
277 + return $error;
278 + }
279 +
188 280 return $result;
189 281 }
190 282
191 283 // Remove duplicate and blank emails, if any.
@@ -199,14 +291,33 @@
199 291 */
200 292
201 293 // Look for an existing WordPress account matching the externally
202 294 // authenticated user. Perform the match either by username or email.
203 - if ( isset( $auth_settings['cas_link_on_username'] ) && 1 === intval( $auth_settings['cas_link_on_username'] ) ) {
295 + $link_on_username = false;
296 + if ( 'cas' === $authenticated_by ) {
297 + // Check the specific CAS server's link_on_username setting.
298 + $cas_server_id = isset( $result['cas_server_id'] ) ? intval( $result['cas_server_id'] ) : 1;
299 + $suffix = $cas_server_id > 1 ? '_' . $cas_server_id : '';
300 + $cas_link_on_username_key = 'cas_link_on_username' . $suffix;
301 + if ( isset( $auth_settings[ $cas_link_on_username_key ] ) && 1 === intval( $auth_settings[ $cas_link_on_username_key ] ) ) {
302 + $link_on_username = true;
303 + }
304 + } elseif ( 'oidc' === $authenticated_by ) {
305 + // Check the specific OIDC server's link_on_username setting.
306 + $oidc_server_id = isset( $result['oidc_server_id'] ) ? intval( $result['oidc_server_id'] ) : 1;
307 + $suffix = $oidc_server_id > 1 ? '_' . $oidc_server_id : '';
308 + $oidc_link_on_username_key = 'oidc_link_on_username' . $suffix;
309 + if ( isset( $auth_settings[ $oidc_link_on_username_key ] ) && 1 === intval( $auth_settings[ $oidc_link_on_username_key ] ) ) {
310 + $link_on_username = true;
311 + }
312 + }
313 +
314 + if ( $link_on_username ) {
204 315 // Get the external user's WordPress account by username. This is less
205 316 // secure, but a user reported having an installation where a previous
206 317 // CAS plugin had created over 9000 WordPress accounts without email
207 318 // addresses. This option was created to support that case, and any
208 - // other CAS servers where emails are not used as account identifiers.
319 + // other CAS/OIDC servers where emails are not used as account identifiers.
209 320 $user = get_user_by( 'login', $result['username'] );
210 321 } else {
211 322 // Get the external user's WordPress account by email address. This is
212 323 // the normal behavior (and the most secure).
@@ -218,27 +329,60 @@
218 329 }
219 330 }
220 331 }
221 332
222 - // We'll track how this user was authenticated in user meta.
223 - if ( $user ) {
224 - update_user_meta( $user->ID, 'authenticated_by', $authenticated_by );
333 + // Always clean up OIDC session variables after authentication attempt,
334 + // regardless of user lookup result. This prevents session pollution if OIDC
335 + // authentication succeeds but user verification fails.
336 + // Note: oidc_redirect_to is preserved here and cleaned up later in
337 + // maybe_redirect_after_oidc_login() after it's used for the login redirect.
338 + if ( 'oidc' === $authenticated_by && PHP_SESSION_NONE !== session_status() ) {
339 + unset( $_SESSION['oidc_server_id'] );
225 340 }
226 341
227 342 // Check this external user's access against the access lists
228 343 // (pending, approved, blocked).
229 - $result = Authorization::get_instance()->check_user_access( $user, $externally_authenticated_emails, $result );
344 + $check_user_access_result = Authorization::get_instance()->check_user_access(
345 + $user,
346 + $externally_authenticated_emails,
347 + $result
348 + );
230 349
231 350 // Fail with message if there was an error creating/adding the user.
232 - if ( is_wp_error( $result ) || 0 === $result ) {
233 - return $result;
351 + if ( is_wp_error( $check_user_access_result ) || 0 === $check_user_access_result ) {
352 + // Clean up oidc_redirect_to if access check fails (redirect filter won't run).
353 + if ( 'oidc' === $authenticated_by && PHP_SESSION_NONE !== session_status() ) {
354 + unset( $_SESSION['oidc_redirect_to'] );
355 + }
356 +
357 + return $check_user_access_result;
234 358 }
235 359
236 360 // If we have a valid user from check_user_access(), log that user in.
237 - if ( get_class( $result ) === 'WP_User' ) {
238 - $user = $result;
361 + if ( get_class( $check_user_access_result ) === 'WP_User' ) {
362 + $user = $check_user_access_result;
239 363 }
240 364
365 + // If this is an OIDC login, update OIDC user meta for the successfully
366 + // logged in user.
367 + if ( $user && 'oidc' === $authenticated_by ) {
368 + // Always store server ID if present (needed to determine which OIDC server was used).
369 + if ( isset( $result['oidc_server_id'] ) ) {
370 + update_user_meta( $user->ID, 'oidc_server_id', intval( $result['oidc_server_id'] ) );
371 + }
372 + // Store ID token only if present and non-empty (needed for RP-initiated logout).
373 + if ( ! empty( $result['oidc_id_token'] ) ) {
374 + update_user_meta( $user->ID, 'oidc_id_token', $result['oidc_id_token'] );
375 + }
376 + }
377 +
378 + // Integration: disable Cloudflare Turnstile verification from the
379 + // simple-cloudflare-turnstile plugin if it is activated (conflicts with
380 + // our redirects from external services). We assume that we dont't need bot
381 + // protection from this plugin after coming back from a successful external
382 + // service authentication.
383 + add_filter( 'cfturnstile_widget_disable', '__return_true' );
384 +
241 385 // If we haven't exited yet, we have a valid/approved user, so authenticate them.
242 386 return $user;
243 387 }
244 388
@@ -243,8 +387,848 @@
243 387 }
244 388
245 389
246 390 /**
391 + * Validate this user's credentials against selected OAuth2 provider.
392 + *
393 + * @param array $auth_settings Plugin settings.
394 + * @return array|WP_Error Array containing email, authenticated_by, first_name,
395 + * last_name, and username strings for the successfully
396 + * authenticated user, or WP_Error() object on failure,
397 + * or null if not attempting an oauth2 login.
398 + */
399 + protected function custom_authenticate_oauth2( $auth_settings ) {
400 + // Move on if oauth2 hasn't been requested here or OAuth2 server ID is invalid.
401 + if ( empty( $auth_settings['oauth2_num_servers'] ) ) {
402 + $auth_settings['oauth2_num_servers'] = 1;
403 + }
404 +
405 + // Workaround: because Azure doesn't let us specify a querystring in a
406 + // redirect_uri, we have to detect those redirects separately because we
407 + // can't include external=oauth2 or id={oauth_server_id} in the URL.
408 + // Instead, detect the absence of the `external` param, and the presence of
409 + // `code` and `state` params.
410 + if ( empty( $_GET['external'] ) && ! empty( $_GET['code'] ) && ! empty( $_GET['state'] ) ) {
411 + // Fetch the OAuth2 server id from the session variable created during the
412 + // initial request.
413 + if ( PHP_SESSION_NONE === session_status() ) {
414 + session_start();
415 + }
416 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
417 + $_GET['id'] = $_SESSION['oauth2_server_id'] ?? 1;
418 + $_GET['external'] = 'oauth2';
419 + }
420 +
421 + // If this is an OAuth2 login attempt and the id param is missing, default
422 + // it to 1.
423 + if ( ! empty( $_GET['external'] ) && 'oauth2' === $_GET['external'] && empty( $_GET['id'] ) ) {
424 + $_GET['id'] = 1;
425 + }
426 +
427 + // phpcs:ignore WordPress.Security.NonceVerification
428 + if ( empty( $_GET['external'] ) || 'oauth2' !== $_GET['external'] || empty( $_GET['id'] ) || ! in_array( intval( $_GET['id'] ), range( 1, 20 ), true ) || intval( $_GET['id'] ) > intval( $auth_settings['oauth2_num_servers'] ) ) {
429 + return null;
430 + }
431 +
432 + // Get the OAuth2 server id (since multiple OAuth2 servers can be configured),
433 + // and the relevant settings for that server.
434 + // phpcs:ignore WordPress.Security.NonceVerification
435 + $oauth2_server_id = empty( $_GET['id'] ) ? 1 : intval( $_GET['id'] );
436 + $suffix = $oauth2_server_id > 1 ? '_' . $oauth2_server_id : '';
437 + $oauth2_provider = $auth_settings[ 'oauth2_provider' . $suffix ] ?? '';
438 + $oauth2_clientid = $auth_settings[ 'oauth2_clientid' . $suffix ] ?? '';
439 + $oauth2_clientsecret = $auth_settings[ 'oauth2_clientsecret' . $suffix ] ?? '';
440 + $oauth2_hosteddomain = $auth_settings[ 'oauth2_hosteddomain' . $suffix ] ?? '';
441 + $oauth2_tenant_id = $auth_settings[ 'oauth2_tenant_id' . $suffix ] ?? '';
442 + $oauth2_url_authorize = $auth_settings[ 'oauth2_url_authorize' . $suffix ] ?? '';
443 + $oauth2_url_token = $auth_settings[ 'oauth2_url_token' . $suffix ] ?? '';
444 + $oauth2_url_resource = $auth_settings[ 'oauth2_url_resource' . $suffix ] ?? '';
445 + $oauth2_attr_username = $auth_settings[ 'oauth2_attr_username' . $suffix ] ?? '';
446 + $oauth2_attr_email = $auth_settings[ 'oauth2_attr_email' . $suffix ] ?? '';
447 + $oauth2_attr_first_name = $auth_settings[ 'oauth2_attr_first_name' . $suffix ] ?? '';
448 + $oauth2_attr_last_name = $auth_settings[ 'oauth2_attr_last_name' . $suffix ] ?? '';
449 +
450 + // Fetch the Oauth2 Client ID (allow overrides from filter or constant).
451 + // Note: constant/filter overrides are only supported for a single OAuth2 server.
452 + if ( defined( 'AUTHORIZER_OAUTH2_CLIENT_ID' ) ) {
453 + $oauth2_clientid = \AUTHORIZER_OAUTH2_CLIENT_ID;
454 + }
455 + /**
456 + * Filters the Oauth2 Client ID used by Authorizer to authenticate.
457 + *
458 + * @since 3.9.0
459 + *
460 + * @param string $oauth2_client_id The stored Oauth2 Client ID.
461 + */
462 + $oauth2_clientid = apply_filters( 'authorizer_oauth2_client_id', $oauth2_clientid );
463 +
464 + // Fetch the Oauth2 Client Secret (allow overrides from filter or constant).
465 + // Note: constant/filter overrides are only supported for a single OAuth2 server.
466 + if ( defined( 'AUTHORIZER_OAUTH2_CLIENT_SECRET' ) ) {
467 + $oauth2_clientsecret = \AUTHORIZER_OAUTH2_CLIENT_SECRET;
468 + }
469 + /**
470 + * Filters the Oauth2 Client Secret used by Authorizer to authenticate.
471 + *
472 + * @since 3.6.1
473 + *
474 + * @param string $oauth2_client_secret The stored Oauth2 Client Secret.
475 + */
476 + $oauth2_clientsecret = apply_filters( 'authorizer_oauth2_client_secret', $oauth2_clientsecret );
477 +
478 + // Move on if required params aren't specified in settings.
479 + if (
480 + empty( $oauth2_clientid ) ||
481 + empty( $oauth2_clientsecret )
482 + ) {
483 + return null;
484 + }
485 +
486 + // Build the redirectUri for the OAuth2 provider to redirect back to.
487 + // Note: omit the id param if it is 1 (default server) for backwards
488 + // compatibility with installations already configured before Authorizer
489 + // supported multiple OAuth2 servers (so it doesn't break existing
490 + // redirectUris authorized on the external service).
491 + $redirect_uri = site_url( '/wp-login.php?external=oauth2' );
492 + if ( $oauth2_server_id > 1 ) {
493 + $redirect_uri .= '&id=' . $oauth2_server_id;
494 + }
495 + if ( 'azure' === $oauth2_provider ) {
496 + // Microsoft Azure does not support querystrings in the redirectUri, so
497 + // we have to use the base wp-login.php URL. We save parameters in the
498 + // session instead (see below).
499 + $redirect_uri = site_url( '/wp-login.php' );
500 + }
501 +
502 + // Authenticate with GitHub.
503 + // See: https://github.com/thephpleague/oauth2-github.
504 + if ( 'github' === $oauth2_provider ) {
505 + if ( PHP_SESSION_NONE === session_status() ) {
506 + session_start();
507 + }
508 + $provider = new \League\OAuth2\Client\Provider\Github( array(
509 + 'clientId' => $oauth2_clientid,
510 + 'clientSecret' => $oauth2_clientsecret,
511 + 'redirectUri' => $redirect_uri,
512 + ) );
513 +
514 + // If we don't have an authorization code, then get one.
515 + if ( ! isset( $_REQUEST['code'] ) ) {
516 + $auth_url = $provider->getAuthorizationUrl( array(
517 + 'scope' => 'user:email',
518 + ) );
519 + $_SESSION['oauth2state'] = $provider->getState();
520 + header( 'Location: ' . $auth_url );
521 + exit;
522 +
523 + } elseif ( empty( $_REQUEST['state'] ) || empty( $_SESSION['oauth2state'] ) || $_REQUEST['state'] !== $_SESSION['oauth2state'] ) {
524 + // Check state against previously stored one to mitigate CSRF attacks.
525 + unset( $_SESSION['oauth2state'] );
526 + exit;
527 +
528 + } else {
529 + // Try to get an access token (using the authorization code grant).
530 + try {
531 + $token = $provider->getAccessToken( 'authorization_code', array(
532 + 'code' => $_REQUEST['code'],
533 + ) );
534 + } catch ( \Exception $e ) {
535 + // Failed to get token; try again from the beginning. Usually a
536 + // bad_verification_code error. See: https://docs.github.com/en/free-pro-team@latest/developers/apps/troubleshooting-oauth-app-access-token-request-errors#bad-verification-code.
537 + $auth_url = $provider->getAuthorizationUrl( array(
538 + 'scope' => 'user:email',
539 + ) );
540 + $_SESSION['oauth2state'] = $provider->getState();
541 +
542 + // Log the error for debugging.
543 + error_log( __( 'OAuth2 server returned an Exception. Details:', 'authorizer' ) ); // phpcs:ignore
544 + error_log( $e->getMessage() ); // phpcs:ignore
545 +
546 + // Also log the error to the Simple History plugin (if it is active).
547 + apply_filters(
548 + 'simple_history_log_warning',
549 + __( 'OAuth2 server returned an Exception. Details:', 'authorizer' ),
550 + array(
551 + 'error' => $e->getMessage(),
552 + )
553 + );
554 +
555 + header( 'Location: ' . $auth_url );
556 + exit;
557 + }
558 +
559 + try {
560 + // Look up user using token.
561 + $user = $provider->getResourceOwner( $token );
562 +
563 + $email = $user->getEmail();
564 + $username = $user->getNickname();
565 + $attributes = $user->toArray();
566 +
567 + // If user has no public email, fetch all emails and use those.
568 + if ( empty( $email ) ) {
569 + $request = $provider->getAuthenticatedRequest(
570 + 'GET',
571 + $provider->getResourceOwnerDetailsUrl( $token ) . '/emails',
572 + $token
573 + );
574 + $attributes['emails'] = array_filter( array_map(
575 + function ( $entry ) {
576 + return empty( $entry['email'] ) ? '' : $entry['email'];
577 + },
578 + (array) $provider->getParsedResponse( $request )
579 + ) );
580 + $email = $attributes['emails'];
581 + }
582 + } catch ( \Exception $e ) {
583 + // Failed to get user details.
584 + return null;
585 + }
586 + }
587 + } elseif ( 'azure' === $oauth2_provider ) {
588 + // Authenticate with the Microsoft Azure oauth2 client.
589 + // See: https://github.com/thenetworg/oauth2-azure.
590 + if ( PHP_SESSION_NONE === session_status() ) {
591 + session_start();
592 + }
593 + try {
594 + // Save the redirect URL for WordPress so we can restore it after a
595 + // successful login (note: we can't add the redirect_to querystring
596 + // param to the redirectUri param below because it won't match the
597 + // approved URI set in the Azure portal).
598 + $login_querystring = array();
599 + if ( isset( $_SERVER['QUERY_STRING'] ) ) {
600 + parse_str( $_SERVER['QUERY_STRING'], $login_querystring ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
601 + }
602 + if ( isset( $login_querystring['redirect_to'] ) ) {
603 + $_SESSION['oauth2_redirect_to'] = $login_querystring['redirect_to'];
604 + }
605 + // Save the OAuth2 server id so we can restore it after a successful
606 + // login (note: we can't add the id querystring param to the redirectUri
607 + // param below because it won't match the approved URI set in the Azure
608 + // portal).
609 + $_SESSION['oauth2_server_id'] = $oauth2_server_id;
610 +
611 + $provider = new \TheNetworg\OAuth2\Client\Provider\Azure( array(
612 + 'clientId' => $oauth2_clientid,
613 + 'clientSecret' => $oauth2_clientsecret,
614 + 'redirectUri' => $redirect_uri,
615 + 'tenant' => empty( $oauth2_tenant_id ) ? 'common' : $oauth2_tenant_id,
616 + ) );
617 + // Use v2 API. Set to Azure::ENDPOINT_VERSION_1_0 to use v1 API.
618 + $provider->defaultEndPointVersion = \TheNetworg\OAuth2\Client\Provider\Azure::ENDPOINT_VERSION_2_0;
619 +
620 + $baseGraphUri = $provider->getRootMicrosoftGraphUri( null );
621 + $provider->scope = 'openid profile email offline_access ' . $baseGraphUri . '/User.Read';
622 + } catch ( \Exception $e ) {
623 + // Invalid configuration, so this in not a successful login. Show error
624 + // message to user.
625 + return new \WP_Error( 'empty_username', $e->getMessage() );
626 + }
627 +
628 + // If we don't have an authorization code, then get one.
629 + if ( ! isset( $_REQUEST['code'] ) ) {
630 + try {
631 + $auth_url = $provider->getAuthorizationUrl( array(
632 + 'scope' => $provider->scope,
633 + ) );
634 + $_SESSION['oauth2state'] = $provider->getState();
635 + header( 'Location: ' . $auth_url );
636 + exit;
637 + } catch ( \Exception $e ) {
638 + // Invalid configuration, so this in not a successful login. Show error
639 + // message to user.
640 + return new \WP_Error( 'empty_username', $e->getMessage() );
641 + }
642 + } elseif ( empty( $_REQUEST['state'] ) || empty( $_SESSION['oauth2state'] ) || $_REQUEST['state'] !== $_SESSION['oauth2state'] ) {
643 + // Check state against previously stored one to mitigate CSRF attacks.
644 + unset( $_SESSION['oauth2state'] );
645 + exit;
646 + } else {
647 + // Try to get an access token (using the authorization code grant).
648 + try {
649 + $token = $provider->getAccessToken( 'authorization_code', array(
650 + 'code' => $_REQUEST['code'],
651 + 'scope' => $provider->scope,
652 + ) );
653 + } catch ( \Exception $e ) {
654 + // Failed to get token; try again from the beginning.
655 + $auth_url = $provider->getAuthorizationUrl( array(
656 + 'scope' => $provider->scope,
657 + ) );
658 + $_SESSION['oauth2state'] = $provider->getState();
659 +
660 + // Log the error for debugging.
661 + error_log( __( 'OAuth2 server returned an Exception. Details:', 'authorizer' ) ); // phpcs:ignore
662 + error_log( $e->getMessage() ); // phpcs:ignore
663 +
664 + // Also log the error to the Simple History plugin (if it is active).
665 + apply_filters(
666 + 'simple_history_log_warning',
667 + __( 'OAuth2 server returned an Exception. Details:', 'authorizer' ),
668 + array(
669 + 'error' => $e->getMessage(),
670 + )
671 + );
672 +
673 + header( 'Location: ' . $auth_url );
674 + exit;
675 + }
676 +
677 + try {
678 + // Look up user using token.
679 + $user = $provider->getResourceOwner( $token );
680 +
681 + $attributes = $user->toArray();
682 + $email = empty( $attributes['email'] ) ? '' : $attributes['email'];
683 + $username = empty( $attributes['preferred_username'] ) ? '' : $attributes['preferred_username'];
684 +
685 + // Attempt to find an email address in the resource owner attributes
686 + // if we couldn't find one in the `email` attribute.
687 + if ( empty( $email ) ) {
688 + $email = Helper::find_emails_in_multi_array( $attributes );
689 + }
690 + } catch ( \Exception $e ) {
691 + // Failed to get user details.
692 + return null;
693 + }
694 +
695 + /**
696 + * Filter the generic oauth2 authenticated user email.
697 + *
698 + * @param string $email Discovered email (or empty string).
699 + *
700 + * @param array $attributes Resource Owner attributes returned from oauth2 endpoint.
701 + */
702 + $email = apply_filters( 'authorizer_oauth2_generic_authenticated_email', $email, $attributes );
703 +
704 + /**
705 + * Filter the azure oauth2 authenticated user email.
706 + *
707 + * @param string $email Discovered email (or empty string).
708 + *
709 + * @param array $attributes Resource Owner attributes returned from oauth2 endpoint.
710 + */
711 + $email = apply_filters( 'authorizer_oauth2_azure_authenticated_email', $email, $attributes );
712 +
713 + // Set the username to the email prefix (if we don't have one).
714 + if ( ! empty( $email ) && empty( $username ) ) {
715 + if ( is_array( $email ) && ! empty( $email[0] ) ) {
716 + $username = current( explode( '@', $email[0] ) );
717 + } else {
718 + $username = current( explode( '@', $email ) );
719 + }
720 + }
721 + }
722 + } elseif ( 'generic' === $oauth2_provider ) {
723 + // Authenticate with the generic oauth2 client.
724 + // See: https://github.com/thephpleague/oauth2-client.
725 + // Move on if required params aren't specified in settings.
726 + if (
727 + empty( $oauth2_url_authorize ) ||
728 + empty( $oauth2_url_token ) ||
729 + empty( $oauth2_url_resource )
730 + ) {
731 + return null;
732 + }
733 +
734 + if ( PHP_SESSION_NONE === session_status() ) {
735 + session_start();
736 + }
737 + // Save the redirect URL for WordPress so we can restore it after a
738 + // successful login (note: many OAuth2 providers discard the param).
739 + $login_querystring = array();
740 + if ( isset( $_SERVER['QUERY_STRING'] ) ) {
741 + parse_str( $_SERVER['QUERY_STRING'], $login_querystring ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
742 + }
743 + if ( isset( $login_querystring['redirect_to'] ) ) {
744 + $_SESSION['oauth2_redirect_to'] = $login_querystring['redirect_to'];
745 + }
746 +
747 + $provider = new \League\OAuth2\Client\Provider\GenericProvider( array(
748 + 'clientId' => $oauth2_clientid,
749 + 'clientSecret' => $oauth2_clientsecret,
750 + 'redirectUri' => $redirect_uri,
751 + 'urlAuthorize' => $oauth2_url_authorize,
752 + 'urlAccessToken' => $oauth2_url_token,
753 + 'urlResourceOwnerDetails' => $oauth2_url_resource,
754 + ) );
755 +
756 + // If we don't have an authorization code, then get one.
757 + if ( ! isset( $_REQUEST['code'] ) ) {
758 + $auth_url = $provider->getAuthorizationUrl(
759 + /**
760 + * Filter the parameters passed to the generic oauth2 authorization endpoint.
761 + *
762 + * @param array() $params Array of key/value pairs where keys represent
763 + * a GET param and value is its value.
764 + */
765 + apply_filters( 'authorizer_oauth2_generic_authorization_parameters', array() )
766 + );
767 + $_SESSION['oauth2state'] = $provider->getState();
768 + header( 'Location: ' . $auth_url );
769 + exit;
770 + } elseif ( empty( $_REQUEST['state'] ) || empty( $_SESSION['oauth2state'] ) || $_REQUEST['state'] !== $_SESSION['oauth2state'] ) {
771 + // Check state against previously stored one to mitigate CSRF attacks.
772 + unset( $_SESSION['oauth2state'] );
773 + exit;
774 + } else {
775 + // Try to get an access token (using the authorization code grant).
776 + try {
777 + $token = $provider->getAccessToken( 'authorization_code', array(
778 + 'code' => $_REQUEST['code'],
779 + ) );
780 + } catch ( \Exception $e ) {
781 + // Failed to get token; try again from the beginning.
782 + $auth_url = $provider->getAuthorizationUrl(
783 + /**
784 + * Filter the parameters passed to the generic oauth2 authorization endpoint.
785 + *
786 + * @param array() $params Array of key/value pairs where keys represent
787 + * a GET param and value is its value.
788 + */
789 + apply_filters( 'authorizer_oauth2_generic_authorization_parameters', array() )
790 + );
791 + $_SESSION['oauth2state'] = $provider->getState();
792 +
793 + // Log the error for debugging.
794 + error_log( __( 'OAuth2 server returned an Exception. Details:', 'authorizer' ) ); // phpcs:ignore
795 + error_log( $e->getMessage() ); // phpcs:ignore
796 +
797 + // Also log the error to the Simple History plugin (if it is active).
798 + apply_filters(
799 + 'simple_history_log_warning',
800 + __( 'OAuth2 server returned an Exception. Details:', 'authorizer' ),
801 + array(
802 + 'error' => $e->getMessage(),
803 + )
804 + );
805 +
806 + header( 'Location: ' . $auth_url );
807 + exit;
808 + }
809 +
810 + try {
811 + // Look up user using token.
812 + $user = $provider->getResourceOwner( $token );
813 +
814 + $email = '';
815 + $username = '';
816 + $attributes = $user->toArray();
817 +
818 + // Attempt to find an email address in the resource owner attributes.
819 + $email = Helper::find_emails_in_multi_array( $attributes );
820 + } catch ( \Exception $e ) {
821 + // Failed to get user details.
822 + return null;
823 + }
824 +
825 + // Get custom username attribute, if specified (handle string or array results from attribute).
826 + if ( ! empty( $oauth2_attr_username ) && ! empty( $attributes[ $oauth2_attr_username ] ) ) {
827 + if ( is_string( $attributes[ $oauth2_attr_username ] ) ) {
828 + $username = trim( $attributes[ $oauth2_attr_username ] );
829 + } elseif ( is_array( $attributes[ $oauth2_attr_username ] ) ) {
830 + $username = trim( array_shift( $attributes[ $oauth2_attr_username ] ) );
831 + }
832 + }
833 +
834 + // Get custom email attribute, if specified.
835 + if ( ! empty( $oauth2_attr_email ) && ! empty( $attributes[ $oauth2_attr_email ] ) ) {
836 + if ( is_string( $attributes[ $oauth2_attr_email ] ) ) {
837 + $email = trim( $attributes[ $oauth2_attr_email ] );
838 + } elseif ( is_array( $attributes[ $oauth2_attr_email ] ) ) {
839 + $email = $attributes[ $oauth2_attr_email ];
840 + }
841 + }
842 +
843 + /**
844 + * Filter the generic oauth2 authenticated user email.
845 + *
846 + * @param string|array $email Discovered email or array of emails (or empty string).
847 + * @param array $attributes Resource Owner attributes returned from oauth2 endpoint.
848 + */
849 + $email = apply_filters( 'authorizer_oauth2_generic_authenticated_email', $email, $attributes );
850 +
851 + // Set the username to the email prefix (if we don't have one).
852 + if ( ! empty( $email ) && empty( $username ) ) {
853 + if ( is_array( $email ) && ! empty( $email[0] ) ) {
854 + $username = current( explode( '@', $email[0] ) );
855 + } else {
856 + $username = current( explode( '@', $email ) );
857 + }
858 + }
859 + }
860 + } else {
861 + // Move on if a supported providers wasn't selected.
862 + return null;
863 + }
864 +
865 + // Make sure email is lowercase.
866 + if ( is_array( $email ) ) {
867 + $externally_authenticated_email = array();
868 + foreach ( $email as $external_email ) {
869 + $externally_authenticated_email[] = Helper::lowercase( $external_email );
870 + }
871 + } else {
872 + $externally_authenticated_email = array_filter( array( Helper::lowercase( $email ) ) );
873 + }
874 +
875 + // Move on if no emails were found.
876 + if ( empty( $externally_authenticated_email ) ) {
877 + return null;
878 + }
879 +
880 + /**
881 + * Fail if hosteddomain param is set and the logging in user's email address
882 + * doesn't match the allowed hosted domain.
883 + */
884 + if (
885 + array_key_exists( 'oauth2_hosteddomain', $auth_settings ) &&
886 + strlen( $oauth2_hosteddomain ) > 0
887 + ) {
888 + // Allow multiple whitelisted domains.
889 + $oauth2_hosteddomains = explode( "\n", str_replace( "\r", '', $oauth2_hosteddomain ) );
890 + $valid_domain = false;
891 + foreach ( $externally_authenticated_email as $email ) {
892 + $email_domain = substr( strrchr( $email, '@' ), 1 );
893 + if ( in_array( $email_domain, $oauth2_hosteddomains, true ) ) {
894 + $valid_domain = true;
895 + }
896 + }
897 + if ( ! $valid_domain ) {
898 + $this->custom_logout();
899 + return new \WP_Error( 'invalid_oauth2_login', __( 'Email address does not match the allowed hosted domain', 'authorizer' ) );
900 + }
901 + }
902 +
903 + // Get user first name (handle string or array results from attribute).
904 + $first_name = '';
905 + $oauth2_attr_first_name = $oauth2_attr_first_name ?? '';
906 + if ( ! empty( $oauth2_attr_first_name ) && ! empty( $attributes[ $oauth2_attr_first_name ] ) ) {
907 + if ( is_string( $attributes[ $oauth2_attr_first_name ] ) ) {
908 + $first_name = $attributes[ $oauth2_attr_first_name ];
909 + } elseif ( is_array( $attributes[ $oauth2_attr_first_name ] ) ) {
910 + $first_name = trim( implode( ' ', $attributes[ $oauth2_attr_first_name ] ) );
911 + }
912 + }
913 +
914 + // Get user last name (handle string or array results from attribute).
915 + $last_name = '';
916 + $oauth2_attr_last_name = $oauth2_attr_last_name ?? '';
917 + if ( ! empty( $oauth2_attr_last_name ) && ! empty( $attributes[ $oauth2_attr_last_name ] ) ) {
918 + if ( is_string( $attributes[ $oauth2_attr_last_name ] ) ) {
919 + $last_name = $attributes[ $oauth2_attr_last_name ];
920 + } elseif ( is_array( $attributes[ $oauth2_attr_last_name ] ) ) {
921 + $last_name = trim( implode( ' ', $attributes[ $oauth2_attr_last_name ] ) );
922 + }
923 + }
924 +
925 + return array(
926 + 'email' => $externally_authenticated_email,
927 + 'username' => sanitize_user( $username ),
928 + 'first_name' => $first_name,
929 + 'last_name' => $last_name,
930 + 'authenticated_by' => 'oauth2',
931 + 'oauth2_provider' => $oauth2_provider,
932 + 'oauth2_attributes' => $attributes,
933 + 'oauth2_server_id' => $oauth2_server_id,
934 + );
935 + }
936 +
937 + /**
938 + * Validate this user's credentials against OIDC provider.
939 + *
940 + * @param array $auth_settings Plugin settings.
941 + * @return array|WP_Error Array containing email, authenticated_by, first_name,
942 + * last_name, and username strings for the successfully
943 + * authenticated user, or WP_Error() object on failure,
944 + * or null if not attempting an OIDC login.
945 + */
946 + protected function custom_authenticate_oidc( $auth_settings ) {
947 + // Move on if oidc hasn't been requested here or OIDC server ID is invalid.
948 + if ( empty( $auth_settings['oidc_num_servers'] ) ) {
949 + $auth_settings['oidc_num_servers'] = 1;
950 + }
951 +
952 + // Workaround: because some OIDC providers don't let us specify a querystring in a
953 + // redirect_uri, we have to detect those redirects separately because we
954 + // can't include external=oidc or id={oidc_server_id} in the URL.
955 + // Instead, detect the absence of the `external` param, and the presence of
956 + // `code` and `state` params (OIDC uses authorization code flow).
957 + if ( empty( $_GET['external'] ) && ! empty( $_GET['code'] ) && ! empty( $_GET['state'] ) ) {
958 + // Fetch the OIDC server id from the session variable created during the
959 + // initial request.
960 + if ( PHP_SESSION_NONE === session_status() ) {
961 + session_start();
962 + }
963 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
964 + $_GET['id'] = $_SESSION['oidc_server_id'] ?? 1;
965 + $_GET['external'] = 'oidc';
966 + }
967 +
968 + // If this is an OIDC login attempt and the id param is missing, default
969 + // it to 1.
970 + if ( ! empty( $_GET['external'] ) && 'oidc' === $_GET['external'] && empty( $_GET['id'] ) ) {
971 + $_GET['id'] = 1;
972 + }
973 +
974 + // phpcs:ignore WordPress.Security.NonceVerification
975 + if ( empty( $_GET['external'] ) || 'oidc' !== $_GET['external'] || empty( $_GET['id'] ) || ! in_array( intval( $_GET['id'] ), range( 1, 20 ), true ) || intval( $_GET['id'] ) > intval( $auth_settings['oidc_num_servers'] ) ) {
976 + return null;
977 + }
978 +
979 + // Get the OIDC server id (since multiple OIDC servers can be configured),
980 + // and the relevant settings for that server.
981 + // phpcs:ignore WordPress.Security.NonceVerification
982 + $oidc_server_id = empty( $_GET['id'] ) ? 1 : intval( $_GET['id'] );
983 + $suffix = $oidc_server_id > 1 ? '_' . $oidc_server_id : '';
984 + $oidc_issuer = $auth_settings[ 'oidc_issuer' . $suffix ] ?? '';
985 + $oidc_client_id = $auth_settings[ 'oidc_client_id' . $suffix ] ?? '';
986 + $oidc_client_secret = $auth_settings[ 'oidc_client_secret' . $suffix ] ?? '';
987 + $oidc_scopes = $auth_settings[ 'oidc_scopes' . $suffix ] ?? 'openid email profile';
988 + $oidc_prompt = $auth_settings[ 'oidc_prompt' . $suffix ] ?? '';
989 + $oidc_login_hint = $auth_settings[ 'oidc_login_hint' . $suffix ] ?? '';
990 + $oidc_max_age = $auth_settings[ 'oidc_max_age' . $suffix ] ?? '';
991 + $oidc_attr_username = $auth_settings[ 'oidc_attr_username' . $suffix ] ?? 'preferred_username';
992 + $oidc_attr_email = $auth_settings[ 'oidc_attr_email' . $suffix ] ?? 'email';
993 + $oidc_attr_first_name = $auth_settings[ 'oidc_attr_first_name' . $suffix ] ?? 'given_name';
994 + $oidc_attr_last_name = $auth_settings[ 'oidc_attr_last_name' . $suffix ] ?? 'family_name';
995 + $oidc_require_verified_email = $auth_settings[ 'oidc_require_verified_email' . $suffix ] ?? '';
996 + $oidc_link_on_username = $auth_settings[ 'oidc_link_on_username' . $suffix ] ?? '';
997 + $oidc_hosteddomain = $auth_settings[ 'oidc_hosteddomain' . $suffix ] ?? '';
998 +
999 + // Fetch the OIDC Client ID (allow overrides from filter or constant).
1000 + // Note: constant/filter overrides are only supported for a single OIDC server.
1001 + if ( 1 === $oidc_server_id && defined( 'AUTHORIZER_OIDC_CLIENT_ID' ) ) {
1002 + $oidc_client_id = \AUTHORIZER_OIDC_CLIENT_ID;
1003 + }
1004 + /**
1005 + * Filters the OIDC Client ID used by Authorizer to authenticate.
1006 + *
1007 + * @since 3.11.0
1008 + *
1009 + * @param string $oidc_client_id The stored OIDC Client ID.
1010 + */
1011 + if ( 1 === $oidc_server_id ) {
1012 + $oidc_client_id = apply_filters( 'authorizer_oidc_client_id', $oidc_client_id );
1013 + }
1014 +
1015 + // Fetch the OIDC Client Secret (allow overrides from filter or constant).
1016 + // Note: constant/filter overrides are only supported for a single OIDC server.
1017 + if ( 1 === $oidc_server_id && defined( 'AUTHORIZER_OIDC_CLIENT_SECRET' ) ) {
1018 + $oidc_client_secret = \AUTHORIZER_OIDC_CLIENT_SECRET;
1019 + }
1020 + /**
1021 + * Filters the OIDC Client Secret used by Authorizer to authenticate.
1022 + *
1023 + * @since 3.11.0
1024 + *
1025 + * @param string $oidc_client_secret The stored OIDC Client Secret.
1026 + */
1027 + if ( 1 === $oidc_server_id ) {
1028 + $oidc_client_secret = apply_filters( 'authorizer_oidc_client_secret', $oidc_client_secret );
1029 + }
1030 +
1031 + // Move on if required params aren't specified in settings.
1032 + if (
1033 + empty( $oidc_issuer ) ||
1034 + empty( $oidc_client_id ) ||
1035 + empty( $oidc_client_secret )
1036 + ) {
1037 + return null;
1038 + }
1039 +
1040 + // Build the redirectUri for the OIDC provider to redirect back to.
1041 + // Note: omit the id param if it is 1 (default server) for consistency with
1042 + // the CAS and OAuth2 implementations.
1043 + $redirect_uri = site_url( '/wp-login.php?external=oidc' );
1044 + if ( $oidc_server_id > 1 ) {
1045 + $redirect_uri .= '&id=' . $oidc_server_id;
1046 + }
1047 +
1048 + // Start session for state/nonce/PKCE storage.
1049 + if ( PHP_SESSION_NONE === session_status() ) {
1050 + session_start();
1051 + }
1052 +
1053 + // Save redirect_to parameter if present.
1054 + if ( ! empty( $_GET['redirect_to'] ) ) {
1055 + // phpcs:ignore WordPress.Security.NonceVerification
1056 + $_SESSION['oidc_redirect_to'] = sanitize_url( wp_unslash( $_GET['redirect_to'] ) );
1057 + }
1058 +
1059 + // Initialize jumbojett OIDC client.
1060 + try {
1061 + $oidc = new \Jumbojett\OpenIDConnectClient(
1062 + $oidc_issuer,
1063 + $oidc_client_id,
1064 + $oidc_client_secret
1065 + );
1066 +
1067 + // Set redirect URL.
1068 + $oidc->setRedirectURL( $redirect_uri );
1069 +
1070 + // Save the OIDC server id so we can restore it after a successful
1071 + // login (note: we can't add the id querystring param to the redirectUri
1072 + // param above because some providers won't match the approved URI set in their portal).
1073 + $_SESSION['oidc_server_id'] = $oidc_server_id;
1074 +
1075 + // Enable PKCE with S256.
1076 + $oidc->setCodeChallengeMethod( 'S256' );
1077 +
1078 + // Add scopes.
1079 + $scopes = array_filter( array_map( 'trim', explode( ' ', $oidc_scopes ) ) );
1080 + if ( empty( $scopes ) ) {
1081 + $scopes = array( 'openid', 'email', 'profile' );
1082 + }
1083 + $oidc->addScope( $scopes );
1084 +
1085 + // Add optional parameters.
1086 + if ( ! empty( $oidc_prompt ) ) {
1087 + $oidc->addAuthParam( array( 'prompt' => $oidc_prompt ) );
1088 + }
1089 + if ( ! empty( $oidc_login_hint ) ) {
1090 + $oidc->addAuthParam( array( 'login_hint' => $oidc_login_hint ) );
1091 + }
1092 + if ( ! empty( $oidc_max_age ) ) {
1093 + $oidc->addAuthParam( array( 'max_age' => $oidc_max_age ) );
1094 + }
1095 +
1096 + // Authenticate (library handles PKCE, nonce, state).
1097 + $oidc->authenticate();
1098 +
1099 + // Get ID token for RP-initiated logout (will be stored in user meta after user is found).
1100 + $id_token = $oidc->getIdToken();
1101 +
1102 + // Get user info from userinfo endpoint (if available).
1103 + // Convert stdClass object to array to match codebase pattern (like OAuth2).
1104 + $user_info = array();
1105 + try {
1106 + $user_info = (array) $oidc->requestUserInfo();
1107 + } catch ( \Exception $e ) {
1108 + // Userinfo endpoint may not be available or may fail, continue with ID token.
1109 + }
1110 +
1111 + // Also get ID token payload (email is often in ID token).
1112 + $id_token_payload = array();
1113 + try {
1114 + $id_token_payload_obj = $oidc->getIdTokenPayload();
1115 + if ( $id_token_payload_obj ) {
1116 + $id_token_payload = (array) $id_token_payload_obj;
1117 + }
1118 + } catch ( \Exception $e ) {
1119 + // ID token payload unavailable.
1120 + }
1121 +
1122 + // Merge ID token claims with userinfo (userinfo takes precedence).
1123 + $user_info = array_merge( $id_token_payload, $user_info );
1124 +
1125 + // Extract email.
1126 + $email = '';
1127 + if ( ! empty( $oidc_attr_email ) && ! empty( $user_info[ $oidc_attr_email ] ) ) {
1128 + $email = Helper::lowercase( sanitize_email( $user_info[ $oidc_attr_email ] ) );
1129 + } elseif ( ! empty( $user_info['email'] ) ) {
1130 + $email = Helper::lowercase( sanitize_email( $user_info['email'] ) );
1131 + }
1132 +
1133 + // Extract username.
1134 + $username = '';
1135 + if ( ! empty( $oidc_attr_username ) && ! empty( $user_info[ $oidc_attr_username ] ) ) {
1136 + $username = sanitize_user( $user_info[ $oidc_attr_username ] );
1137 + } elseif ( ! empty( $user_info['preferred_username'] ) ) {
1138 + $username = sanitize_user( $user_info['preferred_username'] );
1139 + } elseif ( ! empty( $user_info['sub'] ) ) {
1140 + $username = sanitize_user( $user_info['sub'] );
1141 + }
1142 +
1143 + // If linking by username is enabled, email is optional.
1144 + // Otherwise, email is required.
1145 + if ( '1' !== $oidc_link_on_username ) {
1146 + if ( empty( $email ) ) {
1147 + // Clean up session variables before returning error.
1148 + \Authorizer\Options\External\Oidc::get_instance()->maybe_unset_oidc_session_vars();
1149 + return new \WP_Error( 'oidc_no_email', __( '<strong>ERROR</strong>: OIDC provider did not return an email address.', 'authorizer' ) );
1150 + }
1151 +
1152 + // Enforce email verification if required.
1153 + if ( '1' === $oidc_require_verified_email ) {
1154 + if ( empty( $user_info['email_verified'] ) || true !== $user_info['email_verified'] ) {
1155 + // Clean up session variables before returning error.
1156 + \Authorizer\Options\External\Oidc::get_instance()->maybe_unset_oidc_session_vars();
1157 + return new \WP_Error( 'oidc_email_not_verified', __( '<strong>ERROR</strong>: Email address must be verified to log in.', 'authorizer' ) );
1158 + }
1159 + }
1160 +
1161 + // Enforce hosted domain allowlist if configured.
1162 + if ( ! empty( $oidc_hosteddomain ) ) {
1163 + $allowed_domains = array_filter( array_map( 'trim', explode( "\n", str_replace( "\r", '', $oidc_hosteddomain ) ) ) );
1164 + $email_domain = substr( strrchr( $email, '@' ), 1 );
1165 + if ( ! in_array( $email_domain, $allowed_domains, true ) ) {
1166 + // Clean up session variables before returning error.
1167 + \Authorizer\Options\External\Oidc::get_instance()->maybe_unset_oidc_session_vars();
1168 + return new \WP_Error( 'oidc_domain_not_allowed', __( '<strong>ERROR</strong>: Your email domain is not allowed to log in.', 'authorizer' ) );
1169 + }
1170 + }
1171 +
1172 + // Fallback username to email username part if username is empty.
1173 + if ( empty( $username ) ) {
1174 + $username = sanitize_user( substr( $email, 0, strpos( $email, '@' ) ) );
1175 + }
1176 + } elseif ( empty( $username ) ) {
1177 + // When linking by username, username is required.
1178 + // Clean up session variables before returning error.
1179 + \Authorizer\Options\External\Oidc::get_instance()->maybe_unset_oidc_session_vars();
1180 + return new \WP_Error( 'oidc_no_username', __( '<strong>ERROR</strong>: OIDC provider did not return a username.', 'authorizer' ) );
1181 + }
1182 +
1183 + // Extract first name.
1184 + $first_name = '';
1185 + if ( ! empty( $oidc_attr_first_name ) && ! empty( $user_info[ $oidc_attr_first_name ] ) ) {
1186 + $first_name = sanitize_text_field( $user_info[ $oidc_attr_first_name ] );
1187 + } elseif ( ! empty( $user_info['given_name'] ) ) {
1188 + $first_name = sanitize_text_field( $user_info['given_name'] );
1189 + }
1190 +
1191 + // Extract last name.
1192 + $last_name = '';
1193 + if ( ! empty( $oidc_attr_last_name ) && ! empty( $user_info[ $oidc_attr_last_name ] ) ) {
1194 + $last_name = sanitize_text_field( $user_info[ $oidc_attr_last_name ] );
1195 + } elseif ( ! empty( $user_info['family_name'] ) ) {
1196 + $last_name = sanitize_text_field( $user_info['family_name'] );
1197 + }
1198 +
1199 + return array(
1200 + 'email' => $email,
1201 + 'username' => $username,
1202 + 'first_name' => $first_name,
1203 + 'last_name' => $last_name,
1204 + 'authenticated_by' => 'oidc',
1205 + 'oidc_attributes' => $user_info,
1206 + 'oidc_server_id' => $oidc_server_id,
1207 + 'oidc_id_token' => $id_token,
1208 + );
1209 + } catch ( \Exception $e ) {
1210 + // Clean up session variables on exception.
1211 + \Authorizer\Options\External\Oidc::get_instance()->maybe_unset_oidc_session_vars();
1212 +
1213 + // Log the error to error_log.
1214 + error_log( __( 'OIDC authentication failed. Details:', 'authorizer' ) ); // phpcs:ignore
1215 + error_log( $e->getMessage() ); // phpcs:ignore
1216 +
1217 + // Also log the error to the Simple History plugin (if it is active).
1218 + apply_filters(
1219 + 'simple_history_log_warning',
1220 + __( 'OIDC authentication failed. Details:', 'authorizer' ),
1221 + array(
1222 + 'error' => $e->getMessage(),
1223 + )
1224 + );
1225 +
1226 + return new \WP_Error( 'oidc_error', __( '<strong>ERROR</strong>: OIDC authentication failed.', 'authorizer' ) . ' ' . esc_html( $e->getMessage() ) );
1227 + }
1228 + }
1229 +
1230 + /**
247 1231 * Validate this user's credentials against Google.
248 1232 *
249 1233 * @param array $auth_settings Plugin settings.
250 1234 * @return array|WP_Error Array containing email, authenticated_by, first_name,
@@ -259,47 +1243,75 @@
259 1243 return null;
260 1244 }
261 1245
262 1246 // Get one time use token.
263 - session_start();
264 - $token = array_key_exists( 'token', $_SESSION ) ? json_decode( $_SESSION['token'] ) : null;
1247 + if ( PHP_SESSION_NONE === session_status() ) {
1248 + session_start();
1249 + }
1250 + $token = array_key_exists( 'token', $_SESSION ) ? $_SESSION['token'] : null;
265 1251
266 1252 // No token, so this is not a succesful Google login.
267 - if ( is_null( $token ) ) {
1253 + if ( empty( $token ) ) {
268 1254 return null;
269 1255 }
270 1256
271 - // Add Google API PHP Client.
272 - // @see https://github.com/google/google-api-php-client branch:v1-master
273 - if ( ! function_exists( 'google_api_php_client_autoload' ) ) {
274 - require_once dirname( plugin_root() ) . '/vendor/google-api-php-client/src/Google/autoload.php';
1257 + // Fetch the Google Client ID (allow overrides from filter or constant).
1258 + if ( defined( 'AUTHORIZER_GOOGLE_CLIENT_ID' ) ) {
1259 + $auth_settings['google_clientid'] = \AUTHORIZER_GOOGLE_CLIENT_ID;
275 1260 }
1261 + /**
1262 + * Filters the Google Client ID used by Authorizer to authenticate.
1263 + *
1264 + * @since 3.9.0
1265 + *
1266 + * @param string $google_client_id The stored Google Client ID.
1267 + */
1268 + $auth_settings['google_clientid'] = apply_filters( 'authorizer_google_client_id', $auth_settings['google_clientid'] );
276 1269
1270 + // Fetch the Google Client Secret (allow overrides from filter or constant).
1271 + if ( defined( 'AUTHORIZER_GOOGLE_CLIENT_SECRET' ) ) {
1272 + $auth_settings['google_clientsecret'] = \AUTHORIZER_GOOGLE_CLIENT_SECRET;
1273 + }
1274 + /**
1275 + * Filters the Google Client Secret used by Authorizer to authenticate.
1276 + *
1277 + * @since 3.6.1
1278 + *
1279 + * @param string $google_client_secret The stored Google Client Secret.
1280 + */
1281 + $auth_settings['google_clientsecret'] = apply_filters( 'authorizer_google_client_secret', $auth_settings['google_clientsecret'] );
1282 +
277 1283 // Build the Google Client.
278 1284 $client = new \Google_Client();
279 1285 $client->setApplicationName( 'WordPress' );
280 - $client->setClientId( $auth_settings['google_clientid'] );
281 - $client->setClientSecret( $auth_settings['google_clientsecret'] );
1286 + $client->setClientId( trim( $auth_settings['google_clientid'] ) );
1287 + $client->setClientSecret( trim( $auth_settings['google_clientsecret'] ) );
282 1288 $client->setRedirectUri( 'postmessage' );
283 1289
284 1290 /**
285 - * If the hosted domain parameter is set, restrict logins to that domain.
286 - * Note: Will have to upgrade to google-api-php-client v2 or higher for
287 - * this to function server-side; it's not complete in v1, so this check
288 - * is performed manually later.
289 - * if (
290 - * array_key_exists( 'google_hosteddomain', $auth_settings ) &&
291 - * strlen( $auth_settings['google_hosteddomain'] ) > 0
292 - * ) {
293 - * $google_hosteddomains = explode( "\n", str_replace( "\r", '', $auth_settings['google_hosteddomain'] ) );
294 - * $google_hosteddomain = trim( $google_hosteddomains[0] );
295 - * $client->setHostedDomain( $google_hosteddomain );
296 - * }
1291 + * If the hosted domain parameter is set, restrict logins to that domain
1292 + * (only available in google-api-php-client v2 or higher).
297 1293 */
1294 + if (
1295 + array_key_exists( 'google_hosteddomain', $auth_settings ) &&
1296 + strlen( $auth_settings['google_hosteddomain'] ) > 0 &&
1297 + $client::LIBVER >= '2.0.0'
1298 + ) {
1299 + $google_hosteddomains = explode( "\n", str_replace( "\r", '', $auth_settings['google_hosteddomain'] ) );
1300 + $google_hosteddomain = trim( $google_hosteddomains[0] );
1301 + $client->setHostedDomain( $google_hosteddomain );
1302 + }
298 1303
1304 + // Allow minor clock drift between this server's clock and Google's.
1305 + // See: https://github.com/googleapis/google-api-php-client/issues/1630
1306 + \Firebase\JWT\JWT::$leeway = 30;
1307 +
299 1308 // Verify this is a successful Google authentication.
300 1309 try {
301 - $ticket = $client->verifyIdToken( $token->id_token, $auth_settings['google_clientid'] );
1310 + $payload = $client->verifyIdToken( $token );
1311 + } catch ( \Firebase\JWT\BeforeValidException $e ) {
1312 + // Server clock out of sync with Google servers.
1313 + return new \WP_Error( 'invalid_google_login', __( 'The authentication timestamp is too old, please try again.', 'authorizer' ) );
302 1314 } catch ( Google_Auth_Exception $e ) {
303 1315 // Invalid ticket, so this in not a successful Google login.
304 1316 return new \WP_Error( 'invalid_google_login', __( 'Invalid Google credentials provided.', 'authorizer' ) );
305 1317 }
@@ -304,15 +1316,15 @@
304 1316 return new \WP_Error( 'invalid_google_login', __( 'Invalid Google credentials provided.', 'authorizer' ) );
305 1317 }
306 1318
307 1319 // Invalid ticket, so this in not a successful Google login.
308 - if ( ! $ticket ) {
1320 + if ( empty( $payload['email'] ) ) {
309 1321 return new \WP_Error( 'invalid_google_login', __( 'Invalid Google credentials provided.', 'authorizer' ) );
310 1322 }
311 1323
312 1324 // Get email address.
313 - $attributes = $ticket->getAttributes();
314 - $email = Helper::lowercase( $attributes['payload']['email'] );
1325 + $email = Helper::lowercase( $payload['email'] );
1326 +
315 1327 $email_domain = substr( strrchr( $email, '@' ), 1 );
316 1328 $username = current( explode( '@', $email ) );
317 1329
318 1330 /**
@@ -321,13 +1333,14 @@
321 1333 *
322 1334 * See: https://developers.google.com/identity/protocols/OpenIDConnect#hd-param
323 1335 * See: https://github.com/google/google-api-php-client/blob/v1-master/src/Google/Client.php#L407-L416
324 1336 *
325 - * Note: Will have to upgrade to google-api-php-client v2 or higher for
326 - * this to function server-side; it's not complete in v1, so this check
327 - * is only performed here.
1337 + * Note: this is a failsafe if the setHostedDomain() feature in v2 does not work above.
328 1338 */
329 - if ( array_key_exists( 'google_hosteddomain', $auth_settings ) && strlen( $auth_settings['google_hosteddomain'] ) > 0 ) {
1339 + if (
1340 + array_key_exists( 'google_hosteddomain', $auth_settings ) &&
1341 + strlen( $auth_settings['google_hosteddomain'] ) > 0
1342 + ) {
330 1343 // Allow multiple whitelisted domains.
331 1344 $google_hosteddomains = explode( "\n", str_replace( "\r", '', $auth_settings['google_hosteddomain'] ) );
332 1345 if ( ! in_array( $email_domain, $google_hosteddomains, true ) ) {
333 1346 $this->custom_logout();
@@ -340,9 +1353,9 @@
340 1353 'username' => $username,
341 1354 'first_name' => '',
342 1355 'last_name' => '',
343 1356 'authenticated_by' => 'google',
344 - 'google_attributes' => $attributes,
1357 + 'google_attributes' => $payload,
345 1358 );
346 1359 }
347 1360
348 1361
@@ -354,30 +1367,50 @@
354 1367 * for the successfully authenticated user, or WP_Error()
355 1368 * object on failure, or null if not attempting a CAS login.
356 1369 */
357 1370 protected function custom_authenticate_cas( $auth_settings ) {
358 - // Move on if CAS hasn't been requested here.
1371 + // Move on if CAS hasn't been requested here or the CAS server ID is invalid.
1372 + if ( empty( $auth_settings['cas_num_servers'] ) ) {
1373 + $auth_settings['cas_num_servers'] = 1;
1374 + }
359 1375 // phpcs:ignore WordPress.Security.NonceVerification
360 - if ( empty( $_GET['external'] ) || 'cas' !== $_GET['external'] ) {
1376 + if ( empty( $_GET['external'] ) || 'cas' !== $_GET['external'] || empty( $_GET['id'] ) || ! in_array( intval( $_GET['id'] ), range( 1, 10 ), true ) || intval( $_GET['id'] ) > intval( $auth_settings['cas_num_servers'] ) ) {
361 1377 return null;
362 1378 }
1379 + // Get the CAS server id (since multiple CAS servers can be configured), and
1380 + // the relevant CAS settings for that server.
1381 + // phpcs:ignore WordPress.Security.NonceVerification
1382 + $cas_server_id = intval( $_GET['id'] );
1383 + $suffix = $cas_server_id > 1 ? '_' . $cas_server_id : '';
1384 + $cas_host = $auth_settings[ 'cas_host' . $suffix ];
1385 + $cas_port = $auth_settings[ 'cas_port' . $suffix ];
1386 + $cas_path = $auth_settings[ 'cas_path' . $suffix ];
1387 + $cas_method = $auth_settings[ 'cas_method' . $suffix ];
1388 + $cas_version = $auth_settings[ 'cas_version' . $suffix ];
1389 + $cas_attr_email = $auth_settings[ 'cas_attr_email' . $suffix ];
1390 + $cas_attr_first_name = $auth_settings[ 'cas_attr_first_name' . $suffix ];
1391 + $cas_attr_last_name = $auth_settings[ 'cas_attr_last_name' . $suffix ];
363 1392
364 1393 /**
365 - * Get the CAS server version (default to SAML_VERSION_1_1).
1394 + * Get the CAS server protocol version (default to SAML 1.1).
366 1395 *
367 - * @see: https://developer.jasig.org/cas-clients/php/1.3.4/docs/api/group__public.html
1396 + * @see: https://apereo.github.io/phpCAS/api/group__public.html#gadea9415f40b8d2afc39f140c9be83bbe
368 1397 */
369 - $cas_version = SAML_VERSION_1_1;
370 - if ( 'CAS_VERSION_3_0' === $auth_settings['cas_version'] ) {
371 - $cas_version = CAS_VERSION_3_0;
372 - } elseif ( 'CAS_VERSION_2_0' === $auth_settings['cas_version'] ) {
373 - $cas_version = CAS_VERSION_2_0;
374 - } elseif ( 'CAS_VERSION_1_0' === $auth_settings['cas_version'] ) {
375 - $cas_version = CAS_VERSION_1_0;
376 - }
1398 + $cas_version = Options\External\Cas::get_instance()->sanitize_cas_version( $cas_version );
377 1399
1400 + /**
1401 + * Get valid service URLs for the CAS client to validate against.
1402 + *
1403 + * @see: https://github.com/apereo/phpCAS/security/advisories/GHSA-8q72-6qq8-xv64
1404 + */
1405 + $valid_base_urls = Options\External\Cas::get_instance()->get_valid_cas_service_urls();
1406 +
378 1407 // Set the CAS client configuration.
379 - \phpCAS::client( $cas_version, $auth_settings['cas_host'], intval( $auth_settings['cas_port'] ), $auth_settings['cas_path'] );
1408 + if ( 'PROXY' === strtoupper( $cas_method ) ) {
1409 + \phpCAS::proxy( $cas_version, $cas_host, intval( $cas_port ), $cas_path, $valid_base_urls );
1410 + } else {
1411 + \phpCAS::client( $cas_version, $cas_host, intval( $cas_port ), $cas_path, $valid_base_urls );
1412 + }
380 1413
381 1414 // Allow redirects at the CAS server endpoint (e.g., allow connections
382 1415 // at an old CAS URL that redirects to a newer CAS URL).
383 1416 \phpCAS::setExtraCurlOption( CURLOPT_FOLLOWLOCATION, true );
@@ -385,9 +1418,9 @@
385 1418 // Use the WordPress certificate bundle at /wp-includes/certificates/ca-bundle.crt.
386 1419 \phpCAS::setCasServerCACert( ABSPATH . WPINC . '/certificates/ca-bundle.crt' );
387 1420
388 1421 // Set the CAS service URL (including the redirect URL for WordPress when it comes back from CAS).
389 - $cas_service_url = site_url( '/wp-login.php?external=cas' );
1422 + $cas_service_url = site_url( '/wp-login.php?external=cas&id=' . $cas_server_id );
390 1423 $login_querystring = array();
391 1424 if ( isset( $_SERVER['QUERY_STRING'] ) ) {
392 1425 parse_str( $_SERVER['QUERY_STRING'], $login_querystring ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
393 1426 }
@@ -397,15 +1430,26 @@
397 1430 \phpCAS::setFixedServiceURL( $cas_service_url );
398 1431
399 1432 // Authenticate against CAS.
400 1433 try {
1434 + // phpcs:ignore Squiz.PHP.CommentedOutCode
1435 + // \phpCAS::setDebug( dirname( __FILE__ ) . '/../../debug.log' );
401 1436 \phpCAS::forceAuthentication();
402 - } catch ( CAS_AuthenticationException $e ) {
1437 + } catch ( \CAS_AuthenticationException $e ) {
403 1438 // CAS server threw an error in isAuthenticated(), potentially because
404 1439 // the cached ticket is outdated. Try renewing the authentication.
405 1440 error_log( __( 'CAS server returned an Authentication Exception. Details:', 'authorizer' ) ); // phpcs:ignore
406 - error_log( print_r( $e, true ) ); // phpcs:ignore
1441 + error_log( $e->getMessage() ); // phpcs:ignore
407 1442
1443 + // Also log the error to the Simple History plugin (if it is active).
1444 + apply_filters(
1445 + 'simple_history_log_warning',
1446 + __( 'CAS server returned an Authentication Exception. Details:', 'authorizer' ),
1447 + array(
1448 + 'error' => $e->getMessage(),
1449 + )
1450 + );
1451 +
408 1452 // CAS server is throwing errors on this login, so try logging the
409 1453 // user out of CAS and redirecting them to the login page.
410 1454 \phpCAS::logoutWithRedirectService( wp_login_url() );
411 1455 die();
@@ -419,9 +1463,9 @@
419 1463 if ( ! filter_var( $externally_authenticated_email, FILTER_VALIDATE_EMAIL ) ) {
420 1464 // If we can't get the user's email address from a CAS attribute,
421 1465 // try to guess the domain from the CAS server hostname. This will only
422 1466 // be used if we can't discover the email address from CAS attributes.
423 - $domain_guess = preg_match( '/[^.]*\.[^.]*$/', $auth_settings['cas_host'], $matches ) === 1 ? $matches[0] : '';
1467 + $domain_guess = preg_match( '/[^.]*\.[^.]*$/', $cas_host, $matches ) === 1 ? $matches[0] : '';
424 1468 $externally_authenticated_email = Helper::lowercase( $username ) . '@' . $domain_guess;
425 1469 }
426 1470
427 1471 // Retrieve the user attributes (e.g., email address, first name, last name) from the CAS server.
@@ -427,44 +1471,60 @@
427 1471 // Retrieve the user attributes (e.g., email address, first name, last name) from the CAS server.
428 1472 $cas_attributes = \phpCAS::getAttributes();
429 1473
430 1474 // Get user email if it is specified in another field.
431 - if ( array_key_exists( 'cas_attr_email', $auth_settings ) && strlen( $auth_settings['cas_attr_email'] ) > 0 ) {
1475 + if ( ! empty( $cas_attr_email ) ) {
432 1476 // If the email attribute starts with an at symbol (@), assume that the
433 1477 // email domain is manually entered there (instead of a reference to a
434 1478 // CAS attribute), and combine that with the username to create the email.
435 1479 // Otherwise, look up the CAS attribute for email.
436 - if ( substr( $auth_settings['cas_attr_email'], 0, 1 ) === '@' ) {
437 - $externally_authenticated_email = Helper::lowercase( $username . $auth_settings['cas_attr_email'] );
1480 + if ( substr( $cas_attr_email, 0, 1 ) === '@' ) {
1481 + $externally_authenticated_email = Helper::lowercase( $username . $cas_attr_email );
438 1482 } elseif (
439 1483 // If a CAS attribute has been specified as containing the email address, use that instead.
440 1484 // Email attribute can be a string or an array of strings.
441 - array_key_exists( $auth_settings['cas_attr_email'], $cas_attributes ) && (
1485 + array_key_exists( $cas_attr_email, $cas_attributes ) && (
442 1486 (
443 - is_array( $cas_attributes[ $auth_settings['cas_attr_email'] ] ) &&
444 - count( $cas_attributes[ $auth_settings['cas_attr_email'] ] ) > 0
1487 + is_array( $cas_attributes[ $cas_attr_email ] ) &&
1488 + count( $cas_attributes[ $cas_attr_email ] ) > 0
445 1489 ) || (
446 - is_string( $cas_attributes[ $auth_settings['cas_attr_email'] ] ) &&
447 - strlen( $cas_attributes[ $auth_settings['cas_attr_email'] ] ) > 0
1490 + is_string( $cas_attributes[ $cas_attr_email ] ) &&
1491 + strlen( $cas_attributes[ $cas_attr_email ] ) > 0
448 1492 )
449 1493 )
450 1494 ) {
451 1495 // Each of the emails in the array needs to be set to lowercase.
452 - if ( is_array( $cas_attributes[ $auth_settings['cas_attr_email'] ] ) ) {
1496 + if ( is_array( $cas_attributes[ $cas_attr_email ] ) ) {
453 1497 $externally_authenticated_email = array();
454 - foreach ( $cas_attributes[ $auth_settings['cas_attr_email'] ] as $external_email ) {
1498 + foreach ( $cas_attributes[ $cas_attr_email ] as $external_email ) {
455 1499 $externally_authenticated_email[] = Helper::lowercase( $external_email );
456 1500 }
457 1501 } else {
458 - $externally_authenticated_email = Helper::lowercase( $cas_attributes[ $auth_settings['cas_attr_email'] ] );
1502 + $externally_authenticated_email = Helper::lowercase( $cas_attributes[ $cas_attr_email ] );
459 1503 }
460 1504 }
461 1505 }
462 1506
463 - // Get user first name and last name.
464 - $first_name = array_key_exists( 'cas_attr_first_name', $auth_settings ) && strlen( $auth_settings['cas_attr_first_name'] ) > 0 && array_key_exists( $auth_settings['cas_attr_first_name'], $cas_attributes ) && strlen( $cas_attributes[ $auth_settings['cas_attr_first_name'] ] ) > 0 ? $cas_attributes[ $auth_settings['cas_attr_first_name'] ] : '';
465 - $last_name = array_key_exists( 'cas_attr_last_name', $auth_settings ) && strlen( $auth_settings['cas_attr_last_name'] ) > 0 && array_key_exists( $auth_settings['cas_attr_last_name'], $cas_attributes ) && strlen( $cas_attributes[ $auth_settings['cas_attr_last_name'] ] ) > 0 ? $cas_attributes[ $auth_settings['cas_attr_last_name'] ] : '';
1507 + // Get user first name (handle string or array results from CAS attribute).
1508 + $first_name = '';
1509 + if ( ! empty( $cas_attr_first_name ) && ! empty( $cas_attributes[ $cas_attr_first_name ] ) ) {
1510 + if ( is_string( $cas_attributes[ $cas_attr_first_name ] ) ) {
1511 + $first_name = $cas_attributes[ $cas_attr_first_name ];
1512 + } elseif ( is_array( $cas_attributes[ $cas_attr_first_name ] ) ) {
1513 + $first_name = trim( implode( ' ', $cas_attributes[ $cas_attr_first_name ] ) );
1514 + }
1515 + }
466 1516
1517 + // Get user last name (handle string or array results from CAS attribute).
1518 + $last_name = '';
1519 + if ( ! empty( $cas_attr_last_name ) && ! empty( $cas_attributes[ $cas_attr_last_name ] ) ) {
1520 + if ( is_string( $cas_attributes[ $cas_attr_last_name ] ) ) {
1521 + $last_name = $cas_attributes[ $cas_attr_last_name ];
1522 + } elseif ( is_array( $cas_attributes[ $cas_attr_last_name ] ) ) {
1523 + $last_name = trim( implode( ' ', $cas_attributes[ $cas_attr_last_name ] ) );
1524 + }
1525 + }
1526 +
467 1527 return array(
468 1528 'email' => $externally_authenticated_email,
469 1529 'username' => $username,
470 1530 'first_name' => $first_name,
@@ -470,8 +1530,9 @@
470 1530 'first_name' => $first_name,
471 1531 'last_name' => $last_name,
472 1532 'authenticated_by' => 'cas',
473 1533 'cas_attributes' => $cas_attributes,
1534 + 'cas_server_id' => $cas_server_id,
474 1535 );
475 1536 }
476 1537
477 1538
@@ -480,19 +1541,65 @@
480 1541 *
481 1542 * @param array $auth_settings Plugin settings.
482 1543 * @param string $username Attempted username from authenticate action.
483 1544 * @param string $password Attempted password from authenticate action.
1545 + * @param array $debug If provided, filled with an array of debug
1546 + * messages. Defaults to null.
1547 + *
484 1548 * @return array|WP_Error Array containing 'email' and 'authenticated_by' strings
485 1549 * for the successfully authenticated user, or WP_Error()
486 1550 * object on failure, or null if skipping LDAP auth and
487 1551 * falling back to WP auth.
488 1552 */
489 - protected function custom_authenticate_ldap( $auth_settings, $username, $password ) {
1553 + public function custom_authenticate_ldap( $auth_settings, $username, $password, &$debug = null ) {
1554 + // Make sure all LDAP settings are defined (user and password can be
1555 + // overridden by constant or filter and may not exist in auth_settings).
1556 + $defaults = array(
1557 + 'ldap' => '',
1558 + 'ldap_host' => '',
1559 + 'ldap_port' => '389',
1560 + 'ldap_tls' => '1',
1561 + 'ldap_search_base' => '',
1562 + 'ldap_search_filter' => '',
1563 + 'ldap_uid' => 'uid',
1564 + 'ldap_attr_email' => '',
1565 + 'ldap_user' => '',
1566 + 'ldap_password' => '',
1567 + 'ldap_lostpassword_url' => '',
1568 + 'ldap_attr_first_name' => '',
1569 + 'ldap_attr_last_name' => '',
1570 + 'ldap_attr_update_on_login' => '',
1571 + 'ldap_test_user' => '',
1572 + );
1573 + $auth_settings = wp_parse_args( $auth_settings, $defaults );
1574 +
1575 + // Initialize debug array if a variable was passed in.
1576 + if ( ! is_null( $debug ) ) {
1577 + $debug = array(
1578 + /* TRANSLATORS: Current time */
1579 + sprintf( __( '[%s] Attempting to authenticate via LDAP.', 'authorizer' ), wp_date( get_option( 'time_format' ) ) ),
1580 + );
1581 + }
1582 +
1583 + // Get LDAP host(s), and attempt each until we have a valid connection.
1584 + $ldap_hosts = explode( "\n", str_replace( "\r", '', trim( $auth_settings['ldap_host'] ) ) );
1585 +
1586 + // Fail silently (fall back to WordPress authentication) if no LDAP host specified.
1587 + if ( count( $ldap_hosts ) < 1 ) {
1588 + if ( is_array( $debug ) ) {
1589 + $debug[] = __( 'Failed: no LDAP Host(s) specified.', 'authorizer' );
1590 + }
1591 + return null;
1592 + }
1593 +
490 1594 // Get LDAP search base(s).
491 1595 $search_bases = explode( "\n", str_replace( "\r", '', trim( $auth_settings['ldap_search_base'] ) ) );
492 1596
493 1597 // Fail silently (fall back to WordPress authentication) if no search base specified.
494 1598 if ( count( $search_bases ) < 1 ) {
1599 + if ( is_array( $debug ) ) {
1600 + $debug[] = __( 'Failed: no LDAP Search Base(s) specified.', 'authorizer' );
1601 + }
495 1602 return null;
496 1603 }
497 1604
498 1605 // Get the FQDN from the first LDAP search base domain components (dc). For
@@ -510,9 +1617,9 @@
510 1617 // If we can't get the logging in user's email address from an LDAP attribute,
511 1618 // just use the domain from the LDAP host. This will only be used if we
512 1619 // can't discover the email address from an LDAP attribute.
513 1620 if ( empty( $domain ) ) {
514 - $domain = preg_match( '/[^.]*\.[^.]*$/', $auth_settings['ldap_host'], $matches ) === 1 ? $matches[0] : '';
1621 + $domain = preg_match( '/[^.]*\.[^.]*$/', $ldap_hosts[0], $matches ) === 1 ? $matches[0] : '';
515 1622 }
516 1623
517 1624 // remove @domain if it exists in the username (i.e., if user entered their email).
518 1625 $username = str_replace( '@' . $domain, '', $username );
@@ -521,21 +1628,33 @@
521 1628 // and password are empty (this will be the case when visiting wp-login.php
522 1629 // for the first time, or when clicking the Log In button without filling
523 1630 // out either field.
524 1631 if ( empty( $username ) && empty( $password ) ) {
1632 + if ( is_array( $debug ) ) {
1633 + $debug[] = __( 'Failed: empty username and password.', 'authorizer' );
1634 + }
525 1635 return null;
526 1636 }
527 1637
528 1638 // Fail with error message if username or password is blank.
529 1639 if ( empty( $username ) ) {
1640 + if ( is_array( $debug ) ) {
1641 + $debug[] = __( 'Failed: empty username.', 'authorizer' );
1642 + }
530 1643 return new \WP_Error( 'empty_username', __( 'You must provide a username or email.', 'authorizer' ) );
531 1644 }
532 1645 if ( empty( $password ) ) {
1646 + if ( is_array( $debug ) ) {
1647 + $debug[] = __( 'Failed: empty password.', 'authorizer' );
1648 + }
533 1649 return new \WP_Error( 'empty_password', __( 'You must provide a password.', 'authorizer' ) );
534 1650 }
535 1651
536 1652 // If php5-ldap extension isn't installed on server, fall back to WP auth.
537 1653 if ( ! function_exists( 'ldap_connect' ) ) {
1654 + if ( is_array( $debug ) ) {
1655 + $debug[] = __( 'Failed: php-ldap extension not installed.', 'authorizer' );
1656 + }
538 1657 return null;
539 1658 }
540 1659
541 1660 // Authenticate against LDAP using options provided in plugin settings.
@@ -544,52 +1663,178 @@
544 1663 $first_name = '';
545 1664 $last_name = '';
546 1665 $email = '';
547 1666
548 - // Construct LDAP connection parameters. ldap_connect() takes either a
549 - // hostname or a full LDAP URI as its first parameter (works with OpenLDAP
550 - // 2.x.x or later). If it's an LDAP URI, the second parameter, $port, is
551 - // ignored, and port must be specified in the full URI. An LDAP URI is of
552 - // the form ldap://hostname:port or ldaps://hostname:port.
553 - $ldap_host = $auth_settings['ldap_host'];
554 - $ldap_port = intval( $auth_settings['ldap_port'] );
555 - $parsed_host = wp_parse_url( $ldap_host );
556 - // Fail (fall back to WordPress auth) if invalid host is specified.
557 - if ( false === $parsed_host ) {
558 - return null;
559 - }
560 - // If a scheme is in the LDAP host, use full LDAP URI instead of just hostname.
561 - if ( array_key_exists( 'scheme', $parsed_host ) ) {
562 - // If the port isn't in the LDAP URI, use the one in the LDAP port field.
563 - if ( ! array_key_exists( 'port', $parsed_host ) ) {
564 - $parsed_host['port'] = $ldap_port;
1667 + // Attempt each LDAP host until we have a valid connection.
1668 + $ldap_valid = false;
1669 + foreach ( $ldap_hosts as $ldap_host ) {
1670 + // Construct LDAP connection parameters. In PHP < 8.3, ldap_connect()
1671 + // takes either a hostname or a full LDAP URI as its first parameter
1672 + // (works with OpenLDAP 2.x.x or later). If it's an LDAP URI, the second
1673 + // parameter, $port, is ignored, and port must be specified in the full
1674 + // URI. An LDAP URI is of the form ldap://hostname:port or
1675 + // ldaps://hostname:port.
1676 + // In PHP 8.3, ldap_connect() only takes a single param (the signature
1677 + // with 2 params is deprecated). We thus convert all LDAP hosts to a full
1678 + // LDAP URI, defaulting to ldap:// if the full URI isn't provided.
1679 + $ldap_port = intval( $auth_settings['ldap_port'] );
1680 + $parsed_host = wp_parse_url( $ldap_host );
1681 +
1682 + // Fail if invalid host is specified.
1683 + if ( false === $parsed_host ) {
1684 + if ( is_array( $debug ) ) {
1685 + /* TRANSLATORS: LDAP Host */
1686 + $debug[] = sprintf( __( 'Warning: could not parse host %s with wp_parse_url().', 'authorizer' ), $ldap_host );
1687 + }
1688 + continue;
565 1689 }
566 - $ldap_host = Helper::build_url( $parsed_host );
567 - }
568 1690
569 - // Establish LDAP connection.
570 - $ldap = ldap_connect( $ldap_host, $ldap_port );
571 - ldap_set_option( $ldap, LDAP_OPT_PROTOCOL_VERSION, 3 );
572 - if ( 1 === intval( $auth_settings['ldap_tls'] ) ) {
573 - if ( ! ldap_start_tls( $ldap ) ) {
574 - return null;
1691 + // If a scheme is in the LDAP host, use full LDAP URI instead of just hostname.
1692 + if ( array_key_exists( 'scheme', $parsed_host ) ) {
1693 + // If the port isn't in the LDAP URI, use the one in the LDAP port field.
1694 + if ( ! array_key_exists( 'port', $parsed_host ) ) {
1695 + $parsed_host['port'] = $ldap_port;
1696 + }
1697 + $ldap_host = Helper::build_url( $parsed_host );
1698 + } else {
1699 + // Construct the LDAP URI from the provided host and port.
1700 + $ldap_host = 'ldap://' . $ldap_host . ':' . $ldap_port;
575 1701 }
576 - }
577 1702
578 - // Set bind credentials; attempt an anonymous bind if not provided.
579 - $bind_rdn = null;
580 - $bind_password = null;
581 - if ( strlen( $auth_settings['ldap_user'] ) > 0 ) {
582 - $bind_rdn = $auth_settings['ldap_user'];
583 - $bind_password = Helper::decrypt( $auth_settings['ldap_password'] );
1703 + // Create LDAP connection.
1704 + $ldap = ldap_connect( $ldap_host );
1705 + ldap_set_option( $ldap, LDAP_OPT_PROTOCOL_VERSION, 3 );
1706 + ldap_set_option( $ldap, LDAP_OPT_REFERRALS, 0 );
1707 +
1708 + // Fail if we don't have a plausible LDAP URI.
1709 + if ( false === $ldap ) {
1710 + if ( is_array( $debug ) ) {
1711 + /* TRANSLATORS: LDAP Host */
1712 + $debug[] = sprintf( __( 'Warning: syntax check failed on host %s in ldap_connect().', 'authorizer' ), $ldap_host );
1713 + }
1714 + continue;
1715 + }
1716 +
1717 + // Attempt to start TLS if that setting is checked and we're not using ldaps protocol.
1718 + if ( 1 === intval( $auth_settings['ldap_tls'] ) && false === strpos( $ldap_host, 'ldaps://' ) ) {
1719 + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
1720 + if ( ! @ldap_start_tls( $ldap ) ) {
1721 + if ( is_array( $debug ) ) {
1722 + /* TRANSLATORS: LDAP Host */
1723 + $debug[] = sprintf( __( 'Warning: unable to start TLS on host %s:', 'authorizer' ), $ldap_host );
1724 + $debug[] = ldap_error( $ldap );
1725 + }
1726 + continue;
1727 + }
1728 + }
1729 +
1730 + // Allow overrides of the LDAP user from filter or constant.
1731 + if ( defined( 'AUTHORIZER_LDAP_USER' ) ) {
1732 + $auth_settings['ldap_user'] = \AUTHORIZER_LDAP_USER;
1733 + }
1734 + /**
1735 + * Filters the LDAP user used by Authorizer to authenticate.
1736 + *
1737 + * @since 3.6.2
1738 + *
1739 + * @param string $ldap_user The stored Oauth2 Client Secret.
1740 + */
1741 + $auth_settings['ldap_user'] = apply_filters( 'authorizer_ldap_user', $auth_settings['ldap_user'] );
1742 +
1743 + // Allow overrides of the LDAP password from filter or constant.
1744 + if ( defined( 'AUTHORIZER_LDAP_PASSWORD' ) ) {
1745 + $auth_settings['ldap_password'] = \AUTHORIZER_LDAP_PASSWORD;
1746 + }
1747 + /**
1748 + * Filters the LDAP password used by Authorizer to authenticate.
1749 + *
1750 + * @since 3.6.2
1751 + *
1752 + * @param string $ldap_password The stored Oauth2 Client Secret.
1753 + */
1754 + $auth_settings['ldap_password'] = apply_filters( 'authorizer_ldap_password', $auth_settings['ldap_password'] );
1755 +
1756 + // Set bind credentials; attempt an anonymous bind if not provided.
1757 + $bind_rdn = null;
1758 + $bind_password = null;
1759 + if ( strlen( $auth_settings['ldap_user'] ) > 0 ) {
1760 + $bind_rdn = $auth_settings['ldap_user'];
1761 + $bind_password = $auth_settings['ldap_password'];
1762 +
1763 + // Decrypt LDAP password if coming from wp_options database (not needed
1764 + // if it was provided via constant or filter).
1765 + if ( ! defined( 'AUTHORIZER_LDAP_PASSWORD' ) && ! has_filter( 'authorizer_ldap_password' ) ) {
1766 + $bind_password = Helper::decrypt( $bind_password );
1767 + }
1768 +
1769 + // If the bind user contains the [username] wildcard, replace it with
1770 + // the username and password of the user logging in.
1771 + if ( false !== strpos( $bind_rdn, '[username]' ) ) {
1772 + $bind_rdn = str_replace( '[username]', $username, $bind_rdn );
1773 + $bind_password = $password;
1774 +
1775 + if ( is_array( $debug ) ) {
1776 + /* TRANSLATORS: LDAP User DN */
1777 + $debug[] = sprintf( __( 'Performing bind as user logging in: %s.', 'authorizer' ), $bind_rdn );
1778 + }
1779 + }
1780 + }
1781 +
1782 + // Attempt LDAP bind.
1783 + $result = @ldap_bind( $ldap, $bind_rdn, stripslashes( $bind_password ) ); // phpcs:ignore
1784 + if ( ! $result ) {
1785 + if ( is_array( $debug ) ) {
1786 + /* TRANSLATORS: LDAP Host */
1787 + $debug[] = sprintf( __( 'Warning: unable to bind on host %1$s using directory user:', 'authorizer' ), $ldap_host );
1788 + $debug[] = ldap_error( $ldap );
1789 + }
1790 +
1791 + // We failed either an anonymous bind or a bind with a service account,
1792 + // so try to bind with the logging in user's credentials before failing.
1793 + // Note: multiple search bases can be provided, so iterate through them
1794 + // trying to bind as the user logging in.
1795 + foreach ( $search_bases as $search_base ) {
1796 + $bind_user_dn = $auth_settings['ldap_uid'] . '=' . $username . ',' . $search_base;
1797 + $result = @ldap_bind( $ldap, $bind_user_dn, stripslashes( $password ) ); // phpcs:ignore
1798 + if ( $result ) {
1799 + if ( is_array( $debug ) ) {
1800 + /* TRANSLATORS: LDAP User DN */
1801 + $debug[] = sprintf( __( 'Successful bind using LDAP user DN %s instead of directory user.', 'authorizer' ), $bind_user_dn );
1802 + }
1803 +
1804 + break;
1805 + }
1806 + }
1807 +
1808 + if ( ! $result ) {
1809 + if ( is_array( $debug ) ) {
1810 + /* TRANSLATORS: LDAP User */
1811 + $debug[] = sprintf( __( 'Failed: password incorrect for LDAP user %s.', 'authorizer' ), $username );
1812 + $debug[] = ldap_error( $ldap );
1813 + }
1814 +
1815 + // Can't connect to LDAP, so fall back to WordPress authentication.
1816 + continue;
1817 + }
1818 + }
1819 +
1820 + // If we've reached this, we have a valid ldap connection and bind.
1821 + $ldap_valid = true;
1822 + if ( is_array( $debug ) ) {
1823 + /* TRANSLATORS: LDAP Host */
1824 + $debug[] = sprintf( __( 'Connected to LDAP host %s.', 'authorizer' ), $ldap_host );
1825 + }
1826 + break;
584 1827 }
585 1828
586 - // Attempt LDAP bind.
587 - $result = @ldap_bind( $ldap, $bind_rdn, stripslashes( $bind_password ) ); // phpcs:ignore
588 - if ( ! $result ) {
589 - // Can't connect to LDAP, so fall back to WordPress authentication.
1829 + // Move to next authentication method if we don't have a valid LDAP connection.
1830 + if ( ! $ldap_valid ) {
1831 + if ( is_array( $debug ) ) {
1832 + $debug[] = __( 'Failed: unable to connect to any LDAP host.', 'authorizer' );
1833 + }
590 1834 return null;
591 1835 }
1836 +
592 1837 // Look up the bind DN (and first/last name) of the user trying to
593 1838 // log in by performing an LDAP search for the login username in
594 1839 // the field specified in the LDAP settings. This setup is common.
595 1840 $ldap_attributes_to_retrieve = array( 'dn' );
@@ -602,11 +1847,36 @@
602 1847 if ( array_key_exists( 'ldap_attr_email', $auth_settings ) && strlen( $auth_settings['ldap_attr_email'] ) > 0 && substr( $auth_settings['ldap_attr_email'], 0, 1 ) !== '@' ) {
603 1848 array_push( $ldap_attributes_to_retrieve, Helper::lowercase( $auth_settings['ldap_attr_email'] ) );
604 1849 }
605 1850
606 - // Create default LDAP search filter (uid=$username).
607 - $search_filter = '(' . $auth_settings['ldap_uid'] . '=' . $username . ')';
1851 + /**
1852 + * Specify additional LDAP user attributes to retrieve during authentication.
1853 + * May be used by plugins in `authorizer_user_register`.
1854 + *
1855 + * @param array $attributes LDAP attributes to retrieve in addition to first name, last name and email.
1856 + */
1857 + $additional_ldap_attributes_to_retrieve = apply_filters( 'authorizer_additional_ldap_attributes_to_retrieve', array() );
1858 + $ldap_attributes_to_retrieve = array_merge( $ldap_attributes_to_retrieve, $additional_ldap_attributes_to_retrieve );
608 1859
1860 + // Create default LDAP search filter. If LDAP email attribute is provided,
1861 + // use (|(uid=$username)(mail=$username)) instead (so logins with either a
1862 + // username or an email address will work). Otherwise use (uid=$username).
1863 + if ( array_key_exists( 'ldap_attr_email', $auth_settings ) && strlen( $auth_settings['ldap_attr_email'] ) > 0 && substr( $auth_settings['ldap_attr_email'], 0, 1 ) !== '@' ) {
1864 + $search_filter =
1865 + '(|' .
1866 + '(' . $auth_settings['ldap_uid'] . '=' . $username . ')' .
1867 + '(' . $auth_settings['ldap_attr_email'] . '=' . $username . ')' .
1868 + ')';
1869 + } else {
1870 + $search_filter = '(' . $auth_settings['ldap_uid'] . '=' . $username . ')';
1871 + }
1872 +
1873 + // Merge LDAP search filter from plugin settings if it exists.
1874 + $ldap_search_filter = trim( $auth_settings['ldap_search_filter'] );
1875 + if ( ! empty( $ldap_search_filter ) ) {
1876 + $search_filter = '(&' . $search_filter . $ldap_search_filter . ')';
1877 + }
1878 +
609 1879 /**
610 1880 * Filter LDAP search filter.
611 1881 *
612 1882 * Allows for custom LDAP authentication rules (e.g., restricting login
@@ -617,24 +1887,40 @@
617 1887 * @param string $username The username attempting to log in.
618 1888 */
619 1889 $search_filter = apply_filters( 'authorizer_ldap_search_filter', $search_filter, $auth_settings['ldap_uid'], $username );
620 1890
1891 + if ( is_array( $debug ) ) {
1892 + /* TRANSLATORS: LDAP search filter */
1893 + $debug[] = sprintf( __( 'Using LDAP search filter: %s', 'authorizer' ), $search_filter );
1894 + }
1895 +
621 1896 // Multiple search bases can be provided, so iterate through them until a match is found.
622 1897 foreach ( $search_bases as $search_base ) {
623 - $ldap_search = ldap_search(
1898 + $ldap_search = @ldap_search( // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
624 1899 $ldap,
625 1900 $search_base,
626 1901 $search_filter,
627 1902 $ldap_attributes_to_retrieve
628 1903 );
629 - $ldap_entries = ldap_get_entries( $ldap, $ldap_search );
1904 + $ldap_entries = empty( $ldap_search ) ? array( 'count' => 0 ) : ldap_get_entries( $ldap, $ldap_search );
630 1905 if ( $ldap_entries['count'] > 0 ) {
1906 + if ( is_array( $debug ) ) {
1907 + /* TRANSLATORS: 1: LDAP user 2: LDAP search base */
1908 + $debug[] = sprintf( __( 'Found user %1$s in search base: %2$s', 'authorizer' ), $username, $search_base );
1909 + }
631 1910 break;
1911 + } elseif ( is_array( $debug ) ) {
1912 + /* TRANSLATORS: 1: LDAP user 2: LDAP search base */
1913 + $debug[] = sprintf( __( 'Failed to find user %1$s in %2$s. Trying next search base.', 'authorizer' ), $username, $search_base );
632 1914 }
633 1915 }
634 1916
635 1917 // If we didn't find any users in ldap, fall back to WordPress authentication.
636 1918 if ( $ldap_entries['count'] < 1 ) {
1919 + if ( is_array( $debug ) ) {
1920 + /* TRANSLATORS: LDAP User */
1921 + $debug[] = sprintf( __( 'Failed: no LDAP user %s found.', 'authorizer' ), $username );
1922 + }
637 1923 return null;
638 1924 }
639 1925
640 1926 // Get the bind dn and first/last names; if there are multiple results returned, just get the last one.
@@ -666,8 +1952,12 @@
666 1952 }
667 1953
668 1954 $result = @ldap_bind( $ldap, $ldap_user_dn, stripslashes( $password ) ); // phpcs:ignore
669 1955 if ( ! $result ) {
1956 + if ( is_array( $debug ) ) {
1957 + /* TRANSLATORS: LDAP User */
1958 + $debug[] = sprintf( __( 'Failed: password incorrect for LDAP user %s.', 'authorizer' ), $username );
1959 + }
670 1960 // We have a real ldap user, but an invalid password. Pass
671 1961 // through to wp authentication after failing LDAP (since
672 1962 // this could be a local account that happens to be the
673 1963 // same name as an LDAP user).
@@ -681,8 +1971,13 @@
681 1971 if ( strlen( $email ) > 0 ) {
682 1972 $externally_authenticated_email = Helper::lowercase( $email );
683 1973 }
684 1974
1975 + if ( is_array( $debug ) ) {
1976 + /* TRANSLATORS: 1: Current time 2: LDAP User 3: LDAP user email */
1977 + $debug[] = sprintf( __( '[%1$s] Successfully authenticated user %2$s (%3$s) via LDAP.', 'authorizer' ), wp_date( get_option( 'time_format' ) ), $username, $externally_authenticated_email );
1978 + }
1979 +
685 1980 return array(
686 1981 'email' => $externally_authenticated_email,
687 1982 'username' => $username,
688 1983 'first_name' => $first_name,
@@ -693,15 +1988,37 @@
693 1988 }
694 1989
695 1990
696 1991 /**
1992 + * Fetch the logging out user's external service (so we can log out of it
1993 + * below in the wp_logout hook).
1994 + *
1995 + * Action: clear_auth_cookie
1996 + *
1997 + * @return void
1998 + */
1999 + public function pre_logout() {
2000 + self::$authenticated_by = get_user_meta( get_current_user_id(), 'authenticated_by', true );
2001 +
2002 + // If we didn't find an authenticated method, check $_REQUEST (if this is a
2003 + // pending user facing the "no access" message, their logout link will
2004 + // include "external=?" since they don't have a WP_User to attach the
2005 + // "authenticated_by" usermeta to).
2006 + if ( empty( self::$authenticated_by ) && ! empty( $_REQUEST['external'] ) ) {
2007 + self::$authenticated_by = $_REQUEST['external'];
2008 + }
2009 + }
2010 +
2011 + /**
697 2012 * Log out of the attached external service.
698 2013 *
699 2014 * Action: wp_logout
700 2015 *
2016 + * @param int $user_id ID of the user that was logged out.
2017 + *
701 2018 * @return void
702 2019 */
703 - public function custom_logout() {
2020 + public function custom_logout( $user_id ) {
704 2021 // Grab plugin settings.
705 2022 $options = Options::get_instance();
706 2023 $auth_settings = $options->get_all( Helper::SINGLE_CONTEXT, 'allow override' );
707 2024
@@ -707,34 +2024,32 @@
707 2024
708 2025 // Reset option containing old error messages.
709 2026 delete_option( 'auth_settings_advanced_login_error' );
710 2027
711 - if ( session_id() === '' ) {
712 - session_start();
713 - }
714 -
715 - $current_user_authenticated_by = get_user_meta( get_current_user_id(), 'authenticated_by', true );
716 -
717 2028 // If logged in to CAS, Log out of CAS.
718 - if ( 'cas' === $current_user_authenticated_by && '1' === $auth_settings['cas'] ) {
2029 + if ( 'cas' === self::$authenticated_by && '1' === $auth_settings['cas'] ) {
719 2030 if ( ! array_key_exists( 'PHPCAS_CLIENT', $GLOBALS ) || ! array_key_exists( 'phpCAS', $_SESSION ) ) {
720 2031
721 2032 /**
722 - * Get the CAS server version (default to SAML_VERSION_1_1).
2033 + * Get the CAS server protocol version (default to SAML 1.1).
723 2034 *
724 - * @see: https://developer.jasig.org/cas-clients/php/1.3.4/docs/api/group__public.html
2035 + * @see: https://apereo.github.io/phpCAS/api/group__public.html#gadea9415f40b8d2afc39f140c9be83bbe
725 2036 */
726 - $cas_version = SAML_VERSION_1_1;
727 - if ( 'CAS_VERSION_3_0' === $auth_settings['cas_version'] ) {
728 - $cas_version = CAS_VERSION_3_0;
729 - } elseif ( 'CAS_VERSION_2_0' === $auth_settings['cas_version'] ) {
730 - $cas_version = CAS_VERSION_2_0;
731 - } elseif ( 'CAS_VERSION_1_0' === $auth_settings['cas_version'] ) {
732 - $cas_version = CAS_VERSION_1_0;
733 - }
2037 + $cas_version = Options\External\Cas::get_instance()->sanitize_cas_version( $auth_settings['cas_version'] );
734 2038
2039 + /**
2040 + * Get valid service URLs for the CAS client to validate against.
2041 + *
2042 + * @see: https://github.com/apereo/phpCAS/security/advisories/GHSA-8q72-6qq8-xv64
2043 + */
2044 + $valid_base_urls = Options\External\Cas::get_instance()->get_valid_cas_service_urls();
2045 +
735 2046 // Set the CAS client configuration if it hasn't been set already.
736 - \phpCAS::client( $cas_version, $auth_settings['cas_host'], intval( $auth_settings['cas_port'] ), $auth_settings['cas_path'] );
2047 + if ( 'PROXY' === strtoupper( $auth_settings['cas_method'] ) ) {
2048 + \phpCAS::proxy( $cas_version, $auth_settings['cas_host'], intval( $auth_settings['cas_port'] ), $auth_settings['cas_path'], $valid_base_urls );
2049 + } else {
2050 + \phpCAS::client( $cas_version, $auth_settings['cas_host'], intval( $auth_settings['cas_port'] ), $auth_settings['cas_path'], $valid_base_urls );
2051 + }
737 2052 // Allow redirects at the CAS server endpoint (e.g., allow connections
738 2053 // at an old CAS URL that redirects to a newer CAS URL).
739 2054 \phpCAS::setExtraCurlOption( CURLOPT_FOLLOWLOCATION, true );
740 2055 // Restrict logout request origin to the CAS server only (prevent DDOS).
@@ -751,22 +2066,45 @@
751 2066 }
752 2067 }
753 2068
754 2069 // If session token set, log out of Google.
755 - if ( 'google' === $current_user_authenticated_by || array_key_exists( 'token', $_SESSION ) ) {
756 - $token = json_decode( $_SESSION['token'] )->access_token;
2070 + if ( PHP_SESSION_NONE === session_status() ) {
2071 + session_start();
2072 + }
2073 + if ( 'google' === self::$authenticated_by && array_key_exists( 'token', $_SESSION ) ) {
2074 + $token = $_SESSION['token'];
757 2075
758 - // Add Google API PHP Client.
759 - // @see https://github.com/google/google-api-php-client branch:v1-master
760 - if ( ! function_exists( 'google_api_php_client_autoload' ) ) {
761 - require_once dirname( plugin_root() ) . '/vendor/google-api-php-client/src/Google/autoload.php';
2076 + // Fetch the Google Client ID (allow overrides from filter or constant).
2077 + if ( defined( 'AUTHORIZER_GOOGLE_CLIENT_ID' ) ) {
2078 + $auth_settings['google_clientid'] = \AUTHORIZER_GOOGLE_CLIENT_ID;
762 2079 }
2080 + /**
2081 + * Filters the Google Client ID used by Authorizer to authenticate.
2082 + *
2083 + * @since 3.9.0
2084 + *
2085 + * @param string $google_client_id The stored Google Client ID.
2086 + */
2087 + $auth_settings['google_clientid'] = apply_filters( 'authorizer_google_client_id', $auth_settings['google_clientid'] );
763 2088
2089 + // Fetch the Google Client Secret (allow overrides from filter or constant).
2090 + if ( defined( 'AUTHORIZER_GOOGLE_CLIENT_SECRET' ) ) {
2091 + $auth_settings['google_clientsecret'] = \AUTHORIZER_GOOGLE_CLIENT_SECRET;
2092 + }
2093 + /**
2094 + * Filters the Google Client Secret used by Authorizer to authenticate.
2095 + *
2096 + * @since 3.6.1
2097 + *
2098 + * @param string $google_client_secret The stored Google Client Secret.
2099 + */
2100 + $auth_settings['google_clientsecret'] = apply_filters( 'authorizer_google_client_secret', $auth_settings['google_clientsecret'] );
2101 +
764 2102 // Build the Google Client.
765 2103 $client = new \Google_Client();
766 2104 $client->setApplicationName( 'WordPress' );
767 - $client->setClientId( $auth_settings['google_clientid'] );
768 - $client->setClientSecret( $auth_settings['google_clientsecret'] );
2105 + $client->setClientId( trim( $auth_settings['google_clientid'] ) );
2106 + $client->setClientSecret( trim( $auth_settings['google_clientsecret'] ) );
769 2107 $client->setRedirectUri( 'postmessage' );
770 2108
771 2109 // Revoke the token.
772 2110 $client->revokeToken( $token );
@@ -773,7 +2111,100 @@
773 2111
774 2112 // Remove the credentials from the user's session.
775 2113 unset( $_SESSION['token'] );
776 2114 }
2115 +
2116 + // If logged in via OIDC, perform RP-initiated logout if supported.
2117 + if ( 'oidc' === self::$authenticated_by && '1' === $auth_settings['oidc'] ) {
2118 + $id_token_hint = get_user_meta( $user_id, 'oidc_id_token', true );
2119 + $oidc_server_id = get_user_meta( $user_id, 'oidc_server_id', true );
2120 + if ( empty( $oidc_server_id ) ) {
2121 + $oidc_server_id = 1;
2122 + }
2123 +
2124 + // Get issuer and credentials for the server that was used.
2125 + $suffix = $oidc_server_id > 1 ? '_' . $oidc_server_id : '';
2126 + $oidc_issuer = $auth_settings[ 'oidc_issuer' . $suffix ] ?? '';
2127 + $oidc_client_id = $auth_settings[ 'oidc_client_id' . $suffix ] ?? '';
2128 + $oidc_client_secret = $auth_settings[ 'oidc_client_secret' . $suffix ] ?? '';
2129 +
2130 + // Fetch the OIDC Client ID (allow overrides from filter or constant).
2131 + // Note: constant/filter overrides are only supported for a single OIDC server.
2132 + if ( 1 === $oidc_server_id && defined( 'AUTHORIZER_OIDC_CLIENT_ID' ) ) {
2133 + $oidc_client_id = \AUTHORIZER_OIDC_CLIENT_ID;
2134 + }
2135 + /**
2136 + * Filters the OIDC Client ID used by Authorizer to authenticate.
2137 + *
2138 + * @since 3.11.0
2139 + *
2140 + * @param string $oidc_client_id The stored OIDC Client ID.
2141 + */
2142 + if ( 1 === $oidc_server_id ) {
2143 + $oidc_client_id = apply_filters( 'authorizer_oidc_client_id', $oidc_client_id );
2144 + }
2145 +
2146 + // Fetch the OIDC Client Secret (allow overrides from filter or constant).
2147 + // Note: constant/filter overrides are only supported for a single OIDC server.
2148 + if ( 1 === $oidc_server_id && defined( 'AUTHORIZER_OIDC_CLIENT_SECRET' ) ) {
2149 + $oidc_client_secret = \AUTHORIZER_OIDC_CLIENT_SECRET;
2150 + }
2151 + /**
2152 + * Filters the OIDC Client Secret used by Authorizer to authenticate.
2153 + *
2154 + * @since 3.11.0
2155 + *
2156 + * @param string $oidc_client_secret The stored OIDC Client Secret.
2157 + */
2158 + if ( 1 === $oidc_server_id ) {
2159 + $oidc_client_secret = apply_filters( 'authorizer_oidc_client_secret', $oidc_client_secret );
2160 + }
2161 +
2162 + if ( ! empty( $oidc_issuer ) && ! empty( $oidc_client_id ) && ! empty( $oidc_client_secret ) ) {
2163 + try {
2164 + // Initialize OIDC client (library handles discovery automatically).
2165 + $oidc = new \Jumbojett\OpenIDConnectClient(
2166 + $oidc_issuer,
2167 + $oidc_client_id,
2168 + $oidc_client_secret
2169 + );
2170 +
2171 + // Determine redirect URL.
2172 + $redirect_to = site_url( '/' );
2173 + if ( ! empty( $_REQUEST['redirect_to'] ) && isset( $_REQUEST['_wpnonce'] ) && wp_verify_nonce( sanitize_key( $_REQUEST['_wpnonce'] ), 'log-out' ) ) {
2174 + $redirect_to = esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) );
2175 + }
2176 +
2177 + // Set session flag to prevent auto-login after logout redirect (only if auto-login is enabled).
2178 + // This survives the external redirect through the IDP and works regardless
2179 + // of where the IDP redirects back to (wp-login.php, wp-admin, /, etc.).
2180 + // Only needed if auto-login is enabled; if disabled, there's no auto-login to prevent.
2181 + if ( ! empty( $auth_settings['oidc_auto_login'] ) && in_array( intval( $auth_settings['oidc_auto_login'] ), range( 1, 20 ), true ) ) {
2182 + if ( PHP_SESSION_NONE === session_status() ) {
2183 + session_start();
2184 + }
2185 + $_SESSION['oidc_logged_out'] = true;
2186 + }
2187 +
2188 + // Clean up user meta before redirect (library's signOut() will exit).
2189 + \Authorizer\Options\External\Oidc::get_instance()->delete_oidc_user_meta( $user_id );
2190 +
2191 + // Use library's signOut() method (handles discovery, URL building, and redirect).
2192 + // Pass empty string if no ID token (library will still include it in params).
2193 + $oidc->signOut( $id_token_hint ?? '', $redirect_to );
2194 + // signOut() calls exit, so this line should never be reached.
2195 + } catch ( \Jumbojett\OpenIDConnectClientException $e ) {
2196 + // Provider doesn't support RP-initiated logout (no end_session_endpoint) or other error.
2197 + // Clean up and continue with normal WordPress logout.
2198 + \Authorizer\Options\External\Oidc::get_instance()->delete_oidc_user_meta( $user_id );
2199 + } catch ( \Exception $e ) {
2200 + // Fallback to local logout if RP-initiated logout fails.
2201 + // Clean up and continue with normal WordPress logout.
2202 + \Authorizer\Options\External\Oidc::get_instance()->delete_oidc_user_meta( $user_id );
2203 + }
2204 + } else {
2205 + // No OIDC issuer/credentials configured - clean up and continue with normal WordPress logout.
2206 + \Authorizer\Options\External\Oidc::get_instance()->delete_oidc_user_meta( $user_id );
2207 + }
2208 + }
777 2209 }
778 -
779 2210 }