feat: generic OAuth for email

This commit is contained in:
phot0n 2022-05-31 01:14:04 +05:30
parent b930cb923b
commit ebc5861210
7 changed files with 176 additions and 114 deletions

View file

@ -135,7 +135,7 @@ frappe.ui.form.on("Email Account", {
show_gmail_message_for_less_secure_apps: function(frm) {
frm.dashboard.clear_headline();
let msg = __("GMail will only work if you enable 2-step authentication and use app-specific password OR use Google OAuth.");
let msg = __("GMail will only work if you enable 2-step authentication and use app-specific password OR use OAuth.");
let cta = __("Read the step by step guide here.");
msg += ` <a target="_blank" href="https://docs.erpnext.com/docs/v13/user/manual/en/setting-up/email/email_account_setup_with_gmail">${cta}</a>`;
if (frm.doc.service==="GMail") {
@ -143,11 +143,12 @@ frappe.ui.form.on("Email Account", {
}
},
authorize_google_api_access: function(frm) {
authorize_api_access: function(frm) {
frappe.call({
method: "frappe.email.doctype.email_account.email_account.authorize_google_access",
method: "frappe.email.doctype.email_account.email_account.oauth_access",
args: {
"email_account": frm.doc.name,
"service": frm.doc.service,
"reauthorize": frm.doc.refresh_token ? 1 : 0
},
callback: function(r) {

View file

@ -18,10 +18,10 @@
"awaiting_password",
"ascii_encode_password",
"column_break_10",
"use_google_oauth",
"authorize_google_api_access",
"google_refresh_token",
"google_access_token",
"use_oauth",
"authorize_api_access",
"refresh_token",
"access_token",
"login_id_is_different",
"login_id",
"mailbox_settings",
@ -585,28 +585,28 @@
{
"default": "0",
"depends_on": "eval: doc.service === \"GMail\"",
"fieldname": "use_google_oauth",
"fieldname": "use_oauth",
"fieldtype": "Check",
"label": "Use Google OAuth"
"label": "Use OAuth"
},
{
"depends_on": "eval: doc.service === \"GMail\" && doc.use_google_oauth && !doc.__islocal",
"fieldname": "authorize_google_api_access",
"depends_on": "eval: doc.service === \"GMail\" && doc.use_oauth && !doc.__islocal",
"fieldname": "authorize_api_access",
"fieldtype": "Button",
"label": "Authorize API Access"
},
{
"fieldname": "google_refresh_token",
"fieldname": "refresh_token",
"fieldtype": "Data",
"hidden": 1,
"label": "Google Refresh Token",
"label": "Refresh Token",
"read_only": 1
},
{
"fieldname": "google_access_token",
"fieldname": "access_token",
"fieldtype": "Small Text",
"hidden": 1,
"label": "Google Access Token",
"label": "Access Token",
"read_only": 1
}
],

View file

@ -89,8 +89,11 @@ class EmailAccount(Document):
if frappe.local.flags.in_patch or frappe.local.flags.in_test:
return
if not frappe.local.flags.in_install and (self.use_google_oauth or not self.awaiting_password):
if self.google_refresh_token or self.password or self.smtp_server in ("127.0.0.1", "localhost"):
if getattr(self, "service", "") != "GMail" and self.use_oauth:
self.use_oauth = 0
if not frappe.local.flags.in_install and (self.use_oauth or not self.awaiting_password):
if self.refresh_token or self.password or self.smtp_server in ("127.0.0.1", "localhost"):
if self.enable_incoming:
self.get_incoming_server()
self.no_failed = 0
@ -99,9 +102,9 @@ class EmailAccount(Document):
self.validate_smtp_conn()
else:
if self.enable_incoming or (self.enable_outgoing and not self.no_smtp_authentication):
if self.use_google_oauth:
if self.use_oauth:
if not self.is_new():
frappe.throw(_("Please Authorize Google by using `Authorize API access` button"))
frappe.throw(_("Please Enable OAuth by using `Authorize API access` button"))
else:
frappe.throw(_("Password is required or select Awaiting Password"))
@ -158,9 +161,9 @@ class EmailAccount(Document):
)
def after_insert(self):
if self.use_google_oauth and not self.google_refresh_token:
if self.use_oauth and not self.refresh_token:
frappe.msgprint(
_("Please Authorize Google by using `Authorize API access` button"),
_("Please Enable OAuth by using `Authorize API access` button"),
indicator="orange"
)
@ -211,13 +214,14 @@ class EmailAccount(Document):
"host": self.email_server,
"use_ssl": self.use_ssl,
"username": getattr(self, "login_id", None) or self.email_id,
"service": getattr(self, "service", None),
"use_imap": self.use_imap,
"email_sync_rule": email_sync_rule,
"incoming_port": get_port(self),
"initial_sync_count": self.initial_sync_count or 100,
"use_google_oauth": self.use_google_oauth or 0,
"google_refresh_token": getattr(self, "google_refresh_token", None),
"google_access_token": getattr(self, "google_access_token", None),
"use_oauth": self.use_oauth or 0,
"refresh_token": getattr(self, "refresh_token", None),
"access_token": getattr(self, "access_token", None),
}
)
@ -285,7 +289,7 @@ class EmailAccount(Document):
@property
def _password(self):
raise_exception = not (
self.use_google_oauth or self.no_smtp_authentication or frappe.flags.in_test
self.use_oauth or self.no_smtp_authentication or frappe.flags.in_test
)
return self.get_password(raise_exception=raise_exception)
@ -424,9 +428,10 @@ class EmailAccount(Document):
"password": self._password,
"use_ssl": cint(self.use_ssl_for_outgoing),
"use_tls": cint(self.use_tls),
"use_google_oauth": self.use_google_oauth or 0,
"google_refresh_token": getattr(self, "google_refresh_token", None),
"google_access_token": getattr(self, "google_access_token", None),
"service": getattr(self, "service", None),
"use_oauth": self.use_oauth or 0,
"refresh_token": getattr(self, "refresh_token", None),
"access_token": getattr(self, "access_token", None),
}
def get_smtp_server(self):
@ -799,7 +804,7 @@ def pull(now=False):
for email_account in frappe.get_list(
"Email Account",
filters={"enable_incoming": 1},
or_filters={"awaiting_password": 0, "use_google_oauth": 1},
or_filters={"awaiting_password": 0, "use_oauth": 1},
):
if now:
pull_from_email_account(email_account.name)
@ -930,9 +935,15 @@ def set_email_password(email_account, user, password):
@frappe.whitelist(methods=["POST"])
def authorize_google_access(email_account, reauthorize=False, code=None):
def oauth_access(email_account: str, reauthorize: bool = False, service: str = None):
doctype = "Email Account"
refresh_token = frappe.db.get_value(doctype, email_account, "google_refresh_token")
refresh_token = frappe.db.get_value(doctype, email_account, "refresh_token")
if service == "GMail":
return authorize_google_access(email_account, reauthorize, refresh_token, doctype)
def authorize_google_access(email_account, reauthorize: bool = False, refresh_token: str = None, doctype: str = "Email Account", code: str = None):
oauth_obj = GoogleOAuth("mail")
if not (refresh_token or code) or reauthorize:
@ -946,5 +957,5 @@ def authorize_google_access(email_account, reauthorize=False, code=None):
)
res = oauth_obj.authorize(code, get_request_site_address(True))
frappe.db.set_value(doctype, email_account, "google_refresh_token", res.get("refresh_token"))
frappe.db.set_value(doctype, email_account, "google_access_token", res.get("access_token"))
frappe.db.set_value(doctype, email_account, "refresh_token", res.get("refresh_token"))
frappe.db.set_value(doctype, email_account, "access_token", res.get("access_token"))

100
frappe/email/oauth.py Normal file
View file

@ -0,0 +1,100 @@
import base64
from typing import Callable, Union
from imaplib import IMAP4
from poplib import POP3
from smtplib import SMTP
import frappe
from frappe.integrations.google_oauth import GoogleOAuth
class OAuthenticationError(Exception):
pass
class Oauth:
def __init__(self,
conn: Union[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_implementation()
def validate_implementation(self) -> None:
if self.service != "GMail":
raise NotImplementedError(f"Service {self.service} currently doesn't have oauth implementation.")
@property
def _auth_string(self) -> str:
return "user=%s\1auth=Bearer %s\1\1" % (self.email, self._access_token)
def connect(self, _retry: int = 0) -> None:
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:
# maybe the access token expired - refreshing
access_token = self._refresh_access_token()
print(self._auth_string)
if not access_token or _retry > 0:
frappe.throw(
frappe._("Authentication Failed. Please Check and Update the credentials."),
OAuthenticationError,
frappe._("OAuth Error"),
)
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 {0} {1}".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:
service_obj = self._get_service_object()
access_token = service_obj.refresh_access_token(self._refresh_token).get("access_token", None)
# set the new access token in db
frappe.db.set_value("Email Account", self.email_account, "access_token", access_token)
frappe.db.commit()
return access_token
def _get_service_object(self):
return {
"GMail": GoogleOAuth("mail"),
}[self.service]

View file

@ -18,7 +18,7 @@ from email_reply_parser import EmailReplyParser
import frappe
from frappe import _, safe_decode, safe_encode
from frappe.core.doctype.file import MaxFileSizeReachedError, get_random_filename
from frappe.email.utils import connect_google_oauth
from frappe.email.oauth import Oauth
from frappe.utils import (
add_days,
cint,
@ -100,14 +100,16 @@ class EmailServer:
self.settings.host, self.settings.incoming_port, timeout=frappe.conf.get("pop_timeout")
)
if self.settings.use_google_oauth and self.settings.google_refresh_token:
connect_google_oauth(
if self.settings.use_oauth and self.settings.refresh_token:
Oauth(
self.imap,
self.settings.email_account,
self.settings.username,
self.settings.google_access_token,
self.settings.google_refresh_token,
)
self.settings.access_token,
self.settings.refresh_token,
self.settings.service
).connect()
else:
self.imap.login(self.settings.username, self.settings.password)
@ -131,14 +133,16 @@ class EmailServer:
self.settings.host, self.settings.incoming_port, timeout=frappe.conf.get("pop_timeout")
)
if self.settings.use_google_oauth and self.settings.google_refresh_token:
connect_google_oauth(
if self.settings.use_oauth and self.settings.refresh_token:
Oauth(
self.pop,
self.settings.email_account,
self.settings.username,
self.settings.google_access_token,
self.settings.google_refresh_token,
)
self.settings.access_token,
self.settings.refresh_token,
self.settings.service
).connect()
else:
self.pop.user(self.settings.username)
self.pop.pass_(self.settings.password)

View file

@ -5,7 +5,7 @@ import smtplib
import frappe
from frappe import _
from frappe.email.utils import connect_google_oauth
from frappe.email.oauth import Oauth
from frappe.utils import cint, cstr
@ -53,9 +53,10 @@ class SMTPServer:
port=None,
use_tls=None,
use_ssl=None,
use_google_oauth=0,
google_refresh_token=None,
google_access_token=None,
use_oauth=0,
refresh_token=None,
access_token=None,
service=None
):
self.login = login
self.email_account = email_account
@ -64,9 +65,10 @@ class SMTPServer:
self._port = port
self.use_tls = use_tls
self.use_ssl = use_ssl
self.use_google_oauth = use_google_oauth
self.google_refresh_token = google_refresh_token
self.google_access_token = google_access_token
self.use_oauth = use_oauth
self.refresh_token = refresh_token
self.access_token = access_token
self.service = service
self._session = None
if not self.server:
@ -109,14 +111,15 @@ class SMTPServer:
self.secure_session(_session)
if self.use_google_oauth and self.google_refresh_token:
connect_google_oauth(
if self.use_oauth and self.refresh_token:
Oauth(
_session,
self.email_account,
self.login,
self.google_access_token,
self.google_refresh_token,
)
self.access_token,
self.refresh_token,
self.service
).connect()
elif self.password:
res = _session.login(str(self.login or ""), str(self.password or ""))

View file

@ -1,13 +1,9 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
# License: MIT. See LICENSE
import base64
import imaplib
import poplib
import smtplib
from typing import Union
from frappe import _, db, log_error, throw
from frappe.integrations.google_oauth import GoogleAuthenticationError, GoogleOAuth
from frappe.utils import cint
@ -20,56 +16,3 @@ def get_port(doc):
doc.incoming_port = poplib.POP3_SSL_PORT if doc.use_ssl else poplib.POP3_PORT
return cint(doc.incoming_port)
def connect_google_oauth(
connection_obj: Union[imaplib.IMAP4, poplib.POP3, smtplib.SMTP],
email_account: str,
email: str,
google_access_token: str,
google_refresh_token: str,
retry: int = 0,
) -> None:
auth_string = "user=%s\1auth=Bearer %s\1\1" % (email, google_access_token)
mechanism = "XOAUTH2"
_func = lambda x: auth_string # noqa: E731
try:
if isinstance(connection_obj, poplib.POP3):
# poplib doesn't have AUTH command implementation
res = connection_obj._shortcmd(
"AUTH {0} {1}".format(mechanism, base64.b64encode(bytes(auth_string, "utf-8")).decode("utf-8"))
)
if not res.startswith(b"+OK"):
raise
elif isinstance(connection_obj, imaplib.IMAP4):
connection_obj.authenticate(mechanism, _func)
else:
# SMTP
connection_obj.auth(mechanism, _func, initial_response_ok=False)
except Exception:
# maybe the access token expired - refreshing
access_token = refresh_google_access_token(email_account, google_refresh_token)
if not access_token or retry > 0:
throw(
_("Google Authentication Failed. Please Check and Update the credentials."),
GoogleAuthenticationError,
_("Google Authentication Error"),
)
connect_google_oauth(
connection_obj, email_account, email, access_token, google_refresh_token, retry + 1
)
def refresh_google_access_token(email_account: str, google_refresh_token: str) -> str:
oauth_obj = GoogleOAuth("mail")
res = oauth_obj.refresh_access_token(google_refresh_token)
db.set_value("Email Account", email_account, "google_access_token", res.get("access_token"))
return res.get("access_token")