diff --git a/README.md b/README.md index 56141ec..1a68a39 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Bugs, issues and contributions can be requested to both the mailing list or the * Python v2.7 * Python SQlite (optional while recommended) +* Python json and urllib (used for XOAuth2 authentication) ## Documentation diff --git a/offlineimap.conf b/offlineimap.conf index 1db5b52..60285a8 100644 --- a/offlineimap.conf +++ b/offlineimap.conf @@ -688,9 +688,42 @@ remoteuser = username # limitations, if GSSAPI is set, it will be tried first, no matter where it was # specified in the list. # -#auth_mechanisms = GSSAPI, CRAM-MD5, PLAIN, LOGIN +#auth_mechanisms = GSSAPI, CRAM-MD5, XOAUTH2, PLAIN, LOGIN +# This option stands in the [Repository RemoteExample] section. +# +# XOAuth2 authentication (for instance, to use with Gmail). +# +# This feature is currently EXPERIMENTAL (tested on Gmail only, but should work +# with type = IMAP for compatible servers). +# +# Mandatory parameters are "oauth2_client_id", "oauth2_client_secret" and +# "oauth2_refresh_token". See below to learn how to get those. +# +# Specify the OAuth2 client id and secret to use for the connection.. +# Here's how to register an OAuth2 client for Gmail, as of 10-2-2016: +# - Go to the Google developer console +# https://console.developers.google.com/project +# - Create a new project +# - In API & Auth, select Credentials +# - Setup the OAuth Consent Screen +# - Then add Credentials of type OAuth 2.0 Client ID +# - Choose application type Other; type in a name for your client +# - You now have a client ID and client secret +# +#oauth2_client_id = YOUR_CLIENT_ID +#oauth2_client_secret = YOUR_CLIENT_SECRET + +# Specify the refresh token to use for the connection to the mail server. +# Here's an example of a way to get a refresh token: +# - Clone this project: https://github.com/google/gmail-oauth2-tools +# - Type the following command-line in a terminal and follow the instructions +# python python/oauth2.py --generate_oauth2_token \ +# --client_id=YOUR_CLIENT_ID --client_secret=YOUR_CLIENT_SECRET +# +#oauth2_refresh_token = REFRESH_TOKEN + ########## Passwords # There are six ways to specify the password for the IMAP server: diff --git a/offlineimap/imapserver.py b/offlineimap/imapserver.py index f0b2248..72da4cc 100644 --- a/offlineimap/imapserver.py +++ b/offlineimap/imapserver.py @@ -19,6 +19,10 @@ from threading import Lock, BoundedSemaphore, Thread, Event, currentThread import hmac import socket import base64 + +import json +import urllib + import time import errno from sys import exc_info @@ -89,6 +93,12 @@ class IMAPServer: self.fingerprint = repos.get_ssl_fingerprint() self.sslversion = repos.getsslversion() + self.oauth2_refresh_token = repos.getoauth2_refresh_token() + self.oauth2_client_id = repos.getoauth2_client_id() + self.oauth2_client_secret = repos.getoauth2_client_secret() + self.oauth2_request_url = repos.getoauth2_request_url() + self.oauth2_access_token = None + self.delim = None self.root = None self.maxconnections = repos.getmaxconnections() @@ -199,7 +209,33 @@ class IMAPServer: return retval - # XXX: describe function + def __xoauth2handler(self, response): + if self.oauth2_refresh_token is None: + return None + + if self.oauth2_access_token is None: + # need to move these to config + # generate new access token + params = {} + params['client_id'] = self.oauth2_client_id + params['client_secret'] = self.oauth2_client_secret + params['refresh_token'] = self.oauth2_refresh_token + params['grant_type'] = 'refresh_token' + + self.ui.debug('imap', 'xoauth2handler: url "%s"' % self.oauth2_request_url) + self.ui.debug('imap', 'xoauth2handler: params "%s"' % params) + + response = urllib.urlopen(self.oauth2_request_url, urllib.urlencode(params)).read() + resp = json.loads(response) + self.ui.debug('imap', 'xoauth2handler: response "%s"' % resp) + self.oauth2_access_token = resp['access_token'] + + self.ui.debug('imap', 'xoauth2handler: access_token "%s"' % self.oauth2_access_token) + auth_string = 'user=%s\1auth=Bearer %s\1\1' % (self.username, self.oauth2_access_token) + #auth_string = base64.b64encode(auth_string) + self.ui.debug('imap', 'xoauth2handler: returning "%s"' % auth_string) + return auth_string + def __gssauth(self, response): data = base64.b64encode(response) try: @@ -283,6 +319,10 @@ class IMAPServer: imapobj.authenticate('PLAIN', self.__plainhandler) return True + def __authn_xoauth2(self, imapobj): + imapobj.authenticate('XOAUTH2', self.__xoauth2handler) + return True + def __authn_login(self, imapobj): # Use LOGIN command, unless LOGINDISABLED is advertized # (per RFC 2595) @@ -314,6 +354,7 @@ class IMAPServer: auth_methods = { "GSSAPI": (self.__authn_gssapi, False, True), "CRAM-MD5": (self.__authn_cram_md5, True, True), + "XOAUTH2": (self.__authn_xoauth2, True, True), "PLAIN": (self.__authn_plain, True, True), "LOGIN": (self.__authn_login, True, False), } diff --git a/offlineimap/repository/Gmail.py b/offlineimap/repository/Gmail.py index 2e23e62..1e2069b 100644 --- a/offlineimap/repository/Gmail.py +++ b/offlineimap/repository/Gmail.py @@ -29,6 +29,8 @@ class GmailRepository(IMAPRepository): # Gmail IMAP server port PORT = 993 + OAUTH2_URL = 'https://accounts.google.com/o/oauth2/token' + def __init__(self, reposname, account): """Initialize a GmailRepository object.""" # Enforce SSL usage @@ -49,6 +51,18 @@ class GmailRepository(IMAPRepository): self._host = GmailRepository.HOSTNAME return self._host + def getoauth2_request_url(self): + """Return the server name to connect to. + + Gmail implementation first checks for the usual IMAP settings + and falls back to imap.gmail.com if not specified.""" + try: + return super(GmailRepository, self).getoauth2_request_url() + except OfflineImapError: + # nothing was configured, cache and return hardcoded one + self._oauth2_request_url = GmailRepository.OAUTH2_URL + return self._oauth2_request_url + def getport(self): return GmailRepository.PORT diff --git a/offlineimap/repository/IMAP.py b/offlineimap/repository/IMAP.py index ed95607..414b918 100644 --- a/offlineimap/repository/IMAP.py +++ b/offlineimap/repository/IMAP.py @@ -34,6 +34,7 @@ class IMAPRepository(BaseRepository): BaseRepository.__init__(self, reposname, account) # self.ui is being set by the BaseRepository self._host = None + self._oauth2_request_url = None self.imapserver = imapserver.IMAPServer(self) self.folders = None # Only set the newmail_hook in an IMAP repository. @@ -130,12 +131,12 @@ class IMAPRepository(BaseRepository): return self.getconf('remote_identity', default=None) def get_auth_mechanisms(self): - supported = ["GSSAPI", "CRAM-MD5", "PLAIN", "LOGIN"] + supported = ["GSSAPI", "XOAUTH2", "CRAM-MD5", "PLAIN", "LOGIN"] # Mechanisms are ranged from the strongest to the # weakest ones. # TODO: we need DIGEST-MD5, it must come before CRAM-MD5 # TODO: due to the chosen-plaintext resistance. - default = ["GSSAPI", "CRAM-MD5", "PLAIN", "LOGIN"] + default = ["GSSAPI", "XOAUTH2", "CRAM-MD5", "PLAIN", "LOGIN"] mechs = self.getconflist('auth_mechanisms', r',\s*', default) @@ -257,6 +258,30 @@ class IMAPRepository(BaseRepository): value = self.getconf('cert_fingerprint', "") return [f.strip().lower() for f in value.split(',') if f] + def getoauth2_request_url(self): + if self._oauth2_request_url: # use cached value if possible + return self._oauth2_request_url + + oauth2_request_url = self.getconf('oauth2_request_url', None) + if oauth2_request_url != None: + self._oauth2_request_url = oauth2_request_url + return self._oauth2_request_url + + # no success + raise OfflineImapError("No remote oauth2_request_url for repository "\ + "'%s' specified." % self, + OfflineImapError.ERROR.REPO) + return self.getconf('oauth2_request_url', None) + + def getoauth2_refresh_token(self): + return self.getconf('oauth2_refresh_token', None) + + def getoauth2_client_id(self): + return self.getconf('oauth2_client_id', None) + + def getoauth2_client_secret(self): + return self.getconf('oauth2_client_secret', None) + def getpreauthtunnel(self): return self.getconf('preauthtunnel', None)