Draft code for #1645

This commit is contained in:
the-djmaze 2024-07-01 11:21:02 +02:00
parent 9f3f41f308
commit 7f7ee97106
2 changed files with 274 additions and 0 deletions

View file

@ -0,0 +1,50 @@
(rl => {
const client_id = rl.pluginSettingsGet('login-gmail', 'client_id'),
login = () => {
document.location = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?' + (new URLSearchParams({
response_type: 'code',
client_id: client_id,
redirect_uri: document.location.href + '?LoginGMail',
scope: [
// Associate personal info
'openid',
'offline_access',
'email',
'profile',
// Access IMAP and SMTP through OAUTH
'https://graph.microsoft.com/IMAP.AccessAsUser.All',
'https://graph.microsoft.com/Mail.Send'
/* // Legacy:
'https://outlook.office.com/SMTP.Send',
'https://outlook.office.com/IMAP.AccessAsUser.All'
*/
].join(' '),
state: 'gmail', // + rl.settings.app('token') + localStorage.getItem('smctoken')
// Force authorize screen, so we always get a refresh_token
access_type: 'offline',
prompt: 'consent'
}));
};
if (client_id) {
addEventListener('sm-user-login', e => {
if (event.detail.get('Email').includes('@gmail.com')) {
e.preventDefault();
login();
}
});
addEventListener('rl-view-model', e => {
if ('Login' === e.detail.viewModelTemplateID) {
const
container = e.detail.viewModelDom.querySelector('#plugin-Login-BottomControlGroup'),
btn = Element.fromHTML('<button type="button">Outlook</button>'),
div = Element.fromHTML('<div class="controls"></div>');
btn.onclick = login;
div.append(btn);
container && container.append(div);
}
});
}
})(window.rl);

View file

