PluginProbe
Authorizer / 2.9.12
Authorizer v2.9.12
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
authorizer / src / authorizer / class-authentication.php

class-authentication.php in Authorizer 2.9.12, at src/authorizer/class-authentication.php

896 lines 37.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Authorizer
4 *
5 * @license GPL-2.0+
6 * @link https://github.com/uhm-coe/authorizer
7 * @package authorizer
8 */
9
10 namespace Authorizer;
11
12 use Authorizer\Helper;
13 use Authorizer\Options;
14 use Authorizer\Authorization;
15
16 /**
17 * Implements the authentication (is user who they say they are?) features of
18 * the plugin.
19 */
20 class Authentication extends Static_Instance {
21
22 /**
23 * Tracks the external service used by the user currently logging out.
24 * @var string
25 */
26 private static $authenticated_by = '';
27
28 /**
29 * Authenticate against an external service.
30 *
31 * Filter: authenticate
32 *
33 * @param WP_User $user user to authenticate.
34 * @param string $username optional username to authenticate.
35 * @param string $password optional password to authenticate.
36 * @return WP_User|WP_Error WP_User on success, WP_Error on failure.
37 */
38 public function custom_authenticate( $user, $username, $password ) {
39 // Pass through if already authenticated.
40 if ( is_a( $user, 'WP_User' ) ) {
41 return $user;
42 } else {
43 $user = null;
44 }
45
46 // If username and password are blank, this isn't a log in attempt.
47 $is_login_attempt = strlen( $username ) > 0 && strlen( $password ) > 0;
48
49 // Check to make sure that $username is not locked out due to too
50 // many invalid login attempts. If it is, tell the user how much
51 // time remains until they can try again.
52 $unauthenticated_user = $is_login_attempt ? get_user_by( 'login', $username ) : false;
53 $unauthenticated_user_is_blocked = false;
54 if ( $is_login_attempt && false !== $unauthenticated_user ) {
55 $last_attempt = get_user_meta( $unauthenticated_user->ID, 'auth_settings_advanced_lockouts_time_last_failed', true );
56 $num_attempts = get_user_meta( $unauthenticated_user->ID, 'auth_settings_advanced_lockouts_failed_attempts', true );
57 // Also check the auth_blocked user_meta flag (users in blocked list will get this flag).
58 $unauthenticated_user_is_blocked = get_user_meta( $unauthenticated_user->ID, 'auth_blocked', true ) === 'yes';
59 } else {
60 $last_attempt = get_option( 'auth_settings_advanced_lockouts_time_last_failed' );
61 $num_attempts = get_option( 'auth_settings_advanced_lockouts_failed_attempts' );
62 }
63
64 // Inactive users should be treated like deleted users (we just
65 // do this to preserve any content they created, but here we should
66 // pretend they don't exist).
67 if ( $unauthenticated_user_is_blocked ) {
68 remove_filter( 'authenticate', 'wp_authenticate_username_password', 20, 3 );
69 remove_filter( 'authenticate', 'wp_authenticate_email_password', 20, 3 );
70 return new \WP_Error( 'empty_password', __( '<strong>ERROR</strong>: Incorrect username or password.', 'authorizer' ) );
71 }
72
73 // Grab plugin settings.
74 $options = Options::get_instance();
75 $auth_settings = $options->get_all( Helper::SINGLE_CONTEXT, 'allow override' );
76
77 // Make sure $last_attempt (time) and $num_attempts are positive integers.
78 // Note: this addresses resetting them if either is unset from above.
79 $last_attempt = abs( intval( $last_attempt ) );
80 $num_attempts = abs( intval( $num_attempts ) );
81
82 // Create semantic lockout variables.
83 $lockouts = $auth_settings['advanced_lockouts'];
84 $time_since_last_fail = time() - $last_attempt;
85 $reset_duration = $lockouts['reset_duration'] * 60; // minutes to seconds.
86 $num_attempts_long_lockout = $lockouts['attempts_1'] + $lockouts['attempts_2'];
87 $num_attempts_short_lockout = $lockouts['attempts_1'];
88 $seconds_remaining_long_lockout = $lockouts['duration_2'] * 60 - $time_since_last_fail;
89 $seconds_remaining_short_lockout = $lockouts['duration_1'] * 60 - $time_since_last_fail;
90
91 // Check if we need to institute a lockout delay.
92 if ( $is_login_attempt && $time_since_last_fail > $reset_duration ) {
93 // Enough time has passed since the last invalid attempt and
94 // now that we can reset the failed attempt count, and let this
95 // login attempt go through.
96 $num_attempts = 0; // This does nothing, but include it for semantic meaning.
97 } elseif ( $is_login_attempt && $num_attempts > $num_attempts_long_lockout && $seconds_remaining_long_lockout > 0 ) {
98 // Stronger lockout (1st/2nd round of invalid attempts reached)
99 // Note: set the error code to 'empty_password' so it doesn't
100 // trigger the wp_login_failed hook, which would continue to
101 // increment the failed attempt count.
102 remove_filter( 'authenticate', 'wp_authenticate_username_password', 20, 3 );
103 remove_filter( 'authenticate', 'wp_authenticate_email_password', 20, 3 );
104 return new \WP_Error(
105 'empty_password',
106 sprintf(
107 /* TRANSLATORS: 1: username 2: duration of lockout in seconds 3: duration of lockout as a phrase 4: lost password URL */
108 __( '<strong>ERROR</strong>: There have been too many invalid login attempts for the username <strong>%1$s</strong>. Please wait <strong id="seconds_remaining" data-seconds="%2$s">%3$s</strong> before trying again. <a href="%4$s" title="Password Lost and Found">Lost your password</a>?', 'authorizer' ),
109 $username,
110 $seconds_remaining_long_lockout,
111 Helper::seconds_as_sentence( $seconds_remaining_long_lockout ),
112 wp_lostpassword_url()
113 )
114 );
115 } elseif ( $is_login_attempt && $num_attempts > $num_attempts_short_lockout && $seconds_remaining_short_lockout > 0 ) {
116 // Normal lockout (1st round of invalid attempts reached)
117 // Note: set the error code to 'empty_password' so it doesn't
118 // trigger the wp_login_failed hook, which would continue to
119 // increment the failed attempt count.
120 remove_filter( 'authenticate', 'wp_authenticate_username_password', 20, 3 );
121 remove_filter( 'authenticate', 'wp_authenticate_email_password', 20, 3 );
122 return new \WP_Error(
123 'empty_password',
124 sprintf(
125 /* TRANSLATORS: 1: username 2: duration of lockout in seconds 3: duration of lockout as a phrase 4: lost password URL */
126 __( '<strong>ERROR</strong>: There have been too many invalid login attempts for the username <strong>%1$s</strong>. Please wait <strong id="seconds_remaining" data-seconds="%2$s">%3$s</strong> before trying again. <a href="%4$s" title="Password Lost and Found">Lost your password</a>?', 'authorizer' ),
127 $username,
128 $seconds_remaining_short_lockout,
129 Helper::seconds_as_sentence( $seconds_remaining_short_lockout ),
130 wp_lostpassword_url()
131 )
132 );
133 }
134
135 // Start external authentication.
136 $externally_authenticated_emails = array();
137 $authenticated_by = '';
138 $result = null;
139
140 // Try Google authentication if it's enabled and we don't have a
141 // successful login yet.
142 if (
143 '1' === $auth_settings['google'] &&
144 0 === count( $externally_authenticated_emails ) &&
145 ! is_wp_error( $result )
146 ) {
147 $result = $this->custom_authenticate_google( $auth_settings );
148 if ( ! is_null( $result ) && ! is_wp_error( $result ) ) {
149 if ( is_array( $result['email'] ) ) {
150 $externally_authenticated_emails = $result['email'];
151 } else {
152 $externally_authenticated_emails[] = $result['email'];
153 }
154 $authenticated_by = $result['authenticated_by'];
155 }
156 }
157
158 // Try CAS authentication if it's enabled and we don't have a
159 // successful login yet.
160 if (
161 '1' === $auth_settings['cas'] &&
162 0 === count( $externally_authenticated_emails ) &&
163 ! is_wp_error( $result )
164 ) {
165 $result = $this->custom_authenticate_cas( $auth_settings );
166 if ( ! is_null( $result ) && ! is_wp_error( $result ) ) {
167 if ( is_array( $result['email'] ) ) {
168 $externally_authenticated_emails = $result['email'];
169 } else {
170 $externally_authenticated_emails[] = $result['email'];
171 }
172 $authenticated_by = $result['authenticated_by'];
173 }
174 }
175
176 // Try LDAP authentication if it's enabled and we don't have an
177 // authenticated user yet.
178 if (
179 '1' === $auth_settings['ldap'] &&
180 0 === count( $externally_authenticated_emails ) &&
181 ! is_wp_error( $result )
182 ) {
183 $result = $this->custom_authenticate_ldap( $auth_settings, $username, $password );
184 if ( ! is_null( $result ) && ! is_wp_error( $result ) ) {
185 if ( is_array( $result['email'] ) ) {
186 $externally_authenticated_emails = $result['email'];
187 } else {
188 $externally_authenticated_emails[] = $result['email'];
189 }
190 $authenticated_by = $result['authenticated_by'];
191 }
192 }
193
194 // If we don't have an externally authenticated user, either skip to
195 // WordPress authentication (if WordPress logins are enabled), or return
196 // an error (if WordPress logins are disabled and at least one external
197 // service is enabled).
198 if ( count( array_filter( $externally_authenticated_emails ) ) < 1 ) {
199 if (
200 array_key_exists( 'advanced_disable_wp_login', $auth_settings ) &&
201 '1' === $auth_settings['advanced_disable_wp_login'] &&
202 (
203 '1' === $auth_settings['cas'] ||
204 '1' === $auth_settings['google'] ||
205 '1' === $auth_settings['ldap']
206 )
207 ) {
208 remove_filter( 'authenticate', 'wp_authenticate_username_password', 20, 3 );
209 remove_filter( 'authenticate', 'wp_authenticate_email_password', 20, 3 );
210
211 $error = new \WP_Error();
212
213 if ( empty( $username ) ) {
214 $error->add( 'empty_username', __( '<strong>ERROR</strong>: The username field is empty.' ) );
215 }
216
217 if ( empty( $password ) ) {
218 $error->add( 'empty_password', __( '<strong>ERROR</strong>: The password field is empty.' ) );
219 }
220
221 return $error;
222 }
223
224 return $result;
225 }
226
227 // Remove duplicate and blank emails, if any.
228 $externally_authenticated_emails = array_filter( array_unique( $externally_authenticated_emails ) );
229
230 /**
231 * If we've made it this far, we should have an externally
232 * authenticated user. The following should be set:
233 * $externally_authenticated_emails
234 * $authenticated_by
235 */
236
237 // Look for an existing WordPress account matching the externally
238 // authenticated user. Perform the match either by username or email.
239 if ( isset( $auth_settings['cas_link_on_username'] ) && 1 === intval( $auth_settings['cas_link_on_username'] ) ) {
240 // Get the external user's WordPress account by username. This is less
241 // secure, but a user reported having an installation where a previous
242 // CAS plugin had created over 9000 WordPress accounts without email
243 // addresses. This option was created to support that case, and any
244 // other CAS servers where emails are not used as account identifiers.
245 $user = get_user_by( 'login', $result['username'] );
246 } else {
247 // Get the external user's WordPress account by email address. This is
248 // the normal behavior (and the most secure).
249 foreach ( $externally_authenticated_emails as $externally_authenticated_email ) {
250 $user = get_user_by( 'email', Helper::lowercase( $externally_authenticated_email ) );
251 // Stop trying email addresses once we have found a match.
252 if ( false !== $user ) {
253 break;
254 }
255 }
256 }
257
258 // We'll track how this user was authenticated in user meta.
259 if ( $user ) {
260 update_user_meta( $user->ID, 'authenticated_by', $authenticated_by );
261 }
262
263 // Check this external user's access against the access lists
264 // (pending, approved, blocked).
265 $result = Authorization::get_instance()->check_user_access( $user, $externally_authenticated_emails, $result );
266
267 // Fail with message if there was an error creating/adding the user.
268 if ( is_wp_error( $result ) || 0 === $result ) {
269 return $result;
270 }
271
272 // If we have a valid user from check_user_access(), log that user in.
273 if ( get_class( $result ) === 'WP_User' ) {
274 $user = $result;
275 }
276
277 // If we haven't exited yet, we have a valid/approved user, so authenticate them.
278 return $user;
279 }
280
281
282 /**
283 * Validate this user's credentials against Google.
284 *
285 * @param array $auth_settings Plugin settings.
286 * @return array|WP_Error Array containing email, authenticated_by, first_name,
287 * last_name, and username strings for the successfully
288 * authenticated user, or WP_Error() object on failure,
289 * or null if not attempting a google login.
290 */
291 protected function custom_authenticate_google( $auth_settings ) {
292 // Move on if Google auth hasn't been requested here.
293 // phpcs:ignore WordPress.Security.NonceVerification
294 if ( empty( $_GET['external'] ) || 'google' !== $_GET['external'] ) {
295 return null;
296 }
297
298 // Get one time use token.
299 session_start();
300 $token = array_key_exists( 'token', $_SESSION ) ? json_decode( $_SESSION['token'], true ) : null;
301
302 // No token, so this is not a succesful Google login.
303 if ( empty( $token ) ) {
304 return null;
305 }
306
307 // Add Google API PHP Client.
308 // @see https://github.com/googleapis/google-api-php-client/releases v2.2.4_PHP54
309 if ( ! class_exists( 'Google_Client' ) ) {
310 require_once dirname( plugin_root() ) . '/vendor/google-api-php-client-v2/vendor/autoload.php';
311 }
312
313 // Build the Google Client.
314 $client = new \Google_Client();
315 $client->setApplicationName( 'WordPress' );
316 $client->setClientId( $auth_settings['google_clientid'] );
317 $client->setClientSecret( $auth_settings['google_clientsecret'] );
318 $client->setRedirectUri( 'postmessage' );
319
320 /**
321 * If the hosted domain parameter is set, restrict logins to that domain
322 * (only available in google-api-php-client v2 or higher).
323 */
324 if (
325 array_key_exists( 'google_hosteddomain', $auth_settings ) &&
326 strlen( $auth_settings['google_hosteddomain'] ) > 0 &&
327 $client::LIBVER >= '2.0.0'
328 ) {
329 $google_hosteddomains = explode( "\n", str_replace( "\r", '', $auth_settings['google_hosteddomain'] ) );
330 $google_hosteddomain = trim( $google_hosteddomains[0] );
331 $client->setHostedDomain( $google_hosteddomain );
332 }
333
334 // Verify this is a successful Google authentication.
335 // NOTE: verifyIdToken originally returned an object as per vendor/google/auth/src/OAuth2.php.
336 // However, it looks as though this function is overridden by src/Google/Client.php and returns an array instead
337 // in the v2 library. Treating as an array for purposes of this functionality.
338 // See https://github.com/googleapis/google-api-php-client/blob/master/src/Google/AccessToken/Verify.php#L77
339 try {
340 $ticket = $client->verifyIdToken( $token['id_token'], $auth_settings['google_clientid'] );
341 } catch ( Google_Auth_Exception $e ) {
342 // Invalid ticket, so this in not a successful Google login.
343 return new \WP_Error( 'invalid_google_login', __( 'Invalid Google credentials provided.', 'authorizer' ) );
344 }
345
346 // Invalid ticket, so this in not a successful Google login.
347 if ( ! $ticket ) {
348 return new \WP_Error( 'invalid_google_login', __( 'Invalid Google credentials provided.', 'authorizer' ) );
349 }
350
351 // Get email address.
352 // Edge case: if another plugin has already defined the Google_Client class,
353 // and it's a version earlier than v2, then we need to handle $token as a
354 // json-encoded string instead of an array.
355 if ( is_object( $ticket ) && method_exists( $ticket, 'getAttributes' ) ) {
356 $attributes = $ticket->getAttributes();
357 $email = Helper::lowercase( $attributes['payload']['email'] );
358 } else {
359 $email = Helper::lowercase( $ticket['email'] );
360 }
361
362 $email_domain = substr( strrchr( $email, '@' ), 1 );
363 $username = current( explode( '@', $email ) );
364
365 /**
366 * Fail if hd param is set and the logging in user's email address doesn't
367 * match the allowed hosted domain.
368 *
369 * See: https://developers.google.com/identity/protocols/OpenIDConnect#hd-param
370 * See: https://github.com/google/google-api-php-client/blob/v1-master/src/Google/Client.php#L407-L416
371 *
372 * Note: this is a failsafe if the setHostedDomain() feature in v2 does not work above.
373 */
374 if (
375 array_key_exists( 'google_hosteddomain', $auth_settings ) &&
376 strlen( $auth_settings['google_hosteddomain'] ) > 0
377 ) {
378 // Allow multiple whitelisted domains.
379 $google_hosteddomains = explode( "\n", str_replace( "\r", '', $auth_settings['google_hosteddomain'] ) );
380 if ( ! in_array( $email_domain, $google_hosteddomains, true ) ) {
381 $this->custom_logout();
382 return new \WP_Error( 'invalid_google_login', __( 'Google credentials do not match the allowed hosted domain', 'authorizer' ) );
383 }
384 }
385
386 return array(
387 'email' => $email,
388 'username' => $username,
389 'first_name' => '',
390 'last_name' => '',
391 'authenticated_by' => 'google',
392 'google_attributes' => $ticket,
393 );
394 }
395
396
397 /**
398 * Validate this user's credentials against CAS.
399 *
400 * @param array $auth_settings Plugin settings.
401 * @return array|WP_Error Array containing 'email' and 'authenticated_by' strings
402 * for the successfully authenticated user, or WP_Error()
403 * object on failure, or null if not attempting a CAS login.
404 */
405 protected function custom_authenticate_cas( $auth_settings ) {
406 // Move on if CAS hasn't been requested here.
407 // phpcs:ignore WordPress.Security.NonceVerification
408 if ( empty( $_GET['external'] ) || 'cas' !== $_GET['external'] ) {
409 return null;
410 }
411
412 /**
413 * Get the CAS server version (default to SAML_VERSION_1_1).
414 *
415 * @see: https://developer.jasig.org/cas-clients/php/1.3.4/docs/api/group__public.html
416 */
417 $cas_version = SAML_VERSION_1_1;
418 if ( 'CAS_VERSION_3_0' === $auth_settings['cas_version'] ) {
419 $cas_version = CAS_VERSION_3_0;
420 } elseif ( 'CAS_VERSION_2_0' === $auth_settings['cas_version'] ) {
421 $cas_version = CAS_VERSION_2_0;
422 } elseif ( 'CAS_VERSION_1_0' === $auth_settings['cas_version'] ) {
423 $cas_version = CAS_VERSION_1_0;
424 }
425
426 // Set the CAS client configuration.
427 \phpCAS::client( $cas_version, $auth_settings['cas_host'], intval( $auth_settings['cas_port'] ), $auth_settings['cas_path'] );
428
429 // Allow redirects at the CAS server endpoint (e.g., allow connections
430 // at an old CAS URL that redirects to a newer CAS URL).
431 \phpCAS::setExtraCurlOption( CURLOPT_FOLLOWLOCATION, true );
432
433 // Use the WordPress certificate bundle at /wp-includes/certificates/ca-bundle.crt.
434 \phpCAS::setCasServerCACert( ABSPATH . WPINC . '/certificates/ca-bundle.crt' );
435
436 // Set the CAS service URL (including the redirect URL for WordPress when it comes back from CAS).
437 $cas_service_url = site_url( '/wp-login.php?external=cas' );
438 $login_querystring = array();
439 if ( isset( $_SERVER['QUERY_STRING'] ) ) {
440 parse_str( $_SERVER['QUERY_STRING'], $login_querystring ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
441 }
442 if ( isset( $login_querystring['redirect_to'] ) ) {
443 $cas_service_url .= '&redirect_to=' . rawurlencode( $login_querystring['redirect_to'] );
444 }
445 \phpCAS::setFixedServiceURL( $cas_service_url );
446
447 // Authenticate against CAS.
448 try {
449 \phpCAS::forceAuthentication();
450 } catch ( \CAS_AuthenticationException $e ) {
451 // CAS server threw an error in isAuthenticated(), potentially because
452 // the cached ticket is outdated. Try renewing the authentication.
453 error_log( __( 'CAS server returned an Authentication Exception. Details:', 'authorizer' ) ); // phpcs:ignore
454 error_log( $e->getMessage() ); // phpcs:ignore
455
456 // CAS server is throwing errors on this login, so try logging the
457 // user out of CAS and redirecting them to the login page.
458 \phpCAS::logoutWithRedirectService( wp_login_url() );
459 die();
460 }
461
462 // Get username (as specified by the CAS server).
463 $username = \phpCAS::getUser();
464
465 // Get email that successfully authenticated against the external service (CAS).
466 $externally_authenticated_email = strtolower( $username );
467 if ( ! filter_var( $externally_authenticated_email, FILTER_VALIDATE_EMAIL ) ) {
468 // If we can't get the user's email address from a CAS attribute,
469 // try to guess the domain from the CAS server hostname. This will only
470 // be used if we can't discover the email address from CAS attributes.
471 $domain_guess = preg_match( '/[^.]*\.[^.]*$/', $auth_settings['cas_host'], $matches ) === 1 ? $matches[0] : '';
472 $externally_authenticated_email = Helper::lowercase( $username ) . '@' . $domain_guess;
473 }
474
475 // Retrieve the user attributes (e.g., email address, first name, last name) from the CAS server.
476 $cas_attributes = \phpCAS::getAttributes();
477
478 // Get user email if it is specified in another field.
479 if ( array_key_exists( 'cas_attr_email', $auth_settings ) && strlen( $auth_settings['cas_attr_email'] ) > 0 ) {
480 // If the email attribute starts with an at symbol (@), assume that the
481 // email domain is manually entered there (instead of a reference to a
482 // CAS attribute), and combine that with the username to create the email.
483 // Otherwise, look up the CAS attribute for email.
484 if ( substr( $auth_settings['cas_attr_email'], 0, 1 ) === '@' ) {
485 $externally_authenticated_email = Helper::lowercase( $username . $auth_settings['cas_attr_email'] );
486 } elseif (
487 // If a CAS attribute has been specified as containing the email address, use that instead.
488 // Email attribute can be a string or an array of strings.
489 array_key_exists( $auth_settings['cas_attr_email'], $cas_attributes ) && (
490 (
491 is_array( $cas_attributes[ $auth_settings['cas_attr_email'] ] ) &&
492 count( $cas_attributes[ $auth_settings['cas_attr_email'] ] ) > 0
493 ) || (
494 is_string( $cas_attributes[ $auth_settings['cas_attr_email'] ] ) &&
495 strlen( $cas_attributes[ $auth_settings['cas_attr_email'] ] ) > 0
496 )
497 )
498 ) {
499 // Each of the emails in the array needs to be set to lowercase.
500 if ( is_array( $cas_attributes[ $auth_settings['cas_attr_email'] ] ) ) {
501 $externally_authenticated_email = array();
502 foreach ( $cas_attributes[ $auth_settings['cas_attr_email'] ] as $external_email ) {
503 $externally_authenticated_email[] = Helper::lowercase( $external_email );
504 }
505 } else {
506 $externally_authenticated_email = Helper::lowercase( $cas_attributes[ $auth_settings['cas_attr_email'] ] );
507 }
508 }
509 }
510
511 // Get user first name and last name.
512 $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'] ] : '';
513 $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'] ] : '';
514
515 return array(
516 'email' => $externally_authenticated_email,
517 'username' => $username,
518 'first_name' => $first_name,
519 'last_name' => $last_name,
520 'authenticated_by' => 'cas',
521 'cas_attributes' => $cas_attributes,
522 );
523 }
524
525
526 /**
527 * Validate this user's credentials against LDAP.
528 *
529 * @param array $auth_settings Plugin settings.
530 * @param string $username Attempted username from authenticate action.
531 * @param string $password Attempted password from authenticate action.
532 * @return array|WP_Error Array containing 'email' and 'authenticated_by' strings
533 * for the successfully authenticated user, or WP_Error()
534 * object on failure, or null if skipping LDAP auth and
535 * falling back to WP auth.
536 */
537 protected function custom_authenticate_ldap( $auth_settings, $username, $password ) {
538 // Get LDAP host(s), and attempt each until we have a valid connection.
539 $ldap_hosts = explode( "\n", str_replace( "\r", '', trim( $auth_settings['ldap_host'] ) ) );
540
541 // Fail silently (fall back to WordPress authentication) if no LDAP host specified.
542 if ( count( $ldap_hosts ) < 1 ) {
543 return null;
544 }
545
546 // Get LDAP search base(s).
547 $search_bases = explode( "\n", str_replace( "\r", '', trim( $auth_settings['ldap_search_base'] ) ) );
548
549 // Fail silently (fall back to WordPress authentication) if no search base specified.
550 if ( count( $search_bases ) < 1 ) {
551 return null;
552 }
553
554 // Get the FQDN from the first LDAP search base domain components (dc). For
555 // example, ou=people,dc=example,dc=edu,dc=uk would yield user@example.edu.uk.
556 $search_base_components = explode( ',', trim( $search_bases[0] ) );
557 $domain = array();
558 foreach ( $search_base_components as $search_base_component ) {
559 $component = explode( '=', $search_base_component );
560 if ( 2 === count( $component ) && 'dc' === $component[0] ) {
561 $domain[] = $component[1];
562 }
563 }
564 $domain = implode( '.', $domain );
565
566 // If we can't get the logging in user's email address from an LDAP attribute,
567 // just use the domain from the LDAP host. This will only be used if we
568 // can't discover the email address from an LDAP attribute.
569 if ( empty( $domain ) ) {
570 $domain = preg_match( '/[^.]*\.[^.]*$/', $ldap_hosts[0], $matches ) === 1 ? $matches[0] : '';
571 }
572
573 // remove @domain if it exists in the username (i.e., if user entered their email).
574 $username = str_replace( '@' . $domain, '', $username );
575
576 // Fail silently (fall back to WordPress authentication) if both username
577 // and password are empty (this will be the case when visiting wp-login.php
578 // for the first time, or when clicking the Log In button without filling
579 // out either field.
580 if ( empty( $username ) && empty( $password ) ) {
581 return null;
582 }
583
584 // Fail with error message if username or password is blank.
585 if ( empty( $username ) ) {
586 return new \WP_Error( 'empty_username', __( 'You must provide a username or email.', 'authorizer' ) );
587 }
588 if ( empty( $password ) ) {
589 return new \WP_Error( 'empty_password', __( 'You must provide a password.', 'authorizer' ) );
590 }
591
592 // If php5-ldap extension isn't installed on server, fall back to WP auth.
593 if ( ! function_exists( 'ldap_connect' ) ) {
594 return null;
595 }
596
597 // Authenticate against LDAP using options provided in plugin settings.
598 $result = false;
599 $ldap_user_dn = '';
600 $first_name = '';
601 $last_name = '';
602 $email = '';
603
604 // Attempt each LDAP host until we have a valid connection.
605 $ldap_valid = false;
606 foreach ( $ldap_hosts as $ldap_host ) {
607 // Construct LDAP connection parameters. ldap_connect() takes either a
608 // hostname or a full LDAP URI as its first parameter (works with OpenLDAP
609 // 2.x.x or later). If it's an LDAP URI, the second parameter, $port, is
610 // ignored, and port must be specified in the full URI. An LDAP URI is of
611 // the form ldap://hostname:port or ldaps://hostname:port.
612 $ldap_port = intval( $auth_settings['ldap_port'] );
613 $parsed_host = wp_parse_url( $ldap_host );
614
615 // Fail if invalid host is specified.
616 if ( false === $parsed_host ) {
617 continue;
618 }
619
620 // If a scheme is in the LDAP host, use full LDAP URI instead of just hostname.
621 if ( array_key_exists( 'scheme', $parsed_host ) ) {
622 // If the port isn't in the LDAP URI, use the one in the LDAP port field.
623 if ( ! array_key_exists( 'port', $parsed_host ) ) {
624 $parsed_host['port'] = $ldap_port;
625 }
626 $ldap_host = Helper::build_url( $parsed_host );
627 }
628
629 // Create LDAP connection.
630 $ldap = ldap_connect( $ldap_host, $ldap_port );
631 ldap_set_option( $ldap, LDAP_OPT_PROTOCOL_VERSION, 3 );
632 ldap_set_option( $ldap, LDAP_OPT_REFERRALS, 0 );
633
634 // Fail if we don't have a plausible LDAP URI.
635 if ( false === $ldap ) {
636 continue;
637 }
638
639 // Attempt to start TLS if that setting is checked and we're not using ldaps protocol.
640 if ( 1 === intval( $auth_settings['ldap_tls'] ) && false === strpos( $ldap_host, 'ldaps://' ) ) {
641 if ( ! @ldap_start_tls( $ldap ) ) {
642 continue;
643 }
644 }
645
646 // Set bind credentials; attempt an anonymous bind if not provided.
647 $bind_rdn = null;
648 $bind_password = null;
649 if ( strlen( $auth_settings['ldap_user'] ) > 0 ) {
650 $bind_rdn = $auth_settings['ldap_user'];
651 $bind_password = Helper::decrypt( $auth_settings['ldap_password'] );
652 }
653
654 // Attempt LDAP bind.
655 $result = @ldap_bind( $ldap, $bind_rdn, stripslashes( $bind_password ) ); // phpcs:ignore
656 if ( ! $result ) {
657 // Can't connect to LDAP, so fall back to WordPress authentication.
658 continue;
659 }
660
661 // If we've reached this, we have a valid ldap connection and bind.
662 $ldap_valid = true;
663 break;
664 }
665
666 // Move to next authentication method if we don't have a valid LDAP connection.
667 if ( ! $ldap_valid ) {
668 return null;
669 }
670
671 // Look up the bind DN (and first/last name) of the user trying to
672 // log in by performing an LDAP search for the login username in
673 // the field specified in the LDAP settings. This setup is common.
674 $ldap_attributes_to_retrieve = array( 'dn' );
675 if ( array_key_exists( 'ldap_attr_first_name', $auth_settings ) && strlen( $auth_settings['ldap_attr_first_name'] ) > 0 ) {
676 array_push( $ldap_attributes_to_retrieve, $auth_settings['ldap_attr_first_name'] );
677 }
678 if ( array_key_exists( 'ldap_attr_last_name', $auth_settings ) && strlen( $auth_settings['ldap_attr_last_name'] ) > 0 ) {
679 array_push( $ldap_attributes_to_retrieve, $auth_settings['ldap_attr_last_name'] );
680 }
681 if ( array_key_exists( 'ldap_attr_email', $auth_settings ) && strlen( $auth_settings['ldap_attr_email'] ) > 0 && substr( $auth_settings['ldap_attr_email'], 0, 1 ) !== '@' ) {
682 array_push( $ldap_attributes_to_retrieve, Helper::lowercase( $auth_settings['ldap_attr_email'] ) );
683 }
684
685 // Create default LDAP search filter. If LDAP email attribute is provided,
686 // use (|(uid=$username)(mail=$username)) instead (so logins with either a
687 // username or an email address will work). Otherwise use (uid=$username).
688 if ( array_key_exists( 'ldap_attr_email', $auth_settings ) && strlen( $auth_settings['ldap_attr_email'] ) > 0 && substr( $auth_settings['ldap_attr_email'], 0, 1 ) !== '@' ) {
689 $search_filter =
690 '(|' .
691 '(' . $auth_settings['ldap_uid'] . '=' . $username . ')' .
692 '(' . $auth_settings['ldap_attr_email'] . '=' . $username . ')' .
693 ')';
694 } else {
695 $search_filter = '(' . $auth_settings['ldap_uid'] . '=' . $username . ')';
696 }
697
698 /**
699 * Filter LDAP search filter.
700 *
701 * Allows for custom LDAP authentication rules (e.g., restricting login
702 * access to users in multiple groups, or having certain attributes).
703 *
704 * @param string $search_filter The filter to pass to ldap_search().
705 * @param string $ldap_uid The attribute to compare username against (from Authorizer Settings).
706 * @param string $username The username attempting to log in.
707 */
708 $search_filter = apply_filters( 'authorizer_ldap_search_filter', $search_filter, $auth_settings['ldap_uid'], $username );
709
710 // Multiple search bases can be provided, so iterate through them until a match is found.
711 foreach ( $search_bases as $search_base ) {
712 $ldap_search = ldap_search(
713 $ldap,
714 $search_base,
715 $search_filter,
716 $ldap_attributes_to_retrieve
717 );
718 $ldap_entries = ldap_get_entries( $ldap, $ldap_search );
719 if ( $ldap_entries['count'] > 0 ) {
720 break;
721 }
722 }
723
724 // If we didn't find any users in ldap, fall back to WordPress authentication.
725 if ( $ldap_entries['count'] < 1 ) {
726 return null;
727 }
728
729 // Get the bind dn and first/last names; if there are multiple results returned, just get the last one.
730 for ( $i = 0; $i < $ldap_entries['count']; $i++ ) {
731 $ldap_user_dn = $ldap_entries[ $i ]['dn'];
732
733 // Get user first name and last name.
734 $ldap_attr_first_name = array_key_exists( 'ldap_attr_first_name', $auth_settings ) ? Helper::lowercase( $auth_settings['ldap_attr_first_name'] ) : '';
735 if ( strlen( $ldap_attr_first_name ) > 0 && array_key_exists( $ldap_attr_first_name, $ldap_entries[ $i ] ) && $ldap_entries[ $i ][ $ldap_attr_first_name ]['count'] > 0 && strlen( $ldap_entries[ $i ][ $ldap_attr_first_name ][0] ) > 0 ) {
736 $first_name = $ldap_entries[ $i ][ $ldap_attr_first_name ][0];
737 }
738 $ldap_attr_last_name = array_key_exists( 'ldap_attr_last_name', $auth_settings ) ? Helper::lowercase( $auth_settings['ldap_attr_last_name'] ) : '';
739 if ( strlen( $ldap_attr_last_name ) > 0 && array_key_exists( $ldap_attr_last_name, $ldap_entries[ $i ] ) && $ldap_entries[ $i ][ $ldap_attr_last_name ]['count'] > 0 && strlen( $ldap_entries[ $i ][ $ldap_attr_last_name ][0] ) > 0 ) {
740 $last_name = $ldap_entries[ $i ][ $ldap_attr_last_name ][0];
741 }
742 // Get user email if it is specified in another field.
743 $ldap_attr_email = array_key_exists( 'ldap_attr_email', $auth_settings ) ? Helper::lowercase( $auth_settings['ldap_attr_email'] ) : '';
744 if ( strlen( $ldap_attr_email ) > 0 ) {
745 // If the email attribute starts with an at symbol (@), assume that the
746 // email domain is manually entered there (instead of a reference to an
747 // LDAP attribute), and combine that with the username to create the email.
748 // Otherwise, look up the LDAP attribute for email.
749 if ( substr( $ldap_attr_email, 0, 1 ) === '@' ) {
750 $email = Helper::lowercase( $username . $ldap_attr_email );
751 } elseif ( array_key_exists( $ldap_attr_email, $ldap_entries[ $i ] ) && $ldap_entries[ $i ][ $ldap_attr_email ]['count'] > 0 && strlen( $ldap_entries[ $i ][ $ldap_attr_email ][0] ) > 0 ) {
752 $email = Helper::lowercase( $ldap_entries[ $i ][ $ldap_attr_email ][0] );
753 }
754 }
755 }
756
757 $result = @ldap_bind( $ldap, $ldap_user_dn, stripslashes( $password ) ); // phpcs:ignore
758 if ( ! $result ) {
759 // We have a real ldap user, but an invalid password. Pass
760 // through to wp authentication after failing LDAP (since
761 // this could be a local account that happens to be the
762 // same name as an LDAP user).
763 return null;
764 }
765
766 // User successfully authenticated against LDAP, so set the relevant variables.
767 $externally_authenticated_email = Helper::lowercase( $username . '@' . $domain );
768
769 // If an LDAP attribute has been specified as containing the email address, use that instead.
770 if ( strlen( $email ) > 0 ) {
771 $externally_authenticated_email = Helper::lowercase( $email );
772 }
773
774 return array(
775 'email' => $externally_authenticated_email,
776 'username' => $username,
777 'first_name' => $first_name,
778 'last_name' => $last_name,
779 'authenticated_by' => 'ldap',
780 'ldap_attributes' => $ldap_entries,
781 );
782 }
783
784
785 /**
786 * Fetch the logging out user's external service (so we can log out of it
787 * below in the wp_logout hook).
788 *
789 * Action: clear_auth_cookie
790 *
791 * @return void
792 */
793 public function pre_logout() {
794 self::$authenticated_by = get_user_meta( get_current_user_id(), 'authenticated_by', true );
795
796 // If we didn't find an authenticated method, check $_REQUEST (if this is a
797 // pending user facing the "no access" message, their logout link will
798 // include "external=?" since they don't have a WP_User to attach the
799 // "authenticated_by" usermeta to).
800 if ( empty( self::$authenticated_by ) && ! empty( $_REQUEST['external'] ) ) {
801 self::$authenticated_by = $_REQUEST['external'];
802 }
803 }
804
805 /**
806 * Log out of the attached external service.
807 *
808 * Action: wp_logout
809 *
810 * @return void
811 */
812 public function custom_logout() {
813 // Grab plugin settings.
814 $options = Options::get_instance();
815 $auth_settings = $options->get_all( Helper::SINGLE_CONTEXT, 'allow override' );
816
817 // Reset option containing old error messages.
818 delete_option( 'auth_settings_advanced_login_error' );
819
820 if ( session_id() === '' ) {
821 session_start();
822 }
823
824 // If logged in to CAS, Log out of CAS.
825 if ( 'cas' === self::$authenticated_by && '1' === $auth_settings['cas'] ) {
826 if ( ! array_key_exists( 'PHPCAS_CLIENT', $GLOBALS ) || ! array_key_exists( 'phpCAS', $_SESSION ) ) {
827
828 /**
829 * Get the CAS server version (default to SAML_VERSION_1_1).
830 *
831 * @see: https://developer.jasig.org/cas-clients/php/1.3.4/docs/api/group__public.html
832 */
833 $cas_version = SAML_VERSION_1_1;
834 if ( 'CAS_VERSION_3_0' === $auth_settings['cas_version'] ) {
835 $cas_version = CAS_VERSION_3_0;
836 } elseif ( 'CAS_VERSION_2_0' === $auth_settings['cas_version'] ) {
837 $cas_version = CAS_VERSION_2_0;
838 } elseif ( 'CAS_VERSION_1_0' === $auth_settings['cas_version'] ) {
839 $cas_version = CAS_VERSION_1_0;
840 }
841
842 // Set the CAS client configuration if it hasn't been set already.
843 \phpCAS::client( $cas_version, $auth_settings['cas_host'], intval( $auth_settings['cas_port'] ), $auth_settings['cas_path'] );
844 // Allow redirects at the CAS server endpoint (e.g., allow connections
845 // at an old CAS URL that redirects to a newer CAS URL).
846 \phpCAS::setExtraCurlOption( CURLOPT_FOLLOWLOCATION, true );
847 // Restrict logout request origin to the CAS server only (prevent DDOS).
848 \phpCAS::handleLogoutRequests( true, array( $auth_settings['cas_host'] ) );
849 }
850 if ( \phpCAS::isAuthenticated() || \phpCAS::isInitialized() ) {
851 // Redirect to home page, or specified page if it's been provided.
852 $redirect_to = site_url( '/' );
853 if ( ! empty( $_REQUEST['redirect_to'] ) && isset( $_REQUEST['_wpnonce'] ) && wp_verify_nonce( sanitize_key( $_REQUEST['_wpnonce'] ), 'log-out' ) ) {
854 $redirect_to = esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) );
855 }
856
857 \phpCAS::logoutWithRedirectService( $redirect_to );
858 }
859 }
860
861 // If session token set, log out of Google.
862 if ( 'google' === self::$authenticated_by || array_key_exists( 'token', $_SESSION ) ) {
863 $token = $_SESSION['token'];
864
865 // Edge case: if another plugin has already defined the Google_Client class,
866 // and it's a version earlier than v2, then we need to handle $token as a
867 // json-encoded string instead of an array.
868 if ( ! is_array( $token ) ) {
869 $token = json_decode( $token, true );
870 }
871
872 $access_token = isset( $token['access_token'] ) ? $token['access_token'] : null;
873
874 // Add Google API PHP Client.
875 // @see https://github.com/google/google-api-php-client branch:v1-master.
876 if ( ! class_exists( 'Google_Client' ) ) {
877 require_once dirname( plugin_root() ) . '/vendor/google-api-php-client-v2/src/Google/autoload.php';
878 }
879
880 // Build the Google Client.
881 $client = new \Google_Client();
882 $client->setApplicationName( 'WordPress' );
883 $client->setClientId( $auth_settings['google_clientid'] );
884 $client->setClientSecret( $auth_settings['google_clientsecret'] );
885 $client->setRedirectUri( 'postmessage' );
886
887 // Revoke the token.
888 $client->revokeToken( $access_token );
889
890 // Remove the credentials from the user's session.
891 unset( $_SESSION['token'] );
892 }
893 }
894
895 }
896