diff --git a/AuthorizationHelper.php b/AuthorizationHelper.php index 996dab1..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 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. */ - public static function validate_id_token( $id_token, $settings, $antiforgery_id ) { + private static function decode_token( $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,80 @@ 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 string $jwt The JWT token + * @param \AADSSO_Settings $settings The settings to use. + * + */ + private static function validate_token_claims( $jwt, $settings){ + $audience = $jwt->aud; + if ($audience != $settings->resource_id) { + throw new \AADSSO\Firebase\JWT\SignatureInvalidException('Invalid Audience'); + } + + // The Issuer Identifier for the OpenID Provider MUST exactly match the value of the iss (issuer) Claim. + $iss_token = $jwt->iss; + $iss_metadata = $settings->issuer; + + if ($iss_token != $iss_metadata) { + throw new \AADSSO\Firebase\JWT\SignatureInvalidException('Signature invalid'); + } + + } + + /** + * Validates signature and claims of a bearer_token value returned + * + * @param string $bearer_token The bearer token + * @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::decode_token( $bearer_token, $settings); + self::validate_token_claims( $jwt, $settings); + } + catch ( Exception $e ) { + throw $e; + } + + return $jwt; + } + + /** + * Decodes and validates an id_token value returned + * + * @param string $id_token The token + * @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::decode_token( $id_token, $settings); + } + catch ( Exception $e ) { + $last_exception = $e; + } + if ( null == $jwt ) { throw $last_exception; } diff --git a/Settings.php b/Settings.php index 1e14aac..8fa1175 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. @@ -192,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. @@ -213,7 +220,6 @@ public static function init() { set_transient( 'aadsso_openid_configuration', $openid_configuration, 3600 ); } $instance->set_settings( $openid_configuration ); - return $instance; } @@ -273,4 +279,4 @@ function set_settings( $settings ) { } return $this; } -} +} \ No newline at end of file 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 0cda951..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'] ); @@ -278,7 +382,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 ); } } @@ -364,7 +468,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 ) ); }