Merge pull request #2428 from coreos-inc/auth-cleanup-and-messaging
Auth cleanup and messaging
This commit is contained in:
commit
3976735230
42 changed files with 900 additions and 515 deletions
100
auth/basic.py
Normal file
100
auth/basic.py
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from base64 import b64decode
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from app import authentication
|
||||||
|
from auth.oauth import validate_oauth_token
|
||||||
|
from auth.validateresult import ValidateResult, AuthKind
|
||||||
|
from data import model
|
||||||
|
from util.names import parse_robot_username
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ACCESS_TOKEN_USERNAME = '$token'
|
||||||
|
OAUTH_TOKEN_USERNAME = '$oauthtoken'
|
||||||
|
|
||||||
|
|
||||||
|
def has_basic_auth(username):
|
||||||
|
""" Returns true if a basic auth header exists with a username and password pair that validates
|
||||||
|
against the internal authentication system. Returns True on full success and False on any
|
||||||
|
failure (missing header, invalid header, invalid credentials, etc).
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get('authorization', '')
|
||||||
|
result = validate_basic_auth(auth_header)
|
||||||
|
return result.has_user and result.user.username == username
|
||||||
|
|
||||||
|
|
||||||
|
def validate_basic_auth(auth_header):
|
||||||
|
""" Validates the specified basic auth header, returning whether its credentials point
|
||||||
|
to a valid user or token.
|
||||||
|
"""
|
||||||
|
if not auth_header:
|
||||||
|
return ValidateResult(AuthKind.basic, missing=True)
|
||||||
|
|
||||||
|
logger.debug('Attempt to process basic auth header')
|
||||||
|
|
||||||
|
# Parse the basic auth header.
|
||||||
|
credentials, err = _parse_basic_auth_header(auth_header)
|
||||||
|
if err is not None:
|
||||||
|
logger.debug('Got invalid basic auth header: %s', auth_header)
|
||||||
|
return ValidateResult(AuthKind.basic, missing=True)
|
||||||
|
|
||||||
|
auth_username, auth_password_or_token = credentials
|
||||||
|
|
||||||
|
# Check for access tokens.
|
||||||
|
if auth_username == ACCESS_TOKEN_USERNAME:
|
||||||
|
logger.debug('Found basic auth header for access token')
|
||||||
|
try:
|
||||||
|
token = model.token.load_token_data(auth_password_or_token)
|
||||||
|
logger.debug('Successfully validated basic auth for access token %s', token.id)
|
||||||
|
return ValidateResult(AuthKind.basic, token=token)
|
||||||
|
except model.DataModelException:
|
||||||
|
logger.warning('Failed to validate basic auth for access token %s', auth_password_or_token)
|
||||||
|
return ValidateResult(AuthKind.basic, error_message='Invalid access token')
|
||||||
|
|
||||||
|
# Check for OAuth tokens.
|
||||||
|
if auth_username == OAUTH_TOKEN_USERNAME:
|
||||||
|
return validate_oauth_token(auth_password_or_token)
|
||||||
|
|
||||||
|
# Check for robots and users.
|
||||||
|
is_robot = parse_robot_username(auth_username)
|
||||||
|
if is_robot:
|
||||||
|
logger.debug('Found basic auth header for robot %s', auth_username)
|
||||||
|
try:
|
||||||
|
robot = model.user.verify_robot(auth_username, auth_password_or_token)
|
||||||
|
|
||||||
|
logger.debug('Successfully validated basic auth for robot %s', auth_username)
|
||||||
|
return ValidateResult(AuthKind.basic, robot=robot)
|
||||||
|
except model.InvalidRobotException as ire:
|
||||||
|
logger.warning('Failed to validate basic auth for robot %s: %s', auth_username, ire.message)
|
||||||
|
return ValidateResult(AuthKind.basic, error_message=ire.message)
|
||||||
|
|
||||||
|
# Otherwise, treat as a standard user.
|
||||||
|
(authenticated, err) = authentication.verify_and_link_user(auth_username, auth_password_or_token,
|
||||||
|
basic_auth=True)
|
||||||
|
if authenticated:
|
||||||
|
logger.debug('Successfully validated basic auth for user %s', authenticated.username)
|
||||||
|
return ValidateResult(AuthKind.basic, user=authenticated)
|
||||||
|
else:
|
||||||
|
logger.warning('Failed to validate basic auth for user %s: %s', auth_username, err)
|
||||||
|
return ValidateResult(AuthKind.basic, error_message=err)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_basic_auth_header(auth):
|
||||||
|
""" Parses the given basic auth header, returning the credentials found inside.
|
||||||
|
"""
|
||||||
|
normalized = [part.strip() for part in auth.split(' ') if part]
|
||||||
|
if normalized[0].lower() != 'basic' or len(normalized) != 2:
|
||||||
|
return None, 'Invalid basic auth header'
|
||||||
|
|
||||||
|
try:
|
||||||
|
credentials = [part.decode('utf-8') for part in b64decode(normalized[1]).split(':', 1)]
|
||||||
|
except TypeError:
|
||||||
|
logger.exception('Exception when parsing basic auth header: %s', auth)
|
||||||
|
return None, 'Could not parse basic auth header'
|
||||||
|
|
||||||
|
if len(credentials) != 2:
|
||||||
|
return None, 'Unexpected number of credentials found in basic auth header'
|
||||||
|
|
||||||
|
return credentials, None
|
37
auth/cookie.py
Normal file
37
auth/cookie.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from auth.validateresult import AuthKind, ValidateResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def validate_session_cookie(auth_header_unusued=None):
|
||||||
|
""" Attempts to load a user from a session cookie. """
|
||||||
|
if current_user.is_anonymous:
|
||||||
|
return ValidateResult(AuthKind.cookie, missing=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Attempt to parse the user uuid to make sure the cookie has the right value type
|
||||||
|
UUID(current_user.get_id())
|
||||||
|
except ValueError:
|
||||||
|
logger.debug('Got non-UUID for session cookie user: %s', current_user.get_id())
|
||||||
|
return ValidateResult(AuthKind.cookie, error_message='Invalid session cookie format')
|
||||||
|
|
||||||
|
logger.debug('Loading user from cookie: %s', current_user.get_id())
|
||||||
|
db_user = current_user.db_user()
|
||||||
|
if db_user is None:
|
||||||
|
return ValidateResult(AuthKind.cookie, error_message='Could not find matching user')
|
||||||
|
|
||||||
|
# Don't allow disabled users to login.
|
||||||
|
if not db_user.enabled:
|
||||||
|
logger.debug('User %s in session cookie is disabled', db_user.username)
|
||||||
|
return ValidateResult(AuthKind.cookie, error_message='User account is disabled')
|
||||||
|
|
||||||
|
# Don't allow organizations to "login".
|
||||||
|
if db_user.organization:
|
||||||
|
logger.debug('User %s in session cookie is in-fact organization', db_user.username)
|
||||||
|
return ValidateResult(AuthKind.cookie, error_message='Cannot login to organization')
|
||||||
|
|
||||||
|
return ValidateResult(AuthKind.cookie, user=db_user)
|
95
auth/decorators.py
Normal file
95
auth/decorators.py
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
from flask import request, session
|
||||||
|
|
||||||
|
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 util.http import abort
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Check for a valid result.
|
||||||
|
if result.auth_valid:
|
||||||
|
logger.debug('Found valid auth result: %s => (%s)', result.tuple())
|
||||||
|
|
||||||
|
# Set the various pieces of the auth context.
|
||||||
|
result.apply_to_context()
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
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:
|
||||||
|
logger.error('Unable to load namespace or repository from session: %s', session)
|
||||||
|
abort(400, message='Missing namespace in request')
|
||||||
|
|
||||||
|
return func(session['namespace'], session['repository'], *args, **kwargs)
|
||||||
|
return wrapper
|
49
auth/oauth.py
Normal file
49
auth/oauth.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from auth.scopes import scopes_from_scope_string
|
||||||
|
from auth.validateresult import AuthKind, ValidateResult
|
||||||
|
from data import model
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def validate_bearer_auth(auth_header):
|
||||||
|
""" Validates an OAuth token found inside a basic auth `Bearer` token, returning whether it
|
||||||
|
points to a valid OAuth token.
|
||||||
|
"""
|
||||||
|
if not auth_header:
|
||||||
|
return ValidateResult(AuthKind.oauth, missing=True)
|
||||||
|
|
||||||
|
normalized = [part.strip() for part in auth_header.split(' ') if part]
|
||||||
|
if normalized[0].lower() != 'bearer' or len(normalized) != 2:
|
||||||
|
logger.debug('Got invalid bearer token format: %s', auth_header)
|
||||||
|
return ValidateResult(AuthKind.oauth, missing=True)
|
||||||
|
|
||||||
|
(_, oauth_token) = normalized
|
||||||
|
return validate_oauth_token(oauth_token)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_oauth_token(token):
|
||||||
|
""" Validates the specified OAuth token, returning whether it points to a valid OAuth token.
|
||||||
|
"""
|
||||||
|
validated = model.oauth.validate_access_token(token)
|
||||||
|
if not validated:
|
||||||
|
logger.warning('OAuth access token could not be validated: %s', token)
|
||||||
|
return ValidateResult(AuthKind.oauth,
|
||||||
|
error_message='OAuth access token could not be validated')
|
||||||
|
|
||||||
|
if validated.expires_at <= datetime.utcnow():
|
||||||
|
logger.info('OAuth access with an expired token: %s', token)
|
||||||
|
return ValidateResult(AuthKind.oauth, error_message='OAuth access token has expired')
|
||||||
|
|
||||||
|
# Don't allow disabled users to login.
|
||||||
|
if not validated.authorized_user.enabled:
|
||||||
|
return ValidateResult(AuthKind.oauth,
|
||||||
|
error_message='Granter of the oauth access token is disabled')
|
||||||
|
|
||||||
|
# We have a valid token
|
||||||
|
scope_set = scopes_from_scope_string(validated.scope)
|
||||||
|
logger.debug('Successfully validated oauth access token: %s with scope: %s', token,
|
||||||
|
scope_set)
|
||||||
|
return ValidateResult(AuthKind.oauth, oauthtoken=validated)
|
292
auth/process.py
292
auth/process.py
|
@ -1,292 +0,0 @@
|
||||||
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 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 _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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
set_authenticated_user(db_user)
|
|
||||||
loaded = QuayDeferredPermissionUser.for_user(db_user)
|
|
||||||
identity_changed.send(app, identity=loaded)
|
|
||||||
return db_user
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def require_session_login(func):
|
|
||||||
@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)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def extract_namespace_repo_from_session(func):
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
if 'namespace' not in session or 'repository' not in session:
|
|
||||||
logger.error('Unable to load namespace or repository from session: %s', session)
|
|
||||||
abort(400, message='Missing namespace in request')
|
|
||||||
|
|
||||||
return func(session['namespace'], session['repository'], *args, **kwargs)
|
|
||||||
return wrapper
|
|
55
auth/signedgrant.py
Normal file
55
auth/signedgrant.py
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask.sessions import SecureCookieSessionInterface, BadSignature
|
||||||
|
|
||||||
|
from app import app
|
||||||
|
from auth.validateresult import AuthKind, ValidateResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# The prefix for all signatures of signed granted.
|
||||||
|
SIGNATURE_PREFIX = 'sigv2='
|
||||||
|
|
||||||
|
def generate_signed_token(grants, user_context):
|
||||||
|
""" Generates a signed session token with the given grants and 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 validate_signed_grant(auth_header):
|
||||||
|
""" Validates a signed grant as found inside an auth header and returns whether it points to
|
||||||
|
a valid grant.
|
||||||
|
"""
|
||||||
|
if not auth_header:
|
||||||
|
return ValidateResult(AuthKind.signed_grant, missing=True)
|
||||||
|
|
||||||
|
# Try to parse the token from the header.
|
||||||
|
normalized = [part.strip() for part in auth_header.split(' ') if part]
|
||||||
|
if normalized[0].lower() != 'token' or len(normalized) != 2:
|
||||||
|
logger.debug('Not a token: %s', auth_header)
|
||||||
|
return ValidateResult(AuthKind.signed_grant, missing=True)
|
||||||
|
|
||||||
|
# Check that it starts with the expected prefix.
|
||||||
|
if not normalized[1].startswith(SIGNATURE_PREFIX):
|
||||||
|
logger.debug('Not a signed grant token: %s', auth_header)
|
||||||
|
return ValidateResult(AuthKind.signed_grant, missing=True)
|
||||||
|
|
||||||
|
# Decrypt the grant.
|
||||||
|
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)
|
||||||
|
return ValidateResult(AuthKind.signed_grant,
|
||||||
|
error_message='Signed grant could not be validated')
|
||||||
|
|
||||||
|
logger.debug('Successfully validated signed grant with data: %s', token_data)
|
||||||
|
return ValidateResult(AuthKind.signed_grant, signed_data=token_data)
|
69
auth/test/test_basic.py
Normal file
69
auth/test/test_basic.py
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from base64 import b64encode
|
||||||
|
|
||||||
|
from auth.basic import validate_basic_auth, ACCESS_TOKEN_USERNAME, OAUTH_TOKEN_USERNAME
|
||||||
|
from auth.validateresult import AuthKind, ValidateResult
|
||||||
|
from data import model
|
||||||
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
|
|
||||||
|
def _token(username, password):
|
||||||
|
return 'basic ' + b64encode('%s:%s' % (username, password))
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('token, expected_result', [
|
||||||
|
('', ValidateResult(AuthKind.basic, missing=True)),
|
||||||
|
('someinvalidtoken', ValidateResult(AuthKind.basic, missing=True)),
|
||||||
|
('somefoobartoken', ValidateResult(AuthKind.basic, missing=True)),
|
||||||
|
('basic ', ValidateResult(AuthKind.basic, missing=True)),
|
||||||
|
('basic some token', ValidateResult(AuthKind.basic, missing=True)),
|
||||||
|
('basic sometoken', ValidateResult(AuthKind.basic, missing=True)),
|
||||||
|
|
||||||
|
(_token(ACCESS_TOKEN_USERNAME, 'invalid'),
|
||||||
|
ValidateResult(AuthKind.basic, error_message='Invalid access token')),
|
||||||
|
|
||||||
|
(_token(OAUTH_TOKEN_USERNAME, 'invalid'),
|
||||||
|
ValidateResult(AuthKind.oauth, error_message='OAuth access token could not be validated')),
|
||||||
|
|
||||||
|
(_token('devtable', 'invalid'),
|
||||||
|
ValidateResult(AuthKind.basic, error_message='Invalid Username or Password')),
|
||||||
|
|
||||||
|
(_token('devtable+somebot', 'invalid'),
|
||||||
|
ValidateResult(AuthKind.basic,
|
||||||
|
error_message='Could not find robot with username: devtable+somebot ' +
|
||||||
|
'and supplied password.')),
|
||||||
|
|
||||||
|
(_token('disabled', 'password'),
|
||||||
|
ValidateResult(AuthKind.basic,
|
||||||
|
error_message='This user has been disabled. Please contact your administrator.')),
|
||||||
|
])
|
||||||
|
def test_validate_basic_auth_token(token, expected_result, app):
|
||||||
|
result = validate_basic_auth(token)
|
||||||
|
assert result == expected_result
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_user(app):
|
||||||
|
token = _token('devtable', 'password')
|
||||||
|
result = validate_basic_auth(token)
|
||||||
|
assert result == ValidateResult(AuthKind.basic, user=model.user.get_user('devtable'))
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_robot(app):
|
||||||
|
robot, password = model.user.create_robot('somerobot', model.user.get_user('devtable'))
|
||||||
|
token = _token(robot.username, password)
|
||||||
|
result = validate_basic_auth(token)
|
||||||
|
assert result == ValidateResult(AuthKind.basic, robot=robot)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_token(app):
|
||||||
|
access_token = model.token.create_delegate_token('devtable', 'simple', 'sometoken')
|
||||||
|
token = _token(ACCESS_TOKEN_USERNAME, access_token.code)
|
||||||
|
result = validate_basic_auth(token)
|
||||||
|
assert result == ValidateResult(AuthKind.basic, token=access_token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_oauth(app):
|
||||||
|
user = model.user.get_user('devtable')
|
||||||
|
oauth_token = list(model.oauth.list_access_tokens_for_user(user))[0]
|
||||||
|
token = _token(OAUTH_TOKEN_USERNAME, oauth_token.access_token)
|
||||||
|
result = validate_basic_auth(token)
|
||||||
|
assert result == ValidateResult(AuthKind.oauth, oauthtoken=oauth_token)
|
61
auth/test/test_cookie.py
Normal file
61
auth/test/test_cookie.py
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from flask_login import login_user
|
||||||
|
|
||||||
|
from app import LoginWrappedDBUser
|
||||||
|
from data import model
|
||||||
|
from auth.cookie import validate_session_cookie
|
||||||
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
|
|
||||||
|
def test_anonymous_cookie(app):
|
||||||
|
assert validate_session_cookie().missing
|
||||||
|
|
||||||
|
def test_invalidformatted_cookie(app):
|
||||||
|
# "Login" with a non-UUID reference.
|
||||||
|
someuser = model.user.get_user('devtable')
|
||||||
|
login_user(LoginWrappedDBUser('somenonuuid', someuser))
|
||||||
|
|
||||||
|
# Ensure we get an invalid session cookie format error.
|
||||||
|
result = validate_session_cookie()
|
||||||
|
assert result.authed_user is None
|
||||||
|
assert result.identity is None
|
||||||
|
assert not result.has_user
|
||||||
|
assert result.error_message == 'Invalid session cookie format'
|
||||||
|
|
||||||
|
def test_disabled_user(app):
|
||||||
|
# "Login" with a disabled user.
|
||||||
|
someuser = model.user.get_user('disabled')
|
||||||
|
login_user(LoginWrappedDBUser(someuser.uuid, someuser))
|
||||||
|
|
||||||
|
# Ensure we get an invalid session cookie format error.
|
||||||
|
result = validate_session_cookie()
|
||||||
|
assert result.authed_user is None
|
||||||
|
assert result.identity is None
|
||||||
|
assert not result.has_user
|
||||||
|
assert result.error_message == 'User account is disabled'
|
||||||
|
|
||||||
|
def test_valid_user(app):
|
||||||
|
# Login with a valid user.
|
||||||
|
someuser = model.user.get_user('devtable')
|
||||||
|
login_user(LoginWrappedDBUser(someuser.uuid, someuser))
|
||||||
|
|
||||||
|
result = validate_session_cookie()
|
||||||
|
assert result.authed_user == someuser
|
||||||
|
assert result.identity is not None
|
||||||
|
assert result.has_user
|
||||||
|
assert result.error_message is None
|
||||||
|
|
||||||
|
def test_valid_organization(app):
|
||||||
|
# "Login" with a valid organization.
|
||||||
|
someorg = model.user.get_namespace_user('buynlarge')
|
||||||
|
someorg.uuid = str(uuid.uuid4())
|
||||||
|
someorg.verified = True
|
||||||
|
someorg.save()
|
||||||
|
|
||||||
|
login_user(LoginWrappedDBUser(someorg.uuid, someorg))
|
||||||
|
|
||||||
|
result = validate_session_cookie()
|
||||||
|
assert result.authed_user is None
|
||||||
|
assert result.identity is None
|
||||||
|
assert not result.has_user
|
||||||
|
assert result.error_message == 'Cannot login to organization'
|
105
auth/test/test_decorators.py
Normal file
105
auth/test/test_decorators.py
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from flask import session
|
||||||
|
from flask_login import login_user
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
|
from app import LoginWrappedDBUser
|
||||||
|
from auth.auth_context import get_authenticated_user
|
||||||
|
from auth.decorators import (extract_namespace_repo_from_session, require_session_login,
|
||||||
|
process_auth_or_cookie)
|
||||||
|
from data import model
|
||||||
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
|
|
||||||
|
def test_extract_namespace_repo_from_session_missing(app):
|
||||||
|
def emptyfunc():
|
||||||
|
pass
|
||||||
|
|
||||||
|
session.clear()
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
extract_namespace_repo_from_session(emptyfunc)()
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_namespace_repo_from_session_present(app):
|
||||||
|
encountered = []
|
||||||
|
|
||||||
|
def somefunc(namespace, repository):
|
||||||
|
encountered.append(namespace)
|
||||||
|
encountered.append(repository)
|
||||||
|
|
||||||
|
# Add the namespace and repository to the session.
|
||||||
|
session.clear()
|
||||||
|
session['namespace'] = 'foo'
|
||||||
|
session['repository'] = 'bar'
|
||||||
|
|
||||||
|
# Call the decorated method.
|
||||||
|
extract_namespace_repo_from_session(somefunc)()
|
||||||
|
|
||||||
|
assert encountered[0] == 'foo'
|
||||||
|
assert encountered[1] == 'bar'
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_session_login_missing(app):
|
||||||
|
def emptyfunc():
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
require_session_login(emptyfunc)()
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_session_login_valid_user(app):
|
||||||
|
def emptyfunc():
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Login as a valid user.
|
||||||
|
someuser = model.user.get_user('devtable')
|
||||||
|
login_user(LoginWrappedDBUser(someuser.uuid, someuser))
|
||||||
|
|
||||||
|
# Call the function.
|
||||||
|
require_session_login(emptyfunc)()
|
||||||
|
|
||||||
|
# Ensure the authenticated user was updated.
|
||||||
|
assert get_authenticated_user() == someuser
|
||||||
|
|
||||||
|
|
||||||
|
def test_require_session_login_invalid_user(app):
|
||||||
|
def emptyfunc():
|
||||||
|
pass
|
||||||
|
|
||||||
|
# "Login" as a disabled user.
|
||||||
|
someuser = model.user.get_user('disabled')
|
||||||
|
login_user(LoginWrappedDBUser(someuser.uuid, someuser))
|
||||||
|
|
||||||
|
# Call the function.
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
require_session_login(emptyfunc)()
|
||||||
|
|
||||||
|
# Ensure the authenticated user was not updated.
|
||||||
|
assert get_authenticated_user() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_auth_or_cookie_invalid_user(app):
|
||||||
|
def emptyfunc():
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Call the function.
|
||||||
|
process_auth_or_cookie(emptyfunc)()
|
||||||
|
|
||||||
|
# Ensure the authenticated user was not updated.
|
||||||
|
assert get_authenticated_user() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_auth_or_cookie_valid_user(app):
|
||||||
|
def emptyfunc():
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Login as a valid user.
|
||||||
|
someuser = model.user.get_user('devtable')
|
||||||
|
login_user(LoginWrappedDBUser(someuser.uuid, someuser))
|
||||||
|
|
||||||
|
# Call the function.
|
||||||
|
process_auth_or_cookie(emptyfunc)()
|
||||||
|
|
||||||
|
# Ensure the authenticated user was updated.
|
||||||
|
assert get_authenticated_user() == someuser
|
||||||
|
|
48
auth/test/test_oauth.py
Normal file
48
auth/test/test_oauth.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from auth.oauth import validate_bearer_auth, validate_oauth_token
|
||||||
|
from auth.validateresult import AuthKind, ValidateResult
|
||||||
|
from data import model
|
||||||
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('header, expected_result', [
|
||||||
|
('', ValidateResult(AuthKind.oauth, missing=True)),
|
||||||
|
('somerandomtoken', ValidateResult(AuthKind.oauth, missing=True)),
|
||||||
|
('bearer some random token', ValidateResult(AuthKind.oauth, missing=True)),
|
||||||
|
|
||||||
|
('bearer invalidtoken',
|
||||||
|
ValidateResult(AuthKind.oauth, error_message='OAuth access token could not be validated')),
|
||||||
|
])
|
||||||
|
def test_bearer(header, expected_result, app):
|
||||||
|
assert validate_bearer_auth(header) == expected_result
|
||||||
|
|
||||||
|
def test_valid_oauth(app):
|
||||||
|
user = model.user.get_user('devtable')
|
||||||
|
token = list(model.oauth.list_access_tokens_for_user(user))[0]
|
||||||
|
|
||||||
|
result = validate_bearer_auth('bearer ' + token.access_token)
|
||||||
|
assert result.oauthtoken == token
|
||||||
|
assert result.authed_user == user
|
||||||
|
assert result.auth_valid
|
||||||
|
|
||||||
|
def test_disabled_user_oauth(app):
|
||||||
|
user = model.user.get_user('disabled')
|
||||||
|
token = model.oauth.create_access_token_for_testing(user, 'deadbeef', 'repo:admin',
|
||||||
|
access_token='foo')
|
||||||
|
|
||||||
|
result = validate_bearer_auth('bearer ' + token.access_token)
|
||||||
|
assert result.oauthtoken is None
|
||||||
|
assert result.authed_user is None
|
||||||
|
assert not result.auth_valid
|
||||||
|
assert result.error_message == 'Granter of the oauth access token is disabled'
|
||||||
|
|
||||||
|
def test_expired_token(app):
|
||||||
|
user = model.user.get_user('devtable')
|
||||||
|
token = model.oauth.create_access_token_for_testing(user, 'deadbeef', 'repo:admin',
|
||||||
|
access_token='bar', expires_in=-1000)
|
||||||
|
|
||||||
|
result = validate_bearer_auth('bearer ' + token.access_token)
|
||||||
|
assert result.oauthtoken is None
|
||||||
|
assert result.authed_user is None
|
||||||
|
assert not result.auth_valid
|
||||||
|
assert result.error_message == 'OAuth access token has expired'
|
50
auth/test/test_scopes.py
Normal file
50
auth/test/test_scopes.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from auth.scopes import (scopes_from_scope_string, validate_scope_string, ALL_SCOPES,
|
||||||
|
is_subset_string)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('scopes_string, expected', [
|
||||||
|
# Valid single scopes.
|
||||||
|
('repo:read', ['repo:read']),
|
||||||
|
('repo:admin', ['repo:admin']),
|
||||||
|
|
||||||
|
# Invalid scopes.
|
||||||
|
('not:valid', []),
|
||||||
|
('repo:admins', []),
|
||||||
|
|
||||||
|
# Valid scope strings.
|
||||||
|
('repo:read repo:admin', ['repo:read', 'repo:admin']),
|
||||||
|
('repo:read,repo:admin', ['repo:read', 'repo:admin']),
|
||||||
|
('repo:read,repo:admin repo:write', ['repo:read', 'repo:admin', 'repo:write']),
|
||||||
|
|
||||||
|
# Partially invalid scopes.
|
||||||
|
('repo:read,not:valid', []),
|
||||||
|
('repo:read repo:admins', []),
|
||||||
|
|
||||||
|
# Invalid scope strings.
|
||||||
|
('repo:read|repo:admin', []),
|
||||||
|
|
||||||
|
# Mixture of delimiters.
|
||||||
|
('repo:read, repo:admin', []),
|
||||||
|
])
|
||||||
|
def test_parsing(scopes_string, expected):
|
||||||
|
expected_scope_set = {ALL_SCOPES[scope_name] for scope_name in expected}
|
||||||
|
parsed_scope_set = scopes_from_scope_string(scopes_string)
|
||||||
|
assert parsed_scope_set == expected_scope_set
|
||||||
|
assert validate_scope_string(scopes_string) == bool(expected)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('superset, subset, result', [
|
||||||
|
('repo:read', 'repo:read', True),
|
||||||
|
('repo:read repo:admin', 'repo:read', True),
|
||||||
|
('repo:read,repo:admin', 'repo:read', True),
|
||||||
|
('repo:read,repo:admin', 'repo:admin', True),
|
||||||
|
('repo:read,repo:admin', 'repo:admin repo:read', True),
|
||||||
|
|
||||||
|
('', 'repo:read', False),
|
||||||
|
('unknown:tag', 'repo:read', False),
|
||||||
|
('repo:read unknown:tag', 'repo:read', False),
|
||||||
|
('repo:read,unknown:tag', 'repo:read', False),
|
||||||
|
])
|
||||||
|
def test_subset_string(superset, subset, result):
|
||||||
|
assert is_subset_string(superset, subset) == result
|
18
auth/test/test_signedgrant.py
Normal file
18
auth/test/test_signedgrant.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from auth.signedgrant import validate_signed_grant, generate_signed_token, SIGNATURE_PREFIX
|
||||||
|
from auth.validateresult import AuthKind, ValidateResult
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('header, expected_result', [
|
||||||
|
('', ValidateResult(AuthKind.signed_grant, missing=True)),
|
||||||
|
('somerandomtoken', ValidateResult(AuthKind.signed_grant, missing=True)),
|
||||||
|
('token somerandomtoken', ValidateResult(AuthKind.signed_grant, missing=True)),
|
||||||
|
|
||||||
|
('token ' + SIGNATURE_PREFIX + 'foo',
|
||||||
|
ValidateResult(AuthKind.signed_grant, error_message='Signed grant could not be validated')),
|
||||||
|
|
||||||
|
('token ' + generate_signed_token({'a': 'b'}, {'c': 'd'}),
|
||||||
|
ValidateResult(AuthKind.signed_grant, signed_data={'grants': {'a': 'b'}, 'user_context': {'c': 'd'}})),
|
||||||
|
])
|
||||||
|
def test_token(header, expected_result):
|
||||||
|
assert validate_signed_grant(header) == expected_result
|
63
auth/test/test_validateresult.py
Normal file
63
auth/test/test_validateresult.py
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from auth.auth_context import (get_authenticated_user, get_grant_context, get_validated_token,
|
||||||
|
get_validated_oauth_token)
|
||||||
|
from auth.validateresult import AuthKind, ValidateResult
|
||||||
|
from data import model
|
||||||
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
|
|
||||||
|
|
||||||
|
def get_user():
|
||||||
|
return model.user.get_user('devtable')
|
||||||
|
|
||||||
|
def get_robot():
|
||||||
|
robot, _ = model.user.create_robot('somebot', get_user())
|
||||||
|
return robot
|
||||||
|
|
||||||
|
def get_token():
|
||||||
|
return model.token.create_delegate_token('devtable', 'simple', 'sometoken')
|
||||||
|
|
||||||
|
def get_oauthtoken():
|
||||||
|
user = model.user.get_user('devtable')
|
||||||
|
return list(model.oauth.list_access_tokens_for_user(user))[0]
|
||||||
|
|
||||||
|
def get_signeddata():
|
||||||
|
return {'grants': {'a': 'b'}, 'user_context': {'c': 'd'}}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('get_entity,entity_kind', [
|
||||||
|
(get_user, 'user'),
|
||||||
|
(get_robot, 'robot'),
|
||||||
|
(get_token, 'token'),
|
||||||
|
(get_oauthtoken, 'oauthtoken'),
|
||||||
|
(get_signeddata, 'signed_data'),
|
||||||
|
])
|
||||||
|
def test_apply_context(get_entity, entity_kind, app):
|
||||||
|
assert get_authenticated_user() is None
|
||||||
|
assert get_validated_token() is None
|
||||||
|
assert get_validated_oauth_token() is None
|
||||||
|
assert get_grant_context() is None
|
||||||
|
|
||||||
|
entity = get_entity()
|
||||||
|
args = {}
|
||||||
|
args[entity_kind] = entity
|
||||||
|
|
||||||
|
result = ValidateResult(AuthKind.basic, **args)
|
||||||
|
result.apply_to_context()
|
||||||
|
|
||||||
|
expected_user = entity if entity_kind == 'user' or entity_kind == 'robot' else None
|
||||||
|
if entity_kind == 'oauthtoken':
|
||||||
|
expected_user = entity.authorized_user
|
||||||
|
|
||||||
|
expected_token = entity if entity_kind == 'token' else None
|
||||||
|
expected_oauth = entity if entity_kind == 'oauthtoken' else None
|
||||||
|
|
||||||
|
fake_grant = {
|
||||||
|
'user': {'c': 'd'},
|
||||||
|
'kind': 'user',
|
||||||
|
}
|
||||||
|
expected_grant = fake_grant if entity_kind == 'signed_data' else None
|
||||||
|
|
||||||
|
assert get_authenticated_user() == expected_user
|
||||||
|
assert get_validated_token() == expected_token
|
||||||
|
assert get_validated_oauth_token() == expected_oauth
|
||||||
|
assert get_grant_context() == expected_grant
|
101
auth/validateresult.py
Normal file
101
auth/validateresult.py
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
from enum import Enum
|
||||||
|
from flask_principal import Identity, identity_changed
|
||||||
|
|
||||||
|
from app import app
|
||||||
|
from auth.auth_context import (set_authenticated_user, set_validated_token, set_grant_context,
|
||||||
|
set_validated_oauth_token)
|
||||||
|
from auth.scopes import scopes_from_scope_string
|
||||||
|
from auth.permissions import QuayDeferredPermissionUser
|
||||||
|
|
||||||
|
|
||||||
|
class AuthKind(Enum):
|
||||||
|
cookie = 'cookie'
|
||||||
|
basic = 'basic'
|
||||||
|
oauth = 'oauth'
|
||||||
|
signed_grant = 'signed_grant'
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateResult(object):
|
||||||
|
""" A result of validating auth in one form or another. """
|
||||||
|
def __init__(self, kind, missing=False, user=None, token=None, oauthtoken=None,
|
||||||
|
robot=None, signed_data=None, error_message=None):
|
||||||
|
self.kind = kind
|
||||||
|
self.missing = missing
|
||||||
|
self.user = user
|
||||||
|
self.robot = robot
|
||||||
|
self.token = token
|
||||||
|
self.oauthtoken = oauthtoken
|
||||||
|
self.signed_data = signed_data
|
||||||
|
self.error_message = error_message
|
||||||
|
|
||||||
|
def tuple(self):
|
||||||
|
return (self.kind, self.missing, self.user, self.token, self.oauthtoken, self.robot,
|
||||||
|
self.signed_data, self.error_message)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.tuple() == other.tuple()
|
||||||
|
|
||||||
|
def apply_to_context(self):
|
||||||
|
""" Applies this auth result to the auth context and Flask-Principal. """
|
||||||
|
# Set the various pieces of the auth context.
|
||||||
|
if self.oauthtoken:
|
||||||
|
set_authenticated_user(self.authed_user)
|
||||||
|
set_validated_oauth_token(self.oauthtoken)
|
||||||
|
elif self.authed_user:
|
||||||
|
set_authenticated_user(self.authed_user)
|
||||||
|
elif self.token:
|
||||||
|
set_validated_token(self.token)
|
||||||
|
elif self.signed_data:
|
||||||
|
if self.signed_data['user_context']:
|
||||||
|
set_grant_context({
|
||||||
|
'user': self.signed_data['user_context'],
|
||||||
|
'kind': 'user',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Set the identity for Flask-Principal.
|
||||||
|
if self.identity:
|
||||||
|
identity_changed.send(app, identity=self.identity)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def authed_user(self):
|
||||||
|
""" Returns the authenticated user, whether directly, or via an OAuth token. """
|
||||||
|
if not self.auth_valid:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.oauthtoken:
|
||||||
|
return self.oauthtoken.authorized_user
|
||||||
|
|
||||||
|
return self.user if self.user else self.robot
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity(self):
|
||||||
|
""" Returns the identity for the auth result. """
|
||||||
|
if not self.auth_valid:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.oauthtoken:
|
||||||
|
scope_set = scopes_from_scope_string(self.oauthtoken.scope)
|
||||||
|
return QuayDeferredPermissionUser.for_user(self.oauthtoken.authorized_user, scope_set)
|
||||||
|
|
||||||
|
if self.authed_user:
|
||||||
|
return QuayDeferredPermissionUser.for_user(self.authed_user)
|
||||||
|
|
||||||
|
if self.token:
|
||||||
|
return Identity(self.token.code, 'token')
|
||||||
|
|
||||||
|
if self.signed_data:
|
||||||
|
identity = Identity(None, 'signed_grant')
|
||||||
|
identity.provides.update(self.signed_data['grants'])
|
||||||
|
return identity
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_user(self):
|
||||||
|
""" Returns whether a user (not a robot) was authenticated successfully. """
|
||||||
|
return bool(self.user)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_valid(self):
|
||||||
|
""" Returns whether authentication successfully occurred. """
|
||||||
|
return self.user or self.token or self.oauthtoken or self.robot or self.signed_data
|
|
@ -283,8 +283,9 @@ def list_applications_for_org(org):
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
|
||||||
def create_access_token_for_testing(user_obj, client_id, scope, access_token='test'):
|
def create_access_token_for_testing(user_obj, client_id, scope, access_token='test',
|
||||||
expires_at = datetime.utcnow() + timedelta(seconds=10000)
|
expires_in=10000):
|
||||||
|
expires_at = datetime.utcnow() + timedelta(seconds=expires_in)
|
||||||
application = get_application_for_client_id(client_id)
|
application = get_application_for_client_id(client_id)
|
||||||
created = OAuthAccessToken.create(application=application, authorized_user=user_obj, scope=scope,
|
created = OAuthAccessToken.create(application=application, authorized_user=user_obj, scope=scope,
|
||||||
token_type='token', access_token=access_token,
|
token_type='token', access_token=access_token,
|
||||||
|
|
|
@ -2,8 +2,8 @@ import pytest
|
||||||
|
|
||||||
from peewee import IntegrityError
|
from peewee import IntegrityError
|
||||||
|
|
||||||
from endpoints.test.fixtures import database_uri, init_db_path, sqlitedb_file
|
|
||||||
from data.model.repository import create_repository
|
from data.model.repository import create_repository
|
||||||
|
from test.fixtures import database_uri, init_db_path, sqlitedb_file
|
||||||
|
|
||||||
def test_duplicate_repository_different_kinds(database_uri):
|
def test_duplicate_repository_different_kinds(database_uri):
|
||||||
# Create an image repo.
|
# Create an image repo.
|
||||||
|
|
|
@ -19,7 +19,7 @@ from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermissi
|
||||||
UserAdminPermission)
|
UserAdminPermission)
|
||||||
from auth import scopes
|
from auth import scopes
|
||||||
from auth.auth_context import get_authenticated_user, get_validated_oauth_token
|
from auth.auth_context import get_authenticated_user, get_validated_oauth_token
|
||||||
from auth.process import process_oauth
|
from auth.decorators import process_oauth
|
||||||
from endpoints.csrf import csrf_protect
|
from endpoints.csrf import csrf_protect
|
||||||
from endpoints.exception import (ApiException, Unauthorized, InvalidRequest, InvalidResponse,
|
from endpoints.exception import (ApiException, Unauthorized, InvalidRequest, InvalidResponse,
|
||||||
FreshLoginRequired, NotFound)
|
FreshLoginRequired, NotFound)
|
||||||
|
|
|
@ -16,7 +16,7 @@ from endpoints.api.trigger import (BuildTriggerList, BuildTrigger, BuildTriggerS
|
||||||
TriggerBuildList, BuildTriggerFieldValues, BuildTriggerSources,
|
TriggerBuildList, BuildTriggerFieldValues, BuildTriggerSources,
|
||||||
BuildTriggerSourceNamespaces)
|
BuildTriggerSourceNamespaces)
|
||||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
||||||
from endpoints.test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
|
|
||||||
BUILD_ARGS = {'build_uuid': '1234'}
|
BUILD_ARGS = {'build_uuid': '1234'}
|
||||||
IMAGE_ARGS = {'imageid': '1234', 'image_id': 1234}
|
IMAGE_ARGS = {'imageid': '1234', 'image_id': 1234}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import pytest
|
||||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
||||||
from endpoints.api.superuser import SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource
|
from endpoints.api.superuser import SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource
|
||||||
from endpoints.api.superuser import SuperUserRepositoryBuildStatus
|
from endpoints.api.superuser import SuperUserRepositoryBuildStatus
|
||||||
from endpoints.test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
|
|
||||||
TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'}
|
TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'}
|
||||||
BUILD_PARAMS = {'build_uuid': 'test-1234'}
|
BUILD_PARAMS = {'build_uuid': 'test-1234'}
|
||||||
|
|
|
@ -12,8 +12,8 @@ from cnr.exception import (CnrException, InvalidUsage, InvalidParams, InvalidRel
|
||||||
from flask import request, jsonify
|
from flask import request, jsonify
|
||||||
|
|
||||||
from app import authentication
|
from app import authentication
|
||||||
from auth.process import process_auth
|
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
|
from auth.decorators import process_auth
|
||||||
from auth.permissions import CreateRepositoryPermission, ModifyRepositoryPermission
|
from auth.permissions import CreateRepositoryPermission, ModifyRepositoryPermission
|
||||||
from endpoints.appr import appr_bp, require_app_repo_read, require_app_repo_write
|
from endpoints.appr import appr_bp, require_app_repo_read, require_app_repo_write
|
||||||
from endpoints.appr.decorators import disallow_for_image_repository
|
from endpoints.appr.decorators import disallow_for_image_repository
|
||||||
|
|
|
@ -4,9 +4,9 @@ import pytest
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
|
||||||
from endpoints.appr.registry import appr_bp, blobs
|
from endpoints.appr.registry import appr_bp, blobs
|
||||||
from endpoints.api.test.shared import client_with_identity
|
from endpoints.api.test.shared import client_with_identity
|
||||||
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
|
|
||||||
@pytest.mark.parametrize('resource,method,params,owned_by,identity,expected', [
|
@pytest.mark.parametrize('resource,method,params,owned_by,identity,expected', [
|
||||||
('appr.blobs', 'GET', {'digest': 'abcd1235'}, 'devtable', 'public', 401),
|
('appr.blobs', 'GET', {'digest': 'abcd1235'}, 'devtable', 'public', 401),
|
||||||
|
|
|
@ -3,8 +3,8 @@ import pytest
|
||||||
from werkzeug.exceptions import NotImplemented as NIE
|
from werkzeug.exceptions import NotImplemented as NIE
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
|
||||||
from endpoints.appr import require_app_repo_read
|
from endpoints.appr import require_app_repo_read
|
||||||
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
|
|
||||||
def test_require_app_repo_read(app):
|
def test_require_app_repo_read(app):
|
||||||
called = [False]
|
called = [False]
|
|
@ -1,11 +1,10 @@
|
||||||
import json
|
import json
|
||||||
import pytest
|
|
||||||
|
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
|
||||||
from endpoints.appr.registry import appr_bp
|
from endpoints.appr.registry import appr_bp
|
||||||
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
|
|
||||||
def test_invalid_login(app, client):
|
def test_invalid_login(app, client):
|
||||||
app.register_blueprint(appr_bp, url_prefix='/cnr')
|
app.register_blueprint(appr_bp, url_prefix='/cnr')
|
||||||
|
|
|
@ -4,7 +4,7 @@ from flask import request, redirect, url_for, Blueprint
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
from app import app
|
from app import app
|
||||||
from auth.process import require_session_login
|
from auth.decorators import require_session_login
|
||||||
from buildtrigger.basehandler import BuildTriggerHandler
|
from buildtrigger.basehandler import BuildTriggerHandler
|
||||||
from buildtrigger.bitbuckethandler import BitbucketBuildTrigger
|
from buildtrigger.bitbuckethandler import BitbucketBuildTrigger
|
||||||
from data import model
|
from data import model
|
||||||
|
|
|
@ -6,8 +6,8 @@ from flask_login import current_user
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from app import app, github_trigger
|
from app import app, github_trigger
|
||||||
|
from auth.decorators import require_session_login
|
||||||
from auth.permissions import AdministerRepositoryPermission
|
from auth.permissions import AdministerRepositoryPermission
|
||||||
from auth.process import require_session_login
|
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.common import route_show_if, parse_repository_name
|
from endpoints.common import route_show_if, parse_repository_name
|
||||||
from util.http import abort
|
from util.http import abort
|
||||||
|
|
|
@ -6,8 +6,8 @@ from flask_login import current_user
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from app import app, gitlab_trigger
|
from app import app, gitlab_trigger
|
||||||
|
from auth.decorators import require_session_login
|
||||||
from auth.permissions import AdministerRepositoryPermission
|
from auth.permissions import AdministerRepositoryPermission
|
||||||
from auth.process import require_session_login
|
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.common import route_show_if
|
from endpoints.common import route_show_if
|
||||||
from util.http import abort
|
from util.http import abort
|
||||||
|
|
|
@ -8,7 +8,7 @@ import features
|
||||||
|
|
||||||
from app import app, analytics, get_app_url, oauth_login, authentication
|
from app import app, analytics, get_app_url, oauth_login, authentication
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
from auth.process import require_session_login
|
from auth.decorators import require_session_login
|
||||||
from data import model
|
from data import model
|
||||||
from endpoints.common import common_login
|
from endpoints.common import common_login
|
||||||
from endpoints.web import index
|
from endpoints.web import index
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from endpoints.oauth.login import _conduct_oauth_login
|
from endpoints.oauth.login import _conduct_oauth_login
|
||||||
from endpoints.test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
|
||||||
|
|
||||||
from oauth.services.github import GithubOAuthService
|
from oauth.services.github import GithubOAuthService
|
||||||
|
|
||||||
from data import model, database
|
from data import model, database
|
||||||
from data.users import get_users_handler, DatabaseUsers
|
from data.users import get_users_handler, DatabaseUsers
|
||||||
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
from test.test_ldap import mock_ldap
|
from test.test_ldap import mock_ldap
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ from flask import request, Blueprint, abort, Response
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
from app import userevents
|
from app import userevents
|
||||||
from auth.process import require_session_login
|
from auth.decorators import require_session_login
|
||||||
from data.userevent import CannotReadUserEventsException
|
from data.userevent import CannotReadUserEventsException
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from endpoints.notificationevent import NotificationEvent
|
from endpoints.notificationevent import NotificationEvent
|
||||||
from endpoints.test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
|
|
||||||
from util.morecollections import AttrDict
|
from util.morecollections import AttrDict
|
||||||
|
|
||||||
|
|
|
@ -9,10 +9,11 @@ from flask import request, make_response, jsonify, session
|
||||||
from data.interfaces.v1 import pre_oci_model as model
|
from data.interfaces.v1 import pre_oci_model as model
|
||||||
from app import authentication, userevents, metric_queue
|
from app import authentication, userevents, metric_queue
|
||||||
from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token
|
from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token
|
||||||
|
from auth.decorators import process_auth
|
||||||
from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission,
|
from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission,
|
||||||
ReadRepositoryPermission, CreateRepositoryPermission,
|
ReadRepositoryPermission, CreateRepositoryPermission,
|
||||||
repository_read_grant, repository_write_grant)
|
repository_read_grant, repository_write_grant)
|
||||||
from auth.process import process_auth, generate_signed_token
|
from auth.signedgrant import generate_signed_token
|
||||||
from util.http import abort
|
from util.http import abort
|
||||||
from util.names import REPOSITORY_NAME_REGEX
|
from util.names import REPOSITORY_NAME_REGEX
|
||||||
from endpoints.common import parse_repository_name
|
from endpoints.common import parse_repository_name
|
||||||
|
|
|
@ -9,9 +9,9 @@ from flask import make_response, request, session, Response, redirect, abort as
|
||||||
|
|
||||||
from app import storage as store, app, metric_queue
|
from app import storage as store, app, metric_queue
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
|
from auth.decorators import extract_namespace_repo_from_session, process_auth
|
||||||
from auth.permissions import (ReadRepositoryPermission,
|
from auth.permissions import (ReadRepositoryPermission,
|
||||||
ModifyRepositoryPermission)
|
ModifyRepositoryPermission)
|
||||||
from auth.process import process_auth, extract_namespace_repo_from_session
|
|
||||||
from auth.registry_jwt_auth import get_granted_username
|
from auth.registry_jwt_auth import get_granted_username
|
||||||
from data import model, database
|
from data import model, database
|
||||||
from data.interfaces.v1 import pre_oci_model as model
|
from data.interfaces.v1 import pre_oci_model as model
|
||||||
|
|
|
@ -5,9 +5,9 @@ from flask import abort, request, jsonify, make_response, session
|
||||||
|
|
||||||
|
|
||||||
from util.names import TAG_ERROR, TAG_REGEX
|
from util.names import TAG_ERROR, TAG_REGEX
|
||||||
|
from auth.decorators import process_auth
|
||||||
from auth.permissions import (ReadRepositoryPermission,
|
from auth.permissions import (ReadRepositoryPermission,
|
||||||
ModifyRepositoryPermission)
|
ModifyRepositoryPermission)
|
||||||
from auth.process import process_auth
|
|
||||||
from data import model
|
from data import model
|
||||||
from data.interfaces.v1 import pre_oci_model as model
|
from data.interfaces.v1 import pre_oci_model as model
|
||||||
from endpoints.common import parse_repository_name
|
from endpoints.common import parse_repository_name
|
||||||
|
|
|
@ -146,3 +146,12 @@ class Unsupported(V2RegistryException):
|
||||||
'The operation is unsupported.',
|
'The operation is unsupported.',
|
||||||
detail,
|
detail,
|
||||||
405)
|
405)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidLogin(V2RegistryException):
|
||||||
|
def __init__(self, message=None):
|
||||||
|
super(InvalidLogin, self).__init__('UNAUTHORIZED',
|
||||||
|
message or 'Specified credentials are invalid',
|
||||||
|
{},
|
||||||
|
401)
|
||||||
|
|
||||||
|
|
|
@ -6,11 +6,12 @@ from flask import request, jsonify, abort
|
||||||
|
|
||||||
from app import app, userevents, instance_keys
|
from app import app, userevents, instance_keys
|
||||||
from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token
|
from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token
|
||||||
|
from auth.decorators import process_basic_auth
|
||||||
from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission,
|
from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission,
|
||||||
CreateRepositoryPermission, AdministerRepositoryPermission)
|
CreateRepositoryPermission, AdministerRepositoryPermission)
|
||||||
from auth.process import process_auth
|
|
||||||
from endpoints.v2 import v2_bp
|
|
||||||
from endpoints.decorators import anon_protect
|
from endpoints.decorators import anon_protect
|
||||||
|
from endpoints.v2 import v2_bp
|
||||||
|
from endpoints.v2.errors import InvalidLogin
|
||||||
from data.interfaces.v2 import pre_oci_model as model
|
from data.interfaces.v2 import pre_oci_model as model
|
||||||
from util.cache import no_cache
|
from util.cache import no_cache
|
||||||
from util.names import parse_namespace_repository, REPOSITORY_NAME_REGEX
|
from util.names import parse_namespace_repository, REPOSITORY_NAME_REGEX
|
||||||
|
@ -33,10 +34,10 @@ def get_scope_regex():
|
||||||
|
|
||||||
|
|
||||||
@v2_bp.route('/auth')
|
@v2_bp.route('/auth')
|
||||||
@process_auth
|
@process_basic_auth
|
||||||
@no_cache
|
@no_cache
|
||||||
@anon_protect
|
@anon_protect
|
||||||
def generate_registry_jwt():
|
def generate_registry_jwt(auth_result):
|
||||||
"""
|
"""
|
||||||
This endpoint will generate a JWT conforming to the Docker Registry v2 Auth Spec:
|
This endpoint will generate a JWT conforming to the Docker Registry v2 Auth Spec:
|
||||||
https://docs.docker.com/registry/spec/auth/token/
|
https://docs.docker.com/registry/spec/auth/token/
|
||||||
|
@ -56,11 +57,11 @@ def generate_registry_jwt():
|
||||||
oauthtoken = get_validated_oauth_token()
|
oauthtoken = get_validated_oauth_token()
|
||||||
logger.debug('Authenticated OAuth token: %s', oauthtoken)
|
logger.debug('Authenticated OAuth token: %s', oauthtoken)
|
||||||
|
|
||||||
auth_credentials_sent = bool(request.headers.get('authorization', ''))
|
auth_header = request.headers.get('authorization', '')
|
||||||
|
auth_credentials_sent = bool(auth_header)
|
||||||
if auth_credentials_sent and not user and not token:
|
if auth_credentials_sent and not user and not token:
|
||||||
# The auth credentials sent for the user are invalid.
|
# The auth credentials sent for the user are invalid.
|
||||||
logger.debug('Invalid auth credentials')
|
raise InvalidLogin(auth_result.error_message)
|
||||||
abort(401)
|
|
||||||
|
|
||||||
access = []
|
access = []
|
||||||
user_event_data = {
|
user_event_data = {
|
||||||
|
|
|
@ -7,8 +7,8 @@ import features
|
||||||
|
|
||||||
from app import app, signer, storage, metric_queue, license_validator
|
from app import app, signer, storage, metric_queue, license_validator
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
|
from auth.decorators import process_auth
|
||||||
from auth.permissions import ReadRepositoryPermission
|
from auth.permissions import ReadRepositoryPermission
|
||||||
from auth.process import process_auth
|
|
||||||
from data import database
|
from data import database
|
||||||
from data.interfaces.verbs import pre_oci_model as model
|
from data.interfaces.verbs import pre_oci_model as model
|
||||||
from endpoints.common import route_show_if, parse_repository_name
|
from endpoints.common import route_show_if, parse_repository_name
|
||||||
|
|
|
@ -14,10 +14,11 @@ from app import (app, billing as stripe, build_logs, avatar, signer, log_archive
|
||||||
get_app_url, instance_keys, user_analytics)
|
get_app_url, instance_keys, user_analytics)
|
||||||
from auth import scopes
|
from auth import scopes
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
|
from auth.basic import has_basic_auth
|
||||||
|
from auth.decorators import require_session_login, process_oauth, process_auth_or_cookie
|
||||||
from auth.permissions import (AdministerOrganizationPermission, ReadRepositoryPermission,
|
from auth.permissions import (AdministerOrganizationPermission, ReadRepositoryPermission,
|
||||||
SuperUserPermission, AdministerRepositoryPermission,
|
SuperUserPermission, AdministerRepositoryPermission,
|
||||||
ModifyRepositoryPermission, OrganizationMemberPermission)
|
ModifyRepositoryPermission, OrganizationMemberPermission)
|
||||||
from auth.process import require_session_login, process_oauth, has_basic_auth, process_auth_or_cookie
|
|
||||||
from buildtrigger.basehandler import BuildTriggerHandler
|
from buildtrigger.basehandler import BuildTriggerHandler
|
||||||
from buildtrigger.bitbuckethandler import BitbucketBuildTrigger
|
from buildtrigger.bitbuckethandler import BitbucketBuildTrigger
|
||||||
from buildtrigger.customhandler import CustomBuildTrigger
|
from buildtrigger.customhandler import CustomBuildTrigger
|
||||||
|
|
|
@ -4,8 +4,8 @@ from flask import request, make_response, Blueprint
|
||||||
|
|
||||||
from app import billing as stripe
|
from app import billing as stripe
|
||||||
from data import model
|
from data import model
|
||||||
|
from auth.decorators import process_auth
|
||||||
from auth.permissions import ModifyRepositoryPermission
|
from auth.permissions import ModifyRepositoryPermission
|
||||||
from auth.process import process_auth
|
|
||||||
from util.invoice import renderInvoiceToHtml
|
from util.invoice import renderInvoiceToHtml
|
||||||
from util.useremails import send_invoice_email, send_subscription_change, send_payment_failed
|
from util.useremails import send_invoice_email, send_subscription_change, send_payment_failed
|
||||||
from util.http import abort
|
from util.http import abort
|
||||||
|
|
|
@ -1,185 +0,0 @@
|
||||||
import base64
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from flask import g
|
|
||||||
from flask_principal import identity_loaded
|
|
||||||
|
|
||||||
from app import app
|
|
||||||
from auth.scopes import (scopes_from_scope_string, is_subset_string, DIRECT_LOGIN, ADMIN_REPO,
|
|
||||||
ALL_SCOPES)
|
|
||||||
from auth.permissions import QuayDeferredPermissionUser
|
|
||||||
from auth.process import _process_basic_auth
|
|
||||||
from data import model
|
|
||||||
from data.database import OAuthApplication, OAuthAccessToken
|
|
||||||
from endpoints.api import api
|
|
||||||
from endpoints.api.user import User, Signin
|
|
||||||
from test.test_api_usage import ApiTestCase
|
|
||||||
|
|
||||||
|
|
||||||
ADMIN_ACCESS_USER = 'devtable'
|
|
||||||
DISABLED_USER = 'disabled'
|
|
||||||
|
|
||||||
|
|
||||||
@identity_loaded.connect_via(app)
|
|
||||||
def on_identity_loaded(sender, identity):
|
|
||||||
g.identity = identity
|
|
||||||
|
|
||||||
class TestAuth(ApiTestCase):
|
|
||||||
def verify_cookie_auth(self, username):
|
|
||||||
resp = self.getJsonResponse(User)
|
|
||||||
self.assertEquals(resp['username'], username)
|
|
||||||
|
|
||||||
def verify_identity(self, id):
|
|
||||||
try:
|
|
||||||
identity = g.identity
|
|
||||||
except:
|
|
||||||
identity = None
|
|
||||||
|
|
||||||
self.assertIsNotNone(identity)
|
|
||||||
self.assertEquals(identity.id, id)
|
|
||||||
|
|
||||||
def verify_no_identity(self):
|
|
||||||
try:
|
|
||||||
identity = g.identity
|
|
||||||
except:
|
|
||||||
identity = None
|
|
||||||
|
|
||||||
self.assertIsNone(identity)
|
|
||||||
|
|
||||||
def conduct_basic_auth(self, username, password):
|
|
||||||
encoded = base64.b64encode(username + ':' + password)
|
|
||||||
try:
|
|
||||||
_process_basic_auth('Basic ' + encoded)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def create_oauth(self, user):
|
|
||||||
oauth_app = OAuthApplication.create(client_id='onetwothree', redirect_uri='',
|
|
||||||
application_uri='', organization=user,
|
|
||||||
name='someapp')
|
|
||||||
|
|
||||||
expires_at = datetime.utcnow() + timedelta(seconds=50000)
|
|
||||||
OAuthAccessToken.create(application=oauth_app, authorized_user=user,
|
|
||||||
scope='repo:admin',
|
|
||||||
access_token='access1234', token_type='Bearer',
|
|
||||||
expires_at=expires_at, refresh_token=None, data={})
|
|
||||||
|
|
||||||
def test_login(self):
|
|
||||||
password = 'password'
|
|
||||||
resp = self.postJsonResponse(Signin, data=dict(username=ADMIN_ACCESS_USER, password=password))
|
|
||||||
self.assertTrue(resp.get('success'))
|
|
||||||
self.verify_cookie_auth(ADMIN_ACCESS_USER)
|
|
||||||
|
|
||||||
def test_login_disabled(self):
|
|
||||||
password = 'password'
|
|
||||||
self.postJsonResponse(Signin, data=dict(username=DISABLED_USER, password=password),
|
|
||||||
expected_code=403)
|
|
||||||
|
|
||||||
def test_basic_auth_user(self):
|
|
||||||
user = model.user.get_user(ADMIN_ACCESS_USER)
|
|
||||||
self.conduct_basic_auth(ADMIN_ACCESS_USER, 'password')
|
|
||||||
self.verify_identity(user.uuid)
|
|
||||||
|
|
||||||
def test_basic_auth_disabled_user(self):
|
|
||||||
user = model.user.get_user(DISABLED_USER)
|
|
||||||
self.conduct_basic_auth(DISABLED_USER, 'password')
|
|
||||||
self.verify_no_identity()
|
|
||||||
|
|
||||||
def test_basic_auth_token(self):
|
|
||||||
token = model.token.create_delegate_token(ADMIN_ACCESS_USER, 'simple', 'sometoken')
|
|
||||||
self.conduct_basic_auth('$token', token.code)
|
|
||||||
self.verify_identity(token.code)
|
|
||||||
|
|
||||||
def test_basic_auth_invalid_token(self):
|
|
||||||
self.conduct_basic_auth('$token', 'foobar')
|
|
||||||
self.verify_no_identity()
|
|
||||||
|
|
||||||
def test_basic_auth_invalid_user(self):
|
|
||||||
self.conduct_basic_auth('foobarinvalid', 'foobar')
|
|
||||||
self.verify_no_identity()
|
|
||||||
|
|
||||||
def test_oauth_invalid(self):
|
|
||||||
self.conduct_basic_auth('$oauthtoken', 'foobar')
|
|
||||||
self.verify_no_identity()
|
|
||||||
|
|
||||||
def test_oauth_invalid_http_response(self):
|
|
||||||
rv = self.app.get(api.url_for(User), headers={'Authorization': 'Bearer bad_token'})
|
|
||||||
assert 'WWW-Authenticate' in rv.headers
|
|
||||||
self.assertEquals(401, rv.status_code)
|
|
||||||
|
|
||||||
def test_oauth_valid_user(self):
|
|
||||||
user = model.user.get_user(ADMIN_ACCESS_USER)
|
|
||||||
self.create_oauth(user)
|
|
||||||
self.conduct_basic_auth('$oauthtoken', 'access1234')
|
|
||||||
self.verify_identity(user.uuid)
|
|
||||||
|
|
||||||
def test_oauth_disabled_user(self):
|
|
||||||
user = model.user.get_user(DISABLED_USER)
|
|
||||||
self.create_oauth(user)
|
|
||||||
self.conduct_basic_auth('$oauthtoken', 'access1234')
|
|
||||||
self.verify_no_identity()
|
|
||||||
|
|
||||||
def test_basic_auth_robot(self):
|
|
||||||
user = model.user.get_user(ADMIN_ACCESS_USER)
|
|
||||||
robot, passcode = model.user.get_robot('dtrobot', user)
|
|
||||||
self.conduct_basic_auth(robot.username, passcode)
|
|
||||||
self.verify_identity(robot.uuid)
|
|
||||||
|
|
||||||
def test_basic_auth_robot_invalidcode(self):
|
|
||||||
user = model.user.get_user(ADMIN_ACCESS_USER)
|
|
||||||
robot, _ = model.user.get_robot('dtrobot', user)
|
|
||||||
self.conduct_basic_auth(robot.username, 'someinvalidcode')
|
|
||||||
self.verify_no_identity()
|
|
||||||
|
|
||||||
def test_deferred_permissions_scopes(self):
|
|
||||||
self.assertEquals(QuayDeferredPermissionUser.for_id('123454')._scope_set, {DIRECT_LOGIN})
|
|
||||||
self.assertEquals(QuayDeferredPermissionUser.for_id('123454', {})._scope_set, {})
|
|
||||||
self.assertEquals(QuayDeferredPermissionUser.for_id('123454', {ADMIN_REPO})._scope_set, {ADMIN_REPO})
|
|
||||||
|
|
||||||
def assertParsedScopes(self, scopes_str, *args):
|
|
||||||
expected_scope_set = {ALL_SCOPES[scope_name] for scope_name in args}
|
|
||||||
parsed_scope_set = scopes_from_scope_string(scopes_str)
|
|
||||||
self.assertEquals(parsed_scope_set, expected_scope_set)
|
|
||||||
|
|
||||||
def test_scopes_parsing(self):
|
|
||||||
# Valid single scopes.
|
|
||||||
self.assertParsedScopes('repo:read', 'repo:read')
|
|
||||||
self.assertParsedScopes('repo:admin', 'repo:admin')
|
|
||||||
|
|
||||||
# Invalid scopes.
|
|
||||||
self.assertParsedScopes('not:valid')
|
|
||||||
self.assertParsedScopes('repo:admins')
|
|
||||||
|
|
||||||
# Valid scope strings.
|
|
||||||
self.assertParsedScopes('repo:read repo:admin', 'repo:read', 'repo:admin')
|
|
||||||
self.assertParsedScopes('repo:read,repo:admin', 'repo:read', 'repo:admin')
|
|
||||||
self.assertParsedScopes('repo:read,repo:admin repo:write', 'repo:read', 'repo:admin',
|
|
||||||
'repo:write')
|
|
||||||
|
|
||||||
# Partially invalid scopes.
|
|
||||||
self.assertParsedScopes('repo:read,not:valid')
|
|
||||||
self.assertParsedScopes('repo:read repo:admins')
|
|
||||||
|
|
||||||
# Invalid scope strings.
|
|
||||||
self.assertParsedScopes('repo:read|repo:admin')
|
|
||||||
|
|
||||||
# Mixture of delimiters.
|
|
||||||
self.assertParsedScopes('repo:read, repo:admin')
|
|
||||||
|
|
||||||
def test_subset_string(self):
|
|
||||||
self.assertTrue(is_subset_string('repo:read', 'repo:read'))
|
|
||||||
self.assertTrue(is_subset_string('repo:read repo:admin', 'repo:read'))
|
|
||||||
self.assertTrue(is_subset_string('repo:read,repo:admin', 'repo:read'))
|
|
||||||
self.assertTrue(is_subset_string('repo:read,repo:admin', 'repo:admin'))
|
|
||||||
self.assertTrue(is_subset_string('repo:read,repo:admin', 'repo:admin repo:read'))
|
|
||||||
|
|
||||||
self.assertFalse(is_subset_string('', 'repo:read'))
|
|
||||||
self.assertFalse(is_subset_string('unknown:tag', 'repo:read'))
|
|
||||||
self.assertFalse(is_subset_string('repo:read unknown:tag', 'repo:read'))
|
|
||||||
self.assertFalse(is_subset_string('repo:read,unknown:tag', 'repo:read'))
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
|
|
|
@ -345,8 +345,6 @@ class OAuthTestCase(EndpointTestCase):
|
||||||
self.postResponse('web.authorize_application', form=form, with_csrf=False, expected_code=403)
|
self.postResponse('web.authorize_application', form=form, with_csrf=False, expected_code=403)
|
||||||
|
|
||||||
def test_authorize_nocsrf_withinvalidheader(self):
|
def test_authorize_nocsrf_withinvalidheader(self):
|
||||||
self.login('devtable', 'password')
|
|
||||||
|
|
||||||
# Note: Defined in initdb.py
|
# Note: Defined in initdb.py
|
||||||
form = {
|
form = {
|
||||||
'client_id': 'deadbeef',
|
'client_id': 'deadbeef',
|
||||||
|
@ -358,8 +356,6 @@ class OAuthTestCase(EndpointTestCase):
|
||||||
self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=False, expected_code=401)
|
self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=False, expected_code=401)
|
||||||
|
|
||||||
def test_authorize_nocsrf_withbadheader(self):
|
def test_authorize_nocsrf_withbadheader(self):
|
||||||
self.login('devtable', 'password')
|
|
||||||
|
|
||||||
# Note: Defined in initdb.py
|
# Note: Defined in initdb.py
|
||||||
form = {
|
form = {
|
||||||
'client_id': 'deadbeef',
|
'client_id': 'deadbeef',
|
||||||
|
@ -368,7 +364,8 @@ class OAuthTestCase(EndpointTestCase):
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = dict(authorization='Basic ' + base64.b64encode('devtable:invalidpassword'))
|
headers = dict(authorization='Basic ' + base64.b64encode('devtable:invalidpassword'))
|
||||||
self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=False, expected_code=401)
|
self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=False,
|
||||||
|
expected_code=401)
|
||||||
|
|
||||||
def test_authorize_nocsrf_correctheader(self):
|
def test_authorize_nocsrf_correctheader(self):
|
||||||
# Note: Defined in initdb.py
|
# Note: Defined in initdb.py
|
||||||
|
@ -380,13 +377,15 @@ class OAuthTestCase(EndpointTestCase):
|
||||||
|
|
||||||
# Try without the client id being in the whitelist.
|
# Try without the client id being in the whitelist.
|
||||||
headers = dict(authorization='Basic ' + base64.b64encode('devtable:password'))
|
headers = dict(authorization='Basic ' + base64.b64encode('devtable:password'))
|
||||||
self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=False, expected_code=403)
|
self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=False,
|
||||||
|
expected_code=403)
|
||||||
|
|
||||||
# Add the client ID to the whitelist and try again.
|
# Add the client ID to the whitelist and try again.
|
||||||
app.config['DIRECT_OAUTH_CLIENTID_WHITELIST'] = ['deadbeef']
|
app.config['DIRECT_OAUTH_CLIENTID_WHITELIST'] = ['deadbeef']
|
||||||
|
|
||||||
headers = dict(authorization='Basic ' + base64.b64encode('devtable:password'))
|
headers = dict(authorization='Basic ' + base64.b64encode('devtable:password'))
|
||||||
resp = self.postResponse('web.authorize_application', headers=headers, form=form, with_csrf=True, expected_code=302)
|
resp = self.postResponse('web.authorize_application', headers=headers, form=form,
|
||||||
|
with_csrf=True, expected_code=302)
|
||||||
self.assertTrue('access_token=' in resp.headers['Location'])
|
self.assertTrue('access_token=' in resp.headers['Location'])
|
||||||
|
|
||||||
def test_authorize_nocsrf_ratelimiting(self):
|
def test_authorize_nocsrf_ratelimiting(self):
|
||||||
|
|
Reference in a new issue