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)
This commit is contained in:
ralf 2022-12-23 14:32:54 -06:00
parent a1da1a6fa6
commit dc832ce12b
5 changed files with 278 additions and 12 deletions

View File

@ -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);
}
/**

24
api/oauth.php Normal file
View File

@ -0,0 +1,24 @@
<?php
/**
* EGroupware Api: OpenIDConnectClient redirect endpoint
*
* @link https://www.egroupware.org
* @package api
* @subpackage mail
* @author Ralf Becker <rb@egroupware.org>
* @copyright (c) 2013-22 by Ralf Becker <rb@egroupware.org>
* @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();

View File

@ -0,0 +1,94 @@
<?php
/**
* EGroupware Api: OpenIDConnectClient
*
* @link https://www.egroupware.org
* @package api
* @subpackage mail
* @author Ralf Becker <rb@egroupware.org>
* @copyright (c) 2013-22 by Ralf Becker <rb@egroupware.org>
* @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);
}
}

View File

@ -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",

46
composer.lock generated
View File

@ -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"
}