Add an AppSpecificAuthToken data model for app-specific auth tokens. These will be used for the Docker CLI in place of username+password

This commit is contained in:
Joseph Schorr 2017-12-08 17:05:59 -05:00
parent 53b762a875
commit 524d77f527
50 changed files with 943 additions and 289 deletions

View file

@ -1418,6 +1418,26 @@ class BitTorrentPieces(BaseModel):
)
class AppSpecificAuthToken(BaseModel):
""" AppSpecificAuthToken represents a token generated by a user for use with an external
application where putting the user's credentials, even encrypted, is deemed too risky.
"""
user = QuayUserField()
uuid = CharField(default=uuid_generator, max_length=36, index=True)
title = CharField()
token_code = CharField(default=random_string_generator(length=120), unique=True, index=True)
created = DateTimeField(default=datetime.now)
expiration = DateTimeField(null=True)
last_accessed = DateTimeField(null=True)
class Meta:
database = db
read_slaves = (read_slave,)
indexes = (
(('user', 'expiration'), False),
)
beta_classes = set([ManifestLayerScan, Tag, TagKind, BlobPlacementLocation, ManifestLayer, ManifestList,
BitTorrentPieces, MediaType, Label, ManifestBlob, BlobUploading, Blob,
ManifestLayerDockerV1, BlobPlacementLocationPreference, ManifestListManifest,

View file

@ -0,0 +1,57 @@
"""Add support for app specific tokens
Revision ID: 7367229b38d9
Revises: d8989249f8f6
Create Date: 2017-12-12 13:15:42.419764
"""
# revision identifiers, used by Alembic.
revision = '7367229b38d9'
down_revision = 'd8989249f8f6'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
def upgrade(tables):
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('appspecificauthtoken',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('uuid', sa.String(length=36), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('token_code', sa.String(length=255), nullable=False),
sa.Column('created', sa.DateTime(), nullable=False),
sa.Column('expiration', sa.DateTime(), nullable=True),
sa.Column('last_accessed', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_appspecificauthtoken_user_id_user')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_appspecificauthtoken'))
)
op.create_index('appspecificauthtoken_token_code', 'appspecificauthtoken', ['token_code'], unique=True)
op.create_index('appspecificauthtoken_user_id', 'appspecificauthtoken', ['user_id'], unique=False)
op.create_index('appspecificauthtoken_user_id_expiration', 'appspecificauthtoken', ['user_id', 'expiration'], unique=False)
op.create_index('appspecificauthtoken_uuid', 'appspecificauthtoken', ['uuid'], unique=False)
# ### end Alembic commands ###
op.bulk_insert(tables.logentrykind, [
{'name': 'create_app_specific_token'},
{'name': 'revoke_app_specific_token'},
])
def downgrade(tables):
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('appspecificauthtoken')
# ### end Alembic commands ###
op.execute(tables
.logentrykind
.delete()
.where(tables.
logentrykind.name == op.inline_literal('create_app_specific_token')))
op.execute(tables
.logentrykind
.delete()
.where(tables.
logentrykind.name == op.inline_literal('revoke_app_specific_token')))

View file

@ -126,6 +126,7 @@ config = Config()
# There MUST NOT be any circular dependencies between these subsections. If there are fix it by
# moving the minimal number of things to _basequery
from data.model import (
appspecifictoken,
blob,
build,
image,

View file

@ -0,0 +1,108 @@
import logging
from datetime import datetime
from cachetools import lru_cache
from peewee import PeeweeException
from data.database import AppSpecificAuthToken, User, db_transaction
from data.model import config
from util.timedeltastring import convert_to_timedelta
logger = logging.getLogger(__name__)
@lru_cache(maxsize=1)
def _default_expiration():
expiration_str = config.app_config.get('APP_SPECIFIC_TOKEN_EXPIRATION')
return datetime.now() + convert_to_timedelta(expiration_str) if expiration_str else expiration_str
_default_expiration_opt = 'deo'
def create_token(user, title, expiration=_default_expiration_opt):
""" Creates and returns an app specific token for the given user. If no expiration is specified
(including `None`), then the default from config is used. """
expiration = expiration if expiration != _default_expiration_opt else _default_expiration()
return AppSpecificAuthToken.create(user=user, title=title, expiration=expiration)
def list_tokens(user):
""" Lists all tokens for the given user. """
return AppSpecificAuthToken.select().where(AppSpecificAuthToken.user == user)
def revoke_token(token):
""" Revokes an app specific token by deleting it. """
token.delete_instance()
def get_expiring_tokens(user, soon):
""" Returns all tokens owned by the given user that will be expiring "soon", where soon is defined
by the soon parameter (a timedelta from now).
"""
soon_datetime = datetime.now() + soon
return (AppSpecificAuthToken
.select()
.where(AppSpecificAuthToken.user == user,
AppSpecificAuthToken.expiration <= soon_datetime))
def gc_expired_tokens(user):
""" Deletes all expired tokens owned by the given user. """
(AppSpecificAuthToken
.delete()
.where(AppSpecificAuthToken.user == user, AppSpecificAuthToken.expiration < datetime.now())
.execute())
def get_token_by_uuid(uuid, owner=None):
""" Looks up an unexpired app specific token with the given uuid. Returns it if found or
None if none. If owner is specified, only tokens owned by the owner user will be
returned.
"""
try:
query = (AppSpecificAuthToken
.select()
.where(AppSpecificAuthToken.uuid == uuid,
((AppSpecificAuthToken.expiration > datetime.now()) |
(AppSpecificAuthToken.expiration >> None))))
if owner is not None:
query = query.where(AppSpecificAuthToken.user == owner)
return query.get()
except AppSpecificAuthToken.DoesNotExist:
return None
def access_valid_token(token_code):
""" Looks up an unexpired app specific token with the given token code. If found, the token's
last_accessed field is set to now and the token is returned. If not found, returns None.
"""
with db_transaction():
try:
token = (AppSpecificAuthToken
.select(AppSpecificAuthToken, User)
.join(User)
.where(AppSpecificAuthToken.token_code == token_code,
((AppSpecificAuthToken.expiration > datetime.now()) |
(AppSpecificAuthToken.expiration >> None)))
.get())
except AppSpecificAuthToken.DoesNotExist:
return None
token.last_accessed = datetime.now()
try:
token.save()
except PeeweeException as ex:
strict_logging_disabled = config.app_config.get('ALLOW_PULLS_WITHOUT_STRICT_LOGGING')
if strict_logging_disabled:
data = {
'exception': ex,
'token': token.id,
}
logger.exception('update last_accessed for token failed', extra=data)
else:
raise
return token

View file

@ -0,0 +1,77 @@
from datetime import datetime
import pytest
from data import model
from data.model.appspecifictoken import create_token, revoke_token, access_valid_token
from data.model.appspecifictoken import gc_expired_tokens, get_expiring_tokens
from util.timedeltastring import convert_to_timedelta
from test.fixtures import *
@pytest.mark.parametrize('expiration', [
(None),
('-1m'),
('-1d'),
('-1w'),
('10m'),
('10d'),
('10w'),
])
def test_gc(expiration, initialized_db):
user = model.user.get_user('devtable')
expiration_date = None
is_expired = False
if expiration:
if expiration[0] == '-':
is_expired = True
expiration_date = datetime.now() - convert_to_timedelta(expiration[1:])
else:
expiration_date = datetime.now() + convert_to_timedelta(expiration)
# Create a token.
token = create_token(user, 'Some token', expiration=expiration_date)
# GC tokens.
gc_expired_tokens(user)
# Ensure the token was GCed if expired and not if it wasn't.
assert (access_valid_token(token.token_code) is None) == is_expired
def test_access_token(initialized_db):
user = model.user.get_user('devtable')
# Create a token.
token = create_token(user, 'Some token')
assert token.last_accessed is None
# Lookup the token.
token = access_valid_token(token.token_code)
assert token.last_accessed is not None
# Revoke the token.
revoke_token(token)
# Ensure it cannot be accessed
assert access_valid_token(token.token_code) is None
def test_expiring_soon(initialized_db):
user = model.user.get_user('devtable')
# Create some tokens.
create_token(user, 'Some token')
exp_token = create_token(user, 'Some expiring token', datetime.now() + convert_to_timedelta('1d'))
create_token(user, 'Some other token', expiration=datetime.now() + convert_to_timedelta('2d'))
# Get the token expiring soon.
expiring_soon = get_expiring_tokens(user, convert_to_timedelta('25h'))
assert expiring_soon
assert len(expiring_soon) == 1
assert expiring_soon[0].id == exp_token.id
expiring_soon = get_expiring_tokens(user, convert_to_timedelta('49h'))
assert expiring_soon
assert len(expiring_soon) == 2

View file

@ -10,7 +10,7 @@ from data.users.database import DatabaseUsers
from data.users.externalldap import LDAPUsers
from data.users.externaljwt import ExternalJWTAuthN
from data.users.keystone import get_keystone_users
from data.users.oidc import OIDCInternalAuth
from data.users.apptoken import AppTokenInternalAuth
from util.security.aes import AESCipher
logger = logging.getLogger(__name__)
@ -25,7 +25,7 @@ def get_federated_service_name(authentication_type):
if authentication_type == 'Keystone':
return 'keystone'
if authentication_type == 'OIDC':
if authentication_type == 'AppToken':
return None
if authentication_type == 'Database':
@ -84,12 +84,14 @@ def get_users_handler(config, _, override_config_dir):
keystone_admin_password, keystone_admin_tenant, timeout,
requires_email=features.MAILING)
if authentication_type == 'OIDC':
if authentication_type == 'AppToken':
if features.DIRECT_LOGIN:
raise Exception('Direct login feature must be disabled to use OIDC internal auth')
raise Exception('Direct login feature must be disabled to use AppToken internal auth')
login_service = config.get('INTERNAL_OIDC_SERVICE_ID')
return OIDCInternalAuth(config, login_service, requires_email=features.MAILING)
if not features.APP_SPECIFIC_TOKENS:
raise Exception('AppToken internal auth requires app specific token support to be enabled')
return AppTokenInternalAuth()
raise RuntimeError('Unknown authentication type: %s' % authentication_type)

59
data/users/apptoken.py Normal file
View file

@ -0,0 +1,59 @@
import logging
from data import model
from oauth.loginmanager import OAuthLoginManager
from oauth.oidc import PublicKeyLoadException
from util.security.jwtutil import InvalidTokenError
logger = logging.getLogger(__name__)
class AppTokenInternalAuth(object):
""" Forces all internal credential login to go through an app token, by disabling all other
access.
"""
@property
def federated_service(self):
return None
@property
def requires_distinct_cli_password(self):
# Since there is no supported "password".
return False
@property
def supports_encrypted_credentials(self):
# Since there is no supported "password".
return False
def verify_credentials(self, username_or_email, id_token):
return (None, 'An application specific token is required to login')
def verify_and_link_user(self, username_or_email, password):
return self.verify_credentials(username_or_email, password)
def confirm_existing_user(self, username, password):
return self.verify_credentials(username, password)
def link_user(self, username_or_email):
return (None, 'Unsupported for this authentication system')
def get_and_link_federated_user_info(self, user_info):
return (None, 'Unsupported for this authentication system')
def query_users(self, query, limit):
return (None, '', '')
def check_group_lookup_args(self, group_lookup_args):
return (False, 'Not supported')
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
return (None, 'Not supported')
def service_metadata(self):
return {}
def ping(self):
""" Always assumed to be working. If the DB is broken, other checks will handle it. """
return (True, None)

View file

@ -1,92 +0,0 @@
import logging
from data import model
from oauth.loginmanager import OAuthLoginManager
from oauth.oidc import PublicKeyLoadException
from util.security.jwtutil import InvalidTokenError
logger = logging.getLogger(__name__)
class UnknownServiceException(Exception):
pass
class OIDCInternalAuth(object):
""" Handles authentication by delegating authentication to a signed OIDC JWT produced by the
configured OIDC service.
"""
def __init__(self, config, login_service_id, requires_email):
login_manager = OAuthLoginManager(config)
self.login_service_id = login_service_id
self.login_service = login_manager.get_service(login_service_id)
if self.login_service is None:
raise UnknownServiceException('Unknown OIDC login service %s' % login_service_id)
@property
def federated_service(self):
return None
@property
def requires_distinct_cli_password(self):
# Since the "password" is the generated ID token.
return False
@property
def supports_encrypted_credentials(self):
# Since the "password" is already a signed JWT.
return False
def verify_credentials(self, username_or_email, id_token):
# Parse the ID token.
try:
payload = self.login_service.decode_user_jwt(id_token)
except InvalidTokenError as ite:
logger.exception('Got invalid token error on OIDC decode: %s. Token: %s', ite.message, id_token)
return (None, 'Could not validate OIDC token')
except PublicKeyLoadException as pke:
logger.exception('Could not load public key during OIDC decode: %s. Token: %s', pke.message, id_token)
return (None, 'Could not validate OIDC token')
# Find the user ID.
user_id = payload['sub']
# Lookup the federated login and user record with that matching ID and service.
user_found = model.user.verify_federated_login(self.login_service_id, user_id)
if user_found is None:
return (None, 'User does not exist')
if not user_found.enabled:
return (None, 'User account is disabled. Please contact your administrator.')
return (user_found, None)
def verify_and_link_user(self, username_or_email, password):
return self.verify_credentials(username_or_email, password)
def confirm_existing_user(self, username, password):
return self.verify_credentials(username, password)
def link_user(self, username_or_email):
return (None, 'Unsupported for this authentication system')
def get_and_link_federated_user_info(self, user_info):
return (None, 'Unsupported for this authentication system')
def query_users(self, query, limit):
return (None, '', '')
def check_group_lookup_args(self, group_lookup_args):
return (False, 'Not supported')
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
return (None, 'Not supported')
def service_metadata(self):
return {}
def ping(self):
""" Always assumed to be working. If the DB is broken, other checks will handle it. """
return (True, None)

View file

@ -1,38 +0,0 @@
import pytest
from httmock import HTTMock
from data import model
from data.users.oidc import OIDCInternalAuth
from oauth.test.test_oidc import *
from test.fixtures import *
@pytest.mark.parametrize('username, expect_success', [
('devtable', True),
('disabled', False)
])
def test_oidc_login(username, expect_success, app_config, id_token, jwks_handler,
discovery_handler, app):
internal_auth = OIDCInternalAuth(app_config, 'someoidc', False)
with HTTMock(jwks_handler, discovery_handler):
# Try an invalid token.
(user, err) = internal_auth.verify_credentials('someusername', 'invalidtoken')
assert err is not None
assert user is None
# Try a valid token for an unlinked user.
(user, err) = internal_auth.verify_credentials('someusername', id_token)
assert err is not None
assert user is None
# Link the user to the service.
model.user.attach_federated_login(model.user.get_user(username), 'someoidc', 'cooluser')
# Try a valid token for a linked user.
(user, err) = internal_auth.verify_credentials('someusername', id_token)
if expect_success:
assert err is None
assert user.username == username
else:
assert err is not None
assert user is None

View file

@ -5,7 +5,6 @@ from mock import patch
from data.database import model
from data.users.federated import DISABLED_MESSAGE
from data.users.oidc import OIDCInternalAuth
from test.test_ldap import mock_ldap
from test.test_keystone_auth import fake_keystone
from test.test_external_jwt_authn import fake_jwt
@ -38,18 +37,11 @@ def test_auth_createuser(auth_system_builder, user1, user2, config, app):
assert new_user is None
assert err == DISABLED_MESSAGE
@contextmanager
def fake_oidc(app_config):
yield OIDCInternalAuth(app_config, 'someoidc', False)
@pytest.mark.parametrize('auth_system_builder,auth_kwargs', [
(mock_ldap, {}),
(fake_keystone, {'version': 3}),
(fake_keystone, {'version': 2}),
(fake_jwt, {}),
(fake_oidc, {'app_config': {
'SOMEOIDC_LOGIN_CONFIG': {},
}}),
])
def test_ping(auth_system_builder, auth_kwargs, app):
with auth_system_builder(**auth_kwargs) as auth: