diff --git a/plugins/kolab_sso/drivers/openidc.php b/plugins/kolab_sso/drivers/openidc.php index 246f130e..c12d27da 100644 --- a/plugins/kolab_sso/drivers/openidc.php +++ b/plugins/kolab_sso/drivers/openidc.php @@ -1,401 +1,413 @@ * * Copyright (C) 2018, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_sso_openidc { protected $id = 'openidc'; protected $config = array(); protected $plugin; public function __construct($plugin, $config) { $this->plugin = $plugin; $this->config = $config; $this->plugin->require_plugin('libkolab'); } /** * Authentication request (redirect to SSO service) */ public function authorize() { $params = array( 'response_type' => 'code', 'scope' => 'openid email offline_access', 'client_id' => $this->config['client_id'], 'state' => $this->plugin->rc->get_request_token(), 'redirect_uri' => $this->redirect_uri(), ); // TODO: Other params by config: display, prompt, max_age, $url = $this->config['auth_uri'] ?: (unslashify($this->config['uri']) . '/authorize'); $url .= '?' . http_build_query($params); $this->plugin->debug("[{$this->id}][authorize] Redirecting to $url"); header("Location: $url"); die; } /** * Authorization response validation */ public function response() { $this->plugin->debug("[{$this->id}][authorize] Response: " . $_SERVER['REQUEST_URI']); - $error = $this->response_error( + $this->error = $this->error_message( rcube_utils::get_input_value('error', rcube_utils::INPUT_GET), rcube_utils::get_input_value('error_description', rcube_utils::INPUT_GET), rcube_utils::get_input_value('error_uri', rcube_utils::INPUT_GET) ); - if ($error) { - // TODO: display error in UI + if ($this->error) { return; } $state = rcube_utils::get_input_value('state', rcube_utils::INPUT_GET); $code = rcube_utils::get_input_value('code', rcube_utils::INPUT_GET); if (!$state) { $this->plugin->debug("[{$this->id}][response] State missing"); - $error = $this->plugin->gettext('errorinvalidresponse'); + $this->error = $this->plugin->gettext('errorinvalidresponse'); return; } if ($state != $this->plugin->rc->get_request_token()) { $this->plugin->debug("[{$this->id}][response] Invalid response state"); - $error = $this->plugin->gettext('errorinvalidresponse'); + $this->error = $this->plugin->gettext('errorinvalidresponse'); return; } if (!$code) { $this->plugin->debug("[{$this->id}][response] Code missing"); - $error = $this->plugin->gettext('errorinvalidresponse'); + $this->error = $this->plugin->gettext('errorinvalidresponse'); return; } return $this->request_token($code); } + /** + * Error message for the response handler + */ + public function response_error() + { + if ($this->error) { + return $this->plugin->rc->gettext('loginfailed') . ' ' . $this->error; + } + } + /** * Existing session validation */ public function validate_session($session) { $this->plugin->debug("[{$this->id}][validate] Session: " . json_encode($session)); // Sanity checks if (empty($session) || empty($session['code']) || empty($session['validto']) || empty($session['email'])) { $this->plugin->debug("[{$this->id}][validate] Session invalid"); return; } // Check expiration time $now = new DateTime('now', new DateTimezone('UTC')); $validto = new DateTime($session['validto'], new DateTimezone('UTC')); // Don't refresh often than TTL/2 $validto->sub(new DateInterval(sprintf('PT%dS', $session['ttl']/2))); if ($now < $validto) { $this->plugin->debug("[{$this->id}][validate] Token valid, skipping refresh"); return $session; } // No refresh_token, not possible to refresh if (empty($session['refresh_token'])) { $this->plugin->debug("[{$this->id}][validate] Session cannot be refreshed"); return; } // Renew tokens $info = $this->request_token($session['code'], $session['refresh_token']); if (!empty($info)) { // Make sure the email didn't change if (!empty($info['email']) && $info['email'] != $session['email']) { $this->plugin->debug("[{$this->id}][validate] Email address change"); return; } $session = array_merge($session, $info); $this->plugin->debug("[{$this->id}][validate] Session valid: " . json_encode($session)); return $session; } } /** * Authentication Token request (or token refresh) */ protected function request_token($code, $refresh_token = null) { $mode = $refresh_token ? 'token-refresh' : 'token'; $url = $this->config['token_uri'] ?: ($this->config['uri'] . '/token'); $params = array( 'client_id' => $this->config['client_id'], 'client_secret' => $this->config['client_secret'], 'grant_type' => $refresh_token ? 'refresh_token' : 'authorization_code', ); if ($refresh_token) { $params['refresh_token'] = $refresh_token; $params['scope'] = 'openid email offline_access'; } else { $params['code'] = $code; $params['redirect_uri'] = $this->redirect_uri(); } $post = http_build_query($params); $this->plugin->debug("[{$this->id}][$mode] Requesting POST $url?$post"); try { // TODO: JWT-based methods of client authentication // https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.9 $request = $this->get_request($url, 'POST'); $request->setAuth($this->config['client_id'], $this->config['client_secret']); $request->setBody($post); $response = $request->send(); $status = $response->getStatus(); $response = $response->getBody(); $this->plugin->debug("[{$this->id}][$mode] Response: $response"); $response = @json_decode($response, true); if ($status != 200 || !is_array($response) || !empty($response['error'])) { - $err = $this->error_message(is_array($response) ? $response['error'] : null); + $err = $this->error_text(is_array($response) ? $response['error'] : null); throw new Exception("OpenIDC request failed with error: $err"); } } catch (Exception $e) { + $this->error = $this->plugin->gettext('errorunknown'); rcube::raise_error(array( 'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()), true, false); return; } // Example response: { // "access_token":"ACCESS_TOKEN", // "token_type":"bearer", // "expires_in":2592000, // "refresh_token":"REFRESH_TOKEN", // "scope":"read", // "uid":100101, // "info":{"name":"Mark E. Mark","email":"mark@example.com"} // } if (empty($response['access_token']) || empty($response['token_type']) || strtolower($response['token_type']) != 'bearer' ) { + $this->error = $this->plugin->gettext('errorinvalidresponse'); $this->plugin->debug("[{$this->id}][$mode] Error: Invalid or unsupported response"); return; } $ttl = $response['expires_in'] ?: 600; $validto = new DateTime(sprintf('+%d seconds', $ttl), new DateTimezone('UTC')); $result = array( 'code' => $code, 'access_token' => $response['access_token'], // 'token_type' => $response['token_type'], 'validto' => $validto->format(DateTime::ISO8601), 'ttl' => $ttl, ); if (!empty($response['refresh_token'])) { $result['refresh_token'] = $response['refresh_token']; } if (!empty($response['id_token'])) { try { $key = $this->config['client_secret']; if (!empty($this->config['pubkey'])) { $pubkey = trim(preg_replace('/\r?\n[\s\t]+/', "\n", $this->config['pubkey'])); if (strpos($pubkey, '-----') !== 0) { $pubkey = "-----BEGIN PUBLIC KEY-----\n" . trim(chunk_split($pubkey, 64, "\n")) . "\n-----END PUBLIC KEY-----"; } if ($keyid = openssl_pkey_get_public($pubkey)) { $key = $keyid; } else { throw new Exception("Failed to extract public key"); } } $jwt = new Firebase\JWT\JWT; $jwt::$leeway = 60; $payload = $jwt->decode($response['id_token'], $key, array_keys(Firebase\JWT\JWT::$supported_algs)); $email = $this->config['debug_email'] ?: $payload->email; if (empty($email)) { throw new Exception("No email address in JWT token"); } if (!in_array($this->config['client_id'], (array) $payload->aud)) { throw new Exception("Token audience does not match"); } // More extended token validation // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation $result['email'] = $email; } catch (Exception $e) { + $this->error = $this->plugin->gettext('errorinvalidtoken'); rcube::raise_error(array( 'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()), true, false); return; } } return $result; } /** * The URL to use when redirecting the user from SSO back to Roundcube */ protected function redirect_uri() { // response_uri is useful when the Provider does not allow // URIs with parameters. In such case set response_uri = '/sso' // and define a redirect in http server, example for Apache: // RewriteRule "^sso" "/roundcubemail/?_task=login&_action=sso" [L,QSA] $redirect_params = empty($this->config['response_uri']) ? array('_action' => 'sso') : array(); $url = $this->plugin->rc->url($redirect_params, false, true); if (!empty($this->config['response_uri'])) { $url = unslashify(preg_replace('/\?.*$/', '', $url)) . '/' . ltrim($this->config['response_uri'], '/'); } return $url; } /** * Get HTTP/Request2 object */ protected function get_request($url, $type) { $config = array_intersect_key($this->config, array_flip(array( 'ssl_verify_peer', 'ssl_verify_host', 'ssl_cafile', 'ssl_capath', 'ssl_local_cert', 'ssl_passphrase', 'follow_redirects', ))); return libkolab::http_request($url, $type, $config); } /** * Returns (localized) user-friendly error message */ - protected function response_error($error, $description, $uri) + protected function error_message($error, $description, $uri) { if (empty($error)) { return; } - $msg = $this->error_message($error); - - $this->plugin->debug("[{$this->id}] Error: $msg"); + $msg = $this->error_text($error); - // TODO: Add URI to the message + rcube::raise_error(array( + 'message' => "[SSO] $msg." . ($description ? " $description" : '') . ($uri ? " ($uri)" : '') + ), true, false); $label = 'error' . str_replace('_', '', $error); - if ($this->plugin->rc->text_exists($label, 'kolab_sso')) { - return $this->plugin->gettext($label); + if (!$this->plugin->rc->text_exists($label, 'kolab_sso')) { + $label = 'errorunknown'; } - return $this->plugin->gettext('responseerrorunknown'); + return $this->plugin->gettext($label); } /** - * Returns error message for specified OpenIDC error code + * Returns error text for specified OpenIDC error code */ - protected function error_message($error) + protected function error_text($error) { switch ($error) { // OAuth2 codes case 'invalid_request': return "Request malformed"; case 'unauthorized_client': return "The client is not authorized"; case 'invalid_client': return "Client authentication failed"; case 'access_denied': return "Request denied"; case 'unsupported_response_type': return "Unsupported response type"; case 'invalid_grant': return "Invalid authorization grant"; case 'unsupported_grant_type': return "Unsupported authorization grant"; case 'invalid_scope': return "Invalid scope"; case 'server_error': return "Server error"; case 'temporarily_unavailable': return "Service temporarily unavailable"; // OpenIDC codes case 'interaction_required': return "End-User interaction required"; case 'login_required': return "End-User authentication required"; case 'account_selection_required': return "End-User account selection required"; case 'consent_required': return "End-User consent required"; case 'invalid_request_uri': return "Invalid request_uri"; case 'invalid_request_object': return "Invalid Request Object"; case 'request_not_supported': - return "Request param not supported"; + return "Request not supported"; case 'request_uri_not_supported': return "request_uri param not supported"; case 'registration_not_supported': - return "Registration parameter not supported"; + return "Registration not supported"; } return "Unknown error"; } } diff --git a/plugins/kolab_sso/kolab_sso.php b/plugins/kolab_sso/kolab_sso.php index 4c919065..6771e556 100644 --- a/plugins/kolab_sso/kolab_sso.php +++ b/plugins/kolab_sso/kolab_sso.php @@ -1,390 +1,398 @@ * * Copyright (C) 2018, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_sso extends rcube_plugin { public $rc; private $data; private $old_data; private $driver; + private $logon_error; private $debug = false; /** * Plugin initialization */ public function init() { // Roundcube or Chwala if (defined('RCMAIL_VERSION') || defined('FILE_API_START')) { $this->rc = rcube::get_instance(); $this->add_hook('startup', array($this, 'startup')); $this->add_hook('authenticate', array($this, 'authenticate')); $this->rc->add_shutdown_function(array($this, 'shutdown')); } } /** * Startup hook handler */ public function startup($args) { // On login or logout (or when the session expired)... if ($args['task'] == 'login' || $args['task'] == 'logout') { $mode = $args['action'] == 'sso' ? $_SESSION['sso_mode'] : rcube_utils::get_input_value('_sso', rcube_utils::INPUT_GP); // Authorization if ($mode) { $driver = $this->get_driver($mode); // This is where we handle redirections from the SSO provider if ($args['action'] == 'sso') { $this->data = $driver->response(); if (!empty($this->data)) { $this->data['timezone'] = $_SESSION['sso_timezone']; $this->data['url'] = $_SESSION['sso_url']; $this->data['mode'] = $mode; } + else { + $this->logon_error = $driver->response_error(); + } } // This is where we handle clicking one of "Login by SSO" buttons else if ($_SESSION['temp'] && $this->rc->check_request()) { // Remember some logon params for use on SSO response above $_SESSION['sso_timezone'] = rcube_utils::get_input_value('_timezone', rcube_utils::INPUT_POST); $_SESSION['sso_url'] = rcube_utils::get_input_value('_url', rcube_utils::INPUT_POST); $_SESSION['sso_mode'] = $mode; $driver->authorize(); } $args['action'] = 'login'; $args['task'] = 'login'; } } // On valid session... else if (isset($_SESSION['user_id']) && ($data = $_SESSION['sso_data']) && ($data = json_decode($this->rc->decrypt($data), true)) && ($mode = $data['mode']) ) { $driver = $this->get_driver($mode); $this->old_data = $data; // Session validation, token refresh, etc. if ($this->data = $driver->validate_session($data)) { // register storage connection hooks $this->authenticate(array(), true); } else { // Destroy the session $this->rc->kill_session(); // TODO: error message beter explaining the reason // $this->rc->output->show_message('sessionferror', 'error'); } } // Register login form modifications $this->add_hook('template_object_loginform', array($this, 'login_form')); return $args; } /** * Authenticate hook handler */ public function authenticate($args, $internal = false) { // Chwala if (defined('FILE_API_START') && !$internal && empty($args['pass']) && strpos($args['user'], 'RC:') === 0) { // extract session ID and username from the token list(, $sess_id, $user) = explode(':', $args['user']); // unset user, set invalid state $args['valid'] = false; $args['user'] = null; $session = rcube_session::factory($this->rc->config); if ($data = $session->read($sess_id)) { // get SSO data from the existing session $old_session = $_SESSION; session_decode($data); $session_user = $_SESSION['username']; $data = $_SESSION['sso_data']; $_SESSION = $old_session; // TODO: allow only configured REMOTE_ADDR? if ($session_user == $user && $data && ($data = json_decode($this->rc->decrypt($data), true)) && ($mode = $data['mode'])) { $driver = $this->get_driver($mode); // Session validation, token refresh, etc. if ($this->data = $driver->validate_session($data)) { $args['user'] = $user; $args['pass'] = 'fake-sso-password'; $args['valid'] = true; $this->authenticate(array(), true); } } } } // Roundcube else if (!empty($this->data) && ($email = $this->data['email'])) { if (!$internal) { $args['user'] = $email; $args['pass'] = 'fake-sso-password'; $args['valid'] = true; $args['cookiecheck'] = false; $_POST['_timezone'] = $this->data['timezone']; $_POST['_url'] = $this->data['url']; } $this->add_hook('storage_connect', array($this, 'storage_connect')); $this->add_hook('managesieve_connect', array($this, 'storage_connect')); $this->add_hook('smtp_connect', array($this, 'smtp_connect')); $this->add_hook('ldap_connected', array($this, 'ldap_connected')); $this->add_hook('chwala_authenticate', array($this, 'chwala_authenticate')); } + else if ($this->logon_error) { + $args['valid'] = false; + $args['error'] = $this->logon_error; + } return $args; } /** * Shutdown handler */ public function shutdown() { // Between startup and authenticate the session is destroyed. // So, we save the data later than that. if (!empty($this->data) && !empty($_SESSION['user_id']) // update session only when data changed && (empty($this->old_data) || $this->old_data != $this->data) ) { $_SESSION['sso_data'] = $this->rc->encrypt(json_encode($this->data)); } } /** * Storage_connect/managesieve_connect hook handler */ public function storage_connect($args) { $user = $this->rc->config->get('kolab_sso_imap_user'); $pass = $this->rc->config->get('kolab_sso_imap_pass'); if ($user && $pass) { $args['auth_cid'] = $user; $args['auth_pw'] = $pass; $args['auth_type'] = 'PLAIN'; } return $args; } /** * Smtp_connect hook handler */ public function smtp_connect($args) { foreach (array('smtp_server', 'smtp_user', 'smtp_pass') as $prop) { $args[$prop] = $this->rc->config->get("kolab_sso_$prop", $args[$prop]); } return $args; } /** * ldap_connected hook handler */ public function ldap_connected($args) { $user = $this->rc->config->get('kolab_sso_ldap_user'); $pass = $this->rc->config->get('kolab_sso_ldap_pass'); if ($user && $pass && $args['user_specific']) { $args['bind_dn'] = $user; $args['bind_pass'] = $pass; $args['search_filter'] = null; } return $args; } /** * Chwala_authenticate hook handler */ public function chwala_authenticate($args) { // Instead of normal basic auth with user/pass we'll use // Authorization: Bearer $bearer = 'RC:' . session_id() . ':' . $_SESSION['username']; $args['request']->setAuth(null); $args['request']->setHeader('Authorization', 'Bearer ' . base64_encode($bearer)); return $args; } /** * Login form object */ public function login_form($args) { $this->load_config(); $options = (array) $this->rc->config->get('kolab_sso_options'); $disable_login = $this->rc->config->get('kolab_sso_disable_login'); if (empty($options)) { return $args; } $doc = new DOMDocumentHelper('1.0'); $doc->loadHTML($args['content']); $body = $doc->getElementsByTagName('body')->item(0); if ($disable_login) { // Remove login form inputs table $table = $doc->getElementsByTagName('table')->item(0); $table->parentNode->removeChild($table); // Remove original Submit button $submit = $doc->getElementsByTagName('button')->item(0); $submit->parentNode->removeChild($submit); } if (!$this->driver) { $this->add_texts('localization/'); } // Add SSO form elements $form = $doc->createNode('p', null, array('id' => 'sso-form', 'class' => 'formbuttons'), $body); foreach ($options as $idx => $option) { $label = array('name' => 'loginby', 'vars' => array('provider' => $option['name'] ?: $this->gettext('sso'))); $doc->createNode('button', $this->gettext($label), array( 'type' => 'button', 'value' => $idx, 'class' => 'button sso w-100', 'onclick' => 'kolab_sso_submit(this)', ), $form); } $doc->createNode('input', null, array('name' => '_sso', 'type' => 'hidden'), $form); // Save the form content back $args['content'] = preg_replace('||', '', $doc->saveHTML($body)); // Add script $args['content'] .= ""; return $args; } /** * Debug function for drivers */ public function debug($line) { if ($this->debug) { rcube::write_log('sso', $line); } } /** * Initialize SSO driver object */ private function get_driver($name) { if ($this->driver) { return $this->driver; } $this->load_config(); $this->add_texts('localization/'); $options = (array) $this->rc->config->get('kolab_sso_options'); $options = (array) $options[$name]; $driver = $options['driver'] ?: 'openidc'; $class = "kolab_sso_$driver"; if (empty($options) || !file_exists($this->home . "/drivers/$driver.php")) { rcube::raise_error(array( 'line' => __LINE__, 'file' => __FILE__, 'message' => "Unable to find SSO driver" ), true, true); } // Add /lib to include_path $include_path = $this->home . '/lib' . PATH_SEPARATOR; $include_path .= ini_get('include_path'); set_include_path($include_path); require_once $this->home . "/drivers/$driver.php"; $this->debug = $this->rc->config->get('kolab_sso_debug'); $this->driver = new $class($this, $options); return $this->driver; } } /** * DOMDocument wrapper with some shortcut method */ class DOMDocumentHelper extends DOMDocument { public function createNode($name, $value = null, $args = array(), $parent = null, $prepend = false) { $node = parent::createElement($name); if ($value) { $node->appendChild(new DOMText(rcube::Q($value))); } foreach ($args as $attr_name => $attr_value) { $node->setAttribute($attr_name, $attr_value); } if ($parent) { if ($prepend && $parent->firstChild) { $parent->insertBefore($node, $parent->firstChild); } else { $parent->appendChild($node); } } return $node; } } diff --git a/plugins/kolab_sso/localization/en_US.inc b/plugins/kolab_sso/localization/en_US.inc index 949243d8..545b76b9 100644 --- a/plugins/kolab_sso/localization/en_US.inc +++ b/plugins/kolab_sso/localization/en_US.inc @@ -1,4 +1,26 @@