From 479e39fbb7393dcca7d3a475a09128b8c7ea7a43 Mon Sep 17 00:00:00 2001 From: Randy Ramos Date: Mon, 4 Sep 2017 19:20:16 -0400 Subject: Add OAuth2 support for calcurse-caldav OAuth2 authentication is completely optional. It is controlled by the AuthMethod option in the config file. Other required options were appended to config.sample. Signed-off-by: Randy Ramos Signed-off-by: Lukas Fleischer --- contrib/caldav/calcurse-caldav.py | 108 ++++++++++++++++++++++++++++++++++++++ contrib/caldav/config.sample | 16 ++++++ 2 files changed, 124 insertions(+) diff --git a/contrib/caldav/calcurse-caldav.py b/contrib/caldav/calcurse-caldav.py index 3725068..219ce34 100755 --- a/contrib/caldav/calcurse-caldav.py +++ b/contrib/caldav/calcurse-caldav.py @@ -12,6 +12,14 @@ import textwrap import urllib.parse import xml.etree.ElementTree as etree +# Optional libraries for OAuth2 authentication +try: + from oauth2client.client import OAuth2WebServerFlow, HttpAccessTokenRefreshError + from oauth2client.file import Storage + import webbrowser +except ModuleNotFoundError: + pass + def msgfmt(msg, prefix=''): lines = [] @@ -92,6 +100,65 @@ def get_auth_headers(): return headers +def init_auth(client_id, client_secret, scope, redirect_uri, authcode): + # Create OAuth2 session + oauth2_client = OAuth2WebServerFlow(client_id=client_id, + client_secret=client_secret, + scope=scope, + redirect_uri=redirect_uri) + + # If auth code is missing, tell user run script with auth code + if not authcode: + # Generate and open URL for user to authorize + auth_uri = oauth2_client.step1_get_authorize_url() + webbrowser.open(auth_uri) + + prompt = ('\nIf a browser window did not open, go to the URL ' + 'below and log in to authorize syncing. ' + 'Once authorized, pass the string after "code=" from ' + 'the URL in your browser\'s address bar to ' + 'calcurse-caldav.py using the "--authcode" flag. ' + "Example: calcurse-caldav --authcode " + "'your_auth_code_here'\n\n{}\n".format(auth_uri)) + print(prompt) + die("Access token is missing or refresh token is expired.") + + # Create and return Credential object from auth code + credentials = oauth2_client.step2_exchange(authcode) + + # Setup storage file and store credentials + storage = Storage(oauth_file) + credentials.set_store(storage) + storage.put(credentials) + + return credentials + + +def run_auth(authcode): + # Check if credentials file exists + if os.path.isfile(oauth_file): + + # Retrieve token from file + storage = Storage(oauth_file) + credentials = storage.get() + + # Set file to store it in for future functions + credentials.set_store(storage) + + # Refresh the access token if it is expired + if credentials.invalid: + try: + credentials.refresh(httplib2.Http()) + except HttpAccessTokenRefreshError: + # Initialize OAuth2 again if refresh token becomes invalid + credentials = init_auth(client_id, client_secret, scope, redirect_uri, authcode) + else: + # Initialize OAuth2 credentials + credentials = init_auth(client_id, client_secret, scope, redirect_uri, authcode) + + return credentials + + def remote_query(conn, cmd, path, additional_headers, body): headers = custom_headers.copy() headers.update(get_auth_headers()) @@ -384,6 +451,7 @@ configfn = os.path.expanduser("~/.calcurse/caldav/config") lockfn = os.path.expanduser("~/.calcurse/caldav/lock") syncdbfn = os.path.expanduser("~/.calcurse/caldav/sync.db") hookdir = os.path.expanduser("~/.calcurse/caldav/hooks/") +oauth_file = os.path.expanduser("~/.calcurse/caldav/oauth2_cred") # Parse command line arguments. parser = argparse.ArgumentParser('calcurse-caldav') @@ -402,6 +470,9 @@ parser.add_argument('--syncdb', action='store', dest='syncdbfn', parser.add_argument('--hookdir', action='store', dest='hookdir', default=hookdir, help='path to the calcurse-caldav hooks directory') +parser.add_argument('--authcode', action='store', dest='authcode', + default=None, + help='auth code for OAuth2 authentication') parser.add_argument('-v', '--verbose', action='store_true', dest='verbose', default=False, help='print status messages to stdout') @@ -414,6 +485,7 @@ configfn = args.configfn lockfn = args.lockfn syncdbfn = args.syncdbfn hookdir = args.hookdir +authcode = args.authcode verbose = args.verbose debug = args.debug @@ -452,6 +524,11 @@ if not verbose and config.has_option('General', 'Verbose'): if not debug and config.has_option('General', 'Debug'): debug = config.getboolean('General', 'Debug') +if config.has_option('General', 'AuthMethod'): + authmethod = config.get('General', 'AuthMethod').lower() +else: + authmethod = 'basic' + if config.has_option('Auth', 'UserName'): username = config.get('Auth', 'UserName') else: @@ -467,6 +544,26 @@ if config.has_section('CustomHeaders'): else: custom_headers = {} +if config.has_option('OAuth2', 'ClientID'): + client_id = config.get('OAuth2', 'ClientID') +else: + client_id = None + +if config.has_option('OAuth2', 'ClientSecret'): + client_secret = config.get('OAuth2', 'ClientSecret') +else: + client_secret = None + +if config.has_option('OAuth2', 'Scope'): + scope = config.get('OAuth2', 'Scope') +else: + scope = None + +if config.has_option('OAuth2', 'RedirectURI'): + redirect_uri = config.get('OAuth2', 'RedirectURI') +else: + redirect_uri = 'http://127.0.0.1' + # Show disclaimer when performing a dry run. if dry_run: warn(('Dry run; nothing is imported/exported. Add "DryRun = No" to the ' @@ -500,6 +597,13 @@ try: if insecure_ssl: conn.disable_ssl_certificate_validation = True + if authmethod == 'oauth2': + # Authenticate with OAuth2 and authorize HTTP object + cred = run_auth(authcode) + conn = cred.authorize(conn) + elif authmethod != 'basic': + die('Invalid option for AuthMethod in config file. Use "basic" or "oauth2"') + if init: # In initialization mode, start with an empty synchronization database. if args.init == 'keep-remote': @@ -551,6 +655,10 @@ try: # Write the synchronization database. save_syncdb(syncdbfn, syncdb) + #Clear OAuth2 credentials if used + if authmethod == 'oauth2': + conn.clear_credentials() + finally: # Remove lock file. os.remove(lockfn) diff --git a/contrib/caldav/config.sample b/contrib/caldav/config.sample index 9b21b2b..76c3ce3 100644 --- a/contrib/caldav/config.sample +++ b/contrib/caldav/config.sample @@ -12,6 +12,9 @@ Hostname = some.hostname.com # Path to the CalDAV calendar on the host specified above. Path = /path/to/calendar/on/the/server/ +# Type of authentication to use. Must be "basic" or "oauth2" +#AuthMethod = basic + # Enable this if you want to skip SSL certificate checks. InsecureSSL = No @@ -33,3 +36,16 @@ Verbose = Yes # Optionally specify additional HTTP headers here. #[CustomHeaders] #User-Agent = Mac_OS_X/10.9.2 (13C64) CalendarAgent/176 + +# Use the following to synchronize with an OAuth2-based service +# such as Google Calendar. +#[OAuth2] +#ClientID = your_client_id +#ClientSecret = your_client_secret + +# Scope of access for API calls. Synchronization requires read/write. +#Scope = https://example.com/resource/scope + +# Change the redirect URI if you receive errors, but ensure that it is identical +# to the redirect URI you specified in the API settings. +#RedirectURI = http://127.0.0.1 -- cgit v1.2.3-54-g00ecf