Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for Jottacloud cli token #7

Merged
merged 2 commits into from
Jun 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions cli-token.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{longappname}}</title>

<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap-theme.min.css">

<script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/js/bootstrap.min.js"></script>

<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!--[if lt IE 9]>
<script src="//oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
<script src="//oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->
</head>
<body>
<div class="jumbotron">
<h1>{{appname}} for {{service}}</h1>

<p>Type in the CLI token</p>
<form action="/cli-token-login" method="POST">
<input type="hidden" id="id" name="id" value="{{id}}" />
<input class="form-control" type="text" id="token" name="token" required />
<br/>
<br/>
<input class="btn btn-primary btn-lg" role="button" type="submit" value="Login" />
</form>
</div>
</body>
</html>
198 changes: 156 additions & 42 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,41 @@ def find_service(id):
return service


def create_authtoken(provider_id, token):
# We store the ID if we get it back
if token.has_key("user_id"):
user_id = token["user_id"]
else:
user_id = "N/A"

exp_secs = 1800 # 30 min guess
try:
exp_secs = int(token["expires_in"])
except:
pass

# Create a random password and encrypt the response
# This ensures that a hostile takeover will not get access
# to stored access and refresh tokens
password = password_generator.generate_pass()
cipher = simplecrypt.encrypt(password, json.dumps(token))

# Convert to text and prepare for storage
b64_cipher = base64.b64encode(cipher)
expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=exp_secs)

entry = None
keyid = None

# Find a random un-used user ID, and store the encrypted data
while entry is None:
keyid = '%030x' % random.randrange(16 ** 32)
entry = dbmodel.insert_new_authtoken(keyid, user_id, b64_cipher, expires, provider_id)

# Return the keyid and authid
return keyid, keyid + ':' + password


class RedirectToLoginHandler(webapp2.RequestHandler):
"""Creates a state and redirects the user to the login page"""

Expand Down Expand Up @@ -129,12 +164,16 @@ def get(self):
if filtertype is None and n.has_key('hidden') and n['hidden']:
continue

link = '/login?id=' + n['id']
if self.request.get('token', None) is not None:
link += '&token=' + self.request.get('token')
link = ''
if service.has_key('cli-token') and service['cli-token']:
link = '/cli-token?id=' + n['id']
else:
link = '/login?id=' + n['id']
if self.request.get('token', None) is not None:
link += '&token=' + self.request.get('token')

if tokenversion is not None:
link += '&tokenversion=' + str(tokenversion)
if tokenversion is not None:
link += '&tokenversion=' + str(tokenversion)

notes = ''
if n.has_key('notes'):
Expand Down Expand Up @@ -309,39 +348,105 @@ def get(self, service=None):
logging.info('Returned refresh token for service %s', provider['id'])
return

# We store the ID if we get it back
if resp.has_key("user_id"):
user_id = resp["user_id"]
else:
user_id = "N/A"
# Return the id and password to the user
keyid, authid = create_authtoken(provider['id'], resp)

fetchtoken = statetoken.fetchtoken

# If this was part of a polling request, signal completion
dbmodel.update_fetch_token(fetchtoken, authid)

# Report results to the user
template_values = {
'service': display,
'appname': settings.APP_NAME,
'longappname': settings.SERVICE_DISPLAYNAME,
'authid': authid,
'fetchtoken': fetchtoken
}

template = JINJA_ENVIRONMENT.get_template('logged-in.html')
self.response.write(template.render(template_values))
statetoken.delete()

logging.info('Created new authid %s for service %s', keyid, provider['id'])

except:
logging.exception('handler error for ' + display)

template_values = {
'service': display,
'appname': settings.APP_NAME,
'longappname': settings.SERVICE_DISPLAYNAME,
'authid': 'Server error, close window and try again',
'fetchtoken': ''
}

template = JINJA_ENVIRONMENT.get_template('logged-in.html')
self.response.write(template.render(template_values))

class CliTokenHandler(webapp2.RequestHandler):
"""Renders the cli-token.html page"""

def get(self):

provider, service = find_provider_and_service(self.request.get('id', None))

template_values = {
'service': provider['display'],
'appname': settings.APP_NAME,
'longappname': settings.SERVICE_DISPLAYNAME,
'id': provider['id']
}

template = JINJA_ENVIRONMENT.get_template('cli-token.html')
self.response.write(template.render(template_values))


class CliTokenLoginHandler(webapp2.RequestHandler):
"""Handler that processes cli-token login and redirects the user to the logged-in page"""

def post(self):
display = 'Unknown'
error = 'Server error, close window and try again'
try:
id = self.request.POST.get('id')
provider, service = find_provider_and_service(id)
display = provider['display']

exp_secs = 1800 # 30 min guess
try:
exp_secs = int(resp["expires_in"])
data = self.request.POST.get('token')
content = base64.urlsafe_b64decode(str(data) + '=' * (-len(data) % 4))
resp = json.loads(content)
except:
pass
error = 'Error: Invalid CLI token'
raise

# Create a random password and encrypt the response
# This ensures that a hostile takeover will not get access
# to stored access and refresh tokens
password = password_generator.generate_pass()
cipher = simplecrypt.encrypt(password, json.dumps(resp))

