From cc4e72305d1f441ec45a72453c11110c605efad3 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 28 May 2014 20:10:45 -0600 Subject: [PATCH 1/6] Adding gmail oauth2 authentication python-social-auth is an optional dependency --- django_mailbox/google_utils.py | 126 ++++++++++++++++++++++++++ django_mailbox/transports/__init__.py | 1 + django_mailbox/transports/gmail.py | 49 ++++++++++ setup.py | 9 +- 4 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 django_mailbox/google_utils.py create mode 100644 django_mailbox/transports/gmail.py diff --git a/django_mailbox/google_utils.py b/django_mailbox/google_utils.py new file mode 100644 index 0000000..9900b70 --- /dev/null +++ b/django_mailbox/google_utils.py @@ -0,0 +1,126 @@ +from social.apps.django_app.default.models import UserSocialAuth +import requests +from django.conf import settings + + +class AccessTokenNotFound(Exception): + pass + + +class RefreshTokenNotFound(Exception): + pass + + +def get_google_consumer_key(): + return settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY + + +def get_google_consumer_secret(): + return settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET + + +def get_google_access_token(email): + # TODO: This should be cacheable + try: + me = UserSocialAuth.objects.get(uid=email, provider="google-oauth2") + return me.extra_data['access_token'] + except (UserSocialAuth.DoesNotExist, KeyError): + raise AccessTokenNotFound + + +def update_google_extra_data(email, extra_data): + try: + me = UserSocialAuth.objects.get(uid=email, provider="google-oauth2") + me.extra_data = extra_data + me.save() + except (UserSocialAuth.DoesNotExist, KeyError): + raise AccessTokenNotFound + + +def get_google_refresh_token(email): + try: + me = UserSocialAuth.objects.get(uid=email, provider="google-oauth2") + return me.extra_data['refresh_token'] + except (UserSocialAuth.DoesNotExist, KeyError): + raise RefreshTokenNotFound + + +def google_api_get(email, url): + headers = dict( + Authorization="Bearer %s" % get_google_access_token(email), + ) + r = requests.get(url, headers=headers) + print "I got a %s" % r.status_code + if r.status_code == 401: + # Go use the refresh token + refresh_authorization(email) + r = requests.get(url, headers=headers) + print "I got a %s" % r.status_code + if r.status_code == 200: + try: + return r.json() + except ValueError: + print "returning text" + return r.text + + +def google_api_post(email, url, post_data, authorized=True): + # TODO: Make this a lot less ugly. especially the 401 handling + headers = dict() + if authorized is True: + headers.update(dict( + Authorization="Bearer %s" % get_google_access_token(email), + )) + r = requests.post(url, headers=headers, data=post_data) + if r.status_code == 401: + refresh_authorization(email) + r = requests.post(url, headers=headers, data=post_data) + if r.status_code == 200: + try: + return r.json() + except ValueError: + print "returning text" + return r.text + + +def refresh_authorization(email): + refresh_token = get_google_refresh_token(email) + post_data = dict( + refresh_token=refresh_token, + client_id=get_google_consumer_key(), + client_secret=get_google_consumer_secret(), + grant_type='refresh_token', + ) + results = google_api_post( + email, + "https://accounts.google.com/o/oauth2/token?access_type=offline", + post_data, + authorized=False) + results.update({'refresh_token': refresh_token}) + update_google_extra_data(email, results) + + +def fetch_user_info(email): + result = google_api_get( + email, + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json" + ) + return result + + +def fetch_google_contacts(email, limit=10000): + result = google_api_get( + email, + "https://www.google.com/m8/feeds/contacts/default/full?v=3.0&alt=json&max-results=%s" % limit + ) + entries = result['feed']['entry'] + valid_entries = [x for x in entries if u'gd$email' in x.keys() and u'gd$name' in x.keys()] + contacts = [] + for each in valid_entries: + try: + name = each[u'gd$name'][u'gd$fullName'][u'$t'] + except KeyError: + name = None + for each_email in each[u'gd$email']: + contacts.append(dict(name=name, email=each_email[u'address'])) + return contacts diff --git a/django_mailbox/transports/__init__.py b/django_mailbox/transports/__init__.py index fc17082..03b31f0 100644 --- a/django_mailbox/transports/__init__.py +++ b/django_mailbox/transports/__init__.py @@ -5,3 +5,4 @@ from django_mailbox.transports.mbox import MboxTransport from django_mailbox.transports.babyl import BabylTransport from django_mailbox.transports.mh import MHTransport from django_mailbox.transports.mmdf import MMDFTransport +from django_mailbox.transports.gmail import GmailImapTransport diff --git a/django_mailbox/transports/gmail.py b/django_mailbox/transports/gmail.py new file mode 100644 index 0000000..223ee51 --- /dev/null +++ b/django_mailbox/transports/gmail.py @@ -0,0 +1,49 @@ +from django_mailbox.transports.imap import ImapTransport + +class GmailImapTransport(ImapTransport): + + def connect(self, username, password): + # Try to use oauth2 first. It's much safer + try: + self._connect_oauth(username) + except (TypeError, ValueError), e: + print " Couldn't do oauth2", e + self.server = self.transport(self.hostname, self.port) + typ, msg = self.server.login(username, password) + self.server.select() + + def _connect_oauth(self, username): + # username should be an email address that has already been authorized + # for gmail access + try: + from django_mailbox.google_utils import ( + get_google_access_token, + fetch_user_info, + AccessTokenNotFound, + ) + except ImportError: + raise ValueError( + "Install python-social-auth to use oauth2 auth for gmail" + ) + + access_token = None + while access_token is None: + try: + access_token = get_google_access_token(username) + google_email_address = fetch_user_info(username)['email'] + except TypeError: + # This means that the google process took too long + # Trying again is the right thing to do + pass + except AccessTokenNotFound: + raise ValueError( + "No Token available in python-social-auth for %s" % username + ) + + auth_string = 'user=%s\1auth=Bearer %s\1\1' % ( + google_email_address, + access_token + ) + self.server = self.transport(self.hostname, self.port) + self.server.authenticate('XOAUTH2', lambda x: auth_string) + self.server.select() diff --git a/setup.py b/setup.py index c080a93..1f3800b 100755 --- a/setup.py +++ b/setup.py @@ -5,6 +5,10 @@ tests_require = [ 'mock', ] +gmail_oauth2_require = [ + 'python-social-auth', +] + setup( name='django-mailbox', version='3.3', @@ -16,7 +20,10 @@ setup( author='Adam Coddington', author_email='me@adamcoddington.net', tests_require=tests_require, - extras_require={'test': tests_require}, + extras_require={ + 'test': tests_require, + 'gmail-oauth2': gmail_oauth2_require + }, test_suite='django_mailbox.runtests.runtests', classifiers=[ 'Framework :: Django', From 677e420dec863dfa5e3a49d6129fb64254e63132 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 28 May 2014 21:24:46 -0600 Subject: [PATCH 2/6] Adding gmail to the mailbox types and adding the type to models.py --- django_mailbox/models.py | 10 +++++++++- docs/topics/mailbox_types.rst | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/django_mailbox/models.py b/django_mailbox/models.py index f4b5dc6..4e3fef4 100644 --- a/django_mailbox/models.py +++ b/django_mailbox/models.py @@ -15,7 +15,7 @@ from django.core.files.base import ContentFile from django.db import models from django_mailbox.transports import Pop3Transport, ImapTransport,\ MaildirTransport, MboxTransport, BabylTransport, MHTransport, \ - MMDFTransport + MMDFTransport, GmailImapTransport from django_mailbox.signals import message_received import six from six.moves.urllib.parse import parse_qs, unquote, urlparse @@ -168,6 +168,14 @@ class Mailbox(models.Model): archive=self.archive ) conn.connect(self.username, self.password) + elif self.type == 'gmail': + conn = GmailImapTransport( + self.location, + port=self.port if self.port else None, + ssl=True, + archive=self.archive + ) + conn.connect(self.username, self.password) elif self.type == 'pop3': conn = Pop3Transport( self.location, diff --git a/docs/topics/mailbox_types.rst b/docs/topics/mailbox_types.rst index 014960d..eb01ad0 100644 --- a/docs/topics/mailbox_types.rst +++ b/docs/topics/mailbox_types.rst @@ -61,6 +61,20 @@ would enter the following as your URI:: imap+ssl://youremailaddress%40gmail.com:1234@imap.gmail.com?archive=Archived +Gmail IMAP with Oauth2 authentication +------------------------------------- + +Gmail supports using oauth2 for authentication_ which is more secure. +To handle the handshake and storing the credentials, use python-social-auth_. + +.. _authentication: https://developers.google.com/gmail/xoauth2_protocol +.. _python-social-auth: http://psa.matiasaguirre.net/ + +The Gmail Mailbox is also a regular IMAP mailbox, but the password will be ignored if oauth2 succeeds. It can fall back to password as needed. +Build your URI accordingly:: + + gmail+ssl://youremailaddress%40gmail.com:oauth2@imap.gmail.com?archive=Archived + Local File-based Mailboxes -------------------------- From d6f3ff2c5c4375343139570c5d9b0ed77b163158 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 28 May 2014 21:30:04 -0600 Subject: [PATCH 3/6] Adding a missed edit --- docs/topics/mailbox_types.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/topics/mailbox_types.rst b/docs/topics/mailbox_types.rst index eb01ad0..de0616f 100644 --- a/docs/topics/mailbox_types.rst +++ b/docs/topics/mailbox_types.rst @@ -12,6 +12,7 @@ POP3 and IMAP as well as local file-based mailboxes. ============ ============== ================================================================================================================================================================= POP3 ``pop3://`` Can also specify SSL with ``pop3+ssl://`` IMAP ``imap://`` Can also specify SSL with ``imap+ssl://``, or specify a folder to save processed messages into by appending ``?archive=my_archive_folder`` to the end of the URI. + Gmail IMAP ``gmail+ssl://`` Password is ignored if oauth2 succeeds, but can be used as a fallback Maildir ``maildir://`` Mbox ``mbox://`` Babyl ``babyl://`` From 7e4f8cd6cd4f31e0c3d44b7cd03c468e3e0d754f Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 28 May 2014 21:32:25 -0600 Subject: [PATCH 4/6] reformatting --- docs/topics/mailbox_types.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/topics/mailbox_types.rst b/docs/topics/mailbox_types.rst index de0616f..7ec15f3 100644 --- a/docs/topics/mailbox_types.rst +++ b/docs/topics/mailbox_types.rst @@ -7,19 +7,19 @@ POP3 and IMAP as well as local file-based mailboxes. .. table:: 'Protocol' Options - ============ ============== ================================================================================================================================================================= - Mailbox Type 'Protocol':// Notes - ============ ============== ================================================================================================================================================================= - POP3 ``pop3://`` Can also specify SSL with ``pop3+ssl://`` - IMAP ``imap://`` Can also specify SSL with ``imap+ssl://``, or specify a folder to save processed messages into by appending ``?archive=my_archive_folder`` to the end of the URI. + ============ ================ ================================================================================================================================================================= + Mailbox Type 'Protocol':// Notes + ============ ================ ================================================================================================================================================================= + POP3 ``pop3://`` Can also specify SSL with ``pop3+ssl://`` + IMAP ``imap://`` Can also specify SSL with ``imap+ssl://``, or specify a folder to save processed messages into by appending ``?archive=my_archive_folder`` to the end of the URI. Gmail IMAP ``gmail+ssl://`` Password is ignored if oauth2 succeeds, but can be used as a fallback Maildir ``maildir://`` Mbox ``mbox://`` Babyl ``babyl://`` MH ``mh://`` MMDF ``mmdf://`` - Piped Mail *empty* See :ref:`receiving-mail-from-exim4-or-postfix` - ============ ============== ================================================================================================================================================================= + Piped Mail *empty* See :ref:`receiving-mail-from-exim4-or-postfix` + ============ ================ ================================================================================================================================================================= .. warning:: From 857643e97828e9187aa399298064ba5236a698b1 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 28 May 2014 21:33:38 -0600 Subject: [PATCH 5/6] Update mailbox_types.rst --- docs/topics/mailbox_types.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/mailbox_types.rst b/docs/topics/mailbox_types.rst index 7ec15f3..bb8122c 100644 --- a/docs/topics/mailbox_types.rst +++ b/docs/topics/mailbox_types.rst @@ -12,7 +12,7 @@ POP3 and IMAP as well as local file-based mailboxes. ============ ================ ================================================================================================================================================================= POP3 ``pop3://`` Can also specify SSL with ``pop3+ssl://`` IMAP ``imap://`` Can also specify SSL with ``imap+ssl://``, or specify a folder to save processed messages into by appending ``?archive=my_archive_folder`` to the end of the URI. - Gmail IMAP ``gmail+ssl://`` Password is ignored if oauth2 succeeds, but can be used as a fallback + Gmail IMAP ``gmail+ssl://`` Password is only used as a fallback if oauth2 fails Maildir ``maildir://`` Mbox ``mbox://`` Babyl ``babyl://`` From ea793545d9245ca1ed73e5e51bb05931104cee97 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Thu, 29 May 2014 07:31:14 -0600 Subject: [PATCH 6/6] Update gmail.py --- django_mailbox/transports/gmail.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/django_mailbox/transports/gmail.py b/django_mailbox/transports/gmail.py index 223ee51..e477293 100644 --- a/django_mailbox/transports/gmail.py +++ b/django_mailbox/transports/gmail.py @@ -6,8 +6,8 @@ class GmailImapTransport(ImapTransport): # Try to use oauth2 first. It's much safer try: self._connect_oauth(username) - except (TypeError, ValueError), e: - print " Couldn't do oauth2", e + except (TypeError, ValueError) as e: + print " Couldn't do oauth2 because %s" % e self.server = self.transport(self.hostname, self.port) typ, msg = self.server.login(username, password) self.server.select()