From 7bd60dd66d37b251b55a887c9d9bb78c61d34d72 Mon Sep 17 00:00:00 2001 From: Alexander Kras'ko <0m3r.mail@gmail.com> Date: Fri, 14 Jun 2024 16:46:54 +0300 Subject: [PATCH] OAuth2 for Gmail was released (#31) --- Api/Data/ServiceInterface.php | 16 +- Controller/Adminhtml/Email/Service/Save.php | 8 +- Controller/Gmail/GetOAuth2Token.php | 155 ++++++++++++++++++ Mail/Transport/GmailOAuth2.php | 99 +++++++++++ Model/Data/Token.php | 67 ++++++++ Model/Data/Token/Validator.php | 32 ++++ Model/Service.php | 36 +++- Model/Service/Encryptor.php | 37 ++++- Model/ServiceRepository.php | 3 +- Plugin/Model/ServiceOAuth2TokenPlugin.php | 105 ++++++++++++ ...lugin.php => ServiceProtectDataPlugin.php} | 4 +- etc/db_schema.xml | 3 +- etc/db_schema_whitelist.json | 5 +- etc/di.xml | 4 +- etc/frontend/routes.xml | 8 + etc/module.xml | 2 +- 16 files changed, 564 insertions(+), 20 deletions(-) create mode 100644 Controller/Gmail/GetOAuth2Token.php create mode 100644 Mail/Transport/GmailOAuth2.php create mode 100644 Model/Data/Token.php create mode 100644 Model/Data/Token/Validator.php create mode 100644 Plugin/Model/ServiceOAuth2TokenPlugin.php rename Plugin/Model/{ServicePlugin.php => ServiceProtectDataPlugin.php} (96%) create mode 100644 etc/frontend/routes.xml diff --git a/Api/Data/ServiceInterface.php b/Api/Data/ServiceInterface.php index 374bba7..3e011b5 100644 --- a/Api/Data/ServiceInterface.php +++ b/Api/Data/ServiceInterface.php @@ -16,6 +16,7 @@ interface ServiceInterface const PORT = 'port'; const SECURE = 'secure'; const AUTH = 'auth'; + const TOKEN = 'token'; const KEY = 'key'; const REMOVE = 'remove'; @@ -25,6 +26,7 @@ interface ServiceInterface const TYPE_SENDMAIL = 0; const TYPE_SMTP = 10; const TYPE_GMAIL = 15; + const TYPE_GMAILOAUTH2 = 17; const TYPE_SES = 20; const TYPE_MANDRILL = 30; @@ -36,6 +38,7 @@ interface ServiceInterface const AUTH_TYPE_LOGIN = 'login'; const AUTH_TYPE_PLAIN = 'plain'; const AUTH_TYPE_CRAMMD5 = 'crammd5'; + const AUTH_TYPE_XOAUTH2 = 'xoauth2'; /** * Get id @@ -114,6 +117,11 @@ public function getSecure(); */ public function getAuth(); + /** + * @return mixed + */ + public function getToken(); + /** * Get key * @@ -204,7 +212,7 @@ public function setPort($port); * Set secure * * @param int $secure - * return \Swissup\Email\Api\Data\ServiceInterface + * @return \Swissup\Email\Api\Data\ServiceInterface */ public function setSecure($secure); @@ -216,6 +224,12 @@ public function setSecure($secure); */ public function setAuth($auth); + /** + * @param string $token + * @return \Swissup\Email\Api\Data\ServiceInterface + */ + public function setToken($token); + /** * Set key * diff --git a/Controller/Adminhtml/Email/Service/Save.php b/Controller/Adminhtml/Email/Service/Save.php index 3727b23..162217d 100644 --- a/Controller/Adminhtml/Email/Service/Save.php +++ b/Controller/Adminhtml/Email/Service/Save.php @@ -64,7 +64,7 @@ public function execute() unset($data['id']); } - $model->setData($data); + $model->addData($data); // $this->_eventManager->dispatch( // 'swissup_email_service_prepare_save', // ['item' => $model, 'request' => $request] @@ -74,6 +74,12 @@ public function execute() $this->serviceRepository->save($model); $this->messageManager->addSuccess(__('Service succesfully saved.')); $this->session->setFormData(false); + + if ($model->hasData('callback_url')) { + $flowUrl = $model->getData('callback_url'); + return $resultRedirect->setUrl($flowUrl); + } + if ($this->getRequest()->getParam('back')) { return $resultRedirect->setPath( '*/*/edit', diff --git a/Controller/Gmail/GetOAuth2Token.php b/Controller/Gmail/GetOAuth2Token.php new file mode 100644 index 0000000..e6cf941 --- /dev/null +++ b/Controller/Gmail/GetOAuth2Token.php @@ -0,0 +1,155 @@ +session = $session; + $this->serviceRepository = $serviceRepository; + $this->urlDecoder = $urldecoder; + $this->tokenValidator = $tokenValidator; + } + + private function isLoggedIn() + { + $request = $this->getRequest(); + $serviceId = $request->getParam('id', false); + $sessionIdKey = self::SESSION_ID_KEY; + + if (!empty($serviceId)) { + if ($this->tokenValidator->validate($request)) { + $this->session->setData($sessionIdKey, $serviceId); + } else { + $this->session->setData($sessionIdKey, null); + } + } + + return (bool) $this->session->getData($sessionIdKey); + } + + private function getServiceId() + { + $sessionIdKey = self::SESSION_ID_KEY; + return (int) $this->session->getData($sessionIdKey); + } + + private function redirectReferer() + { + $refererUrl = $this->session->getData('referer'); + $refererUrl = empty($refererUrl) ? $this->_redirect->getRedirectUrl() : $refererUrl; + return $this->_redirect($refererUrl); + } + + /** + * Post user question + * + * @inherit + */ + public function execute() + { + if (!$this->isLoggedIn()) { + return $this->redirectReferer(); + } + + $id = $this->getServiceId(); + $service = $this->serviceRepository->getById($id); + + $request = $this->getRequest(); + $refererUrl = $request->getParam('referer'); + $refererUrl = $this->urlDecoder->decode($refererUrl); + if (!empty($refererUrl)) { + $this->session->setData('referer', $refererUrl); + } + + /* @var \League\OAuth2\Client\Provider\Google $provider */ + $provider = new \League\OAuth2\Client\Provider\Google([ + 'clientId' => $service->getUser(), + 'clientSecret' => $service->getPassword(), + 'redirectUri' => $this->_url->getUrl('*/*/*'), +// 'hostedDomain' => 'example.com', // optional; used to restrict access to users on your G Suite/Google Apps for Business accounts + 'scopes' => ['https://mail.google.com/'], + 'accessType' => 'offline' + ]); + + $errorParam = $request->getParam('error'); + $codeParam = $request->getParam('code'); + $stateParam = $request->getParam('state'); + + if (!empty($errorParam)) { + $this->messageManager->addErrorMessage( + htmlspecialchars($errorParam, ENT_QUOTES, 'UTF-8') + ); + return $this->redirectReferer(); + } elseif (empty($codeParam)) { + + // If we don't have an authorization code then get one + $authUrl = $provider->getAuthorizationUrl(/*['scope' => ['https://mail.google.com/']]*/); + $this->session->setData(self::FLOW_STATE_KEY, $provider->getState()); + + $resultRedirect = $this->resultRedirectFactory->create(); + $resultRedirect->setUrl($authUrl); + + return $resultRedirect; + // Check given state against previously stored one to mitigate CSRF attack + } elseif (empty($stateParam) || ($stateParam !== $this->session->getData(self::FLOW_STATE_KEY))) { + $this->session->setData(self::FLOW_STATE_KEY, null); + $this->messageManager->addErrorMessage( + __('Invalid state') + ); + return $this->redirectReferer(); + } else { + // Try to get an access token (using the authorization code grant) + $token = $provider->getAccessToken('authorization_code', [ + 'code' => $codeParam + ]); + + $refreshToken = $token->getRefreshToken(); + if (empty($refreshToken)) { + $authUrl = $provider->getAuthorizationUrl(['prompt' => 'consent', 'access_type' => 'offline']); + $this->session->setData(self::FLOW_STATE_KEY, $provider->getState()); + + $resultRedirect = $this->resultRedirectFactory->create(); + $resultRedirect->setUrl($authUrl); + return $resultRedirect; + } + $this->session->setData(self::FLOW_STATE_KEY, null); + + $tokenOptions = array_merge($token->jsonSerialize(), ['refresh_token' => $refreshToken]); + $service->setToken($tokenOptions); + $this->serviceRepository->save($service); + } + + return $this->redirectReferer(); + } +} diff --git a/Mail/Transport/GmailOAuth2.php b/Mail/Transport/GmailOAuth2.php new file mode 100644 index 0000000..d4ac96e --- /dev/null +++ b/Mail/Transport/GmailOAuth2.php @@ -0,0 +1,99 @@ + $tokenOptions['expires']) { + $phrase = new \Magento\Framework\Phrase('access token is expired'); + throw new \Magento\Framework\Exception\MailException($phrase); + } + + $options = new SmtpOptions( + [ + 'host' => $host, + 'port' => $port, + 'connection_class' => 'xoauth2', + 'connection_config' => + [ + 'username' => $config['email'], + 'access_token' => $tokenOptions['access_token'], + 'ssl' => 'tls' + ] + ] + ); + } + $this->setOptions($options); + } + + /** + * Send a mail using this transport + * + * @return boolean + * @throws \Magento\Framework\Exception\MailException + */ + public function sendMessage() + { + try { + $message = $this->message; + + parent::send($message); + } catch (\Exception $e) { + $phrase = new \Magento\Framework\Phrase($e->getMessage()); + throw new \Magento\Framework\Exception\MailException($phrase, $e); + } + return true; + } + + /** + * + * @return MessageInterface + */ + public function getMessage() + { + return $this->message; + } + + /** + * + * @param MessageInterface $message + */ + public function setMessage($message) + { + // if (!$message instanceof MessageInterface) { + // throw new \InvalidArgumentException( + // 'The message should be an instance of \Magento\Framework\Mail\Message' + // ); + // } + $this->message = $message; + return $this; + } +} diff --git a/Model/Data/Token.php b/Model/Data/Token.php new file mode 100644 index 0000000..87103aa --- /dev/null +++ b/Model/Data/Token.php @@ -0,0 +1,67 @@ +mathRandom = $mathRandom; + $this->cache = $cacheFrontend->get(\Magento\Framework\App\Cache\Frontend\Pool::DEFAULT_FRONTEND_ID); + } + + /** + * Retrieve State Token + * + * @return string A 16 bit unique key + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function getToken() + { + if (!$this->isPresent()) { + $this->set($this->mathRandom->getRandomString(16)); + } + return $this->cache->load(self::CACHE_ID); + } + + /** + * Determine if the token is present in the 'session' + * + * @return bool + */ + public function isPresent() + { + return (bool) $this->cache->test(self::CACHE_ID); + } + + /** + * Save the value of the token + * + * @param string $value + * @return void + */ + public function set($value) + { + $this->cache->save((string)$value, self::CACHE_ID, [self::CACHE_TAG], self::LIFETIME); + } +} diff --git a/Model/Data/Token/Validator.php b/Model/Data/Token/Validator.php new file mode 100644 index 0000000..9c49a39 --- /dev/null +++ b/Model/Data/Token/Validator.php @@ -0,0 +1,32 @@ +token = $token; + } + + /** + * Validate + * + * @param \Magento\Framework\App\RequestInterface $request + * @return bool + */ + public function validate(\Magento\Framework\App\RequestInterface $request) + { + $token = $request->getParam('token', null); + return $token && Security::compareStrings($token, $this->token->getToken()); + } +} diff --git a/Model/Service.php b/Model/Service.php index 7ad020c..e93fdae 100644 --- a/Model/Service.php +++ b/Model/Service.php @@ -87,7 +87,7 @@ public function getStatus() */ public function getType() { - return $this->getData(self::TYPE); + return (int) $this->getData(self::TYPE); } /** @@ -160,6 +160,14 @@ public function getAuth() return $this->getData(self::AUTH); } + /** + * @return string + */ + public function getToken() + { + return $this->getData(self::TOKEN); + } + /** * Get key * @@ -301,6 +309,11 @@ public function setAuth($auth) return $this->setData(self::AUTH, $auth); } + public function setToken($token) + { + return $this->setData(self::TOKEN, $token); + } + /** * Set key * @@ -414,6 +427,12 @@ public function getPreDefinedSmtpProviderSettings() 'auth' => 'login', 'port' => 465, 'secure' => self::SECURE_SSL, + ], [ + 'name' => 'Gmail (OAuth 2)', + 'host' => 'smtp.gmail.com', + 'auth' => 'xoauth2', + 'port' => 587, + 'secure' => self::SECURE_SSL, ], [ 'name' => 'Hotmail', 'host' => 'smtp-mail.outlook.com', @@ -521,11 +540,12 @@ public function getPreDefinedSmtpProviderSettings() public function getTypes() { return [ - self::TYPE_GMAIL => __('Gmail'), - self::TYPE_SMTP => __('SMTP'), - self::TYPE_SES => __('Amazon SES'), - self::TYPE_MANDRILL => __('Mandrill'), - self::TYPE_SENDMAIL => __('Sendmail'), + self::TYPE_GMAIL => __('Gmail'), + self::TYPE_GMAILOAUTH2 => __('Gmail OAuth 2'), + self::TYPE_SMTP => __('SMTP'), + self::TYPE_SES => __('Amazon SES'), + self::TYPE_MANDRILL => __('Mandrill'), + self::TYPE_SENDMAIL => __('Sendmail'), ]; } @@ -541,6 +561,7 @@ public function getTransportNameByType($type = null) } $classes = [ self::TYPE_GMAIL => 'Gmail', + self::TYPE_GMAILOAUTH2 => 'GmailOAuth2', self::TYPE_SMTP => 'Smtp', self::TYPE_SES => 'Ses', self::TYPE_MANDRILL => 'Mandrill', @@ -573,7 +594,8 @@ public function getAuthTypes() self::AUTH_TYPE_NONE => __('None'), self::AUTH_TYPE_LOGIN => __('Login'), self::AUTH_TYPE_PLAIN => __('Plain'), - self::AUTH_TYPE_CRAMMD5 => __('Crammd5') + self::AUTH_TYPE_CRAMMD5 => __('Crammd5'), + self::AUTH_TYPE_XOAUTH2 => __('OAuth 2.0'), ]; } } diff --git a/Model/Service/Encryptor.php b/Model/Service/Encryptor.php index e8bc8a6..46c7db7 100644 --- a/Model/Service/Encryptor.php +++ b/Model/Service/Encryptor.php @@ -2,6 +2,7 @@ namespace Swissup\Email\Model\Service; use Swissup\Email\Api\EncryptorInterface; +use Magento\Framework\Serialize\SerializerInterface; use Swissup\Email\Api\ServiceEncryptorInterface; use Swissup\Email\Api\Data\ServiceInterface; @@ -12,12 +13,18 @@ class Encryptor implements ServiceEncryptorInterface */ private $encryptor; + /** + * @var SerializerInterface + */ + private $serializer; + /** * @param EncryptorInterface $encryptor */ - public function __construct(EncryptorInterface $encryptor) + public function __construct(EncryptorInterface $encryptor, SerializerInterface $serializer) { $this->encryptor = $encryptor; + $this->serializer = $serializer; } /** @@ -25,7 +32,24 @@ public function __construct(EncryptorInterface $encryptor) */ private function getAttributes() { - return ['password']; + return ['password', 'token']; + } + + /** + * @return string[] + */ + private function getSerializableAttributes() + { + return ['token']; + } + + /** + * @param $attribute + * @return bool + */ + private function isSerializableAttribute($attribute) + { + return in_array($attribute, $this->getSerializableAttributes()); } /** @@ -36,6 +60,9 @@ public function encrypt(ServiceInterface $object): void { foreach ($this->getAttributes() as $attributeCode) { $value = $object->getData($attributeCode); + if ($this->isSerializableAttribute($attributeCode)) { + $value = $this->serializer->serialize($value); + } if ($value) { $object->setData($attributeCode, $this->encryptor->encrypt($value)); } @@ -52,7 +79,11 @@ public function decrypt(ServiceInterface $object): void $value = $object->getData($attributeCode); if ($value) { try { - $object->setData($attributeCode, $this->encryptor->decrypt($value)); + $value = $this->encryptor->decrypt($value); + if ($this->isSerializableAttribute($attributeCode)) { + $value = $this->serializer->unserialize($value); + } + $object->setData($attributeCode, $value); } catch (\Exception $e) { // value is not encrypted or something wrong with encrypted data } diff --git a/Model/ServiceRepository.php b/Model/ServiceRepository.php index f24b134..6cf8609 100644 --- a/Model/ServiceRepository.php +++ b/Model/ServiceRepository.php @@ -94,7 +94,8 @@ public function save(\Swissup\Email\Api\Data\ServiceInterface $service) try { $this->resource->save($service); } catch (\Exception $exception) { - throw new CouldNotSaveException(__($exception->getMessage())); + throw $exception; +// throw new CouldNotSaveException(__($exception->getMessage())); } return $service; } diff --git a/Plugin/Model/ServiceOAuth2TokenPlugin.php b/Plugin/Model/ServiceOAuth2TokenPlugin.php new file mode 100644 index 0000000..94890f2 --- /dev/null +++ b/Plugin/Model/ServiceOAuth2TokenPlugin.php @@ -0,0 +1,105 @@ +serviceRepository = $serviceRepository; + $this->urlBuilder = $urlBuilder; + $this->urlEncoder = $urlEncoder; + $this->token = $token; + } + + public function afterAfterCommitCallback(Service $subject): void + { + if ($subject->getAuth() !== Service::AUTH_TYPE_XOAUTH2 || + $subject->getType() !== Service::TYPE_GMAILOAUTH2) { + return; + } + + $tokenOptions = $subject->getToken(); + if (empty($tokenOptions)) { + $urlBuilder = $this->urlBuilder; + $refererUrl = $urlBuilder->getCurrentUrl(); + $refererUrl = $this->urlEncoder->encode($refererUrl); + $callbackUrl = $urlBuilder->getUrl( + 'swissup_email/gmail/getOAuth2Token', + [ + '_nosid' => true, + '_query' => [ + 'id' => $subject->getId(), + 'referer' => $refererUrl, + 'token' => $this->token->getToken(), + ] + ] + ); + $subject->setData('callback_url', $callbackUrl); + } + } + + public function afterAfterLoad(Service $subject): void + { + if ($subject->getAuth() !== Service::AUTH_TYPE_XOAUTH2 || + $subject->getType() !== Service::TYPE_GMAILOAUTH2) { + return; + } + + $tokenOptions = $subject->getToken(); + if (empty($tokenOptions)) { + return; + } + $storedToken = new \League\OAuth2\Client\Token\AccessToken($tokenOptions); + $refreshToken = $storedToken->getRefreshToken(); + + if ($storedToken->hasExpired() && !empty($refreshToken)) { + $urlBuilder = $this->urlBuilder; + $redirectUri = $urlBuilder->getUrl('swissup_email/gmail/getOAuth2Token'); + /* @var \League\OAuth2\Client\Provider\Google $provider */ + $provider = new \League\OAuth2\Client\Provider\Google([ + 'clientId' => $subject->getUser(), + 'clientSecret' => $subject->getPassword(), + 'redirectUri' => $redirectUri, +// 'hostedDomain' => 'example.com', // optional; used to restrict access to users on your G Suite/Google Apps for Business accounts + 'scopes' => ['https://mail.google.com/'], + 'accessType' => 'offline' + ]); + $token = $provider->getAccessToken('refresh_token', [ + 'refresh_token' => $refreshToken + ]); + $tokenOptions = array_merge($token->jsonSerialize(), ['refresh_token' => $refreshToken]); + $subject->setToken($tokenOptions); + $this->serviceRepository->save($subject); + } + } +} diff --git a/Plugin/Model/ServicePlugin.php b/Plugin/Model/ServiceProtectDataPlugin.php similarity index 96% rename from Plugin/Model/ServicePlugin.php rename to Plugin/Model/ServiceProtectDataPlugin.php index d834319..ad29ec2 100644 --- a/Plugin/Model/ServicePlugin.php +++ b/Plugin/Model/ServiceProtectDataPlugin.php @@ -6,7 +6,7 @@ use Magento\Framework\DataObject; use Swissup\Email\Model\Service; -class ServicePlugin +class ServiceProtectDataPlugin { /** * @var \Swissup\Email\Api\ServiceEncryptorInterface @@ -35,4 +35,4 @@ public function afterAfterLoad(Service $subject): void { $this->serviceEncryptor->decrypt($subject); } -} \ No newline at end of file +} diff --git a/etc/db_schema.xml b/etc/db_schema.xml index 4ebf1a7..03992a8 100644 --- a/etc/db_schema.xml +++ b/etc/db_schema.xml @@ -6,12 +6,13 @@ - + + diff --git a/etc/db_schema_whitelist.json b/etc/db_schema_whitelist.json index 62f4db6..d07145d 100644 --- a/etc/db_schema_whitelist.json +++ b/etc/db_schema_whitelist.json @@ -11,7 +11,8 @@ "host": true, "port": true, "secure": true, - "auth": true + "auth": true, + "token": true }, "constraint": { "PRIMARY": true @@ -31,4 +32,4 @@ "PRIMARY": true } } -} \ No newline at end of file +} diff --git a/etc/di.xml b/etc/di.xml index 9d7e3d7..c4f9372 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -48,6 +48,8 @@ - + + + diff --git a/etc/frontend/routes.xml b/etc/frontend/routes.xml new file mode 100644 index 0000000..e12f1c6 --- /dev/null +++ b/etc/frontend/routes.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/etc/module.xml b/etc/module.xml index 45db05e..9278831 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,6 +1,6 @@ - +