diff --git a/plugins/login-o365/LoginOAuth2.js b/plugins/login-o365/LoginOAuth2.js new file mode 100644 index 000000000..931bcfb60 --- /dev/null +++ b/plugins/login-o365/LoginOAuth2.js @@ -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(''), + div = Element.fromHTML('
'); + btn.onclick = login; + div.append(btn); + container && container.append(div); + } + }); + } + +})(window.rl); diff --git a/plugins/login-o365/index.php b/plugins/login-o365/index.php new file mode 100644 index 000000000..1389e8956 --- /dev/null +++ b/plugins/login-o365/index.php @@ -0,0 +1,224 @@ +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; + } +}