the common google callback can be used to trigger any method in the whole codebase restrict it by only allowing domain specific callback method and raise an error if the domain is not found
168 lines
4.4 KiB
Python
168 lines
4.4 KiB
Python
import base64
|
|
from imaplib import IMAP4
|
|
from poplib import POP3
|
|
from smtplib import SMTP
|
|
from urllib.parse import quote
|
|
|
|
import frappe
|
|
from frappe.integrations.google_oauth import GoogleOAuth
|
|
from frappe.utils.password import encrypt
|
|
|
|
|
|
class OAuthenticationError(Exception):
|
|
pass
|
|
|
|
|
|
class Oauth:
|
|
def __init__(
|
|
self,
|
|
conn: IMAP4 | POP3 | SMTP,
|
|
email_account: str,
|
|
email: str,
|
|
access_token: str,
|
|
refresh_token: str,
|
|
service: str,
|
|
mechanism: str = "XOAUTH2",
|
|
) -> None:
|
|
|
|
self.email_account = email_account
|
|
self.email = email
|
|
self.service = service
|
|
self._mechanism = mechanism
|
|
self._conn = conn
|
|
self._access_token = access_token
|
|
self._refresh_token = refresh_token
|
|
|
|
self._validate()
|
|
|
|
def _validate(self) -> None:
|
|
if self.service != "GMail":
|
|
raise NotImplementedError(
|
|
f"Service {self.service} currently doesn't have oauth implementation."
|
|
)
|
|
|
|
if not self._refresh_token:
|
|
frappe.throw(
|
|
frappe._("Please Authorize OAuth."),
|
|
OAuthenticationError,
|
|
frappe._("OAuth Error"),
|
|
)
|
|
|
|
@property
|
|
def _auth_string(self) -> str:
|
|
return f"user={self.email}\1auth=Bearer {self._access_token}\1\1"
|
|
|
|
def connect(self, _retry: int = 0) -> None:
|
|
"""Connection method with retry on exception for Oauth"""
|
|
try:
|
|
if isinstance(self._conn, POP3):
|
|
res = self._connect_pop()
|
|
|
|
if not res.startswith(b"+OK"):
|
|
raise
|
|
|
|
elif isinstance(self._conn, IMAP4):
|
|
self._connect_imap()
|
|
|
|
else:
|
|
# SMTP
|
|
self._connect_smtp()
|
|
|
|
except Exception as e:
|
|
# maybe the access token expired - refreshing
|
|
access_token = self._refresh_access_token()
|
|
|
|
if not access_token or _retry > 0:
|
|
frappe.log_error(
|
|
"OAuth Error - Authentication Failed", str(e), "Email Account", self.email_account
|
|
)
|
|
# raising a bare exception here as we have a lot of exception handling present
|
|
# where the connect method is called from - hence just logging and raising.
|
|
raise
|
|
|
|
self._access_token = access_token
|
|
self.connect(_retry + 1)
|
|
|
|
def _connect_pop(self) -> bytes:
|
|
# poplib doesn't have AUTH command implementation
|
|
res = self._conn._shortcmd(
|
|
"AUTH {} {}".format(
|
|
self._mechanism, base64.b64encode(bytes(self._auth_string, "utf-8")).decode("utf-8")
|
|
)
|
|
)
|
|
|
|
return res
|
|
|
|
def _connect_imap(self) -> None:
|
|
self._conn.authenticate(self._mechanism, lambda x: self._auth_string)
|
|
|
|
def _connect_smtp(self) -> None:
|
|
self._conn.auth(self._mechanism, lambda x: self._auth_string, initial_response_ok=False)
|
|
|
|
def _refresh_access_token(self) -> str:
|
|
"""Refreshes access token via calling `refresh_access_token` method of oauth service object"""
|
|
service_obj = self._get_service_object()
|
|
access_token = service_obj.refresh_access_token(self._refresh_token).get("access_token")
|
|
|
|
if access_token:
|
|
# set the new access token in db
|
|
frappe.db.set_value(
|
|
"Email Account",
|
|
self.email_account,
|
|
"access_token",
|
|
encrypt(access_token),
|
|
update_modified=False,
|
|
)
|
|
|
|
return access_token
|
|
|
|
def _get_service_object(self):
|
|
"""Get Oauth service object"""
|
|
|
|
return {
|
|
"GMail": GoogleOAuth("mail", validate=False),
|
|
}[self.service]
|
|
|
|
|
|
@frappe.whitelist(methods=["POST"])
|
|
def oauth_access(email_account: str, service: str):
|
|
"""Used as a default endpoint/caller for all oauth services.
|
|
Returns authorization url for redirection"""
|
|
|
|
if not service:
|
|
frappe.throw(frappe._("No Service is selected. Please select one and try again!"))
|
|
|
|
doctype = "Email Account"
|
|
|
|
if service == "GMail":
|
|
return authorize_google_access(email_account, doctype)
|
|
|
|
raise NotImplementedError(f"Service {service} currently doesn't have oauth implementation.")
|
|
|
|
|
|
def authorize_google_access(email_account, doctype: str = "Email Account", code: str = None):
|
|
"""Facilitates google oauth for email.
|
|
This is invoked 2 times - first time when user clicks `Authorze API Access` for getting the authorization url
|
|
and second time for setting the refresh and access token in db when google redirects back with oauth code."""
|
|
|
|
oauth_obj = GoogleOAuth("mail")
|
|
|
|
if not code:
|
|
return oauth_obj.get_authentication_url(
|
|
{
|
|
"redirect": f"/app/Form/{quote(doctype)}/{quote(email_account)}",
|
|
"success_query_param": "successful_authorization=1",
|
|
"email_account": email_account,
|
|
},
|
|
)
|
|
|
|
res = oauth_obj.authorize(code)
|
|
frappe.db.set_value(
|
|
doctype,
|
|
email_account,
|
|
{
|
|
"refresh_token": encrypt(res.get("refresh_token")),
|
|
"access_token": encrypt(res.get("access_token")),
|
|
},
|
|
update_modified=False,
|
|
)
|