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:
parent
1bd4422da9
commit
651666b60b
18 changed files with 830 additions and 455 deletions
|
@ -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:
|
||||
|
|
Reference in a new issue