4 changed files with 452 additions and 9 deletions
-
348contrib/oauth2.py
-
43docs/meli.conf.5
-
26melib/src/backends/imap.rs
-
44melib/src/backends/imap/connection.rs
@ -0,0 +1,348 @@ |
|||
#!/usr/bin/env python3 |
|||
# |
|||
# Copyright 2012 Google Inc. |
|||
# Copyright 2020 Manos Pitsidianakis |
|||
# |
|||
# Licensed under the Apache License, Version 2.0 (the "License"); |
|||
# you may not use this file except in compliance with the License. |
|||
# You may obtain a copy of the License at |
|||
# |
|||
# http://www.apache.org/licenses/LICENSE-2.0 |
|||
# |
|||
# Unless required by applicable law or agreed to in writing, software |
|||
# distributed under the License is distributed on an "AS IS" BASIS, |
|||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
# See the License for the specific language governing permissions and |
|||
# limitations under the License. |
|||
|
|||
"""Performs client tasks for testing IMAP OAuth2 authentication. |
|||
|
|||
To use this script, you'll need to have registered with Google as an OAuth |
|||
application and obtained an OAuth client ID and client secret. |
|||
See https://developers.google.com/identity/protocols/OAuth2 for instructions on |
|||
registering and for documentation of the APIs invoked by this code. |
|||
|
|||
This script has 3 modes of operation. |
|||
|
|||
1. The first mode is used to generate and authorize an OAuth2 token, the |
|||
first step in logging in via OAuth2. |
|||
|
|||
oauth2 --user=xxx@gmail.com \ |
|||
--client_id=1038[...].apps.googleusercontent.com \ |
|||
--client_secret=VWFn8LIKAMC-MsjBMhJeOplZ \ |
|||
--generate_oauth2_token |
|||
|
|||
The script will converse with Google and generate an oauth request |
|||
token, then present you with a URL you should visit in your browser to |
|||
authorize the token. Once you get the verification code from the Google |
|||
website, enter it into the script to get your OAuth access token. The output |
|||
from this command will contain the access token, a refresh token, and some |
|||
metadata about the tokens. The access token can be used until it expires, and |
|||
the refresh token lasts indefinitely, so you should record these values for |
|||
reuse. |
|||
|
|||
2. The script will generate new access tokens using a refresh token. |
|||
|
|||
oauth2 --user=xxx@gmail.com \ |
|||
--client_id=1038[...].apps.googleusercontent.com \ |
|||
--client_secret=VWFn8LIKAMC-MsjBMhJeOplZ \ |
|||
--refresh_token=1/Yzm6MRy4q1xi7Dx2DuWXNgT6s37OrP_DW_IoyTum4YA |
|||
|
|||
3. The script will generate an OAuth2 string that can be fed |
|||
directly to IMAP or SMTP. This is triggered with the --generate_oauth2_string |
|||
option. |
|||
|
|||
oauth2 --generate_oauth2_string --user=xxx@gmail.com \ |
|||
--access_token=ya29.AGy[...]ezLg |
|||
|
|||
The output of this mode will be a base64-encoded string. To use it, connect to a |
|||
IMAPFE and pass it as the second argument to the AUTHENTICATE command. |
|||
|
|||
a AUTHENTICATE XOAUTH2 a9sha9sfs[...]9dfja929dk== |
|||
""" |
|||
|
|||
import base64 |
|||
import imaplib |
|||
import json |
|||
from optparse import OptionParser |
|||
import smtplib |
|||
import sys |
|||
import urllib.request, urllib.parse, urllib.error |
|||
|
|||
|
|||
def SetupOptionParser(): |
|||
# Usage message is the module's docstring. |
|||
parser = OptionParser(usage=__doc__) |
|||
parser.add_option('--generate_oauth2_token', |
|||
action='store_true', |
|||
dest='generate_oauth2_token', |
|||
help='generates an OAuth2 token for testing') |
|||
parser.add_option('--generate_oauth2_string', |
|||
action='store_true', |
|||
dest='generate_oauth2_string', |
|||
help='generates an initial client response string for ' |
|||
'OAuth2') |
|||
parser.add_option('--client_id', |
|||
default=None, |
|||
help='Client ID of the application that is authenticating. ' |
|||
'See OAuth2 documentation for details.') |
|||
parser.add_option('--client_secret', |
|||
default=None, |
|||
help='Client secret of the application that is ' |
|||
'authenticating. See OAuth2 documentation for ' |
|||
'details.') |
|||
parser.add_option('--access_token', |
|||
default=None, |
|||
help='OAuth2 access token') |
|||
parser.add_option('--refresh_token', |
|||
default=None, |
|||
help='OAuth2 refresh token') |
|||
parser.add_option('--scope', |
|||
default='https://mail.google.com/', |
|||
help='scope for the access token. Multiple scopes can be ' |
|||
'listed separated by spaces with the whole argument ' |
|||
'quoted.') |
|||
parser.add_option('--test_imap_authentication', |
|||
action='store_true', |
|||
dest='test_imap_authentication', |
|||
help='attempts to authenticate to IMAP') |
|||
parser.add_option('--test_smtp_authentication', |
|||
action='store_true', |
|||
dest='test_smtp_authentication', |
|||
help='attempts to authenticate to SMTP') |
|||
parser.add_option('--user', |
|||
default=None, |
|||
help='email address of user whose account is being ' |
|||
'accessed') |
|||
parser.add_option('--quiet', |
|||
action='store_true', |
|||
default=False, |
|||
dest='quiet', |
|||
help='Omit verbose descriptions and only print ' |
|||
'machine-readable outputs.') |
|||
return parser |
|||
|
|||
|
|||
# The URL root for accessing Google Accounts. |
|||
GOOGLE_ACCOUNTS_BASE_URL = 'https://accounts.google.com' |
|||
|
|||
|
|||
# Hardcoded dummy redirect URI for non-web apps. |
|||
REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob' |
|||
|
|||
|
|||
def AccountsUrl(command): |
|||
"""Generates the Google Accounts URL. |
|||
|
|||
Args: |
|||
command: The command to execute. |
|||
|
|||
Returns: |
|||
A URL for the given command. |
|||
""" |
|||
return '%s/%s' % (GOOGLE_ACCOUNTS_BASE_URL, command) |
|||
|
|||
|
|||
def UrlEscape(text): |
|||
# See OAUTH 5.1 for a definition of which characters need to be escaped. |
|||
return urllib.parse.quote(text, safe='~-._') |
|||
|
|||
|
|||
def UrlUnescape(text): |
|||
# See OAUTH 5.1 for a definition of which characters need to be escaped. |
|||
return urllib.parse.unquote(text) |
|||
|
|||
|
|||
def FormatUrlParams(params): |
|||
"""Formats parameters into a URL query string. |
|||
|
|||
Args: |
|||
params: A key-value map. |
|||
|
|||
Returns: |
|||
A URL query string version of the given parameters. |
|||
""" |
|||
param_fragments = [] |
|||
for param in sorted(iter(params.items()), key=lambda x: x[0]): |
|||
param_fragments.append('%s=%s' % (param[0], UrlEscape(param[1]))) |
|||
return '&'.join(param_fragments) |
|||
|
|||
|
|||
def GeneratePermissionUrl(client_id, scope='https://mail.google.com/'): |
|||
"""Generates the URL for authorizing access. |
|||
|
|||
This uses the "OAuth2 for Installed Applications" flow described at |
|||
https://developers.google.com/accounts/docs/OAuth2InstalledApp |
|||
|
|||
Args: |
|||
client_id: Client ID obtained by registering your app. |
|||
scope: scope for access token, e.g. 'https://mail.google.com' |
|||
Returns: |
|||
A URL that the user should visit in their browser. |
|||
""" |
|||
params = {} |
|||
params['client_id'] = client_id |
|||
params['redirect_uri'] = REDIRECT_URI |
|||
params['scope'] = scope |
|||
params['response_type'] = 'code' |
|||
return '%s?%s' % (AccountsUrl('o/oauth2/auth'), |
|||
FormatUrlParams(params)) |
|||
|
|||
|
|||
def AuthorizeTokens(client_id, client_secret, authorization_code): |
|||
"""Obtains OAuth access token and refresh token. |
|||
|
|||
This uses the application portion of the "OAuth2 for Installed Applications" |
|||
flow at https://developers.google.com/accounts/docs/OAuth2InstalledApp#handlingtheresponse |
|||
|
|||
Args: |
|||
client_id: Client ID obtained by registering your app. |
|||
client_secret: Client secret obtained by registering your app. |
|||
authorization_code: code generated by Google Accounts after user grants |
|||
permission. |
|||
Returns: |
|||
The decoded response from the Google Accounts server, as a dict. Expected |
|||
fields include 'access_token', 'expires_in', and 'refresh_token'. |
|||
""" |
|||
params = {} |
|||
params['client_id'] = client_id |
|||
params['client_secret'] = client_secret |
|||
params['code'] = authorization_code |
|||
params['redirect_uri'] = REDIRECT_URI |
|||
params['grant_type'] = 'authorization_code' |
|||
request_url = AccountsUrl('o/oauth2/token') |
|||
|
|||
response = urllib.request.urlopen(request_url, urllib.parse.urlencode(params).encode()).read() |
|||
return json.loads(response) |
|||
|
|||
|
|||
def RefreshToken(client_id, client_secret, refresh_token): |
|||
"""Obtains a new token given a refresh token. |
|||
|
|||
See https://developers.google.com/accounts/docs/OAuth2InstalledApp#refresh |
|||
|
|||
Args: |
|||
client_id: Client ID obtained by registering your app. |
|||
client_secret: Client secret obtained by registering your app. |
|||
refresh_token: A previously-obtained refresh token. |
|||
Returns: |
|||
The decoded response from the Google Accounts server, as a dict. Expected |
|||
fields include 'access_token', 'expires_in', and 'refresh_token'. |
|||
""" |
|||
params = {} |
|||
params['client_id'] = client_id |
|||
params['client_secret'] = client_secret |
|||
params['refresh_token'] = refresh_token |
|||
params['grant_type'] = 'refresh_token' |
|||
request_url = AccountsUrl('o/oauth2/token') |
|||
|
|||
response = urllib.request.urlopen(request_url, urllib.parse.urlencode(params).encode()).read() |
|||
return json.loads(response) |
|||
|
|||
|
|||
def GenerateOAuth2String(username, access_token, base64_encode=True): |
|||
"""Generates an IMAP OAuth2 authentication string. |
|||
|
|||
See https://developers.google.com/google-apps/gmail/oauth2_overview |
|||
|
|||
Args: |
|||
username: the username (email address) of the account to authenticate |
|||
access_token: An OAuth2 access token. |
|||
base64_encode: Whether to base64-encode the output. |
|||
|
|||
Returns: |
|||
The SASL argument for the OAuth2 mechanism. |
|||
""" |
|||
auth_string = 'user=%s\1auth=Bearer %s\1\1' % (username, access_token) |
|||
if base64_encode: |
|||
auth_string = base64.b64encode(bytes(auth_string, 'utf-8')) |
|||
return auth_string |
|||
|
|||
|
|||
def TestImapAuthentication(user, auth_string): |
|||
"""Authenticates to IMAP with the given auth_string. |
|||
|
|||
Prints a debug trace of the attempted IMAP connection. |
|||
|
|||
Args: |
|||
user: The Gmail username (full email address) |
|||
auth_string: A valid OAuth2 string, as returned by GenerateOAuth2String. |
|||
Must not be base64-encoded, since imaplib does its own base64-encoding. |
|||
""" |
|||
print() |
|||
imap_conn = imaplib.IMAP4_SSL('imap.gmail.com') |
|||
imap_conn.debug = 4 |
|||
imap_conn.authenticate('XOAUTH2', lambda x: auth_string) |
|||
imap_conn.select('INBOX') |
|||
|
|||
|
|||
def TestSmtpAuthentication(user, auth_string): |
|||
"""Authenticates to SMTP with the given auth_string. |
|||
|
|||
Args: |
|||
user: The Gmail username (full email address) |
|||
auth_string: A valid OAuth2 string, not base64-encoded, as returned by |
|||
GenerateOAuth2String. |
|||
""" |
|||
print() |
|||
smtp_conn = smtplib.SMTP('smtp.gmail.com', 587) |
|||
smtp_conn.set_debuglevel(True) |
|||
smtp_conn.ehlo('test') |
|||
smtp_conn.starttls() |
|||
smtp_conn.docmd('AUTH', 'XOAUTH2 ' + base64.b64encode(auth_string)) |
|||
|
|||
|
|||
def RequireOptions(options, *args): |
|||
missing = [arg for arg in args if getattr(options, arg) is None] |
|||
if missing: |
|||
print('Missing options: %s' % ' '.join(missing), file=sys.stderr) |
|||
sys.exit(-1) |
|||
|
|||
|
|||
def main(argv): |
|||
options_parser = SetupOptionParser() |
|||
(options, args) = options_parser.parse_args() |
|||
if options.refresh_token: |
|||
RequireOptions(options, 'client_id', 'client_secret') |
|||
response = RefreshToken(options.client_id, options.client_secret, |
|||
options.refresh_token) |
|||
if options.quiet: |
|||
print(response['access_token']) |
|||
else: |
|||
print('Access Token: %s' % response['access_token']) |
|||
print('Access Token Expiration Seconds: %s' % response['expires_in']) |
|||
elif options.generate_oauth2_string: |
|||
RequireOptions(options, 'user', 'access_token') |
|||
oauth2_string = GenerateOAuth2String(options.user, options.access_token) |
|||
if options.quiet: |
|||
print(oauth2_string.decode('utf-8')) |
|||
else: |
|||
print('OAuth2 argument:\n' + oauth2_string.decode('utf-8')) |
|||
elif options.generate_oauth2_token: |
|||
RequireOptions(options, 'client_id', 'client_secret') |
|||
print('To authorize token, visit this url and follow the directions:') |
|||
print(' %s' % GeneratePermissionUrl(options.client_id, options.scope)) |
|||
authorization_code = input('Enter verification code: ') |
|||
response = AuthorizeTokens(options.client_id, options.client_secret, |
|||
authorization_code) |
|||
print('Refresh Token: %s' % response['refresh_token']) |
|||
print('Access Token: %s' % response['access_token']) |
|||
print('Access Token Expiration Seconds: %s' % response['expires_in']) |
|||
elif options.test_imap_authentication: |
|||
RequireOptions(options, 'user', 'access_token') |
|||
TestImapAuthentication(options.user, |
|||
GenerateOAuth2String(options.user, options.access_token, |
|||
base64_encode=False)) |
|||
elif options.test_smtp_authentication: |
|||
RequireOptions(options, 'user', 'access_token') |
|||
TestSmtpAuthentication(options.user, |
|||
GenerateOAuth2String(options.user, options.access_token, |
|||
base64_encode=False)) |
|||
else: |
|||
options_parser.print_help() |
|||
print('Nothing to do, exiting.') |
|||
return |
|||
|
|||
|
|||
if __name__ == '__main__': |
|||
main(sys.argv) |
Write
Preview
Loading…
Cancel
Save
Reference in new issue