Skip to content
Snippets Groups Projects
Commit f4aad485 authored by Megan Henning's avatar Megan Henning
Browse files

Rework oauth flow

parent c4f3f15a
No related branches found
No related tags found
No related merge requests found
import requests
from . import APIAuthProviderException, APIUnknownUserException
from .. import config, util
log = config.log
AuthProviders = util.Enum('AuthProviders', {
'google' : GoogleAuthProvider,
'ldap' : JWTAuthProvider,
'wechat' : WechatAuthProvider
})
class AuthProvider(object):
"""
This class provides access to mongodb collection elements (called containers).
It is used by ContainerHandler istances for get, create, update and delete operations on containers.
Examples: projects, sessions, acquisitions and collections
"""
def __init__(self, auth_type):
try:
self.config = config.get_auth(auth_type)
except KeyError:
raise NotImplementedError('Auth type {} is not supported by this instance'.format(auth_type))
@staticmethod
def factory(auth_type):
"""
Factory method to aid in the creation of an AuthProvider instance
when auth_type is dynamic.
"""
if auth_type in AuthProviders:
provider_class = AuthProviders[auth_type].value
return provider_class()
except:
raise NotImplementedError('Auth type {} is not supported'.format(auth_type))
class JWTAuthProvider(AuthProvider):
def __init__(self):
super(JWTAuthProvider,self).__init__(AuthProviders.ldap.key)
def validate_code(code):
uid = self.validate_user_exists(code)
return code, None, uid
def validate_user_exists(token):
r = requests.post(self.config['id_endpoint'], data={'token': token})
if not r.ok:
raise APIAuthProviderException('User token not valid')
uid = json.loads(r.content).get('mail')
if not uid:
raise APIAuthProviderException('Auth provider did not provide user email')
return uid
class GoogleOAuthProvider(AuthProvider):
def __init__(self):
super(GoogleAuthProvider,self).__init__(AuthProviders.google.key)
def validate_code(code):
payload = {
'client_id': self.config['client_id']
'client_secret': self.config['client_secret']
'code': code,
'grant_type': 'authorization_code'
}
r = requests.post(self.config['token_url'], data=payload)
if not r.ok:
raise APIAuthProviderException('User code not valid')
response = json.loads(r.content)
token = response['access_token']
refresh_token = response['refresh_token']
uid = self.validate_user_exists(token)
return token, refresh_token, uid
def validate_user_exists(token):
r = requests.get(self.config['id_endpoint'], headers={'Authorization': 'Bearer ' + token})
if not r.ok:
raise APIAuthProviderException('User token not valid')
uid = json.loads(r.content).get('email')
if not uid:
raise APIAuthProviderException('Auth provider did not provide user email')
return uid
class WechatOAuthProvider(AuthProvider):
def __init__(self):
super(WechatAuthProvider,self).__init__(AuthProviders.wechat.key)
def validate_code(code):
payload = {
'client_id': self.config['client_id']
'client_secret': self.config['client_secret']
'code': code,
'grant_type': 'authorization_code'
}
r = requests.post(self.config['token_url'], data=payload)
if not r.ok:
raise APIAuthProviderException('User code not valid')
response = json.loads(r.content)
token = response['access_token']
refresh_token = response['refresh_token']
uid = response['openid']
return token, refresh_token, uid
import base64
import datetime
import json
import jsonschema
import os
import pymongo
import requests
import traceback
......@@ -13,6 +15,7 @@ from .. import files
from .. import config
from ..types import Origin
from .. import validators
from ..auth.authproviders import AuthProvider
from ..dao import APIConsistencyException, APIConflictException, APINotFoundException, APIPermissionException, APIValidationException, dbutil
from ..dao.hierarchy import get_parent_tree
from ..web.request import log_access, AccessType
......@@ -31,7 +34,7 @@ class RequestHandler(webapp2.RequestHandler):
drone_request = False
user_agent = self.request.headers.get('User-Agent', '')
access_token = self.request.headers.get('Authorization', None)
session_token = self.request.headers.get('Authorization', None)
drone_secret = self.request.headers.get('X-SciTran-Auth', None)
drone_method = self.request.headers.get('X-SciTran-Method', None)
drone_name = self.request.headers.get('X-SciTran-Name', None)
......@@ -40,18 +43,18 @@ class RequestHandler(webapp2.RequestHandler):
if site_id is None:
self.abort(503, 'Database not initialized')
if access_token:
if access_token.startswith('scitran-user '):
if session_token:
if session_token.startswith('scitran-user '):
# User (API key) authentication
key = access_token.split()[1]
key = session_token.split()[1]
self.uid = self.authenticate_user_api_key(key)
elif access_token.startswith('scitran-drone '):
elif session_token.startswith('scitran-drone '):
# Drone (API key) authentication
# When supported, remove custom headers and shared secret
self.abort(401, 'Drone API keys are not yet supported')
else:
# User (oAuth) authentication
self.uid = self.authenticate_user_token(access_token)
self.uid = self.authenticate_user_token(session_token)
# Drone shared secret authentication
elif drone_secret is not None:
......@@ -118,7 +121,7 @@ class RequestHandler(webapp2.RequestHandler):
self.abort(401, 'Invalid scitran-user API key')
def authenticate_user_token(self, access_token):
def authenticate_user_token(self, session_token):
"""
AuthN for user accounts. Calls self.abort on failure.
......@@ -127,111 +130,121 @@ class RequestHandler(webapp2.RequestHandler):
uid = None
timestamp = datetime.datetime.utcnow()
cached_token = config.db.authtokens.find_one({'_id': access_token})
cached_token = config.db.authtokens.find_one({'_id': session_token})
if cached_token:
uid = cached_token['uid']
self.request.logger.debug('looked up cached token in %dms', ((datetime.datetime.utcnow() - timestamp).total_seconds() * 1000.))
else:
try:
auth_type, token = access_token.split(' ', 1)
except ValueError:
# If token is not cached, user must provide auth type in header
self.abort(401, 'Auth type not provided with token')
self.abort(401, 'Invalid session token')
uid = self.validate_oauth_token(auth_type, token, timestamp)
self.request.logger.debug('looked up remote token in %dms', ((datetime.datetime.utcnow() - timestamp).total_seconds() * 1000.))
return uid
# Cache the token for future requests
update = {
'uid': uid,
'timestamp': timestamp,
'auth_type': auth_type
}
dbutil.fault_tolerant_replace_one('authtokens', {'_id': token}, update, upsert=True)
return uid
# def validate_oauth_token(self, auth_type, access_token, timestamp):
# """
# Validates a token assertion against the configured ID endpoint. Calls self.abort on failure.
# Returns the user's UID.
# """
# auth_config = config.get_auth(auth_type)
# id_endpoint = auth_config.get('id_endpoint')
# # If we start supporting more than google and ldap, break into classes inherited from abstract class
# if auth_type == 'google':
# r = requests.get(id_endpoint, headers={'Authorization': 'Bearer ' + access_token})
# elif auth_type == 'ldap':
# p = {'token': access_token}
# r = requests.post(id_endpoint, data=p)
# else:
# raise self.abort(401, 'Auth not configured.')
# if not r.ok:
# # Oauth authN failed; for now assume it was an invalid token. Could be more accurate in the future.
# err_msg = 'Invalid OAuth2 token.'
# site_id = config.get_item('site', 'id')
# headers = {'WWW-Authenticate': 'Bearer realm="{}", error="invalid_token", error_description="{}"'.format(site_id, err_msg)}
# self.request.logger.warning('{} Request headers: {}'.format(err_msg, str(self.request.headers.items())))
# self.abort(401, err_msg, headers=headers)
# identity = json.loads(r.content)
# email_key = 'email' if auth_type == 'google' else 'mail'
# uid = identity.get(email_key)
# if not uid:
# self.abort(400, 'OAuth2 token resolution did not return email address')
# # If this is the first time they've logged in, record that
# config.db.users.update_one({'_id': self.uid, 'firstlogin': None}, {'$set': {'firstlogin': timestamp}})
# # Unconditionally set their most recent login time
# config.db.users.update_one({'_id': self.uid}, {'$set': {'lastlogin': timestamp}})
# # Set user's auth provider avatar
# # TODO: switch on auth.provider rather than manually comparing endpoint URL.
# if auth_type == 'google':
# # A google-specific avatar URL is provided in the identity return.
# provider_avatar = identity.get('picture', '')
# # Remove attached size param from URL.
# u = urlparse.urlparse(provider_avatar)
# query = urlparse.parse_qs(u.query)
# query.pop('sz', None)
# u = u._replace(query=urllib.urlencode(query, True))
# provider_avatar = urlparse.urlunparse(u)
# # Update the user's provider avatar if it has changed.
# config.db.users.update_one({'_id': uid, 'avatars.provider': {'$ne': provider_avatar}}, {'$set':{'avatars.provider': provider_avatar, 'modified': timestamp}})
# # If the user has no avatar set, mark their provider_avatar as their chosen avatar.
# config.db.users.update_one({'_id': uid, 'avatar': {'$exists': False}}, {'$set':{'avatar': provider_avatar, 'modified': timestamp}})
# # Look to see if user has a Gravatar
# gravatar = util.resolve_gravatar(uid)
# if gravatar is not None:
# # Update the user's gravatar if it has changed.
# config.db.users.update_one({'_id': uid, 'avatars.gravatar': {'$ne': gravatar}}, {'$set':{'avatars.gravatar': gravatar, 'modified': timestamp}})
# return uid
def validate_oauth_token(self, auth_type, access_token, timestamp):
@log_access(AccessType.user_login)
def log_in(self):
"""
Validates a token assertion against the configured ID endpoint. Calls self.abort on failure.
Return succcess boolean if user successfully authenticates.
Returns the user's UID.
Used for access logging.
Not required to use system as logged in user.
"""
auth_config = config.get_auth(auth_type)
id_endpoint = auth_config.get('id_endpoint')
payload = self.request.json_body
if 'code' not in payload or 'auth_type' not in payload:
self.abort(400, 'Auth code and type required for login')
# If we start supporting more than google and ldap, break into classes inherited from abstract class
if auth_type == 'google':
r = requests.get(id_endpoint, headers={'Authorization': 'Bearer ' + access_token})
elif auth_type == 'ldap':
p = {'token': access_token}
r = requests.post(id_endpoint, data=p)
else:
raise self.abort(401, 'Auth not configured.')
if not r.ok:
# Oauth authN failed; for now assume it was an invalid token. Could be more accurate in the future.
err_msg = 'Invalid OAuth2 token.'
site_id = config.get_item('site', 'id')
headers = {'WWW-Authenticate': 'Bearer realm="{}", error="invalid_token", error_description="{}"'.format(site_id, err_msg)}
self.request.logger.warning('{} Request headers: {}'.format(err_msg, str(self.request.headers.items())))
self.abort(401, err_msg, headers=headers)
try:
auth_provider = AuthProvider.factory(payload['auth_type'])
except NotImplementedError as e:
self.abort(400, str(e))
identity = json.loads(r.content)
email_key = 'email' if auth_type == 'google' else 'mail'
uid = identity.get(email_key)
if not uid:
self.abort(400, 'OAuth2 token resolution did not return email address')
_, refresh_token, uid = auth_provider.validate_code(payload['code'])
# If this is the first time they've logged in, record that
config.db.users.update_one({'_id': self.uid, 'firstlogin': None}, {'$set': {'firstlogin': timestamp}})
# Unconditionally set their most recent login time
config.db.users.update_one({'_id': self.uid}, {'$set': {'lastlogin': timestamp}})
# Set user's auth provider avatar
# TODO: switch on auth.provider rather than manually comparing endpoint URL.
if auth_type == 'google':
# A google-specific avatar URL is provided in the identity return.
provider_avatar = identity.get('picture', '')
# Remove attached size param from URL.
u = urlparse.urlparse(provider_avatar)
query = urlparse.parse_qs(u.query)
query.pop('sz', None)
u = u._replace(query=urllib.urlencode(query, True))
provider_avatar = urlparse.urlunparse(u)
# Update the user's provider avatar if it has changed.
config.db.users.update_one({'_id': uid, 'avatars.provider': {'$ne': provider_avatar}}, {'$set':{'avatars.provider': provider_avatar, 'modified': timestamp}})
# If the user has no avatar set, mark their provider_avatar as their chosen avatar.
config.db.users.update_one({'_id': uid, 'avatar': {'$exists': False}}, {'$set':{'avatar': provider_avatar, 'modified': timestamp}})
# Look to see if user has a Gravatar
gravatar = util.resolve_gravatar(uid)
if gravatar is not None:
# Update the user's gravatar if it has changed.
config.db.users.update_one({'_id': uid, 'avatars.gravatar': {'$ne': gravatar}}, {'$set':{'avatars.gravatar': gravatar, 'modified': timestamp}})
# Generate session token
session_token = base64.urlsafe_b64encode(os.urandom(42))
return uid
@log_access(AccessType.user_login)
def log_in(self):
"""
Return succcess boolean if user successfully authenticates.
Used for access logging.
Not required to use system as logged in user.
"""
if not self.uid:
self.abort(400, 'Only users may log in.')
token_entry = {
'token': session_token,
'refresh_token': refresh_token
'uid': uid,
'timestamp': datetime.datetime.utcnow(),
'auth_type': auth_type
}
config.db.authtokens.insert_one(token_entry)
return {'success': True}
return {'token': session_token}
@log_access(AccessType.user_logout)
......@@ -240,11 +253,12 @@ class RequestHandler(webapp2.RequestHandler):
Remove all cached auth tokens associated with caller's uid.
"""
if not self.uid:
self.abort(400, 'Only users may log out.')
payload = self.request.json_body
if 'token' not in payload:
self.abort(400, 'Token required for log out')
result = config.db.authtokens.delete_many({'uid': self.uid})
return {'auth_tokens_removed': result.deleted_count}
result = config.db.authtokens.delete_one({'_id': token})
return {'tokens_removed': result.deleted_count}
def set_origin(self, drone_request):
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment