From 7ce9613650e4ba5784fc5586231fb2eb5f434fa1 Mon Sep 17 00:00:00 2001 From: daggerhart Date: Thu, 14 May 2015 20:18:16 +0000 Subject: [PATCH] initial commit of working plugin --- admin/openid-connect-generic-settings.php | 303 ++++++++++++ openid-connect-generic.php | 554 ++++++++++++++++++++++ 2 files changed, 857 insertions(+) create mode 100644 admin/openid-connect-generic-settings.php create mode 100644 openid-connect-generic.php diff --git a/admin/openid-connect-generic-settings.php b/admin/openid-connect-generic-settings.php new file mode 100644 index 0000000..d7000cc --- /dev/null +++ b/admin/openid-connect-generic-settings.php @@ -0,0 +1,303 @@ +settings = $settings; + $this->settings_field_group = OPENID_CONNECT_GENERIC_SETTINGS_NAME . '-group'; + + // add our options page the the admin menu + add_action( 'admin_menu', array( $this, 'admin_menu' ) ); + + // register our settings + add_action( 'admin_init', array( $this, 'admin_init' ) ); + + /* + * Simple settings fields simply have: + * + * - title + * - description + * - type ( checkbox | text ) + * - section ( settings/option page section ) + * - example (optional) + */ + $this->settings_fields = array( + 'login_type' => array( + 'title' => __('Login Type'), + 'description' => __('Select how the client (login form) should provide login options.'), + 'type' => 'select', + 'options' => array( + 'button' => __('OpenID Connect button on login form'), + 'auto' => __('Auto Login - SSO'), + ), + 'section' => 'client_settings', + ), + 'ep_login' => array( + 'title' => __('Login Endpoint URL'), + 'description' => __('Identify provider authorization endpoint.'), + 'example' => 'https://example.com/oauth2/authorize', + 'type' => 'text', + 'section' => 'client_settings', + ), + 'ep_token' => array( + 'title' => __('Token Validation Endpoint URL'), + 'description' => __('Identify provider token endpoint.'), + 'example' => 'https://example.com/oauth2/token', + 'type' => 'text', + 'section' => 'client_settings', + ), + 'ep_userinfo' => array( + 'title' => __('Userinfo Endpoint URL'), + 'description' => __('Identify provider User information endpoint.'), + 'example' => 'https://example.com/oauth2/UserInfo', + 'type' => 'text', + 'section' => 'client_settings', + ), + 'no_sslverify' => array( + 'title' => __('Disable SSL Verify'), + 'description' => __('Do not require SSL verification during authorization. The OAuth extension uses curl to make the request. By default CURL will generally verify the SSL certificate to see if its valid an issued by an accepted CA. This setting disabled that verification.'), + 'type' => 'checkbox', + 'section' => 'client_settings', + ), + 'client_id' => array( + 'title' => __('Client ID'), + 'description' => __('The ID this client will be recognized as when connecting the to Identity provider server.'), + 'example' => 'my-wordpress-client-id', + 'type' => 'text', + 'section' => 'client_settings', + ), + 'client_secret' => array( + 'title' => __('Client Secret Key'), + 'description' => __('Arbitrary secret key the server expects from this client. Can be anything, but should be very unique.'), + 'type' => 'text', + 'section' => 'client_settings', + ), + 'scope' => array( + 'title' => __('OpenID Scope'), + 'description' => __('Space separated list of scopes this client should access.'), + 'example' => 'email profile openid', + 'type' => 'text', + 'section' => 'client_settings', + ), + 'identity_key' => array( + 'title' => __('Identity Key'), + 'description' => __('Where in the response array to find the identification data. When in doubt, use "sub".'), + 'example' => 'sub', + 'type' => 'text', + 'section' => 'client_settings', + ), + 'allowed_regex' => array( + 'title' => __('Authorization Regex'), + 'description' => __('Provide a regular expression that enforces your expectations concerning the identity value returned from the IDP.'), + 'type' => 'text', + 'section' => 'authorization_settings', + ), + 'enforce_privacy' => array( + 'title' => __('Enforce Privacy'), + 'description' => __('Require users be logged in to see the site.'), + 'type' => 'checkbox', + 'section' => 'authorization_settings', + ), + ); + + // allow alterations of the fields + $this->settings_fields = apply_filters( 'openid-connect-generic-settings-fields', $this->settings_fields ); + } + + /** + * Implements hook admin_menu to add our options/settings page to the + * dashboard menu + */ + public function admin_menu() { + add_options_page( + __('OpenID Connect - Generic Client'), + __('OpenID Connect Client'), + 'manage_options', + $this->options_page_name, + array( $this, 'settings_page') ); + } + + /** + * Implements hook admin_init to register our settings + */ + public function admin_init() { + register_setting( $this->settings_field_group, OPENID_CONNECT_GENERIC_SETTINGS_NAME, array( $this, 'sanitize_settings' ) ); + + add_settings_section( 'client_settings', + __('Client Settings'), + array( $this, 'client_settings_description' ), + $this->options_page_name + ); + + add_settings_section( 'authorization_settings', + __('Authorization Settings'), + array( $this, 'authorization_settings_description' ), + $this->options_page_name + ); + + // preprocess fields and add them to the page + foreach ( $this->settings_fields as $key => $field ) { + $field['key'] = $key; + $field['name'] = OPENID_CONNECT_GENERIC_SETTINGS_NAME . '[' . $key . ']'; + + // make sure each key exists in the settings array + if ( ! isset( $this->settings[ $key ] ) ){ + $this->settings[ $key ] = null; + } + + // determine appropriate output callback + switch ( $field['type'] ) { + case 'checkbox': + $callback = 'do_checkbox'; + break; + + case 'select': + $callback = 'do_select'; + break; + + case 'text': + default: + $callback = 'do_text_field'; + break; + } + + // add the field + add_settings_field( $key, $field['title'], + array( $this, $callback ), + $this->options_page_name, + $field['section'], + $field + ); + } + } + + /** + * Sanitization callback for settings/option page + * + * @param $input - submitted settings values + * @return array + */ + public function sanitize_settings( $input ) { + $options = array(); + + // loop through settings fields to control what we're saving + foreach ( $this->settings_fields as $key => $field ) { + if ( isset( $input[ $key ] ) ){ + $options[ $key ] = sanitize_text_field( trim( $input[ $key ] ) ); + } + else { + $options[ $key ] = ''; + } + } + + return $options; + } + + /** + * Output the options/settings page + */ + public function settings_page() { + ?> +
+

+
+ settings_field_group ); + do_settings_sections( $this->options_page_name ); + submit_button(); + ?> +
+
+ + + do_field_description( $field ); + } + + /** + * Output a checkbox for a boolean setting + * - hidden field is default value so we don't have to check isset() on save + * + * @param $field + */ + public function do_checkbox( $field ) { + ?> + + settings[ $field['key'] ] , 1 ); ?>> + do_field_description( $field ); + } + + /** + * @param $field + */ + function do_select( $field ) { + $current_value = ( $this->settings[ $field['key'] ] ? $this->settings[ $field['key'] ] : ''); + ?> + + do_field_description( $field ); + } + + /** + * Simply output the field description, and example if present + * + * @param $field + */ + public function do_field_description( $field ){ + ?> +

+ + +
: + +

+ 'button', + 'no_sslverify' => 0, + 'enforce_privacy' => 0, + 'identity_key' => 'sub', + ); + + // storage for plugin settings + private $settings = array(); + + // storage for error messages + private $errors = array(); + + private $redirect_uri; + + /** + * Initialize the plugin + */ + function __construct(){ + add_action( 'init', array( $this, 'init' ) ); + + $this->redirect_uri = admin_url( 'admin-ajax.php?action=openid-connect-authorize' ); + + // translatable errors + $this->errors = array( + 1 => __('Cannot get authentication response'), + 2 => __('Cannot get token response'), + 3 => __('Cannot get user claims'), + 4 => __('Cannot get valid token'), + 5 => __('Cannot get user key'), + 6 => __('Cannot create authorized user'), + 7 => __('User not found'), + 99 => __('Unknown error') + ); + } + + /** + * Get plugin settings + * - settings field logic in admin/settings class + * + * @return array + */ + public function get_settings() { + if ( ! empty( $this->settings ) ){ + return $this->settings; + } + + return wp_parse_args( get_option( OPENID_CONNECT_GENERIC_SETTINGS_NAME, array() ), $this->default_settings ); + } + + /** + * Implements hook init + * - hook plugin into WP as needed + */ + public function init(){ + // check the user's status based on plugin settings + $this->check_user_status(); + + // remove cookies on logout + add_action( 'wp_logout', array( $this, 'wp_logout' ) ); + + // verify legitimacy of user token on admin pages + add_action( 'admin_init', array( $this, 'check_user_token' ) ); + + // alter the login form as dictated by settings + add_filter( 'login_message', array( $this, 'login_message' ), 99 ); + + // alter the requests according to settings + add_filter( 'openid-connect-generic-alter-request', array( $this, 'alter_request' ), 10, 3 ); + + // administration yo! + if ( is_admin() ) { + // use the ajax url to handle processing authorization without any html output + // this callback will occur when then IDP returns with an authenticated value + add_action( 'wp_ajax_openid-connect-authorize', array( $this, 'auth_callback' ) ); + add_action( 'wp_ajax_nopriv_openid-connect-authorize', array( $this, 'auth_callback' ) ); + + // initialize the settings page + require_once OPENID_CONNECT_GENERIC_DIR . '/admin/openid-connect-generic-settings.php'; + new OpenID_Connect_Generic_Settings( $this->get_settings() ); + } + } + + /** + * Validate the user's status based on plugin settings + */ + function check_user_status(){ + $settings = $this->get_settings(); + + // check if privacy enforcement is enabled + if ( $settings['enforce_privacy'] && + ! is_user_logged_in() && + // avoid redirects on cron or ajax + ( ! defined( 'DOING_AJAX') || ! DOING_AJAX ) && + ( ! defined( 'DOING_CRON' ) || ! DOING_CRON ) + ) + { + global $pagenow; + + // avoid redirect loop + if ( $pagenow != 'wp-login.php' && ! isset( $_GET['loggedout'] ) ) { + wp_redirect( wp_login_url() ); + exit; + } + } + + // verify token for any logged in user + if ( is_user_logged_in() ) { + $this->check_user_token(); + } + } + + /** + * Check the user's cookie + */ + function check_user_token(){ + $is_openid_connect_user = get_user_meta( wp_get_current_user()->ID, 'openid-connect-generic-user', true ); + + if ( is_user_logged_in() && ! empty( $is_openid_connect_user ) && ! isset( $_COOKIE[ $this->cookie_id_key ] ) ) { + wp_logout(); + wp_redirect( wp_login_url() ); + exit; + } + } + + /** + * Control the authentication and subsequent authorization of the user when + * returning from the IDP. + */ + function auth_callback(){ + $settings = $this->get_settings(); + + // look for an existing error of some kind + if ( isset( $_GET['error'] ) ) { + $this->error_redirect( 99 ); + } + + // make sure we have a legitimate authentication code and valid state + if ( !isset( $_GET['code'] ) || !isset( $_GET['state'] ) || !$this->check_state( $_GET['state'] ) ) { + $this->error_redirect( 1 ); + } + + // we have an authorization code, make sure it is good by + // attempting to exchange it for an authentication token + $token_result = $this->request_authentication_token( $_GET['code'] ); + + // ensure the token is not an error generated by wp + if ( is_wp_error( $token_result ) ){ + $this->error_redirect( 2 ); + } + + // extract token response from token + $token_response = json_decode( $token_result['body'], true ); + + // we need to ensure 3 specific items exist with the token response in order + // to proceed with confidence: id_token, access_token, and token_type == 'Bearer' + if ( ! isset( $token_response['id_token'] ) || ! isset( $token_response['access_token'] ) || + ! isset( $token_response['token_type'] ) || $token_response['token_type'] !== 'Bearer' ) + { + $this->error_redirect( 4 ); + } + + // - end authentication + // - start authorization + + // The id_token is used to identify the authenticated user, e.g. for SSO. + // The access_token must be used to prove access rights to protected resources + // e.g. for the userinfo endpoint + + // break apart the id_token int eh response for decoding + $tmp = explode('.', $token_response['id_token'] ); + + // Extract the id_token's claims from the token + $id_token_claim = json_decode( base64_decode( $tmp[1] ), true ); + + // make sure we can find our identification data and that it has a value + if ( ! isset( $id_token_claim[ $settings['identity_key'] ] ) || empty( $id_token_claim[ $settings['identity_key'] ] ) ) { + $this->error_redirect( 5 ); + } + + // if desired, admins can use regex to determine if the identity value is valid + // according to their own standards expectations + if ( isset( $settings['allowed_regex'] ) && !empty( $settings['allowed_regex'] ) && + preg_match( $settings['allowed_regex'], $id_token_claim[ $settings['identity_key'] ] ) !== 1) + { + $this->error_redirect( 5 ); + } + + // send a userinfo request to get user claim + $user_claim_result = $this->request_userinfo( $token_response['access_token'] ); + + // make sure we didn't get an error, and that the response body exists + if ( is_wp_error( $user_claim_result ) || ! isset( $user_claim_result['body'] ) ) { + $this->error_redirect( 3 ); + } + + $user_claim = json_decode( $user_claim_result['body'], true ); + + // make sure the id_token sub === user_claim sub, according to spec + if ( $id_token_claim['sub'] !== $user_claim['sub'] ) { + $this->error_redirect( 4 ); + } + + $user_identity = $id_token_claim[ $settings['identity_key'] ]; + $oauth_expiry = $token_response['expires_in'] + current_time( 'timestamp', true ); + setcookie( $this->cookie_id_key, $user_identity, $oauth_expiry, COOKIEPATH, COOKIE_DOMAIN, true ); + + // - end authorization + // - start user handling + + // look for user by their openid-connect-generic-user-identity value + $user_query = new WP_User_Query( array( + 'meta_query' => array( + array( + 'key' => 'openid-connect-generic-user-identity', + 'value' => 746, + ) + ) + )); + + // if we found an existing users, grab the first one returned + if ( $user_query->get_total() > 0 ) { + $user = $user_query->get_results()[0]; + } + // otherwise, user does not exist and we'll need to create it + else { + // default username & email to the user identity, since that is the only + // thing we can be sure to have + $username = $user_identity; + $email = $user_identity; + + // allow claim details to determine username + if ( isset( $user_claim['name'] ) && isset( $user_claim['email'] ) ) { + $username = $user_claim['name']; + $email = $user_claim['email']; + } + // if no name exists, attempt another request for userinfo + else if ( isset( $token_response['access_token'] ) ) { + $user_claim_result = $this->request_userinfo( $token_response['access_token'] ); + + // make sure we didn't get an error + if ( is_wp_error( $user_claim_result ) ) { + $this->error_redirect( 3 ); + } + + $user_claim = json_decode( $user_claim_result['body'], true ); + + if ( isset( $user_claim['name'] ) ) { + $username = $user_claim['name']; + } + + if ( isset( $user_claim['email'] ) ) { + $email = $user_claim['email']; + } + } + + // create the new user + $uid = wp_create_user( $username, wp_generate_password( 32, true, true ), $email ); + + // make sure we didn't fail in creating the user + if ( is_wp_error( $uid ) ) { + $this->error_redirect( 6 ); + } + + $user = get_user_by( 'id', $uid ); + + // save some meta data about this new user for the future + add_user_meta( $user->ID, 'openid-connect-generic-user', true, true ); + add_user_meta( $user->ID, 'openid-connect-generic-user-identity', (string) $user_identity, true ); + } + + // ensure our found user is a real WP_User + if ( ! is_a( $user, 'WP_User' ) || ! $user->exists() ) { + $this->error_redirect( 7 ); + } + + // hey, we made it! + // let's remember the tokens for future reference + update_user_meta( $user->ID, 'openid-connect-generic-last-id-token-claim', $id_token_claim ); + update_user_meta( $user->ID, 'openid-connect-generic-last-user-claim', $user_claim ); + + // get a cookie and go home! + wp_set_auth_cookie( $user->ID, false ); + wp_redirect( home_url() ); + // - end user handling + } + + /** + * Using the authorization_code, request an authentication token from the idp + * + * @param $code - authorization_code + * @return array|\WP_Error + */ + function request_authentication_token( $code ){ + $settings = $this->get_settings(); + + $request = array( + 'body' => array( + 'code' => $code, + 'client_id' => $settings['client_id'], + 'client_secret' => $settings['client_secret'], + 'redirect_uri' => $this->redirect_uri, + 'grant_type' => 'authorization_code', + 'scope' => $settings['scope'], + ) + ); + + // allow modifications to the request + $request = apply_filters( 'openid-connect-generic-alter-request', $request, $settings, 'get-authentication-token' ); + + // call the server and ask for a token + $response = wp_remote_post( $settings['ep_token'], $request ); + + return $response; + } + + /** + * Using an access_token, request the userinfo from the idp + * + * @param $access_token + * @return array|\WP_Error + */ + function request_userinfo( $access_token ){ + $settings = $this->get_settings(); + + // allow modifications to the request + $request = apply_filters( 'openid-connect-generic-alter-request', array(), $settings, 'get-userinfo' ); + + // attempt the request + $response = wp_remote_get( $settings['ep_userinfo'].'?access_token='.$access_token, $request ); + + return $response; + } + + /** + * Modify outgoing requests according to settings + * + * @param $request + * @param $settings + * @param $op + * @return mixed + */ + function alter_request( $request, $settings, $op ){ + if ( isset( $settings['no_sslverify'] ) && $settings['no_sslverify'] ) { + $request['sslverify'] = false; + } + + return $request; + } + + /** + * Create a single use authentication url + * + * @return string + */ + function make_authentication_url() { + $settings = $this->get_settings(); + + $url = sprintf( '%1$s?response_type=code&scope=%2$s&client_id=%3$s&state=%4$s&redirect_uri=%5$s', + $settings['ep_login'], + urlencode( $settings['scope'] ), + urlencode( $settings['client_id'] ), + $this->new_state(), + urlencode( $this->redirect_uri ) + ); + + return $url; + } + + /** + * Generate a new state, + * save it to the states option with a timestamp, + * and return it. + * + * @return string + */ + function new_state(){ + $states = get_option( 'openid-connect-generic-valid-states', array() ); + + // new state w/ timestamp + $new_state = md5( mt_rand() ); + $states[ $new_state ] = time(); + + // save state + update_option( 'openid-connect-generic-valid-states', $states ); + + return $new_state; + } + + /** + * Check the validity of a given state + * + * @param $state + * @return bool + */ + function check_state( $state ){ + $states = get_option( 'openid-connect-generic-valid-states', array() ); + $valid = false; + + // remove any expired states + foreach ( $states as $code => $timestamp ){ + if ( ( $timestamp + $this->state_time_limit ) < time() ) { + unset( $states[ $code ] ); + } + } + + // see if the current state is still within the list of valid states + if ( isset( $states[ $state ] ) ){ + // state is valid, remove it + unset( $states[ $state ] ); + + $valid = true; + } + + // save our altered states + update_option( 'openid-connect-generic-valid-states', $states ); + + return $valid; + } + + /** + * Implements filter login_message + * + * @param $message + * @return string + */ + function login_message( $message ){ + $settings = $this->get_settings(); + + // errors and auto login can't happen at the same time + if ( isset( $_GET['login-error'] ) ) { + $message = $this->error_message( $_GET['login-error'] ); + } + else if ( $settings['login_type'] == 'auto' ) { + wp_redirect( $this->make_authentication_url() ); + exit; + } + + // login button is appended to existing messages in case of error + if ( $settings['login_type'] == 'button' ) { + $message.= $this->login_button(); + } + + return $message; + } + + /** + * Handle errors by redirecting the user to the login form + * along with an error code + * + * @param $error_number + */ + function error_redirect( $error_number ){ + $url = wp_login_url() . '?login-error=' . $error_number; + + wp_redirect( $url ); + exit; + } + + /** + * Display an error message to the user + * + * @param $error_number + * @return string + */ + function error_message( $error_number ){ + // fallback to unknown error + if ( ! isset( $this->errors[ $error_number ] ) ) { + $error_number = 99; + } + + ob_start(); + ?> +
errors[ $error_number ]; ?>
+ make_authentication_url(); + + ob_start(); + ?> +
+ +
+ cookie_id_key , '1', 0, COOKIEPATH, COOKIE_DOMAIN, true ); + } +} + +new OpenID_Connect_Generic();