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 <rramos1295@gmail.com>
Signed-off-by: Lukas Fleischer <lfleischer@calcurse.org>
This commit is contained in:
Randy Ramos 2017-09-04 19:20:16 -04:00 committed by Lukas Fleischer
parent 1e1d61585d
commit 479e39fbb7
2 changed files with 124 additions and 0 deletions

View File

@ -12,6 +12,14 @@ import textwrap
import urllib.parse import urllib.parse
import xml.etree.ElementTree as etree 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=''): def msgfmt(msg, prefix=''):
lines = [] lines = []
@ -92,6 +100,65 @@ def get_auth_headers():
return 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): def remote_query(conn, cmd, path, additional_headers, body):
headers = custom_headers.copy() headers = custom_headers.copy()
headers.update(get_auth_headers()) headers.update(get_auth_headers())
@ -384,6 +451,7 @@ configfn = os.path.expanduser("~/.calcurse/caldav/config")
lockfn = os.path.expanduser("~/.calcurse/caldav/lock") lockfn = os.path.expanduser("~/.calcurse/caldav/lock")
syncdbfn = os.path.expanduser("~/.calcurse/caldav/sync.db") syncdbfn = os.path.expanduser("~/.calcurse/caldav/sync.db")
hookdir = os.path.expanduser("~/.calcurse/caldav/hooks/") hookdir = os.path.expanduser("~/.calcurse/caldav/hooks/")
oauth_file = os.path.expanduser("~/.calcurse/caldav/oauth2_cred")
# Parse command line arguments. # Parse command line arguments.
parser = argparse.ArgumentParser('calcurse-caldav') 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', parser.add_argument('--hookdir', action='store', dest='hookdir',
default=hookdir, default=hookdir,
help='path to the calcurse-caldav hooks directory') 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', parser.add_argument('-v', '--verbose', action='store_true', dest='verbose',
default=False, default=False,
help='print status messages to stdout') help='print status messages to stdout')
@ -414,6 +485,7 @@ configfn = args.configfn
lockfn = args.lockfn lockfn = args.lockfn
syncdbfn = args.syncdbfn syncdbfn = args.syncdbfn
hookdir = args.hookdir hookdir = args.hookdir
authcode = args.authcode
verbose = args.verbose verbose = args.verbose
debug = args.debug 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'): if not debug and config.has_option('General', 'Debug'):
debug = config.getboolean('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'): if config.has_option('Auth', 'UserName'):
username = config.get('Auth', 'UserName') username = config.get('Auth', 'UserName')
else: else:
@ -467,6 +544,26 @@ if config.has_section('CustomHeaders'):
else: else:
custom_headers = {} 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. # Show disclaimer when performing a dry run.
if dry_run: if dry_run:
warn(('Dry run; nothing is imported/exported. Add "DryRun = No" to the ' warn(('Dry run; nothing is imported/exported. Add "DryRun = No" to the '
@ -500,6 +597,13 @@ try:
if insecure_ssl: if insecure_ssl:
conn.disable_ssl_certificate_validation = True 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: if init:
# In initialization mode, start with an empty synchronization database. # In initialization mode, start with an empty synchronization database.
if args.init == 'keep-remote': if args.init == 'keep-remote':
@ -551,6 +655,10 @@ try:
# Write the synchronization database. # Write the synchronization database.
save_syncdb(syncdbfn, syncdb) save_syncdb(syncdbfn, syncdb)
#Clear OAuth2 credentials if used
if authmethod == 'oauth2':
conn.clear_credentials()
finally: finally:
# Remove lock file. # Remove lock file.
os.remove(lockfn) os.remove(lockfn)

View File

@ -12,6 +12,9 @@ Hostname = some.hostname.com
# Path to the CalDAV calendar on the host specified above. # Path to the CalDAV calendar on the host specified above.
Path = /path/to/calendar/on/the/server/ 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. # Enable this if you want to skip SSL certificate checks.
InsecureSSL = No InsecureSSL = No
@ -33,3 +36,16 @@ Verbose = Yes
# Optionally specify additional HTTP headers here. # Optionally specify additional HTTP headers here.
#[CustomHeaders] #[CustomHeaders]
#User-Agent = Mac_OS_X/10.9.2 (13C64) CalendarAgent/176 #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