# Convert to text and prepare for storage
b64_cipher = base64.b64encode(cipher)
expires = datetime.datetime.utcnow() + datetime.timedelta(seconds=exp_secs)
fetchtoken = statetoken.fetchtoken
urlfetch.set_default_fetch_deadline(20)
url = service['auth-url']
data = urllib.urlencode({
'client_id': service['client-id'],
'grant_type': 'password',
'scope': provider['scope'],
'username': resp['username'],
'password': resp['auth_token']
})
try:
req = urllib2.Request(url, data, {'Content-Type': 'application/x-www-form-urlencoded'})
f = urllib2.urlopen(req)
content = f.read()
f.close()
except urllib2.HTTPError as err:
if err.code == 401:
# If trying to re-use a single-use cli token
error = 'Error: CLI token could not be authorized, create a new and try again'
raise err

entry = None
keyid = None
resp = json.loads(content)

# Find a random un-used user ID, and store the encrypted data
while entry is None:
keyid = '%030x' % random.randrange(16 ** 32)
entry = dbmodel.insert_new_authtoken(keyid, user_id, b64_cipher, expires, provider['id'])
keyid, authid = create_authtoken(id, resp)

# Return the id and password to the user
authid = keyid + ':' + password
fetchtoken = dbmodel.create_fetch_token(resp)

# If this was part of a polling request, signal completion
dbmodel.update_fetch_token(fetchtoken, authid)
Expand All @@ -357,9 +462,8 @@ def get(self, service=None):

template = JINJA_ENVIRONMENT.get_template('logged-in.html')
self.response.write(template.render(template_values))
statetoken.delete()

logging.info('Created new authid %s for service %s', keyid, provider['id'])
logging.info('Created new authid %s for service %s', keyid, id)

except:
logging.exception('handler error for ' + display)
Expand All @@ -368,7 +472,7 @@ def get(self, service=None):
'service': display,
'appname': settings.APP_NAME,
'longappname': settings.SERVICE_DISPLAYNAME,
'authid': 'Server error, close window and try again',
'authid': error,
'fetchtoken': ''
}

Expand Down Expand Up @@ -559,11 +663,14 @@ def process(self, authid):
url = service['auth-url']
request_params = {
'client_id': service['client-id'],
'redirect_uri': service['redirect-uri'],
'client_secret': service['client-secret'],
'grant_type': 'refresh_token',
'refresh_token': resp['refresh_token']
}
if service.has_key("client_secret"):
request_params['client_secret'] = service['client-secret']
if service.has_key("redirect_uri"):
request_params['redirect_uri'] = service['redirect-uri']

# Some services do not allow the state to be passed
if service.has_key('no-redirect_uri-for-refresh-request') and service['no-redirect_uri-for-refresh-request']:
del request_params['redirect_uri']
Expand Down Expand Up @@ -673,12 +780,17 @@ def handle_v2(self, inputfragment):
logging.info('Cached response to: %s is invalid because it expires in %s', tokenhash, exp_secs)

url = service['auth-url']
data = urllib.urlencode({'client_id': service['client-id'],
'redirect_uri': service['redirect-uri'],
'client_secret': service['client-secret'],
'grant_type': 'refresh_token',
'refresh_token': refresh_token
})
request_params = {
'client_id': service['client-id'],
'grant_type': 'refresh_token',
'refresh_token': refresh_token
}
if service.has_key("client_secret"):
request_params['client_secret'] = service['client-secret']
if service.has_key("redirect_uri"):
request_params['redirect_uri'] = service['redirect-uri']

data = urllib.urlencode(request_params)

urlfetch.set_default_fetch_deadline(20)

Expand Down Expand Up @@ -983,6 +1095,8 @@ def get(self):
app = webapp2.WSGIApplication([
('/logged-in', LoginHandler),
('/login', RedirectToLoginHandler),
('/cli-token', CliTokenHandler),
('/cli-token-login', CliTokenLoginHandler),
('/refresh', RefreshHandler),
('/fetch', FetchHandler),
('/token-state', TokenStateHandler),
Expand Down
2 changes: 1 addition & 1 deletion revoke.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ <h1>{{appname}}</h1>

<p>Type in the AuthID to revoke</p>
<form action="/revoked" method="POST">
<input type="text" id="authid" name="authid" />
<input class="form-control" type="text" id="authid" name="authid" required />
<br/>
<br/>
<input class="btn btn-primary btn-lg" role="button" type="submit" value="Revoke AuthID" />
Expand Down
17 changes: 17 additions & 0 deletions settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@
DROPBOX_AUTH_URL = 'https://api.dropboxapi.com/oauth2/token'
DROPBOX_LOGIN_URL = 'https://www.dropbox.com/oauth2/authorize'

JOTTACLOUD_AUTH_URL = 'https://id.jottacloud.com/auth/realms/jottacloud/protocol/openid-connect/token'

LOOKUP = {
'wl': {
'display': 'Windows Live',
Expand Down Expand Up @@ -214,6 +216,7 @@
'auth-url': BOX_AUTH_URL,
'login-url': BOX_LOGIN_URL
},

'dropbox': {
'display': 'Dropbox',
'client-id': DROPBOX_CLIENT_ID,
Expand All @@ -225,6 +228,13 @@
'no-state-for-token-request': True,
# Dropbox is a little picky
'no-redirect_uri-for-refresh-request': True
},

'jottacloud': {
'display': 'Jottacloud',
'client-id': "jottacli",
'auth-url': JOTTACLOUD_AUTH_URL,
'cli-token': True
}
}

Expand Down Expand Up @@ -324,6 +334,13 @@
'scope': 'files.content.write files.content.read files.metadata.read files.metadata.write',
'extraurl': 'token_access_type=offline',
'servicelink': 'https://dropbox.com'
},
{
'display': 'Jottacloud',
'type': 'jottacloud',
'id': 'jottacloud',
'scope': 'openid offline_access',
'servicelink': 'https://jottacloud.com'
}
]

Expand Down