From 56d527c1fefc568e9dfd5c6d1cbb7a8259cca04a Mon Sep 17 00:00:00 2001 From: Alessandro Belleno Date: Mon, 19 Sep 2016 16:23:14 +0200 Subject: [PATCH 1/4] use oid instead of upn in function authenticate change: load metadata using tenant or common, depending on presence of org_domain_hint --- Settings.php | 9 ++++++--- aad-sso-wordpress.php | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Settings.php b/Settings.php index 1e14aac..22bf648 100644 --- a/Settings.php +++ b/Settings.php @@ -107,7 +107,8 @@ class AADSSO_Settings { /** * @var string The OpenID Connect configuration discovery endpoint. */ - public $openid_configuration_endpoint = 'https://login.microsoftonline.com/common/.well-known/openid-configuration'; + public $openid_configuration_endpoint_prefix = 'https://login.microsoftonline.com/'; + public $openid_configuration_endpoint_suffix ='/.well-known/openid-configuration'; /** * @var string The OAuth 2.0 authorization endpoint. @@ -206,8 +207,10 @@ public static function init() { */ $openid_configuration = get_transient( 'aadsso_openid_configuration' ); if( false === $openid_configuration || isset( $_GET['aadsso_reload_openid_config'] ) ) { + $tenant = !empty($instance->org_domain_hint) ? $instance->org_domain_hint : 'common'; + $openid_configuration_endpoint = $instance->openid_configuration_endpoint_prefix . $tenant . $instance->openid_configuration_endpoint_suffix; $openid_configuration = json_decode( - self::get_remote_contents( $instance->openid_configuration_endpoint ), + self::get_remote_contents( $openid_configuration_endpoint ), true // Return associative array ); set_transient( 'aadsso_openid_configuration', $openid_configuration, 3600 ); @@ -273,4 +276,4 @@ function set_settings( $settings ) { } return $this; } -} +} \ No newline at end of file diff --git a/aad-sso-wordpress.php b/aad-sso-wordpress.php index 0cda951..9d28ef3 100644 --- a/aad-sso-wordpress.php +++ b/aad-sso-wordpress.php @@ -278,7 +278,7 @@ function authenticate( $user, $username, $password ) { // exists in WordPress (either because it already existed, or we created it // on-the-fly). All that's left is to set the roles based on group membership. if ( true === $this->settings->enable_aad_group_to_wp_role ) { - $user = $this->update_wp_user_roles( $user, $jwt->upn, $jwt->tid ); + $user = $this->update_wp_user_roles( $user, $jwt->oid, $jwt->tid ); } } From 9c07f52c8be81d17ae6f37d77dca1c8d11008c39 Mon Sep 17 00:00:00 2001 From: Alessandro Belleno Date: Wed, 21 Sep 2016 14:45:18 +0200 Subject: [PATCH 2/4] use of $unique_name instead of $jwt->upn in authenticate function --- aad-sso-wordpress.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aad-sso-wordpress.php b/aad-sso-wordpress.php index 9d28ef3..cba3aec 100644 --- a/aad-sso-wordpress.php +++ b/aad-sso-wordpress.php @@ -364,7 +364,7 @@ function get_wp_user_from_aad_user( $jwt ) { 'user_not_registered', sprintf( __( 'ERROR: The authenticated user %s is not a registered user in this blog.', 'aad-sso-wordpress' ), - $jwt->upn + $unique_name ) ); } From 0a8121e8038e083fb5be3145447931435fb9ac49 Mon Sep 17 00:00:00 2001 From: Alessandro Belleno Date: Wed, 21 Sep 2016 14:57:13 +0200 Subject: [PATCH 3/4] added new ability to authenticate rest requests with aad bearer token. Added new settings resourceId and issuer. Added resourceId in Settings page --- AuthorizationHelper.php | 115 +++++++++++++++++++++++++++++++++++++++- Settings.php | 10 ++++ SettingsPage.php | 20 +++++++ aad-sso-wordpress.php | 106 +++++++++++++++++++++++++++++++++++- 4 files changed, 248 insertions(+), 3 deletions(-) diff --git a/AuthorizationHelper.php b/AuthorizationHelper.php index 996dab1..368675e 100644 --- a/AuthorizationHelper.php +++ b/AuthorizationHelper.php @@ -94,14 +94,14 @@ public static function get_and_process_access_token( $authentication_request_bod } /** - * Decodes and validates an id_token value returned + * Decodes and signature validates an id_token value returned * * @param array $authentication_request_body The body to use in the Authentication Request. * @param \AADSSO_Settings $settings The settings to use. * * @return mixed The decoded authorization result. */ - public static function validate_id_token( $id_token, $settings, $antiforgery_id ) { + private static function validate_token_signature( $id_token, $settings) { $jwt = null; $last_exception = null; @@ -132,6 +132,7 @@ public static function validate_id_token( $id_token, $settings, $antiforgery_id . "-----END CERTIFICATE-----\n"; // This throws an exception if the id_token cannot be validated. + $jwt = \AADSSO\Firebase\JWT\JWT::decode( $id_token, $key_pem, self::$allowed_algorithms ); break; } catch ( Exception $e ) { @@ -139,6 +140,116 @@ public static function validate_id_token( $id_token, $settings, $antiforgery_id } } + if ( null == $jwt ) { + throw $last_exception; + } + + return $jwt; + } + + /** + * Validates claims of bearer_token + * + * @param array $authentication_request_body The body to use in the Authentication Request. + * @param \AADSSO_Settings $settings The settings to use. + * + */ + private static function validate_token_claims( $bearer_token, $settings){ + + $data = self::split_id_token($bearer_token); + + $audience = $data['payload']['aud']; + if ($audience != $settings->resource_id) { + throw new \AADSSO\Firebase\JWT\SignatureInvalidException('Invalid Audience'); + } + + + $cur_time = time(); + $not_before = $data['payload']['nbf']; // epoch time, time after which token is valid (so basically nbf < cur time < exp) + $expiration = $data['payload']['exp']; // epoch time, check that the token is still valid + + if ($not_before > $cur_time) { + throw new \AADSSO\Firebase\JWT\BeforeValidException('Bearer before valid'); + } + + if ($cur_time > $expiration) { + throw new \AADSSO\Firebase\JWT\ExpiredException('Bearer expired'); + } + + // The Issuer Identifier for the OpenID Provider MUST exactly match the value of the iss (issuer) Claim. + $iss_token = $data['payload']['iss']; + $iss_metadata = $settings->issuer; + + if ($iss_token != $iss_metadata) { + throw new \AADSSO\Firebase\JWT\SignatureInvalidException('Signature invalid'); + } + + } + + /** + * Splits a bearer_token into header and payload + * + * @param array $authentication_request_body The body to use in the Authentication Request. + * @param \AADSSO_Settings $settings The settings to use. + * + * @return mixed The decoded authorization result. + */ + private static function split_id_token($bearer_token) { + + $retVal = array(); + + // Split the token into Header, Payload, and Signature, and decode + $retVal['id_token_array'] = explode('.', $bearer_token, 3); + $retVal['head'] = json_decode(base64_decode($retVal['id_token_array'][0]), true); + $retVal['payload'] = json_decode(base64_decode($retVal['id_token_array'][1]), true); + + return $retVal; + } + + /** + * Validates signature and claims of a bearer_token value returned + * + * @param array $authentication_request_body The body to use in the Authentication Request. + * @param \AADSSO_Settings $settings The settings to use. + * + * @return mixed The decoded authorization result. + */ + public static function validate_bearer_token( $bearer_token, $settings) { + + $jwt = null; + $last_exception = null; + + try{ + $jwt = self::validate_token_signature( $bearer_token, $settings); + self::validate_token_claims( $bearer_token, $settings); + } + catch ( Exception $e ) { + throw $e; + } + + return $jwt; + } + + /** + * Decodes and validates an id_token value returned + * + * @param array $authentication_request_body The body to use in the Authentication Request. + * @param \AADSSO_Settings $settings The settings to use. + * + * @return mixed The decoded authorization result. + */ + public static function validate_id_token( $id_token, $settings, $antiforgery_id) { + + $jwt = null; + $last_exception = null; + + try{ + $jwt = self::validate_token_signature( $id_token, $settings); + } + catch ( Exception $e ) { + $last_exception = $e; + } + if ( null == $jwt ) { throw $last_exception; } diff --git a/Settings.php b/Settings.php index 22bf648..35f095a 100644 --- a/Settings.php +++ b/Settings.php @@ -18,6 +18,11 @@ class AADSSO_Settings { */ public $client_id = ''; + /** + * @var string The resource ID obtained after registering an application in AAD. + */ + public $resource_id = ''; + /** * @var string The client secret key, which is generated on the app configuration page in AAD. */ @@ -38,6 +43,11 @@ class AADSSO_Settings { */ public $org_display_name = ''; + /** + * @var string The issuer obtained from metadata endpoints. + */ + public $issuer = ''; + /** * The value of the domain_hint is a registered domain for the tenant. If the tenant is federated * to an on-premises directory, AAD redirects to the specified tenant federation server. diff --git a/SettingsPage.php b/SettingsPage.php index 328b67a..a2f6656 100644 --- a/SettingsPage.php +++ b/SettingsPage.php @@ -225,6 +225,14 @@ public function register_settings() { 'aadsso_settings_general' // section ); + add_settings_field( + 'resource_id', // id + __( 'Resource ID', 'aad-sso-wordpress' ), // title + array( $this, 'resource_id_callback' ), // callback + 'aadsso_settings_page', // page + 'aadsso_settings_general' // section + ); + add_settings_field( 'client_secret', // id __( 'Client secret', 'aad-sso-wordpress' ), // title @@ -342,6 +350,7 @@ public function sanitize_settings( $input ) { 'org_display_name', 'org_domain_hint', 'client_id', + 'resource_id', 'client_secret', 'redirect_uri', 'logout_redirect_uri', @@ -486,6 +495,17 @@ public function client_id_callback() { ); } + /** + * Renders the `resource_id` form control + */ + public function resource_id_callback() { + $this->render_text_field( 'resource_id' ); + printf( + '

%s

', + __( 'The resource ID of the Azure AD application representing this blog.', 'aad-sso-wordpress' ) + ); + } + /** * Renders the `client_secret` form control **/ diff --git a/aad-sso-wordpress.php b/aad-sso-wordpress.php index cba3aec..6df4969 100644 --- a/aad-sso-wordpress.php +++ b/aad-sso-wordpress.php @@ -34,6 +34,8 @@ require_once AADSSO_PLUGIN_DIR . '/lib/php-jwt/src/SignatureInvalidException.php'; class AADSSO { + + private $jwt_error = null; static $instance = FALSE; @@ -88,6 +90,108 @@ public function __construct( $settings ) { // Register the textdomain for localization after all plugins are loaded add_action( 'plugins_loaded', array( $this, 'load_textdomain' ) ); + + + + + + add_filter( 'determine_current_user', array( $this, 'determine_current_user' ), 20 ); + + add_filter( 'wp_rest_server_class', array($this, 'wp_rest_server_class' ) ); + + add_filter( 'rest_pre_dispatch', array($this, 'rest_pre_dispatch' ), 10, 2 ); + } + + + /** + * Get the bearer token from the request header + * + * @return string|bool + */ + private function get_bearer(){ + $headers = getallheaders(); + if(isset($headers['Authorization'])){ + if(substr( $headers['Authorization'], 0, 7 ) === 'Bearer '){ + return substr( $headers['Authorization'], 7); + } + } + return false; + } + + /** + * Filter to hook the rest_pre_dispatch, if the is an error in the request + * send it, if there is no error just continue with the current request. + * + * @param $request + */ + public function rest_pre_dispatch($request) + { + if (is_wp_error($this->jwt_error)) { + return $this->jwt_error; + } + return $request; + } + + /** + * Loosely Based on https://github.com/WP-API/Basic-Auth/blob/master/basic-auth.php + * + * @param $input_user + * + * @return WP_User|int + */ + public function determine_current_user( $input_user ){ + // Don't authenticate twice + if ( ! empty( $input_user ) ) { + return $input_user; + } + + if(($bearer = $this->get_bearer()) !== false){ + try{ + $jwt = AADSSO_AuthorizationHelper::validate_bearer_token($bearer, $this->settings); + $user = $this->get_wp_user_from_aad_user( $jwt ); + if ( is_a( $user, 'WP_User' ) ) { + return $user->ID; + } + } + catch ( Exception $e ) { + $this->jwt_error = new WP_Error( + 'aad-sso-wordpress-validation', + $e->getMessage(), + array( + 'status' => 401, + ) + ); + } + + } + + // If it wasn't a user what got returned, just pass on what we had received originally. + return $input_user; + } + + /** + * Prevent caching of unauthenticated status. See comment below. + * + * We don't actually care about the `wp_rest_server_class` filter, it just + * happens right after the constant we do care about is defined. + * + */ + public static function wp_rest_server_class( $class ) { + global $current_user; + if ( defined( 'REST_REQUEST' ) + && REST_REQUEST + && $current_user instanceof WP_User + && 0 === $current_user->ID ) { + /* + * For our authentication to work, we need to remove the cached lack + * of a current user, so the next time it checks, we can detect that + * this is a rest api request and allow our override to happen. This + * is because the constant is defined later than the first get current + * user call may run. + */ + $current_user = null; + } + return $class; } /** @@ -234,7 +338,7 @@ function authenticate( $user, $username, $password ) { /* If 'code' is present, this is the Authorization Response from Azure AD, and 'code' has * the Authorization Code, which will be exchanged for an ID Token and an Access Token. */ - if ( isset( $_GET['code'] ) ) { + if ( isset( $_GET['code'] ) ) { $antiforgery_id = $_SESSION['aadsso_antiforgery-id']; $state_is_missing = ! isset( $_GET['state'] ); From 1ecb6489d543fdaedd82773f3afd6ec54616412b Mon Sep 17 00:00:00 2001 From: Alessandro Belleno Date: Fri, 4 Nov 2016 17:16:22 +0100 Subject: [PATCH 4/4] - edit pull request --- AuthorizationHelper.php | 60 +++++++++-------------------------------- Settings.php | 11 ++------ 2 files changed, 14 insertions(+), 57 deletions(-) diff --git a/AuthorizationHelper.php b/AuthorizationHelper.php index 368675e..5e9ed11 100644 --- a/AuthorizationHelper.php +++ b/AuthorizationHelper.php @@ -94,14 +94,14 @@ public static function get_and_process_access_token( $authentication_request_bod } /** - * Decodes and signature validates an id_token value returned + * Decodes an id_token value returned * - * @param array $authentication_request_body The body to use in the Authentication Request. + * @param string $id_token The JWT token * @param \AADSSO_Settings $settings The settings to use. * * @return mixed The decoded authorization result. */ - private static function validate_token_signature( $id_token, $settings) { + private static function decode_token( $id_token, $settings) { $jwt = null; $last_exception = null; @@ -150,34 +150,18 @@ private static function validate_token_signature( $id_token, $settings) { /** * Validates claims of bearer_token * - * @param array $authentication_request_body The body to use in the Authentication Request. + * @param string $jwt The JWT token * @param \AADSSO_Settings $settings The settings to use. * */ - private static function validate_token_claims( $bearer_token, $settings){ - - $data = self::split_id_token($bearer_token); - - $audience = $data['payload']['aud']; + private static function validate_token_claims( $jwt, $settings){ + $audience = $jwt->aud; if ($audience != $settings->resource_id) { throw new \AADSSO\Firebase\JWT\SignatureInvalidException('Invalid Audience'); } - - $cur_time = time(); - $not_before = $data['payload']['nbf']; // epoch time, time after which token is valid (so basically nbf < cur time < exp) - $expiration = $data['payload']['exp']; // epoch time, check that the token is still valid - - if ($not_before > $cur_time) { - throw new \AADSSO\Firebase\JWT\BeforeValidException('Bearer before valid'); - } - - if ($cur_time > $expiration) { - throw new \AADSSO\Firebase\JWT\ExpiredException('Bearer expired'); - } - // The Issuer Identifier for the OpenID Provider MUST exactly match the value of the iss (issuer) Claim. - $iss_token = $data['payload']['iss']; + $iss_token = $jwt->iss; $iss_metadata = $settings->issuer; if ($iss_token != $iss_metadata) { @@ -185,31 +169,11 @@ private static function validate_token_claims( $bearer_token, $settings){ } } - - /** - * Splits a bearer_token into header and payload - * - * @param array $authentication_request_body The body to use in the Authentication Request. - * @param \AADSSO_Settings $settings The settings to use. - * - * @return mixed The decoded authorization result. - */ - private static function split_id_token($bearer_token) { - - $retVal = array(); - - // Split the token into Header, Payload, and Signature, and decode - $retVal['id_token_array'] = explode('.', $bearer_token, 3); - $retVal['head'] = json_decode(base64_decode($retVal['id_token_array'][0]), true); - $retVal['payload'] = json_decode(base64_decode($retVal['id_token_array'][1]), true); - - return $retVal; - } /** * Validates signature and claims of a bearer_token value returned * - * @param array $authentication_request_body The body to use in the Authentication Request. + * @param string $bearer_token The bearer token * @param \AADSSO_Settings $settings The settings to use. * * @return mixed The decoded authorization result. @@ -220,8 +184,8 @@ public static function validate_bearer_token( $bearer_token, $settings) { $last_exception = null; try{ - $jwt = self::validate_token_signature( $bearer_token, $settings); - self::validate_token_claims( $bearer_token, $settings); + $jwt = self::decode_token( $bearer_token, $settings); + self::validate_token_claims( $jwt, $settings); } catch ( Exception $e ) { throw $e; @@ -233,7 +197,7 @@ public static function validate_bearer_token( $bearer_token, $settings) { /** * Decodes and validates an id_token value returned * - * @param array $authentication_request_body The body to use in the Authentication Request. + * @param string $id_token The token * @param \AADSSO_Settings $settings The settings to use. * * @return mixed The decoded authorization result. @@ -244,7 +208,7 @@ public static function validate_id_token( $id_token, $settings, $antiforgery_id) $last_exception = null; try{ - $jwt = self::validate_token_signature( $id_token, $settings); + $jwt = self::decode_token( $id_token, $settings); } catch ( Exception $e ) { $last_exception = $e; diff --git a/Settings.php b/Settings.php index 35f095a..8fa1175 100644 --- a/Settings.php +++ b/Settings.php @@ -117,8 +117,7 @@ class AADSSO_Settings { /** * @var string The OpenID Connect configuration discovery endpoint. */ - public $openid_configuration_endpoint_prefix = 'https://login.microsoftonline.com/'; - public $openid_configuration_endpoint_suffix ='/.well-known/openid-configuration'; + public $openid_configuration_endpoint = 'https://login.microsoftonline.com/common/.well-known/openid-configuration'; /** * @var string The OAuth 2.0 authorization endpoint. @@ -203,12 +202,9 @@ public static function get_instance() { * @return \AADSSO_Settings The (only) configured instance of this class. */ public static function init() { - $instance = self::get_instance(); - // First, set the settings stored in the WordPress database. $instance->set_settings( get_option( 'aadsso_settings' ) ); - /* * Then, add the settings stored in the OpenID Connect configuration endpoint. * We're using transient as a cache, to prevent from making a request on every WP page load. @@ -217,16 +213,13 @@ public static function init() { */ $openid_configuration = get_transient( 'aadsso_openid_configuration' ); if( false === $openid_configuration || isset( $_GET['aadsso_reload_openid_config'] ) ) { - $tenant = !empty($instance->org_domain_hint) ? $instance->org_domain_hint : 'common'; - $openid_configuration_endpoint = $instance->openid_configuration_endpoint_prefix . $tenant . $instance->openid_configuration_endpoint_suffix; $openid_configuration = json_decode( - self::get_remote_contents( $openid_configuration_endpoint ), + self::get_remote_contents( $instance->openid_configuration_endpoint ), true // Return associative array ); set_transient( 'aadsso_openid_configuration', $openid_configuration, 3600 ); } $instance->set_settings( $openid_configuration ); - return $instance; }