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 * * @return \OpenID_Connect_Generic_Client_Wrapper */ 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' ) ); // alter the requests according to settings add_filter( 'openid-connect-generic-alter-request', array( $client_wrapper, 'alter_request' ), 10, 3 ); add_filter( 'http_request_timeout', array( $client_wrapper, 'alter_http_request_timeout' ) ); 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' ) ); } // verify token for any logged in user if ( is_user_logged_in() ) { $client_wrapper->ensure_tokens_still_fresh(); } return $client_wrapper; } /** * WP Hook for altering remote request timeout * * @param $timeout * * @return int */ function alter_http_request_timeout( $timeout ){ if ( is_numeric( $this->settings->http_request_timeout ) ){ return absint( $this->settings->http_request_timeout ); } return $timeout; } /** * Get the authentication url from the client * * @return string */ function get_authentication_url(){ return $this->client->make_authentication_url(); } function ensure_tokens_still_fresh() { if ( ! is_user_logged_in() ) { return; } $is_openid_connect_user = get_user_meta( wp_get_current_user()->ID, 'openid-connect-generic-user', TRUE ); if ( empty( $is_openid_connect_user ) ) { return; } if ( ! isset( $_COOKIE[ $this->cookie_token_refresh_key] ) ) { wp_logout(); $this->error_redirect( new WP_Error( 'token-refresh-cookie-missing', __( 'Single sign-on cookie missing. Please login again.' ), $_COOKIE ) ); exit; } $user_id = wp_get_current_user()->ID; $current_time = current_time( 'timestamp', TRUE ); $refresh_token_info = $this->read_token_refresh_info_from_cookie( $user_id ); if ( ! $refresh_token_info ) { wp_logout(); $this->error_redirect( new WP_Error( 'token-refresh-cookie-missing', __( 'Single sign-on cookie invalid. Please login again.' ), $_COOKIE ) ); } $next_access_token_refresh_time = $refresh_token_info[ 'next_access_token_refresh_time' ]; $refresh_token = $refresh_token_info[ 'refresh_token' ]; if ( $current_time < $next_access_token_refresh_time ) { return; } if ( ! $refresh_token ) { wp_logout(); $this->error_redirect( new WP_Error( 'access-token-expired', __( 'Session expired. Please login again.' ) ) ); } $token_result = $this->client->request_new_tokens( $refresh_token ); $token_response = $this->client->get_token_response( $token_result ); if ( is_wp_error( $token_response ) ) { wp_logout(); $this->error_redirect( $token_response ); } $this->issue_token_refresh_info_cookie( $user_id, $token_response ); } /** * 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() { // set OpenID Connect user flag to false on logout to allow users to log into the same account without OpenID Connect if( $this->settings->link_existing_users ) { if( get_user_meta( wp_get_current_user()->ID, 'openid-connect-generic-user', TRUE ) ) update_user_meta( wp_get_current_user()->ID, 'openid-connect-generic-user', FALSE ); } setcookie( $this->cookie_token_refresh_key, false, 1, COOKIEPATH, COOKIE_DOMAIN, is_ssl() ); } /** * 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; // start the authentication flow $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 ); } // validate our id_token has required values $valid = $client->validate_id_token_claim( $id_token_claim ); if ( is_wp_error( $valid ) ){ $this->error_redirect( $valid ); } // exchange the token_response for a user_claim $user_claim = $client->get_user_claim( $token_response ); if ( is_wp_error( $user_claim ) ){ $this->error_redirect( $user_claim ); } // validate our user_claim has required values $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 */ $subject_identity = $client->get_subject_identity( $id_token_claim ); $user = $this->get_user_by_identity( $subject_identity ); // if we didn't find an existing user, we'll need to create it if ( ! $user ) { $user = $this->create_new_user( $subject_identity, $user_claim ); } else { // allow plugins / themes to take action using current claims on existing user (e.g. update role) do_action( 'openid-connect-generic-update-user-using-current-claim', $user, $user_claim ); } // validate the found / created user $valid = $this->validate_user( $user ); if ( is_wp_error( $valid ) ){ $this->error_redirect( $valid ); } // login the found / created user $this->login_user( $user, $token_response, $id_token_claim, $user_claim, $subject_identity ); // log our success $this->logger->log( "Successful login for: {$user->user_login} ({$user->ID})", 'login-success' ); // redirect back to the origin page if enabled if( $this->settings->redirect_user_back && !empty( $redirect_url = esc_url( $_COOKIE[ $this->cookie_redirect_key ] ) ) ) { do_action( 'openid-connect-generic-redirect-user-back', $redirect_url, $user ); setcookie( $this->cookie_redirect_key, $redirect_url, 1, COOKIEPATH, COOKIE_DOMAIN, is_ssl() ); wp_redirect( $redirect_url ); } // otherwise, go home! else { 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; } /** * Record user meta data, and provide an authorization cookie * * @param $user */ function login_user( $user, $token_response, $id_token_claim, $user_claim, $subject_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 ); // if we're allowing users to use WordPress and OpenID Connect, we need to set this to true at every login if( $this->settings->link_existing_users ) { update_user_meta( $user->ID, 'openid-connect-generic-user', TRUE ); } // you did great, have a cookie! $this->issue_token_refresh_info_cookie( $user->ID, $token_response ); wp_set_auth_cookie( $user->ID, FALSE ); } function issue_token_refresh_info_cookie( $user_id, $token_response ) { $cookie_value = serialize( array( 'next_access_token_refresh_time' => $token_response['expires_in'] + current_time( 'timestamp' , TRUE ), 'refresh_token' => isset( $token_response[ 'refresh_token' ] ) ? $token_response[ 'refresh_token' ] : false ) ); $key = $this->get_refresh_cookie_encryption_key( $user_id ); $encrypted_cookie_value = \Defuse\Crypto\Crypto::encrypt( $cookie_value, $key ); setcookie( $this->cookie_token_refresh_key, $encrypted_cookie_value, 0, COOKIEPATH, COOKIE_DOMAIN, is_ssl() ); } function read_token_refresh_info_from_cookie( $user_id ) { if ( ! isset( $_COOKIE[ $this->cookie_token_refresh_key ] ) ) { return false; } try { $encrypted_cookie_value = $_COOKIE[$this->cookie_token_refresh_key]; $key = $this->get_refresh_cookie_encryption_key( $user_id ); $cookie_value = unserialize( \Defuse\Crypto\Crypto::decrypt($encrypted_cookie_value, $key) ); if ( ! isset( $cookie_value[ 'next_access_token_refresh_time' ] ) || ! $cookie_value[ 'next_access_token_refresh_time' ] || ! isset( $cookie_value[ 'refresh_token' ] ) ) { return false; } return $cookie_value; } catch ( Exception $e ) { $this->logger->log( $e->getMessage() ); return false; } } function get_refresh_cookie_encryption_key( $user_id ) { $meta_key = 'openid-connect-generic-refresh-cookie-key'; $existing_key_string = get_user_meta( $user_id, $meta_key, true ); try { $user_encryption_key = \Defuse\Crypto\Key::loadFromAsciiSafeString( $existing_key_string ); } catch ( Exception $e ) { $this->logger->log( "Error loading user {$user_id} refresh token cookie key, generating new: " . $e->getMessage() ); $user_encryption_key = \Defuse\Crypto\Key::createNewRandomKey(); update_user_meta( $user_id, $meta_key, $user_encryption_key->saveToAsciiSafeString() ); } return $user_encryption_key; } /** * Get the user that has meta data matching a * * @param $subject_identity * * @return false|\WP_User */ function get_user_by_identity( $subject_identity ){ // look for user by their openid-connect-generic-subject-identity value $user_query = new WP_User_Query( array( 'meta_query' => array( array( 'key' => 'openid-connect-generic-subject-identity', 'value' => $subject_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 ) { // allow settings to take first stab at username if ( !empty( $this->settings->identity_key ) && isset( $user_claim[ $this->settings->identity_key ] ) ) { $desired_username = $user_claim[ $this->settings->identity_key ]; } else 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_username . $count; } return $username; } /** * Create a new user from details in a user_claim * * @param $subject_identity * @param $user_claim * * @return \WP_Error | \WP_User */ function create_new_user( $subject_identity, $user_claim){ // default username & email to the subject identity $username = $subject_identity; $email = $subject_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 ( is_wp_error( $username ) ){ return $username; } } // if no email 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 ); // check for email in claim if ( ! isset( $user_claim['email'] ) ) { return new WP_Error( 'incomplete-user-claim', __( 'User claim incomplete' ), $user_claim ); } $email = $user_claim['email']; $username = $this->get_username_from_claim( $user_claim ); } // before trying to create the user, first check if a user with the same email already exists if( $this->settings->link_existing_users ) { if( $uid = email_exists( $email ) ) { return $this->update_existing_user( $uid, $subject_identity ); } } // 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 ); } // retrieve our new user $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-subject-identity', (string) $subject_identity, TRUE ); // log the results $this->logger->log( "New user created: {$user->user_login} ($uid)", 'success' ); // allow plugins / themes to take action on new user creation do_action( 'openid-connect-generic-user-create', $user, $user_claim ); return $user; } /** * Update an existing user with OpenID Connect meta data * * @param $uid * @param $subject_identity * * @return \WP_Error | \WP_User */ function update_existing_user( $uid, $subject_identity ) { // add the OpenID Connect meta data add_user_meta( $uid, 'openid-connect-generic-user', TRUE, TRUE ); add_user_meta( $uid, 'openid-connect-generic-subject-identity', (string) $subject_identity, TRUE ); // allow plugins / themes to take action on user update do_action( 'openid-connect-generic-user-update', $uid ); // return our updated user return get_user_by( 'id', $uid ); } }