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 @@
-
+