Refactor our auth handling code to be cleaner

Breaks out the validation code from the auth context modification calls, makes decorators easier to define and adds testing for each individual piece. Will be the basis of better error messaging in the following change.
This commit is contained in:
Joseph Schorr 2017-03-16 17:05:26 -04:00
parent 1bd4422da9
commit 651666b60b
18 changed files with 830 additions and 455 deletions

View file

@ -1,287 +1,90 @@
import logging
from functools import wraps
from uuid import UUID
from datetime import datetime
from base64 import b64decode
from flask import request, session
from flask.sessions import SecureCookieSessionInterface, BadSignature
from flask_login import current_user
from flask_principal import identity_changed, Identity
import scopes
from app import metric_queue
from auth.basic import validate_basic_auth
from auth.oauth import validate_bearer_auth
from auth.cookie import validate_session_cookie
from auth.signedgrant import validate_signed_grant
from app import app, authentication, metric_queue
from auth_context import (set_authenticated_user, set_validated_token, set_grant_context,
set_validated_oauth_token)
from data import model
from endpoints.exception import InvalidToken, ExpiredToken
from permissions import QuayDeferredPermissionUser
from util.http import abort
logger = logging.getLogger(__name__)
SIGNATURE_PREFIX = 'sigv2='
def _auth_decorator(pass_result=False, handlers=None):
""" Builds an auth decorator that runs the given handlers and, if any return successfully,
sets up the auth context. The wrapped function will be invoked *regardless of success or
failure of the auth handler(s)*
"""
def processor(func):
@wraps(func)
def wrapper(*args, **kwargs):
auth_header = request.headers.get('authorization', '')
result = None
def _load_user_from_cookie():
if not current_user.is_anonymous:
try:
# Attempt to parse the user uuid to make sure the cookie has the right value type
UUID(current_user.get_id())
except ValueError:
return None
for handler in handlers:
result = handler(auth_header)
# If the handler was missing the necessary information, skip it and try the next one.
if result.missing:
continue
logger.debug('Loading user from cookie: %s', current_user.get_id())
db_user = current_user.db_user()
if db_user is not None:
# Don't allow disabled users to login.
if not db_user.enabled:
return None
# Check for a valid result.
if result.auth_valid:
logger.debug('Found valid auth result: %s => (%s)', result.tuple())
set_authenticated_user(db_user)
loaded = QuayDeferredPermissionUser.for_user(db_user)
identity_changed.send(app, identity=loaded)
return db_user
# Set the various pieces of the auth context.
result.apply_to_context()
return None
# Log the metric.
metric_queue.authentication_count.Inc(labelvalues=[result.kind, True])
break
# Otherwise, report the error.
if result.error_message is not None:
# Log the failure.
metric_queue.authentication_count.Inc(labelvalues=[result.kind, False])
break
if pass_result:
kwargs['auth_result'] = result
return func(*args, **kwargs)
return wrapper
return processor
def _validate_and_apply_oauth_token(token):
validated = model.oauth.validate_access_token(token)
if not validated:
logger.warning('OAuth access token could not be validated: %s', token)
metric_queue.authentication_count.Inc(labelvalues=['oauth', False])
raise InvalidToken('OAuth access token could not be validated: {token}'.format(token=token))
elif validated.expires_at <= datetime.utcnow():
logger.info('OAuth access with an expired token: %s', token)
metric_queue.authentication_count.Inc(labelvalues=['oauth', False])
raise ExpiredToken('OAuth access token has expired: {token}'.format(token=token))
# Don't allow disabled users to login.
if not validated.authorized_user.enabled:
metric_queue.authentication_count.Inc(labelvalues=['oauth', False])
return False
# We have a valid token
scope_set = scopes.scopes_from_scope_string(validated.scope)
logger.debug('Successfully validated oauth access token: %s with scope: %s', token,
scope_set)
set_authenticated_user(validated.authorized_user)
set_validated_oauth_token(validated)
new_identity = QuayDeferredPermissionUser.for_user(validated.authorized_user, scope_set)
identity_changed.send(app, identity=new_identity)
metric_queue.authentication_count.Inc(labelvalues=['oauth', True])
return True
def _parse_basic_auth_header(auth):
normalized = [part.strip() for part in auth.split(' ') if part]
if normalized[0].lower() != 'basic' or len(normalized) != 2:
logger.debug('Invalid basic auth format.')
return None
logger.debug('Found basic auth header: %s', auth)
try:
credentials = [part.decode('utf-8') for part in b64decode(normalized[1]).split(':', 1)]
except TypeError:
logger.exception('Exception when parsing basic auth header')
return None
if len(credentials) != 2:
logger.debug('Invalid basic auth credential format.')
return None
return credentials
def _process_basic_auth(auth):
credentials = _parse_basic_auth_header(auth)
if credentials is None:
return
if credentials[0] == '$token':
# Use as token auth
try:
token = model.token.load_token_data(credentials[1])
logger.debug('Successfully validated token: %s', credentials[1])
set_validated_token(token)
identity_changed.send(app, identity=Identity(token.code, 'token'))
metric_queue.authentication_count.Inc(labelvalues=['token', True])
return True
except model.DataModelException:
logger.debug('Invalid token: %s', credentials[1])
metric_queue.authentication_count.Inc(labelvalues=['token', False])
elif credentials[0] == '$oauthtoken':
oauth_token = credentials[1]
return _validate_and_apply_oauth_token(oauth_token)
elif '+' in credentials[0]:
logger.debug('Trying robot auth with credentials %s', str(credentials))
# Use as robot auth
try:
robot = model.user.verify_robot(credentials[0], credentials[1])
logger.debug('Successfully validated robot: %s', credentials[0])
set_authenticated_user(robot)
deferred_robot = QuayDeferredPermissionUser.for_user(robot)
identity_changed.send(app, identity=deferred_robot)
metric_queue.authentication_count.Inc(labelvalues=['robot', True])
return True
except model.InvalidRobotException:
logger.debug('Invalid robot or password for robot: %s', credentials[0])
metric_queue.authentication_count.Inc(labelvalues=['robot', False])
else:
(authenticated, _) = authentication.verify_and_link_user(credentials[0], credentials[1],
basic_auth=True)
if authenticated:
logger.debug('Successfully validated user: %s', authenticated.username)
set_authenticated_user(authenticated)
new_identity = QuayDeferredPermissionUser.for_user(authenticated)
identity_changed.send(app, identity=new_identity)
metric_queue.authentication_count.Inc(labelvalues=['user', True])
return True
else:
metric_queue.authentication_count.Inc(labelvalues=['user', False])
# We weren't able to authenticate via basic auth.
logger.debug('Basic auth present but could not be validated.')
return False
def has_basic_auth(username):
auth = request.headers.get('authorization', '')
if not auth:
return False
credentials = _parse_basic_auth_header(auth)
if not credentials:
return False
(authenticated, _) = authentication.verify_and_link_user(credentials[0], credentials[1],
basic_auth=True)
if not authenticated:
return False
return authenticated.username == username
def generate_signed_token(grants, user_context):
ser = SecureCookieSessionInterface().get_signing_serializer(app)
data_to_sign = {
'grants': grants,
'user_context': user_context,
}
encrypted = ser.dumps(data_to_sign)
return '{0}{1}'.format(SIGNATURE_PREFIX, encrypted)
def _process_signed_grant(auth):
normalized = [part.strip() for part in auth.split(' ') if part]
if normalized[0].lower() != 'token' or len(normalized) != 2:
logger.debug('Not a token: %s', auth)
return False
if not normalized[1].startswith(SIGNATURE_PREFIX):
logger.debug('Not a signed grant token: %s', auth)
return False
encrypted = normalized[1][len(SIGNATURE_PREFIX):]
ser = SecureCookieSessionInterface().get_signing_serializer(app)
try:
token_data = ser.loads(encrypted, max_age=app.config['SIGNED_GRANT_EXPIRATION_SEC'])
except BadSignature:
logger.warning('Signed grant could not be validated: %s', encrypted)
metric_queue.authentication_count.Inc(labelvalues=['signed', False])
abort(401, message='Signed grant could not be validated: %(auth)s', issue='invalid-auth-token',
auth=auth)
logger.debug('Successfully validated signed grant with data: %s', token_data)
loaded_identity = Identity(None, 'signed_grant')
if token_data['user_context']:
set_grant_context({
'user': token_data['user_context'],
'kind': 'user',
})
loaded_identity.provides.update(token_data['grants'])
identity_changed.send(app, identity=loaded_identity)
metric_queue.authentication_count.Inc(labelvalues=['signed', True])
return True
def process_oauth(func):
@wraps(func)
def wrapper(*args, **kwargs):
auth = request.headers.get('authorization', '')
if auth:
normalized = [part.strip() for part in auth.split(' ') if part]
if normalized[0].lower() != 'bearer' or len(normalized) != 2:
logger.debug('Invalid oauth bearer token format.')
return func(*args, **kwargs)
token = normalized[1]
_validate_and_apply_oauth_token(token)
elif _load_user_from_cookie() is None:
logger.debug('No auth header or login cookie.')
return func(*args, **kwargs)
return wrapper
def process_auth(func):
@wraps(func)
def wrapper(*args, **kwargs):
auth = request.headers.get('authorization', '')
if auth:
logger.debug('Validating auth header: %s', auth)
_process_signed_grant(auth)
_process_basic_auth(auth)
else:
logger.debug('No auth header.')
return func(*args, **kwargs)
return wrapper
def process_auth_or_cookie(func):
@wraps(func)
def wrapper(*args, **kwargs):
auth = request.headers.get('authorization', '')
if auth:
logger.debug('Validating auth header: %s', auth)
_process_basic_auth(auth)
else:
logger.debug('No auth header.')
_load_user_from_cookie()
return func(*args, **kwargs)
return wrapper
process_oauth = _auth_decorator(handlers=[validate_bearer_auth, validate_session_cookie])
process_auth = _auth_decorator(handlers=[validate_signed_grant, validate_basic_auth])
process_auth_or_cookie = _auth_decorator(handlers=[validate_basic_auth, validate_session_cookie])
process_basic_auth = _auth_decorator(handlers=[validate_basic_auth], pass_result=True)
def require_session_login(func):
""" Decorates a function and ensures that a valid session cookie exists or a 401 is raised. If
a valid session cookie does exist, the authenticated user and identity are also set.
"""
@wraps(func)
def wrapper(*args, **kwargs):
loaded = _load_user_from_cookie()
if loaded is None or loaded.organization:
abort(401, message='Method requires login and no valid login could be loaded.')
return func(*args, **kwargs)
result = validate_session_cookie()
if result.has_user:
result.apply_to_context()
metric_queue.authentication_count.Inc(labelvalues=[result.kind, True])
return func(*args, **kwargs)
elif not result.missing:
metric_queue.authentication_count.Inc(labelvalues=[result.kind, False])
abort(401, message='Method requires login and no valid login could be loaded.')
return wrapper
def extract_namespace_repo_from_session(func):
""" Extracts the namespace and repository name from the current session (which must exist)
and passes them into the decorated function as the first and second arguments. If the
session doesn't exist or does not contain these arugments, a 400 error is raised.
"""
@wraps(func)
def wrapper(*args, **kwargs):
if 'namespace' not in session or 'repository' not in session: