diff --git a/includes/openid-connect-generic-client-wrapper.php b/includes/openid-connect-generic-client-wrapper.php new file mode 100644 index 0000000..70b39d2 --- /dev/null +++ b/includes/openid-connect-generic-client-wrapper.php @@ -0,0 +1,455 @@ +client = $client; + $this->settings = $settings; + $this->logger = $logger; + } + + /** + * Hook the client into WP + * + * @param \OpenID_Connect_Generic_Client $client + * @param \WP_Option_Settings $settings + * @param \WP_Option_Logger $logger + */ + static public function register( OpenID_Connect_Generic_Client $client, WP_Option_Settings $settings, WP_Option_Logger $logger ){ + $client_wrapper = new self( $client, $settings, $logger ); + + // remove cookies on logout + add_action( 'wp_logout', array( $client_wrapper, 'wp_logout' ) ); + + // verify legitimacy of user token on admin pages + add_action( 'admin_init', array( $client_wrapper, 'check_user_token' ) ); + + // alter the requests according to settings + add_filter( 'openid-connect-generic-alter-request', array( $client_wrapper, 'alter_request' ), 10, 3 ); + + 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( $client_wrapper, 'authentication_request_callback' ) ); + add_action( 'wp_ajax_nopriv_openid-connect-authorize', array( $client_wrapper, 'authentication_request_callback' ) ); + } + + $client_wrapper->startup(); + + return $client_wrapper; + } + + /** + * Handle the initial validation that should occur on each page load + */ + function startup(){ + $this->handle_privacy(); + + // verify token for any logged in user + if ( is_user_logged_in() ) { + $this->check_user_token(); + } + } + + /** + * Get the authentication url from the client + * + * @return string + */ + function get_authentication_url(){ + return $this->client->make_authentication_url(); + } + + /** + * Handle the privacy settings + */ + function handle_privacy() { + // check if privacy enforcement is enabled + if ( $this->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'] ) && ! isset( $_GET['login-error'] ) ) { + $this->error_redirect( new WP_Error( 'privacy', __( 'This site requires login.' ), $_GET ) ); + } + } + } + + /** + * 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(); + $this->error_redirect( new WP_Error( 'mismatch-identity', __( 'Mismatch identity' ), $_COOKIE ) ); + } + } + + /** + * Handle errors by redirecting the user to the login form + * along with an error code + * + * @param $error WP_Error + */ + function error_redirect( $error ) { + $this->logger->log( $error ); + + // redirect user back to login page + wp_redirect( + wp_login_url() . + '?login-error=' . $error->get_error_code() . + '&message=' . urlencode( $error->get_error_message() ) + ); + exit; + } + + /** + * Get the current error state + * + * @return bool | WP_Error + */ + function get_error(){ + return $this->error; + } + + /** + * Implements hook wp_logout + * + * Remove cookies + */ + function wp_logout() { + setcookie( $this->cookie_id_key, '1', 0, COOKIEPATH, COOKIE_DOMAIN, TRUE ); + } + + /** + * Modify outgoing requests according to settings + * + * @param $request + * @param $op + * + * @return mixed + */ + function alter_request( $request, $op ) { + if ( $this->settings->no_sslverify ) { + $request['sslverify'] = FALSE; + } + + return $request; + } + + /** + * Control the authentication and subsequent authorization of the user when + * returning from the IDP. + */ + function authentication_request_callback() { + $settings = $this->settings; + $client = $this->client; + + // + $authentication_request = $client->validate_authentication_request( $_GET ); + + if ( is_wp_error( $authentication_request ) ){ + $this->error_redirect( $authentication_request ); + } + + // retrieve the authentication code from the authentication request + $code = $client->get_authentication_code( $authentication_request ); + + if ( is_wp_error( $code ) ){ + $this->error_redirect( $code ); + } + + // attempting to exchange an authorization code for an authentication token + $token_result = $client->request_authentication_token( $code ); + + if ( is_wp_error( $token_result ) ) { + $this->error_redirect( $token_result ); + } + + // get the decoded response from the authentication request result + $token_response = $client->get_token_response( $token_result ); + + if ( is_wp_error( $token_response ) ){ + $this->error_redirect( $token_response ); + } + + // ensure the that response contains required information + $valid = $client->validate_token_response( $token_response ); + + if ( is_wp_error( $valid ) ) { + $this->error_redirect( $valid ); + } + + // - 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 + + // + $id_token_claim = $client->get_id_token_claim( $token_response ); + + if ( is_wp_error( $id_token_claim ) ){ + $this->error_redirect( $id_token_claim ); + } + + // + $valid = $client->validate_id_token_claim( $id_token_claim ); + + if ( is_wp_error( $valid ) ){ + $this->error_redirect( $valid ); + } + + +// +// // if desired, admins can use regex to determine if the identity value is valid +// // according to their own standards expectations +// if ( ! empty( $settings->allowed_regex ) && +// preg_match( $settings->allowed_regex, $id_token_claim[ $settings->identity_key ] ) !== 1 +// ) { +// return new WP_Error( 'no-subject-identity', __( 'No subject identity' ), $id_token_claim ); +// } + + + // + $user_claim = $client->get_user_claim( $token_response ); + + if ( is_wp_error( $user_claim ) ){ + $this->error_redirect( $user_claim ); + } + + // + $valid = $client->validate_user_claim( $user_claim, $id_token_claim ); + + if ( is_wp_error( $valid ) ){ + $this->error_redirect( $valid ); + } + + // - end authorization + + + + // request is authenticated and authorized + // - start user handling + $user_identity = $client->get_user_identity( $id_token_claim ); + $user = $this->get_user_by_identity( $user_identity ); + + // if we didn't find an existing user, we'll need to create it + if ( ! $user ) { + $user = $this->create_new_user( $user_identity, $user_claim ); + } + + // + $valid = $this->validate_user( $user ); + + if ( is_wp_error( $valid ) ){ + $this->error_redirect( $valid ); + } + + $this->login_user( $user, $token_response, $id_token_claim, $user_claim, $user_identity ); + + $this->logger->log( "Successful login for: {$user->user_login} ({$user->ID})", 'login-success' ); + + wp_redirect( home_url() ); + } + + /** + * Validate the potential WP_User + * + * @param $user + * + * @return \WP_Error + */ + function validate_user( $user ){ + // ensure our found user is a real WP_User + if ( ! is_a( $user, 'WP_User' ) || ! $user->exists() ) { + return new WP_Error( 'invalid-user', __( 'Invalid user' ), $user ); + } + + return true; + } + + /** + * + * + * @param $user + */ + function login_user( $user, $token_response, $id_token_claim, $user_claim, $user_identity ){ + // hey, we made it! + // let's remember the tokens for future reference + update_user_meta( $user->ID, 'openid-connect-generic-last-token-response', $token_response ); + 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 ); + + // save our authorization cookie for the response expiration + $oauth_expiry = $token_response['expires_in'] + current_time( 'timestamp', TRUE ); + setcookie( $this->cookie_id_key, $user_identity, $oauth_expiry, COOKIEPATH, COOKIE_DOMAIN, TRUE ); + + // get a cookie and go home! + wp_set_auth_cookie( $user->ID, FALSE ); + } + + /** + * + * + * @param $user_identity + * + * @return false|\WP_User + */ + function get_user_by_identity( $user_identity ){ + // 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' => $user_identity, + ) + ) + ) ); + + // if we found an existing users, grab the first one returned + if ( $user_query->get_total() > 0 ) { + $users = $user_query->get_results(); + return $users[0]; + } + + return false; + } + + /** + * Avoid user_login collisions by incrementing + * + * @param $user_claim array + * + * @return string + */ + private function get_username_from_claim( $user_claim ) { + if ( isset( $user_claim['preferred_username'] ) && ! empty( $user_claim['preferred_username'] ) ) { + $desired_username = $user_claim['preferred_username']; + } + else if ( isset( $user_claim['name'] ) && ! empty( $user_claim['name'] ) ) { + $desired_username = $user_claim['name']; + } + else if ( isset( $user_claim['email'] ) && ! empty( $user_claim['email'] ) ) { + $tmp = explode( '@', $user_claim['email'] ); + $desired_username = $tmp[0]; + } + else { + // nothing to build a name from + return new WP_Error( 'no-username', __( 'No appropriate username found' ), $user_claim ); + } + + // normalize the data a bit + $desired_username = strtolower( preg_replace( '/[^a-zA-Z\_0-9]/', '', $desired_username ) ); + + // copy the username for incrementing + $username = $desired_username; + + // original user gets "name" + // second user gets "name2" + // etc + $count = 1; + while ( username_exists( $username ) ) { + $count ++; + $username = $desired_name . $count; + } + + return $username; + } + + /** + * + * + * @param $user_identity + * @param $user_claim + * + * @return \WP_Error | \WP_User + */ + function create_new_user( $user_identity, $user_claim){ + // 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['email'] ) ) { + $email = $user_claim['email']; + $username = $this->get_username_from_claim( $user_claim ); + } + // if no name exists, attempt another request for userinfo + else if ( isset( $token_response['access_token'] ) ) { + $user_claim_result = $this->client->request_userinfo( $token_response['access_token'] ); + + // make sure we didn't get an error + if ( is_wp_error( $user_claim_result ) ) { + return new WP_Error( 'bad-user-claim-result', __( 'Bad user claim result' ), $user_claim_result ); + } + + $user_claim = json_decode( $user_claim_result['body'], TRUE ); + + if ( isset( $user_claim['email'] ) ) { + $email = $user_claim['email']; + $username = $this->get_username_from_claim( $user_claim ); + } + } + + // allow other plugins / themes to determine authorization + // of new accounts based on the returned user claim + $create_user = apply_filters( 'openid-connect-generic-user-creation-test', TRUE, $user_claim ); + + if ( ! $create_user ) { + return new WP_Error( 'cannot-authorize', __( 'Can not authorize.' ), $create_user ); + } + + // 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 ) ) { + return new WP_Error( 'failed-user-creation', __( 'Failed user creation.' ), $uid ); + } + + $user = get_user_by( 'id', $uid ); + + $this->log( "New user created: {$user->user_login} ($uid)", 'success' ); + + // 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 ); + + // allow plugins / themes to take action on new user creation + do_action( 'openid-connect-generic-user-create', $user, $user_claim ); + + return $user; + } +} \ No newline at end of file diff --git a/includes/openid-connect-generic-client.php b/includes/openid-connect-generic-client.php new file mode 100644 index 0000000..c93cd63 --- /dev/null +++ b/includes/openid-connect-generic-client.php @@ -0,0 +1,323 @@ +client_id = $client_id; + $this->client_secret = $client_secret; + $this->scope = $scope; + $this->endpoint_login = $endpoint_login; + $this->endpoint_userinfo = $endpoint_userinfo; + $this->endpoint_token = $endpoint_token; + $this->redirect_uri = $redirect_uri; + } + + /** + * Create a single use authentication url + * + * @return string + */ + function make_authentication_url() { + $url = sprintf( '%1$s?response_type=code&scope=%2$s&client_id=%3$s&state=%4$s&redirect_uri=%5$s', + $this->endpoint_login, + urlencode( $this->scope ), + urlencode( $this->client_id ), + $this->new_state(), + urlencode( $this->redirect_uri ) + ); + + return $url; + } + + /** + * + * + * @param $request + * + * @return \WP_Error + */ + function validate_authentication_request( $request ){ + // look for an existing error of some kind + if ( isset( $request['error'] ) ) { + return new WP_Error( 'unknown-error', 'An unknown error occurred.', $request ); + } + + // make sure we have a legitimate authentication code and valid state + if ( ! isset( $request['code'] ) ) { + return new WP_Error( 'no-code', 'No authentication code present in the request.', $request ); + } + + // check the client request state + if ( ! isset( $request['state'] ) || ! $this->check_state( $request['state'] ) ){ + return new WP_Error( 'missing-state', __( 'Missing state.' ), $request ); + } + + return $request; + } + + /** + * Get the authorization code from the request + * + * @return string + */ + function get_authentication_code( $request ){ + return $request['code']; + } + + /** + * Using the authorization_code, request an authentication token from the idp + * + * @param $code - authorization_code + * + * @return array|\WP_Error + */ + function request_authentication_token( $code ) { + $request = array( + 'body' => array( + 'code' => $code, + 'client_id' => $this->client_id, + 'client_secret' => $this->client_secret, + 'redirect_uri' => $this->redirect_uri, + 'grant_type' => 'authorization_code', + 'scope' => $this->scope, + ) + ); + + // allow modifications to the request + $request = apply_filters( 'openid-connect-generic-alter-request', $request, 'get-authentication-token' ); + + // call the server and ask for a token + $response = wp_remote_post( $this->endpoint_token, $request ); + + return $response; + } + + + /** + * + * + * @param $token_result + * @return array|mixed|object + */ + function get_token_response( $token_result ){ + if ( ! isset( $token_result['body'] ) ){ + return new WP_Error( 'missing-token-body', __( 'Missing token body.' ), $token_response ); + } + + // extract token response from token + $token_response = json_decode( $token_result['body'], TRUE ); + + return $token_response; + } + + + /** + * Using an access_token, request the userinfo from the idp + * + * @param $access_token + * + * @return array|\WP_Error + */ + function request_userinfo( $access_token ) { + // allow modifications to the request + $request = apply_filters( 'openid-connect-generic-alter-request', array(), 'get-userinfo' ); + + // attempt the request + $response = wp_remote_get( $this->endpoint_userinfo . '?access_token=' . $access_token, $request ); + + return $response; + } + + /** + * 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() . microtime( true ) ); + $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; + } + + /** + * + * + * @param $token_response + * + * @return bool|\WP_Error + */ + function validate_token_response( $token_response ){ + // 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' + ) { + return new WP_Error( 'invalid-token-response', 'Invalid token response', $token_response ); + } + + return true; + } + + /** + * + * + * @param $token_response + * + * @return array|mixed|object|\WP_Error + */ + function get_id_token_claim( $token_response ){ + // name sure we have an id_token + if ( ! isset( $token_response['id_token'] ) ) { + return new WP_Error( 'no-identity-token', __( 'No identity token' ), $token_response ); + } + + // break apart the id_token in the response for decoding + $tmp = explode( '.', $token_response['id_token'] ); + + if ( ! isset( $tmp[1] ) ) { + return new WP_Error( 'no-identity-token', __( 'No identity token' ), $token_response ); + } + + // Extract the id_token's claims from the token + $id_token_claim = json_decode( base64_decode( $tmp[1] ), TRUE ); + + return $id_token_claim; + } + + /** + * + * + * @param $id_token_claim + * + * @return bool|\WP_Error + */ + function validate_id_token_claim( $id_token_claim ){ + // make sure we can find our identification data and that it has a value + if ( ! isset( $id_token_claim['sub'] ) || empty( $id_token_claim['sub'] ) ) { + return new WP_Error( 'no-subject-identity', __( 'No subject identity' ), $id_token_claim ); + } + + return true; + } + + /** + * + * + * @param $token_response + * + * @return array|mixed|object|\WP_Error + */ + function get_user_claim( $token_response ){ + // 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'] ) ) { + return new WP_Error( 'bad-claim', __( 'Bad user claim' ), $user_claim_result ); + } + + $user_claim = json_decode( $user_claim_result['body'], TRUE ); + + return $user_claim; + } + + /** + * + * + * @param $user_claim + * @param $id_token_claim + * + * @return \WP_Error + */ + function validate_user_claim( $user_claim, $id_token_claim ) { + // must be an array + if ( ! is_array( $user_claim ) ){ + return new WP_Error( 'invalid-user-claim', __( 'Invalid user claim' ), $user_claim ); + } + + // make sure the id_token sub === user_claim sub, according to spec + if ( $id_token_claim['sub' ] !== $user_claim['sub'] ) { + return new WP_Error( 'invalid-user-claim', __( 'Invalid user claim' ), func_get_args() ); + } + + // allow for other plugins to alter the login success + $login_user = apply_filters( 'openid-connect-generic-user-login-test', TRUE, $user_claim ); + + if ( ! $login_user ) { + return new WP_Error( 'unauthorized', __( 'Unauthorized' ), $login_user ); + } + + return true; + } + + /** + * + * + * @return mixed + */ + function get_user_identity( $id_token_claim ){ + return $id_token_claim['sub']; + } +} \ No newline at end of file diff --git a/includes/openid-connect-generic-login-form.php b/includes/openid-connect-generic-login-form.php new file mode 100644 index 0000000..3760017 --- /dev/null +++ b/includes/openid-connect-generic-login-form.php @@ -0,0 +1,94 @@ +settings = $settings; + $this->client_wrapper = $client_wrapper; + } + + /** + * @param $settings + * @param $client_wrapper + * + * @return \OpenID_Connect_Generic_Login_Form + */ + static public function register( $settings, $client_wrapper ){ + $login_form = new self( $settings, $client_wrapper ); + + // alter the login form as dictated by settings + add_filter( 'login_message', array( $login_form, 'handle_login_page' ), 99 ); + + return $login_form; + } + + /** + * Implements filter login_message + * + * @param $message + * @return string + */ + function handle_login_page( $message ) { + $settings = $this->settings; + + // errors and auto login can't happen at the same time + if ( isset( $_GET['login-error'] ) ) { + $message = $this->make_error_output( $_GET['login-error'], $_GET['message'] ); + } + else if ( $settings->login_type == 'auto' ) { + wp_redirect( $this->client_wrapper->get_authentication_url() ); + exit; + } + + // login button is appended to existing messages in case of error + if ( $settings->login_type == 'button' ) { + $message .= $this->make_login_button(); + } + + return $message; + } + + /** + * Display an error message to the user + * + * @param $error_code + * + * @return string + */ + function make_error_output( $error_code, $error_message ) { + + ob_start(); + ?> +
+ : + +
+ client_wrapper->get_authentication_url(); + + ob_start(); + ?> +
+ +
+ 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' ) ); - + $this->logger = $logger; + $this->settings_field_group = $this->settings->get_option_name() . '-group'; + /* * Simple settings fields simply have: * @@ -133,13 +129,31 @@ class OpenID_Connect_Generic_Settings { // some simple pre-processing foreach ( $fields as $key => &$field ) { $field['key'] = $key; - $field['name'] = OPENID_CONNECT_GENERIC_SETTINGS_NAME . '[' . $key . ']'; + $field['name'] = $this->settings->get_option_name() . '[' . $key . ']'; } // allow alterations of the fields $this->settings_fields = $fields; } + /** + * @param \WP_Option_Settings $settings + * @param \WP_Option_Logger $logger + * + * @return \OpenID_Connect_Generic_Settings_Page + */ + static public function register( WP_Option_Settings $settings, WP_Option_Logger $logger ){ + $settings_page = new self( $settings, $logger ); + + // add our options page the the admin menu + add_action( 'admin_menu', array( $settings_page, 'admin_menu' ) ); + + // register our settings + add_action( 'admin_init', array( $settings_page, 'admin_init' ) ); + + return $settings_page; + } + /** * Implements hook admin_menu to add our options/settings page to the * dashboard menu @@ -157,7 +171,7 @@ class OpenID_Connect_Generic_Settings { * Implements hook admin_init to register our settings */ public function admin_init() { - register_setting( $this->settings_field_group, OPENID_CONNECT_GENERIC_SETTINGS_NAME, array( + register_setting( $this->settings_field_group, $this->settings->get_option_name(), array( $this, 'sanitize_settings' ) ); @@ -183,8 +197,8 @@ class OpenID_Connect_Generic_Settings { // preprocess fields and add them to the page foreach ( $this->settings_fields as $key => $field ) { // make sure each key exists in the settings array - if ( ! isset( $this->settings[ $key ] ) ) { - $this->settings[ $key ] = NULL; + if ( ! isset( $this->settings->{ $key } ) ) { + $this->settings->{ $key } = NULL; } // determine appropriate output callback @@ -260,31 +274,7 @@ class OpenID_Connect_Generic_Settings {

-

- - - - - - - - - - - - - - - - - -
TypeDateUserData
user_login : 'anonymous'; ?>' . print_r( $log['data'], 1 ) . ''; ?>
- show_logs(); ?> " class="large-text" name="" - value="settings[ $field['key'] ] ); ?>"> + value="settings->{ $field['key'] } ); ?>"> do_field_description( $field ); } @@ -319,7 +309,7 @@ class OpenID_Connect_Generic_Settings { id="" name="" value="1" - settings[ $field['key'] ], 1 ); ?>> + settings->{ $field['key'] }, 1 ); ?>> do_field_description( $field ); } @@ -328,7 +318,7 @@ class OpenID_Connect_Generic_Settings { * @param $field */ function do_select( $field ) { - $current_value = ( $this->settings[ $field['key'] ] ? $this->settings[ $field['key'] ] : '' ); + $current_value = isset( $this->settings->{ $field['key'] } ) ? $this->settings->{ $field['key'] } : ''; ?>