forked from extern/egroupware
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:
parent
a1da1a6fa6
commit
dc832ce12b
@ -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
24
api/oauth.php
Normal 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();
|
94
api/src/Auth/OpenIDConnectClient.php
Normal file
94
api/src/Auth/OpenIDConnectClient.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
46
composer.lock
generated
46
composer.lock
generated
@ -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"
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user