@ -0,0 +1,224 @@
<?php
/**
* Microsoft requires an Azure account that has an active subscription
* I'm not going to pay, so feel free to fix this code yourself.
* https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
* https://answers.microsoft.com/en-us/msoffice/forum/all/configuration-for-imap-pop-and-smtp-with-oauth-in/3db47d43-25ac-4e0b-b957-22585e6caf15
*/
use RainLoop\Model\MainAccount;
use RainLoop\Providers\Storage\Enumerations\StorageType;
class LoginO365Plugin extends \RainLoop\Plugins\AbstractPlugin
{
const
NAME = 'Office365/Outlook OAuth2',
VERSION = '0.1',
RELEASE = '2024-03-27',
REQUIRED = '2.36.0',
CATEGORY = 'Login',
DESCRIPTION = 'Office365/Outlook IMAP, Sieve & SMTP login using RFC 7628 OAuth2';
// Microsoft make up your mind! documentation has "/oauth2/v2.0/" or "/v2.0/oauth2/" ???
const
LOGIN_URI = 'https://login.microsoftonline.com/common/oauth2/v2.0/auth',
TOKEN_URI = 'https://login.microsoftonline.com/common/oauth2/v2.0/token';
private static ?array $auth = null;
public function Init() : void
{
$this->UseLangs(true);
$this->addJs('LoginOAuth2.js');
$this->addHook('imap.before-login', 'clientLogin');
$this->addHook('smtp.before-login', 'clientLogin');
$this->addHook('sieve.before-login', 'clientLogin');
$this->addPartHook('LoginO365', 'ServiceLoginO365');
// Prevent Disallowed Sec-Fetch Dest: document Mode: navigate Site: cross-site User: true
$this->addHook('filter.http-paths', 'httpPaths');
}
public function httpPaths(array $aPaths) : void
{
if (!empty($aPaths[0]) && 'LoginO365' === $aPaths[0]) {
$oConfig = \RainLoop\Api::Config();
$oConfig->Set('security', 'secfetch_allow',
\trim($oConfig->Get('security', 'secfetch_allow', '') . ';site=cross-site', ';')
);
}
}
public function ServiceLoginO365() : string
{
$oActions = \RainLoop\Api::Actions();
$oHttp = $oActions->Http();
$oHttp->ServerNoCache();
try
{
if (isset($_GET['error'])) {
throw new \RuntimeException($_GET['error']);
}
if (!isset($_GET['code']) || empty($_GET['state']) || 'o365' !== $_GET['state']) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$oO365 = $this->o365Connector();
if (!$oO365) {
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
$iExpires = \time();
$aResponse = $oO365->getAccessToken(
static::TOKEN_URI,
'authorization_code',
array(
'code' => $_GET['code'],
'redirect_uri' => $oHttp->GetFullUrl().'?LoginO365'
)
);
if (200 != $aResponse['code']) {
if (isset($aResponse['result']['error'])) {
throw new \RuntimeException(
$aResponse['code']
. ': '
. $aResponse['result']['error']
. ' / '
. $aResponse['result']['error_description']
);
}
throw new \RuntimeException("HTTP: {$aResponse['code']}");
}
$aResponse = $aResponse['result'];
if (empty($aResponse['access_token'])) {
throw new \RuntimeException('access_token missing');
}
if (empty($aResponse['refresh_token'])) {
throw new \RuntimeException('refresh_token missing');
}
$sAccessToken = $aResponse['access_token'];
$iExpires += $aResponse['expires_in'];
$oO365->setAccessToken($sAccessToken);
$aUserInfo = $oO365->fetch('https://www.googleapis.com/oauth2/v2/userinfo');
if (200 != $aUserInfo['code']) {
throw new \RuntimeException("HTTP: {$aResponse['code']}");
}
$aUserInfo = $aUserInfo['result'];
if (empty($aUserInfo['id'])) {
throw new \RuntimeException('unknown id');
}
if (empty($aUserInfo['email'])) {
throw new \RuntimeException('unknown email address');
}
static::$auth = [
'access_token' => $sAccessToken,
'refresh_token' => $aResponse['refresh_token'],
'expires_in' => $aResponse['expires_in'],
'expires' => $iExpires
];
$oPassword = new \SnappyMail\SensitiveString($aUserInfo['id']);
$oAccount = $oActions->LoginProcess($aUserInfo['email'], $oPassword);
// $oAccount = MainAccount::NewInstanceFromCredentials($oActions, $aUserInfo['email'], $aUserInfo['email'], $oPassword, true);
if ($oAccount) {
// $oActions->SetMainAuthAccount($oAccount);
// $oActions->SetAuthToken($oAccount);
$oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON(static::$auth, $oAccount->CryptKey())
);
}
}
catch (\Exception $oException)
{
$oActions->Logger()->WriteException($oException, \LOG_ERR);
}
$oActions->Location(\RainLoop\Utils::WebPath());
exit;
}
public function configMapping() : array
{
return [
\RainLoop\Plugins\Property::NewInstance('client_id')
->SetLabel('Client ID')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetAllowedInJs()
->SetDescription('https://github.com/the-djmaze/snappymail/wiki/FAQ#o365'),
\RainLoop\Plugins\Property::NewInstance('client_secret')
->SetLabel('Client Secret')
->SetType(\RainLoop\Enumerations\PluginPropertyType::STRING)
->SetEncrypted()
];
}
public function clientLogin(\RainLoop\Model\Account $oAccount, \MailSo\Net\NetClient $oClient, \MailSo\Net\ConnectSettings $oSettings) : void
{
if ($oAccount instanceof MainAccount && \str_ends_with($oAccount->Email(), '@o365.com')) {
$oActions = \RainLoop\Api::Actions();
try {
$aData = static::$auth ?: \SnappyMail\Crypt::DecryptFromJSON(
$oActions->StorageProvider()->Get($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken()),
$oAccount->CryptKey()
);
} catch (\Throwable $oException) {
// $oActions->Logger()->WriteException($oException, \LOG_ERR);
return;
}
if (!empty($aData['expires']) && !empty($aData['access_token']) && !empty($aData['refresh_token'])) {
if (\time() >= $aData['expires']) {
$iExpires = \time();
$oO365 = $this->o365Connector();
if ($oO365) {
$aRefreshTokenResponse = $oO365->getAccessToken(
static::TOKEN_URI,
'refresh_token',
array('refresh_token' => $aData['refresh_token'])
);
if (!empty($aRefreshTokenResponse['result']['access_token'])) {
$aData['access_token'] = $aRefreshTokenResponse['result']['access_token'];
$aResponse['expires'] = $iExpires + $aResponse['expires_in'];
$oActions->StorageProvider()->Put($oAccount, StorageType::SESSION, \RainLoop\Utils::GetSessionToken(),
\SnappyMail\Crypt::EncryptToJSON($aData, $oAccount->CryptKey())
);
}
}
}
$oSettings->passphrase = $aData['access_token'];
\array_unshift($oSettings->SASLMechanisms, 'OAUTHBEARER', 'XOAUTH2');
}
}
}
protected function o365Connector() : ?\OAuth2\Client
{
$client_id = \trim($this->Config()->Get('plugin', 'client_id', ''));
$client_secret = \trim($this->Config()->getDecrypted('plugin', 'client_secret', ''));
if ($client_id && $client_secret) {
try
{
$oO365 = new \OAuth2\Client($client_id, $client_secret);
$oActions = \RainLoop\Api::Actions();
$sProxy = $oActions->Config()->Get('labs', 'curl_proxy', '');
if (\strlen($sProxy)) {
$oO365->setCurlOption(CURLOPT_PROXY, $sProxy);
$sProxyAuth = $oActions->Config()->Get('labs', 'curl_proxy_auth', '');
if (\strlen($sProxyAuth)) {
$oO365->setCurlOption(CURLOPT_PROXYUSERPWD, $sProxyAuth);
}
}
return $oO365;
}
catch (\Exception $oException)
{
$oActions->Logger()->WriteException($oException, \LOG_ERR);
}
}
return null;
}
}