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:
parent
53b762a875
commit
524d77f527
50 changed files with 943 additions and 289 deletions
|
@ -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,
|
||||
|
|
|
@ -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')))
|
|
@ -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,
|
||||
|
|
108
data/model/appspecifictoken.py
Normal file
108
data/model/appspecifictoken.py
Normal 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
|
77
data/model/test/test_appspecifictoken.py
Normal file
77
data/model/test/test_appspecifictoken.py
Normal 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
|
|
@ -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
59
data/users/apptoken.py
Normal 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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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:
|
||||
|
|
Reference in a new issue