From dc832ce12b88d8a6c738991a8e05ebc3dd0acc66 Mon Sep 17 00:00:00 2001 From: ralf Date: Fri, 23 Dec 2022 14:32:54 -0600 Subject: [PATCH] WIP OAuth/OpenIDConnect authentication for mail / Office365 mail services Mail wizzard triggers on a *.onmicrosoft.com domain and then automatically uses Office365 servers with OpenIDConnect authentication - access- and refresh-token get acquired with https://outlook.office.com/IMAP.AccessAsUser.All scope ToDo: - find out why Microsoft denies access with the returned access-token - store access-token for its lifetime in the cache - store refresh-token instead of password, to get a new access-token, if it's expired --> add OAuth logic to mail client (not just wizard) --- admin/inc/class.admin_mail.inc.php | 123 +++++++++++++++++++++++++-- api/oauth.php | 24 ++++++ api/src/Auth/OpenIDConnectClient.php | 94 ++++++++++++++++++++ composer.json | 3 +- composer.lock | 46 +++++++++- 5 files changed, 278 insertions(+), 12 deletions(-) create mode 100644 api/oauth.php create mode 100644 api/src/Auth/OpenIDConnectClient.php diff --git a/admin/inc/class.admin_mail.inc.php b/admin/inc/class.admin_mail.inc.php index d3d676806e..242a95e9b2 100644 --- a/admin/inc/class.admin_mail.inc.php +++ b/admin/inc/class.admin_mail.inc.php @@ -14,6 +14,8 @@ use EGroupware\Api\Framework; use EGroupware\Api\Acl; use EGroupware\Api\Etemplate; use EGroupware\Api\Mail; +use EGroupware\Api\Auth\OpenIDConnectClient; +use Jumbojett\OpenIDConnectClientException; /** * Wizard to create mail accounts @@ -204,6 +206,11 @@ class admin_mail ), $readonlys, $content, 2); } + const OFFICE365_CLIENT_ID = 'e09fe57b-ffc5-496e-9ef8-3e6c7d628c09'; + const OFFICE365_CLIENT_SECRET = 'Hd18Q~t-8_-ImvPFXlh8DSFjWKYyvpUTqURRJc7i'; + const OFFICE365_IMAP_HOST = 'outlook.office365.com'; + const OFFICE365_SMTP_HOST = 'smtp.office365.com'; + /** * Try to autoconfig an account * @@ -218,10 +225,10 @@ class admin_mail if (!isset($content['acc_smtp_host'])) $content['acc_smtp_host'] = ''; // do manual mode right away return $this->smtp($content, lang('Skipping IMAP configuration!')); } - $content['output'] = ''; - $sel_options = $readonlys = array(); + $tpl = new Etemplate('admin.mailwizard'); + $sel_options = $readonlys = $hosts = []; - $content['connected'] = $connected = false; + $connected = $content['connected'] ?? null; if (empty($content['acc_imap_username'])) { $content['acc_imap_username'] = $content['ident_email']; @@ -238,6 +245,19 @@ class admin_mail ); } } + // Office 365 Mail + elseif (preg_match('/@[^.]+\.onmicrosoft\.com$/i', $content['acc_imap_username'])) + { + $content['output'] = lang('Using Office365 mail servers')."\n"; + list(, $domain) = explode('@', $content['acc_imap_username']); + $hosts[self::OFFICE365_IMAP_HOST] = array( + self::SSL_TLS => 993, + ); + $content['acc_smpt_host'] = self::OFFICE365_SMTP_HOST; + $content['acc_sieve_enabled'] = false; + $content['acc_oauth_provider_url'] = "https://login.microsoftonline.com/$domain"; + $content['acc_oauth_client_id'] = self::OFFICE365_CLIENT_ID; + } elseif (($ispdb = self::mozilla_ispdb($content['ident_email'])) && count($ispdb['imap'])) { $content['ispdb'] = $ispdb; @@ -267,7 +287,7 @@ class admin_mail } // iterate over all hosts and try to connect - foreach($hosts as $host => $data) + foreach(!isset($connected) ? $hosts : [] as $host => $data) { $content['acc_imap_host'] = $host; // by default we check SSL, STARTTLS and at last an insecure connection @@ -348,8 +368,8 @@ class admin_mail $readonlys['button[manual]'] = true; unset($content['manual_class']); $sel_options['acc_imap_ssl'] = self::$ssl_types; - $tpl = new Etemplate('admin.mailwizard'); - $tpl->exec(static::APP_CLASS.'autoconfig', $content, $sel_options, $readonlys, $content, 2); + $tpl->exec(static::APP_CLASS.'autoconfig', $content, $sel_options, $readonlys, + array_diff_key($content, ['output'=>true]), 2); } /** @@ -1425,9 +1445,9 @@ class admin_mail * @param int $timeout =null default use value returned by Mail\Imap::getTimeOut() * @return Horde_Imap_Client_Socket */ - protected static function imap_client(array $content, $timeout=null) + protected static function imap_client(array &$content, $timeout=null) { - return new Horde_Imap_Client_Socket(array( + $config = [ 'username' => $content['acc_imap_username'], 'password' => $content['acc_imap_password'], 'hostspec' => $content['acc_imap_host'], @@ -1435,7 +1455,92 @@ class admin_mail 'secure' => self::$ssl2secure[(string)array_search($content['acc_imap_ssl'], self::$ssl2type)], 'timeout' => $timeout > 0 ? $timeout : Mail\Imap::getTimeOut(), 'debug' => self::DEBUG_LOG, - )); + ]; + if (!empty($content['acc_oauth_provider_url'])) + { + $config['xoauth2_token'] = self::oauthToken($content); + if (empty($config['password'])) $config['password'] = 'xxx'; // some password is required, even if not used + } + return new Horde_Imap_Client_Socket($config); + } + + /** + * Acquire OAuth access (and refresh) token + */ + protected static function oauthToken(array &$content) + { + if (empty($content['acc_oauth_client_secret'])) + { + switch($content['acc_oauth_client_id']) + { + case self::OFFICE365_CLIENT_ID: + $content['acc_oauth_client_secret'] = self::OFFICE365_CLIENT_SECRET; + break; + default: + throw new Exception(lang("No OAuth client secret for provider '%1'!", $content['acc_oauth_provider_url'])); + } + } + $oidc = new OpenIDConnectClient($content['acc_oauth_provider_url'], + $content['acc_oauth_client_id'], $content['acc_oauth_client_secret']); + + // Office365 requires client-ID as appid GET parameter (https://github.com/jumbojett/OpenID-Connect-PHP/issues/190) + if ($content['acc_oauth_client_id'] = self::OFFICE365_CLIENT_ID) + { + $oidc->setWellKnownConfigParameters(['appid' => $content['acc_oauth_client_id']]); + } + + // we need to use response_code=query / GET request to keep our session token! + $oidc->setResponseTypes(['code']); // to be able to use query, not 'id_token' + $oidc->setAllowImplicitFlow(true); + $oidc->addAuthParam(['response_mode' => 'query']); // to keep cookie, not 'form_post' + + // ToDo: the last 3 are Office365 specific scopes + $oidc->addScope(['openid', 'email', 'profile', 'https://outlook.office.com/IMAP.AccessAsUser.All', 'https://outlook.office.com/SMTP.Send', 'https://outlook.office.com/User.Read']); + + if (!empty($content['acc_oauth_access_token']) || !empty($content['acc_oauth_refresh_token'])) + { + if (empty($content['acc_oauth_access_token'])) + { + $content['acc_oauth_access_token'] = $oidc->refreshToken($content['acc_oauth_refresh_token']); + } + return new Horde_Imap_Client_Password_Xoauth2($content['acc_imap_username'], $content['acc_oauth_access_token']); + } + // Run OAuth authentification, will NOT return, but call success or failure callbacks below + $oidc->authenticateThen(__CLASS__.'::oauthAuthenticated', [$content], __CLASS__.'::oauthFailure', [$content]); + } + + /** + * Oauth success callback calling autoconfig again + * + * @param OpenIDConnectClient $oidc + * @param array $content + * @return void + */ + public static function oauthAuthenticated(OpenIDConnectClient $oidc, array $content) + { + $content['acc_oauth_access_token'] = $oidc->getAccessToken(); + $content['acc_oauth_refresh_token'] = $oidc->getRefreshToken(); + + $GLOBALS['egw_info']['flags']['currentapp'] = 'admin'; + + $obj = new self; + $obj->autoconfig($content); + } + + /** + * Oauth failure callback calling autoconfig again + * + * @param OpenIDConnectClientException|null $exception + * @param array $content + */ + public static function oauthFailure(Throwable $exception=null, array $content) + { + $content['output'] .= lang('OAuth Authentiction').': '.($exception ? $exception->getMessage() : lang('failed')); + + $GLOBALS['egw_info']['flags']['currentapp'] = 'admin'; + + $obj = new self; + $obj->autoconfig($content); } /** diff --git a/api/oauth.php b/api/oauth.php new file mode 100644 index 0000000000..47ff605d2e --- /dev/null +++ b/api/oauth.php @@ -0,0 +1,24 @@ + + * @copyright (c) 2013-22 by Ralf Becker + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +$GLOBALS['egw_info'] = [ + 'flags' => [ + 'currentapp' => 'api', + 'nonavbar' => true, + 'noheader' => true, + ], +]; +require_once __DIR__.'/../header.inc.php'; + +use EGroupware\Api\Auth\OpenIDConnectClient; + +OpenIDConnectClient::process(); \ No newline at end of file diff --git a/api/src/Auth/OpenIDConnectClient.php b/api/src/Auth/OpenIDConnectClient.php new file mode 100644 index 0000000000..8524724c82 --- /dev/null +++ b/api/src/Auth/OpenIDConnectClient.php @@ -0,0 +1,94 @@ + + * @copyright (c) 2013-22 by Ralf Becker + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +namespace EGroupware\Api\Auth; + +use EGroupware\Api; +use Jumbojett\OpenIDConnectClientException; + +if (!empty($GLOBALS['egw_info']['server']['cookie_samesite_attribute']) && $GLOBALS['egw_info']['server']['cookie_samesite_attribute'] === 'Strict') +{ + throw new Api\Exception("OAuth/OpenIDConnect requires SameSite cookie attribute other then 'Strict' set in Admin > Site configuration > Security > Cookies!"); +} + +/** + * Extended OpenIDConnect client allowing to authenticate via some kind of promise, see authenticateThen method. + */ +class OpenIDConnectClient extends \Jumbojett\OpenIDConnectClient +{ + public function __construct($provider_url = null, $client_id = null, $client_secret = null, $issuer = null) + { + parent::__construct($provider_url, $client_id, $client_secret, $issuer); + + // set correct redirect URL, which is NOT the current URL, but always /api/oauth.php + $this->setRedirectURL(Api\Framework::getUrl(Api\Framework::link('/api/oauth.php'))); + } + + /** + * OAuth/OpenIDConnect authenticate incl. redirecting to OIDC provider + * + * This method does NOT return, you have to provide a success and failure callback instead! + * The callbacks can NOT be closures, as they get serialized in the session, but you can use everything else eg. 'class::staticMethod' or [$obj, 'method']. + * + * @param callable $success success callback, first parameter is $oidc object/this containing the access and refresh token + * @param array $success_params further success callback parameters + * @param callable $failure failure callback, first parameter it the exception thrown or false, if authenticate returns false + * @param array $failure_params further failure parameters + */ + public function authenticateThen(callable $success, array $success_params=[], callable $failure=null, array $failure_params=[]) + { + Api\Cache::setSession(__CLASS__, 'oidc', $this); + Api\Cache::setSession(__CLASS__, 'authenticateThenParams', func_get_args()); + + try { + // authenticate might not return, because it redirected + if ($this->authenticate()) + { + array_unshift($success_params, $this); + return call_user_func_array($success, $success_params); + } + } + catch(OpenIDConnectClientException $e) { + _egw_log_exception($e); + } + // authentication failure or exception + array_unshift($failure_params, $e ?? false); + call_user_func_array($failure, $failure_params); + } + + /** + * Reimplemented to work with JSON requests too + * + * @param string $url + */ + public function redirect($url) + { + Api\Framework::redirect($url); + exit; + } + + /** + * Called by /api/oauth.php redirect url + * + * @return void + */ + public static function process() + { + if (empty($oidc = Api\Cache::getSession(__CLASS__, 'oidc')) || + !is_a($oidc, __CLASS__) || + !is_array(($authenticateThenParams = Api\Cache::getSession(__CLASS__, 'authenticateThenParams')))) + { + throw new OpenIDConnectClientException("Missing OpenIDConnectClient state!"); + } + call_user_func_array([$oidc, 'authenticateThen'], $authenticateThenParams); + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index 5c09caa5c8..7304ddc38b 100644 --- a/composer.json +++ b/composer.json @@ -122,6 +122,7 @@ "giggsey/libphonenumber-for-php": "^8.12", "guzzlehttp/guzzle": "^7.4.1", "guzzlehttp/psr7": "^2.1.0", + "jumbojett/openid-connect-php": "^0.9.10", "npm-asset/as-jqplot": "1.0.*", "npm-asset/gridster": "0.5.*", "oomphinc/composer-installers-extender": "^2.0.1", @@ -157,4 +158,4 @@ }, "minimum-stability": "dev", "prefer-stable": true -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index e295734e52..0e631b9a47 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "568f82782cd43d89c4a5790e3663c362", + "content-hash": "8241f145959d661c9a9975aa040cbfb8", "packages": [ { "name": "adldap2/adldap2", @@ -3814,6 +3814,48 @@ ], "time": "2022-06-20T21:43:11+00:00" }, + { + "name": "jumbojett/openid-connect-php", + "version": "v0.9.10", + "source": { + "type": "git", + "url": "https://github.com/jumbojett/OpenID-Connect-PHP.git", + "reference": "45aac47b525f0483dd4db3324bb1f1cab4666061" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jumbojett/OpenID-Connect-PHP/zipball/45aac47b525f0483dd4db3324bb1f1cab4666061", + "reference": "45aac47b525f0483dd4db3324bb1f1cab4666061", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "paragonie/random_compat": ">=2", + "php": ">=5.4", + "phpseclib/phpseclib": "~2.0 || ^3.0" + }, + "require-dev": { + "roave/security-advisories": "dev-master", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Bare-bones OpenID Connect client", + "support": { + "issues": "https://github.com/jumbojett/OpenID-Connect-PHP/issues", + "source": "https://github.com/jumbojett/OpenID-Connect-PHP/tree/v0.9.10" + }, + "time": "2022-09-30T12:34:46+00:00" + }, { "name": "lcobucci/jwt", "version": "3.4.6", @@ -12335,5 +12377,5 @@ "platform-overrides": { "php": "7.4" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.2.0" }