| @@ -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 | } |