diff --git a/inc/class-response-command.php b/inc/class-response-command.php new file mode 100644 index 0000000..631d739 --- /dev/null +++ b/inc/class-response-command.php @@ -0,0 +1,469 @@ + + * + * https://idp.example.com/metadata.php + * + * + * ... + * + * # Get NameID data. + * $ wp simple-saml response name-id-data + * +-----------------------+-----------------------------------------------------+ + * | Field | Value | + * +-----------------------+-----------------------------------------------------+ + * | NameId | _ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7 | + * | NameIdFormat | urn:oasis:names:tc:SAML:2.0:nameid-format:transient | + * | NameIdNameQualifier | | + * | NameIdSPNameQualifier | php-saml | + * +-----------------------+-----------------------------------------------------+ + * + * # Process SAML response. + * $ wp simple-saml response process + * Success: SAML response processed. + * Print SAML2 Auth object? [y/n] + */ +class Response_Command extends WP_CLI_Command { + + /** + * Decoded SAML response data. + * + * @var string + */ + private $response; + + /** + * Process SAML response and return requested attribute. + * + * ## OPTIONS + * + * + * : The name of the attribute to get. + * + * [--file=] + * : The name of the SAML response file to use. If omitted, it will look for 'saml.txt'. + * + * ## EXAMPLES + * + * # Get mail attribute. + * $ wp simple-saml response attribute mail + * john@example.com + * + * # Get mail attribute from custom file. + * $ wp simple-saml response attribute mail --file=jane.saml + * jane@example.com + * + * @param array $args Arguments passed to the command (original order). + * @param array $assoc_args Arguments passed to the command (named). + */ + public function attribute( $args, $assoc_args ) { + if ( empty( $args[0] ) ) { + WP_CLI::error( __( 'Attribute name missing.', 'wp-simple-saml' ) ); + } + + $saml = $this->_process_response( $args, $assoc_args ); + + $value = $saml->getAttribute( $args[0] ); + if ( is_null( $value ) ) { + WP_CLI::warning( sprintf( + /* translators: %s: Attribute name. */ + __( 'Attribute "%s" missing.', 'wp-simple-saml' ), + $args[0] + ) ); + + return; + } + + $value = $this->_get_attribute_value( $value ); + + WP_CLI::line( $value ); + } + + /** + * Process SAML response and return all attributes. + * + * ## OPTIONS + * + * [--field=] + * : Instead of returning all fields, return the value of a single field. + * + * [--fields=] + * : Limit the output to specific fields. Defaults to all fields. + * + * [--file=] + * : The name of the SAML response file to use. If omitted, it will look for 'saml.txt'. + * + * [--format=] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - yaml + * --- + * + * ## EXAMPLES + * + * # Get all attributes. + * $ wp simple-saml response attributes + * +------------+------------------+ + * | Field | Value | + * +------------+------------------+ + * | user_login | 4815162342 | + * | user_email | john@example.com | + * | first_name | John | + * | last_name | Doe | + * +------------+------------------+ + * + * # Get all attributes from custom file. + * $ wp simple-saml response attributes --file=jane.saml + * +------------+------------------+ + * | Field | Value | + * +------------+------------------+ + * | user_login | 1234567890 | + * | user_email | jane@example.com | + * | first_name | Jane | + * | last_name | Doe | + * +------------+------------------+ + * + * # Get specified attributes only. + * $ wp simple-saml response attributes --fields=user_login,user_email + * +------------+------------------+ + * | Field | Value | + * +------------+------------------+ + * | user_login | 4815162342 | + * | user_email | john@example.com | + * +------------+------------------+ + * + * # Get all attributes as JSON. + * $ wp simple-saml response attributes --format=json + * {"user_login":"4815162342","user_email":"john@example.com","first_name":"John","last_name":"Doe"} + * + * @param array $args Arguments passed to the command (original order). + * @param array $assoc_args Arguments passed to the command (named). + */ + public function attributes( $args, $assoc_args ) { + $saml = $this->_process_response( $args, $assoc_args ); + + $attributes = $saml->getAttributes(); + if ( ! $attributes ) { + WP_CLI::warning( __( 'Attributes empty.', 'wp-simple-saml' ) ); + + return; + } + + $data = array_map( [ $this, '_get_attribute_value' ], $attributes ); + + $this->_display_data( $assoc_args, $data ); + } + + /** + * Decode and print SAML response. + * + * ## OPTIONS + * + * [--file=] + * : The name of the SAML response file to use. If omitted, it will look for 'saml.txt'. + * + * ## EXAMPLES + * + * # Decode SAML response. + * $ wp simple-saml response decode + * + * + * https://idp.example.com/metadata.php + * + * + * ... + * + * # Decode SAML response from custom file. + * $ wp simple-saml response decode --file=jane.saml + * + * + * https://idp.example.com/metadata.php + * + * + * ... + * + * @param array $args Arguments passed to the command (original order). + * @param array $assoc_args Arguments passed to the command (named). + */ + public function decode( $args, $assoc_args ) { + $this->_validate_response( $args, $assoc_args ); + + $document = new DOMDocument(); + $document->formatOutput = true; + $document->preserveWhiteSpace = false; + $document->loadXML( $this->response ); + + $xml = $document->saveXML(); + + WP_CLI::print_value( $xml ); + } + + /** + * Process SAML response and return NameID data. + * + * ## OPTIONS + * + * [--field=] + * : Instead of returning all fields, return the value of a single field. + * + * [--fields=] + * : Limit the output to specific fields. Defaults to all fields. + * + * [--file=] + * : The name of the SAML response file to use. If omitted, it will look for 'saml.txt'. + * + * [--format=] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - yaml + * --- + * + * ## EXAMPLES + * + * # Get NameID data. + * $ wp simple-saml response name-id-data + * +-----------------------+-----------------------------------------------------+ + * | Field | Value | + * +-----------------------+-----------------------------------------------------+ + * | NameId | _ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7 | + * | NameIdFormat | urn:oasis:names:tc:SAML:2.0:nameid-format:transient | + * | NameIdNameQualifier | | + * | NameIdSPNameQualifier | php-saml | + * +-----------------------+-----------------------------------------------------+ + * + * # Get NameID data from custom file. + * $ wp simple-saml response name-id-data --file=jane.saml + * +-----------------------+----------------------------------------------------+ + * | Field | Value | + * +-----------------------+----------------------------------------------------+ + * | NameId | 1234567890 | + * | NameIdFormat | urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos | + * | NameIdNameQualifier | | + * | NameIdSPNameQualifier | php-saml | + * +-----------------------+----------------------------------------------------+ + * + * # Get specified attributes only. + * $ wp simple-saml response name-id-data --fields=NameId,NameIdFormat + * +--------------+-----------------------------------------------------+ + * | Field | Value | + * +--------------+-----------------------------------------------------+ + * | NameId | _ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7 | + * | NameIdFormat | urn:oasis:names:tc:SAML:2.0:nameid-format:transient | + * +--------------+-----------------------------------------------------+ + * + * # Get all attributes as JSON. + * $ wp simple-saml response name-id-data --format=json + * {"NameId":"_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7","NameIdFormat":"urn:oasis:names:tc:SAML:2.0:nameid-format:transient","NameIdNameQualifier":"","NameIdSPNameQualifier":"php-saml"} + * + * @subcommand name-id-data + * @alias nameid-data + * + * @param array $args Arguments passed to the command (original order). + * @param array $assoc_args Arguments passed to the command (named). + */ + public function name_id_data( $args, $assoc_args ) { + $saml = $this->_process_response( $args, $assoc_args ); + + $data = [ + 'NameId' => $saml->getNameId(), + 'NameIdFormat' => $saml->getNameIdFormat(), + 'NameIdNameQualifier' => $saml->getNameIdNameQualifier(), + 'NameIdSPNameQualifier' => $saml->getNameIdSPNameQualifier(), + ]; + + $this->_display_data( $assoc_args, $data ); + } + + /** + * Process SAML response and optionally print SAML2 Auth object. + * + * ## OPTIONS + * + * [--file=] + * : The name of the SAML response file to use. If omitted, it will look for 'saml.txt'. + * + * [--yes] + * : Answer yes to the confirmation message. + * + * ## EXAMPLES + * + * # Process SAML response. + * $ wp simple-saml response process + * Success: SAML response processed. + * Print SAML2 Auth object? [y/n] + * + * # Process SAML response from custom file. + * $ wp simple-saml response process --file=jane.saml + * Success: SAML response processed. + * Print SAML2 Auth object? [y/n] + * + * # Process SAML response and print SAML2 Auth object. + * $ wp simple-saml response process --yes + * Success: SAML response processed. + * OneLogin\Saml2\Auth::__set_state(array( + * '_settings' => + * OneLogin\Saml2\Settings::__set_state(array( + * '_paths' => + * array ( + * ... + * + * @param array $args Arguments passed to the command (original order). + * @param array $assoc_args Arguments passed to the command (named). + */ + public function process( $args, $assoc_args ) { + $saml = $this->_process_response( $args, $assoc_args ); + + WP_CLI::success( __( 'SAML response processed.', 'wp-simple-saml' ) ); + + WP_CLI::confirm( __( 'Print SAML2 Auth object?', 'wp-simple-saml' ), $assoc_args ); + + WP_CLI::print_value( $saml ); + } + + /** + * Display given data according to passed arguments. + * + * @param array $format_args Arguments passed to the command (original order). + * @param array $data Data to display. + * + * @return void + */ + private function _display_data( $format_args, $data ) { + $fields = array_keys( $data ); + + $formatter = new WP_CLI\Formatter( $format_args, $fields ); + $formatter->display_item( $data ); + } + + /** + * Return (first) attribute value. + * + * @param string|string[] $value One or more attribute values. + * + * @return string Attribute value. + */ + private function _get_attribute_value( $value ) { + return is_array( $value ) ? reset( $value ) : $value; + } + + /** + * Validate and process SAML response data and return SAML2 Auth object. + * + * @param array $args Arguments passed to the command (original order). + * @param array $assoc_args Arguments passed to the command (named). + * + * @return void|\OneLogin\Saml2\Auth|\WP_Error SAML2 Auth object, or WordPress error. + * + * @throws WP_CLI\ExitException If error. + */ + private function _process_response( $args, $assoc_args ) { + $this->_validate_response( $args, $assoc_args ); + + // Ensure strict mode is disabled. + // Otherwise, validating the response will fail due to expired timestamps or mismatching destinations etc. + add_filter( 'wpsimplesaml_config', function ( array $config ) { + return array_merge( $config, [ 'strict' => false ] ); + }, 100 ); + + $saml = process_response(); + if ( is_wp_error( $saml ) ) { + WP_CLI::error( $saml, false ); + + $saml = instance(); + if ( $saml ) { + $error = $saml->getLastErrorReason(); + if ( $error ) { + WP_CLI::print_value( $error ); + } + } + + exit( 1 ); + } + + return $saml; + } + + /** + * Validate SAML response for subsequent use. If invalid, exit. + * + * @param array $args Arguments passed to the command (original order). + * @param array $assoc_args Arguments passed to the command (named). + * + * @return void + * + * @throws WP_CLI\ExitException If response invalid. + */ + private function _validate_response( $args, $assoc_args ) { + $file = $assoc_args['file'] ?? 'saml.txt'; + if ( ! file_exists( $file ) || ! is_file( $file ) ) { + WP_CLI::error( sprintf( + /* translators: %s: File name. */ + __( 'Unable to read content from "%s".', 'wp-simple-saml' ), + $file + ) ); + } + + $response = file_get_contents( $file ); + if ( ! $response ) { + WP_CLI::error( __( 'Response missing or empty.', 'wp-simple-saml' ), false ); + WP_CLI::print_value( $response ); + + exit( 1 ); + } + + $response = preg_replace( '/[\n\r]+/', "\n", trim( $response ) ); + + $_POST['SAMLResponse'] = $response; + + $response = base64_decode( $response ); + if ( ! $response || ! is_string( $response ) ) { + WP_CLI::error( __( 'Response data invalid or empty.', 'wp-simple-saml' ), false ); + WP_CLI::print_value( $response ); + + exit( 1 ); + } + + $this->response = $response; + } +} diff --git a/inc/class-simple-saml-command.php b/inc/class-simple-saml-command.php new file mode 100644 index 0000000..8e8077b --- /dev/null +++ b/inc/class-simple-saml-command.php @@ -0,0 +1,110 @@ + + * + * ... + */ +class Simple_Saml_Command extends WP_CLI_Command { + + /** + * Print mapping of SAML attributes to user data attributes. + * + * ## OPTIONS + * + * [--field=] + * : Instead of returning all fields, return the value of a single field. + * + * [--fields=] + * : Limit the output to specific fields. Defaults to all fields. + * + * [--format=] + * : Render output in a particular format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - yaml + * --- + * + * ## EXAMPLES + * + * # Print attribute mapping. + * $ wp simple-saml attribute-mapping + * +------------+------------+ + * | Field | Value | + * +------------+------------+ + * | user_login | EmployeeID | + * | user_email | EmailID | + * | first_name | FirstName | + * | last_name | LastName | + * +------------+------------+ + * + * @subcommand attribute-mapping + * @alias attribute-map + * + * @param array $args Arguments passed to the command (original order). + * @param array $assoc_args Arguments passed to the command (named). + */ + public function attribute_map( $args, $assoc_args ) { + $map = get_attribute_map(); + + ( new WP_CLI\Formatter( $assoc_args, array_keys( $map ) ) )->display_item( $map ); + } + + /** + * Print SP metadata (XML). + * + * ## EXAMPLES + * + * # Print SP metadata (XML). + * $ wp simple-saml metadata + * + * + * ... + * + * @param array $args Arguments passed to the command (original order). + * @param array $assoc_args Arguments passed to the command (named). + */ + public function metadata( $args, $assoc_args ) { + $metadata = get_metadata(); + if ( is_wp_error( $metadata ) ) { + WP_CLI::error( $metadata ); + } + + WP_CLI::print_value( $metadata ); + } +} diff --git a/inc/namespace.php b/inc/namespace.php index 4689be8..81a4e0d 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -284,14 +284,13 @@ function action_verify() { } /** - * Output metadata of SP + * Get metadata of SP * - * @action wpsimplesaml_action_metadata + * @return string|\WP_Error */ -function action_metadata() { - $auth = instance(); - $settings = $auth->getSettings(); - $metadata = null; +function get_metadata() { + $settings = instance()->getSettings(); + try { $metadata = $settings->getSPMetadata(); $errors = $settings->validateMetadata( $metadata ); @@ -299,7 +298,26 @@ function action_metadata() { $errors = $e->getMessage(); } - if ( $errors ) { + if ( empty( $errors ) ) { + return $metadata; + } + + return new \WP_Error( + 'wpsimplesaml_invalid_settings', + esc_html__( 'Invalid SSO settings. Contact your administrator.', 'wp-simple-saml' ), + $errors + ); +} + +/** + * Output metadata of SP + * + * @action wpsimplesaml_action_metadata + */ +function action_metadata() { + $metadata = get_metadata(); + + if ( is_wp_error( $metadata ) ) { wp_die( esc_html__( 'Invalid SSO settings. Contact your administrator.', 'wp-simple-saml' ) ); } @@ -309,13 +327,15 @@ function action_metadata() { } /** - * Handle authentication responses - * - * @return \WP_User|\WP_Error + * @return Auth|\WP_Error */ -function get_sso_user() { +function process_response() { $saml = instance(); + if ( ! $saml ) { + return new \WP_Error( 'no-saml-instance', esc_html__( 'Unable to get instance of SAML2 Auth object.', 'wp-simple-saml' ) ); + } + try { $config = Admin\get_config(); if ( is_wp_error( $config ) ) { @@ -360,6 +380,21 @@ function get_sso_user() { return new \WP_Error( 'not-authenticated', esc_html__( 'Error: Authentication wasn\'t completed successfully.', 'wp-simple-saml' ) ); } + return $saml; +} + +/** + * Handle authentication responses + * + * @return \WP_User|\WP_Error + */ +function get_sso_user() { + $saml = process_response(); + + if ( is_wp_error( $saml ) ) { + return $saml; + } + return get_or_create_wp_user( $saml ); } diff --git a/plugin.php b/plugin.php index 8436507..b3ac371 100644 --- a/plugin.php +++ b/plugin.php @@ -29,6 +29,8 @@ namespace HumanMade\SimpleSaml; +use WP_CLI; + require_once __DIR__ . '/inc/namespace.php'; require_once __DIR__ . '/inc/admin/namespace.php'; @@ -38,3 +40,10 @@ add_action( 'plugins_loaded', __NAMESPACE__ . '\\bootstrap' ); add_action( 'plugins_loaded', __NAMESPACE__ . '\\Admin\\admin_bootstrap' ); + +if ( defined( 'WP_CLI' ) && WP_CLI ) { + require_once __DIR__ . '/inc/class-simple-saml-command.php'; + require_once __DIR__ . '/inc/class-response-command.php'; + WP_CLI::add_command( 'simple-saml', 'HumanMade\\SimpleSaml\\Simple_Saml_Command' ); + WP_CLI::add_command( 'simple-saml response', 'HumanMade\\SimpleSaml\\Response_Command' ); +}