diff --git a/auth/auth_context.py b/auth/auth_context.py index f4a1206aa..e2c24e624 100644 --- a/auth/auth_context.py +++ b/auth/auth_context.py @@ -60,6 +60,15 @@ def set_validated_oauth_token(token): ctx.validated_oauth_token = token +def get_validated_app_specific_token(): + return getattr(_request_ctx_stack.top, 'validated_app_specific_token', None) + + +def set_validated_app_specific_token(token): + ctx = _request_ctx_stack.top + ctx.validated_app_specific_token = token + + def get_validated_token(): return getattr(_request_ctx_stack.top, 'validated_token', None) diff --git a/auth/credentials.py b/auth/credentials.py index 4b15a2cbe..f03f697ec 100644 --- a/auth/credentials.py +++ b/auth/credentials.py @@ -2,6 +2,8 @@ import logging from enum import Enum +import features + from app import authentication from auth.oauth import validate_oauth_token from auth.validateresult import ValidateResult, AuthKind @@ -12,6 +14,7 @@ logger = logging.getLogger(__name__) ACCESS_TOKEN_USERNAME = '$token' OAUTH_TOKEN_USERNAME = '$oauthtoken' +APP_SPECIFIC_TOKEN_USERNAME = '$app' class CredentialKind(Enum): @@ -19,22 +22,44 @@ class CredentialKind(Enum): robot = 'robot' token = ACCESS_TOKEN_USERNAME oauth_token = OAUTH_TOKEN_USERNAME + app_specific_token = APP_SPECIFIC_TOKEN_USERNAME def validate_credentials(auth_username, auth_password_or_token): """ Validates a pair of auth username and password/token credentials. """ # Check for access tokens. if auth_username == ACCESS_TOKEN_USERNAME: - logger.debug('Found basic auth header for access token') + logger.debug('Found credentials 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) + logger.debug('Successfully validated credentials for access token %s', token.id) return ValidateResult(AuthKind.credentials, token=token), CredentialKind.token except model.DataModelException: - logger.warning('Failed to validate basic auth for access token %s', auth_password_or_token) + logger.warning('Failed to validate credentials for access token %s', auth_password_or_token) return (ValidateResult(AuthKind.credentials, error_message='Invalid access token'), CredentialKind.token) + # Check for App Specific tokens. + if features.APP_SPECIFIC_TOKENS and auth_username == APP_SPECIFIC_TOKEN_USERNAME: + logger.debug('Found credentials for app specific auth token') + token = model.appspecifictoken.access_valid_token(auth_password_or_token) + if token is None: + logger.debug('Failed to validate credentials for app specific token: %s', + auth_password_or_token) + return (ValidateResult(AuthKind.credentials, error_message='Invalid token'), + CredentialKind.app_specific_token) + + if not token.user.enabled: + logger.debug('Tried to use an app specific token for a disabled user: %s', + token.uuid) + return (ValidateResult(AuthKind.credentials, + error_message='This user has been disabled. Please contact your administrator.'), + CredentialKind.app_specific_token) + + logger.debug('Successfully validated credentials for app specific token %s', token.id) + return (ValidateResult(AuthKind.credentials, appspecifictoken=token), + CredentialKind.app_specific_token) + # Check for OAuth tokens. if auth_username == OAUTH_TOKEN_USERNAME: return validate_oauth_token(auth_password_or_token), CredentialKind.oauth_token @@ -42,21 +67,21 @@ def validate_credentials(auth_username, 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) + logger.debug('Found credentials 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) + logger.debug('Successfully validated credentials for robot %s', auth_username) return ValidateResult(AuthKind.credentials, robot=robot), CredentialKind.robot except model.InvalidRobotException as ire: - logger.warning('Failed to validate basic auth for robot %s: %s', auth_username, ire.message) + logger.warning('Failed to validate credentials for robot %s: %s', auth_username, ire.message) return ValidateResult(AuthKind.credentials, error_message=ire.message), CredentialKind.robot # 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) + logger.debug('Successfully validated credentials for user %s', authenticated.username) return ValidateResult(AuthKind.credentials, user=authenticated), CredentialKind.user else: - logger.warning('Failed to validate basic auth for user %s: %s', auth_username, err) + logger.warning('Failed to validate credentials for user %s: %s', auth_username, err) return ValidateResult(AuthKind.credentials, error_message=err), CredentialKind.user diff --git a/auth/registry_jwt_auth.py b/auth/registry_jwt_auth.py index 4208a8462..35126b86f 100644 --- a/auth/registry_jwt_auth.py +++ b/auth/registry_jwt_auth.py @@ -19,7 +19,7 @@ from data import model logger = logging.getLogger(__name__) -CONTEXT_KINDS = ['user', 'token', 'oauth'] +CONTEXT_KINDS = ['user', 'token', 'oauth', 'app_specific_token'] ACCESS_SCHEMA = { @@ -66,10 +66,11 @@ class InvalidJWTException(Exception): class GrantedEntity(object): - def __init__(self, user=None, token=None, oauth=None): + def __init__(self, user=None, token=None, oauth=None, app_specific_token=None): self.user = user self.token = token self.oauth = oauth + self.app_specific_token = app_specific_token def get_granted_entity(): @@ -85,6 +86,13 @@ def get_granted_entity(): if not kind in CONTEXT_KINDS: return None + if kind == 'app_specific_token': + app_specific_token = model.appspecifictoken.get_token_by_uuid(context.get('ast', '')) + if app_specific_token is None: + return None + + return GrantedEntity(app_specific_token=app_specific_token, user=app_specific_token.user) + if kind == 'user': user = model.user.get_user(context.get('user', '')) if not user: diff --git a/auth/test/test_basic.py b/auth/test/test_basic.py index 54e4db8a4..0bfb60606 100644 --- a/auth/test/test_basic.py +++ b/auth/test/test_basic.py @@ -3,7 +3,8 @@ import pytest from base64 import b64encode from auth.basic import validate_basic_auth -from auth.credentials import ACCESS_TOKEN_USERNAME, OAUTH_TOKEN_USERNAME +from auth.credentials import (ACCESS_TOKEN_USERNAME, OAUTH_TOKEN_USERNAME, + APP_SPECIFIC_TOKEN_USERNAME) from auth.validateresult import AuthKind, ValidateResult from data import model @@ -21,6 +22,8 @@ def _token(username, password): ('basic ', ValidateResult(AuthKind.basic, missing=True)), ('basic some token', ValidateResult(AuthKind.basic, missing=True)), ('basic sometoken', ValidateResult(AuthKind.basic, missing=True)), + (_token(APP_SPECIFIC_TOKEN_USERNAME, 'invalid'), ValidateResult(AuthKind.basic, + error_message='Invalid token')), (_token(ACCESS_TOKEN_USERNAME, 'invalid'), ValidateResult(AuthKind.basic, error_message='Invalid access token')), (_token(OAUTH_TOKEN_USERNAME, 'invalid'), @@ -64,3 +67,12 @@ def test_valid_oauth(app): token = _token(OAUTH_TOKEN_USERNAME, oauth_token.access_token) result = validate_basic_auth(token) assert result == ValidateResult(AuthKind.basic, oauthtoken=oauth_token) + + +def test_valid_app_specific_token(app): + user = model.user.get_user('devtable') + app_specific_token = model.appspecifictoken.create_token(user, 'some token') + token = _token(APP_SPECIFIC_TOKEN_USERNAME, app_specific_token.token_code) + result = validate_basic_auth(token) + print result.tuple() + assert result == ValidateResult(AuthKind.basic, appspecifictoken=app_specific_token) diff --git a/auth/test/test_credentials.py b/auth/test/test_credentials.py index 78438b143..4b795ed6d 100644 --- a/auth/test/test_credentials.py +++ b/auth/test/test_credentials.py @@ -1,5 +1,5 @@ from auth.credentials import (ACCESS_TOKEN_USERNAME, OAUTH_TOKEN_USERNAME, validate_credentials, - CredentialKind) + CredentialKind, APP_SPECIFIC_TOKEN_USERNAME) from auth.validateresult import AuthKind, ValidateResult from data import model @@ -16,6 +16,18 @@ def test_valid_robot(app): assert kind == CredentialKind.robot assert result == ValidateResult(AuthKind.credentials, robot=robot) +def test_valid_robot_for_disabled_user(app): + user = model.user.get_user('devtable') + user.enabled = False + user.save() + + robot, password = model.user.create_robot('somerobot', user) + result, kind = validate_credentials(robot.username, password) + assert kind == CredentialKind.robot + + err = 'This user has been disabled. Please contact your administrator.' + assert result == ValidateResult(AuthKind.credentials, error_message=err) + def test_valid_token(app): access_token = model.token.create_delegate_token('devtable', 'simple', 'sometoken') result, kind = validate_credentials(ACCESS_TOKEN_USERNAME, access_token.code) @@ -34,3 +46,29 @@ def test_invalid_user(app): assert kind == CredentialKind.user assert result == ValidateResult(AuthKind.credentials, error_message='Invalid Username or Password') + +def test_valid_app_specific_token(app): + user = model.user.get_user('devtable') + app_specific_token = model.appspecifictoken.create_token(user, 'some token') + + result, kind = validate_credentials(APP_SPECIFIC_TOKEN_USERNAME, app_specific_token.token_code) + assert kind == CredentialKind.app_specific_token + assert result == ValidateResult(AuthKind.credentials, appspecifictoken=app_specific_token) + +def test_valid_app_specific_token_for_disabled_user(app): + user = model.user.get_user('devtable') + user.enabled = False + user.save() + + app_specific_token = model.appspecifictoken.create_token(user, 'some token') + + result, kind = validate_credentials(APP_SPECIFIC_TOKEN_USERNAME, app_specific_token.token_code) + assert kind == CredentialKind.app_specific_token + + err = 'This user has been disabled. Please contact your administrator.' + assert result == ValidateResult(AuthKind.credentials, error_message=err) + +def test_invalid_app_specific_token(app): + result, kind = validate_credentials(APP_SPECIFIC_TOKEN_USERNAME, 'somecode') + assert kind == CredentialKind.app_specific_token + assert result == ValidateResult(AuthKind.credentials, error_message='Invalid token') diff --git a/auth/validateresult.py b/auth/validateresult.py index d285a2c88..3b5d74dca 100644 --- a/auth/validateresult.py +++ b/auth/validateresult.py @@ -3,7 +3,7 @@ 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) + set_validated_oauth_token, set_validated_app_specific_token) from auth.scopes import scopes_from_scope_string from auth.permissions import QuayDeferredPermissionUser @@ -19,19 +19,20 @@ class AuthKind(Enum): 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): + robot=None, appspecifictoken=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.appspecifictoken = appspecifictoken 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) + self.appspecifictoken, self.signed_data, self.error_message) def __eq__(self, other): return self.tuple() == other.tuple() @@ -42,6 +43,9 @@ class ValidateResult(object): if self.oauthtoken: set_authenticated_user(self.authed_user) set_validated_oauth_token(self.oauthtoken) + elif self.appspecifictoken: + set_authenticated_user(self.authed_user) + set_validated_app_specific_token(self.appspecifictoken) elif self.authed_user: set_authenticated_user(self.authed_user) elif self.token: @@ -60,7 +64,7 @@ class ValidateResult(object): def with_kind(self, kind): """ Returns a copy of this result, but with the kind replaced. """ return ValidateResult(kind, self.missing, self.user, self.token, self.oauthtoken, self.robot, - self.signed_data, self.error_message) + self.appspecifictoken, self.signed_data, self.error_message) @property def authed_user(self): @@ -71,6 +75,9 @@ class ValidateResult(object): if self.oauthtoken: return self.oauthtoken.authorized_user + if self.appspecifictoken: + return self.appspecifictoken.user + return self.user if self.user else self.robot @property @@ -104,4 +111,5 @@ class ValidateResult(object): @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 + return (self.user or self.token or self.oauthtoken or self.appspecifictoken or self.robot or + self.signed_data) diff --git a/config.py b/config.py index fc60d2441..40426a1b4 100644 --- a/config.py +++ b/config.py @@ -497,3 +497,9 @@ class DefaultConfig(ImmutableConfig): # The lifetime for a user recovery token before it becomes invalid. USER_RECOVERY_TOKEN_LIFETIME = '30m' + + # If specified, when app specific passwords expire by default. + APP_SPECIFIC_TOKEN_EXPIRATION = None + + # Feature Flag: If enabled, users can create and use app specific tokens to login via the CLI. + FEATURE_APP_SPECIFIC_TOKENS = True diff --git a/data/database.py b/data/database.py index 82f6c1f01..9250c0159 100644 --- a/data/database.py +++ b/data/database.py @@ -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, diff --git a/data/migrations/versions/7367229b38d9_add_support_for_app_specific_tokens.py b/data/migrations/versions/7367229b38d9_add_support_for_app_specific_tokens.py new file mode 100644 index 000000000..12d378020 --- /dev/null +++ b/data/migrations/versions/7367229b38d9_add_support_for_app_specific_tokens.py @@ -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'))) diff --git a/data/model/__init__.py b/data/model/__init__.py index 9078f2880..5fcfee29f 100644 --- a/data/model/__init__.py +++ b/data/model/__init__.py @@ -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, diff --git a/data/model/appspecifictoken.py b/data/model/appspecifictoken.py new file mode 100644 index 000000000..37489326e --- /dev/null +++ b/data/model/appspecifictoken.py @@ -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 diff --git a/data/model/test/test_appspecifictoken.py b/data/model/test/test_appspecifictoken.py new file mode 100644 index 000000000..1cf6fb70a --- /dev/null +++ b/data/model/test/test_appspecifictoken.py @@ -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 diff --git a/data/users/__init__.py b/data/users/__init__.py index 01c5ebe7d..6d52d5b94 100644 --- a/data/users/__init__.py +++ b/data/users/__init__.py @@ -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) diff --git a/data/users/apptoken.py b/data/users/apptoken.py new file mode 100644 index 000000000..271b8eef7 --- /dev/null +++ b/data/users/apptoken.py @@ -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) diff --git a/data/users/oidc.py b/data/users/oidc.py deleted file mode 100644 index c077a3b21..000000000 --- a/data/users/oidc.py +++ /dev/null @@ -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) diff --git a/data/users/test/test_oidc.py b/data/users/test/test_oidc.py deleted file mode 100644 index 23342316a..000000000 --- a/data/users/test/test_oidc.py +++ /dev/null @@ -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 diff --git a/data/users/test/test_users.py b/data/users/test/test_users.py index 408ae4bab..946c211f3 100644 --- a/data/users/test/test_users.py +++ b/data/users/test/test_users.py @@ -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: diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 44b767cbb..625e1926a 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -382,6 +382,7 @@ def define_json_response(schema_name): return wrapper +import endpoints.api.appspecifictokens import endpoints.api.billing import endpoints.api.build import endpoints.api.discovery diff --git a/endpoints/api/appspecifictokens.py b/endpoints/api/appspecifictokens.py new file mode 100644 index 000000000..3e3525dc1 --- /dev/null +++ b/endpoints/api/appspecifictokens.py @@ -0,0 +1,112 @@ +""" Manages app specific tokens for the current user. """ + +import logging + +from flask import request + +import features + +from auth.auth_context import get_authenticated_user +from data import model +from endpoints.api import (ApiResource, nickname, resource, validate_json_request, + log_action, require_user_admin, require_fresh_login, + path_param, NotFound, format_date, show_if) + +logger = logging.getLogger(__name__) + +def token_view(token, include_code=False): + data = { + 'uuid': token.uuid, + 'title': token.title, + 'last_accessed': format_date(token.last_accessed), + 'created': format_date(token.created), + 'expiration': format_date(token.expiration), + } + + if include_code: + data.update({ + 'token_code': token.token_code, + }) + + return data + +@resource('/v1/user/apptoken') +@show_if(features.APP_SPECIFIC_TOKENS) +class AppTokens(ApiResource): + """ Lists all app specific tokens for a user """ + schemas = { + 'NewToken': { + 'type': 'object', + 'required': [ + 'title', + ], + 'properties': { + 'title': { + 'type': 'string', + 'description': 'The user-defined title for the token', + }, + } + }, + } + + @require_user_admin + @nickname('listAppTokens') + def get(self): + """ Lists the app specific tokens for the user. """ + tokens = model.appspecifictoken.list_tokens(get_authenticated_user()) + return { + 'tokens': [token_view(token, include_code=False) for token in tokens], + } + + @require_user_admin + @require_fresh_login + @nickname('createAppToken') + @validate_json_request('NewToken') + def post(self): + """ Create a new app specific token for user. """ + title = request.get_json()['title'] + token = model.appspecifictoken.create_token(get_authenticated_user(), title) + + log_action('create_app_specific_token', get_authenticated_user().username, + {'app_specific_token_title': token.title, + 'app_specific_token': token.uuid}) + + return { + 'token': token_view(token, include_code=True), + } + + +@resource('/v1/user/apptoken/') +@show_if(features.APP_SPECIFIC_TOKENS) +@path_param('token_uuid', 'The uuid of the app specific token') +class AppToken(ApiResource): + """ Provides operations on an app specific token """ + @require_user_admin + @require_fresh_login + @nickname('getAppToken') + def get(self, token_uuid): + """ Returns a specific app token for the user. """ + token = model.appspecifictoken.get_token_by_uuid(token_uuid, owner=get_authenticated_user()) + if token is None: + raise NotFound() + + return { + 'token': token_view(token, include_code=True), + } + + @require_user_admin + @require_fresh_login + @nickname('revokeAppToken') + def delete(self, token_uuid): + """ Revokes a specific app token for the user. """ + token = model.appspecifictoken.get_token_by_uuid(token_uuid, owner=get_authenticated_user()) + if token is None: + raise NotFound() + + model.appspecifictoken.revoke_token(token) + + log_action('revoke_app_specific_token', get_authenticated_user().username, + {'app_specific_token_title': token.title, + 'app_specific_token': token.uuid}) + + return '', 204 diff --git a/endpoints/api/test/test_appspecifictoken.py b/endpoints/api/test/test_appspecifictoken.py new file mode 100644 index 000000000..e9e95fc59 --- /dev/null +++ b/endpoints/api/test/test_appspecifictoken.py @@ -0,0 +1,33 @@ +from endpoints.api.appspecifictokens import AppTokens, AppToken +from endpoints.api.test.shared import conduct_api_call +from endpoints.test.shared import client_with_identity +from test.fixtures import * + +def test_app_specific_tokens(app, client): + with client_with_identity('devtable', client) as cl: + # Add an app specific token. + token_data = {'title': 'Testing 123'} + resp = conduct_api_call(cl, AppTokens, 'POST', None, token_data, 200).json + token_uuid = resp['token']['uuid'] + assert 'token_code' in resp['token'] + + # List the tokens and ensure we have the one added. + resp = conduct_api_call(cl, AppTokens, 'GET', None, None, 200).json + assert len(resp['tokens']) + assert token_uuid in set([token['uuid'] for token in resp['tokens']]) + assert not set([token['token_code'] for token in resp['tokens'] if 'token_code' in token]) + + # Get the token and ensure we have its code. + resp = conduct_api_call(cl, AppToken, 'GET', {'token_uuid': token_uuid}, None, 200).json + assert resp['token']['uuid'] == token_uuid + assert 'token_code' in resp['token'] + + # Delete the token. + conduct_api_call(cl, AppToken, 'DELETE', {'token_uuid': token_uuid}, None, 204) + + # Ensure the token no longer exists. + resp = conduct_api_call(cl, AppTokens, 'GET', None, None, 200).json + assert len(resp['tokens']) + assert token_uuid not in set([token['uuid'] for token in resp['tokens']]) + + conduct_api_call(cl, AppToken, 'GET', {'token_uuid': token_uuid}, None, 404) diff --git a/endpoints/api/test/test_security.py b/endpoints/api/test/test_security.py index bc06d296e..b701f7856 100644 --- a/endpoints/api/test/test_security.py +++ b/endpoints/api/test/test_security.py @@ -13,6 +13,7 @@ from endpoints.api.signing import RepositorySignatures from endpoints.api.search import ConductRepositorySearch from endpoints.api.superuser import SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource from endpoints.api.superuser import SuperUserRepositoryBuildStatus +from endpoints.api.appspecifictokens import AppTokens, AppToken from endpoints.test.shared import client_with_identity, toggle_feature from test.fixtures import * @@ -22,9 +23,29 @@ BUILD_PARAMS = {'build_uuid': 'test-1234'} REPO_PARAMS = {'repository': 'devtable/someapp'} SEARCH_PARAMS = {'query': ''} NOTIFICATION_PARAMS = {'namespace': 'devtable', 'repository': 'devtable/simple', 'uuid': 'some uuid'} - +TOKEN_PARAMS = {'token_uuid': 'someuuid'} @pytest.mark.parametrize('resource,method,params,body,identity,expected', [ + (AppTokens, 'GET', {}, {}, None, 401), + (AppTokens, 'GET', {}, {}, 'freshuser', 200), + (AppTokens, 'GET', {}, {}, 'reader', 200), + (AppTokens, 'GET', {}, {}, 'devtable', 200), + + (AppTokens, 'POST', {}, {}, None, 403), + (AppTokens, 'POST', {}, {}, 'freshuser', 400), + (AppTokens, 'POST', {}, {}, 'reader', 400), + (AppTokens, 'POST', {}, {}, 'devtable', 400), + + (AppToken, 'GET', TOKEN_PARAMS, {}, None, 401), + (AppToken, 'GET', TOKEN_PARAMS, {}, 'freshuser', 404), + (AppToken, 'GET', TOKEN_PARAMS, {}, 'reader', 404), + (AppToken, 'GET', TOKEN_PARAMS, {}, 'devtable', 404), + + (AppToken, 'DELETE', TOKEN_PARAMS, {}, None, 403), + (AppToken, 'DELETE', TOKEN_PARAMS, {}, 'freshuser', 404), + (AppToken, 'DELETE', TOKEN_PARAMS, {}, 'reader', 404), + (AppToken, 'DELETE', TOKEN_PARAMS, {}, 'devtable', 404), + (OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, None, 403), (OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'freshuser', 403), (OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, 'reader', 403), diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 1443f4449..4146fed1b 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -1083,4 +1083,3 @@ class Users(ApiResource): abort(404) return user_view(user) - diff --git a/endpoints/v1/index.py b/endpoints/v1/index.py index f5aeabd28..dcf68e4d7 100644 --- a/endpoints/v1/index.py +++ b/endpoints/v1/index.py @@ -6,8 +6,9 @@ from functools import wraps from flask import request, make_response, jsonify, session -from app import authentication, userevents, metric_queue -from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token +from app import userevents, metric_queue +from auth.auth_context import (get_authenticated_user, get_validated_token, + get_validated_oauth_token, get_validated_app_specific_token) from auth.credentials import validate_credentials, CredentialKind from auth.decorators import process_auth from auth.permissions import ( @@ -121,15 +122,23 @@ def get_user(): if get_validated_oauth_token(): return jsonify({ 'username': '$oauthtoken', - 'email': None,}) + 'email': None, + }) + elif get_validated_app_specific_token(): + return jsonify({ + 'username': "$app", + 'email': None, + }) elif get_authenticated_user(): return jsonify({ 'username': get_authenticated_user().username, - 'email': get_authenticated_user().email,}) + 'email': get_authenticated_user().email, + }) elif get_validated_token(): return jsonify({ 'username': '$token', - 'email': None,}) + 'email': None, + }) abort(404) diff --git a/endpoints/v2/v2auth.py b/endpoints/v2/v2auth.py index 7eb08e1bb..6e92e9171 100644 --- a/endpoints/v2/v2auth.py +++ b/endpoints/v2/v2auth.py @@ -6,7 +6,8 @@ from flask import request, jsonify, abort import features 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, get_validated_app_specific_token) from auth.decorators import process_basic_auth from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission, CreateRepositoryPermission, AdministerRepositoryPermission) @@ -56,6 +57,9 @@ def generate_registry_jwt(auth_result): oauthtoken = get_validated_oauth_token() logger.debug('Authenticated OAuth token: %s', oauthtoken) + appspecifictoken = get_validated_app_specific_token() + logger.debug('Authenticated app specific token: %s', appspecifictoken) + auth_header = request.headers.get('authorization', '') auth_credentials_sent = bool(auth_header) if auth_credentials_sent and not user and not token: @@ -64,7 +68,8 @@ def generate_registry_jwt(auth_result): access = [] user_event_data = { - 'action': 'login',} + 'action': 'login', + } tuf_root = DISABLED_TUF_ROOT if len(scope_param) > 0: @@ -149,7 +154,8 @@ def generate_registry_jwt(auth_result): access.append({ 'type': 'repository', 'name': registry_and_repo, - 'actions': final_actions,}) + 'actions': final_actions, + }) # Set the user event data for the auth. if 'push' in final_actions: @@ -162,7 +168,8 @@ def generate_registry_jwt(auth_result): user_event_data = { 'action': user_action, 'repository': reponame, - 'namespace': namespace,} + 'namespace': namespace, + } tuf_root = get_tuf_root(repo, namespace, reponame) elif user is None and token is None: @@ -175,9 +182,9 @@ def generate_registry_jwt(auth_result): event = userevents.get_event(user.username) event.publish_event_data('docker-cli', user_event_data) - # Build the signed JWT. + # Build the signed JWT. context, subject = build_context_and_subject(user=user, token=token, oauthtoken=oauthtoken, - tuf_root=tuf_root) + appspecifictoken=appspecifictoken, tuf_root=tuf_root) token = generate_bearer_token(audience_param, subject, context, access, TOKEN_VALIDITY_LIFETIME_S, instance_keys) return jsonify({'token': token}) diff --git a/initdb.py b/initdb.py index 2762751cf..18317e25e 100644 --- a/initdb.py +++ b/initdb.py @@ -353,6 +353,9 @@ def initialize_database(): LogEntryKind.create(name='change_tag_expiration') + LogEntryKind.create(name='create_app_specific_token') + LogEntryKind.create(name='revoke_app_specific_token') + ImageStorageLocation.create(name='local_eu') ImageStorageLocation.create(name='local_us') @@ -804,6 +807,11 @@ def populate_database(minimal=False, with_storage=False): model.service_keys.approve_service_key(key.kid, new_user_1, ServiceKeyApprovalType.SUPERUSER, notes='Test service key for local/test registry testing') + # Add an app specific token. + token = model.appspecifictoken.create_token(new_user_1, 'some app') + token.token_code = 'test' + token.save() + model.log.log_action('org_create_team', org.username, performer=new_user_1, timestamp=week_ago, metadata={'team': 'readers'}) diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html index 45a3f2ca0..7a9d97345 100644 --- a/static/directives/config/config-setup-tool.html +++ b/static/directives/config/config-setup-tool.html @@ -622,14 +622,14 @@

- Authentication for the registry can be handled by either the registry itself, LDAP, Keystone, OIDC or external JWT endpoint. + Authentication for the registry can be handled by either the registry itself, LDAP, Keystone, or external JWT endpoint.

Additional external authentication providers (such as GitHub) can be used in addition for login into the UI.

-
+
It is highly recommended to require encrypted client passwords. External passwords used in the Docker client will be stored in plaintext! Enable this requirement now. @@ -650,7 +650,7 @@ - + @@ -690,21 +690,6 @@ - - - - - - -
OIDC Provider: - -
- An OIDC provider must be configured to use this authentication system -
-
- @@ -782,7 +767,7 @@
A certificate containing the public key portion of the key pair used to sign the JSON Web Tokens. This file must be in PEM format. -
@@ -1091,7 +1076,7 @@ (Delete)
-
+
Warning: This OIDC provider is not bound to your {{ config.AUTHENTICATION_TYPE }} authentication. Logging in via this provider will create a -only user, which is not the recommended approach. It is highly recommended to choose a "Binding Field" below.
@@ -1152,7 +1137,7 @@
- + + + + + + + + +
Binding Field:
External Application tokens +
+ Allow external application tokens +
+
+ If enabled, users will be able to generate external application tokens for use on the Docker and rkt CLI. Note + that these tokens will not be required unless "App Token" is chosen as the Internal Authentication method above. +
+
External application token expiration + +
+ The expiration time for user generated external application tokens. If none, tokens will never expire. +
+
Anonymous Access: diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js index b970b0f32..9d9b15ca0 100644 --- a/static/js/core-config-setup.js +++ b/static/js/core-config-setup.js @@ -45,8 +45,8 @@ angular.module("core-config-setup", ['angularFileUpload']) return config.AUTHENTICATION_TYPE == 'Keystone'; }, 'password': true}, - {'id': 'oidc-auth', 'title': 'OIDC Authentication', 'condition': function(config) { - return config.AUTHENTICATION_TYPE == 'OIDC'; + {'id': 'apptoken-auth', 'title': 'App Token Authentication', 'condition': function(config) { + return config.AUTHENTICATION_TYPE == 'AppToken'; }}, {'id': 'signer', 'title': 'ACI Signing', 'condition': function(config) { diff --git a/static/js/directives/ui/app-specific-token-manager/app-specific-token-manager.component.html b/static/js/directives/ui/app-specific-token-manager/app-specific-token-manager.component.html new file mode 100644 index 000000000..c8eaab5e2 --- /dev/null +++ b/static/js/directives/ui/app-specific-token-manager/app-specific-token-manager.component.html @@ -0,0 +1,33 @@ +
+
+ +
+ + + + + + + + +
+ + +
+
+ Application token "{{ $ctrl.revokeTokenInfo.token.title }}" will be revoked and all applications and CLIs making use of the token will no longer operate. +
+ + Proceed with revocation of this token? +
+
\ No newline at end of file diff --git a/static/js/directives/ui/app-specific-token-manager/app-specific-token-manager.component.ts b/static/js/directives/ui/app-specific-token-manager/app-specific-token-manager.component.ts new file mode 100644 index 000000000..640a96d3d --- /dev/null +++ b/static/js/directives/ui/app-specific-token-manager/app-specific-token-manager.component.ts @@ -0,0 +1,71 @@ +import { Input, Component, Inject } from 'ng-metadata/core'; +import * as bootbox from "bootbox"; + +/** + * A component that displays and manage all app specific tokens for a user. + */ +@Component({ + selector: 'app-specific-token-manager', + templateUrl: '/static/js/directives/ui/app-specific-token-manager/app-specific-token-manager.component.html', +}) +export class AppSpecificTokenManagerComponent { + private appTokensResource: any; + private appTokens: Array; + private tokenCredentials: any; + private revokeTokenInfo: any; + + constructor(@Inject('ApiService') private ApiService: any, @Inject('UserService') private UserService: any) { + this.loadTokens(); + } + + private loadTokens() { + this.appTokensResource = this.ApiService.listAppTokensAsResource().get((resp) => { + this.appTokens = resp['tokens']; + }); + } + + private askCreateToken() { + bootbox.prompt('Please enter a descriptive title for the new application token', (title) => { + if (!title) { return; } + + const errorHandler = this.ApiService.errorDisplay('Could not create the application token'); + this.ApiService.createAppToken({title}).then((resp) => { + this.loadTokens(); + }, errorHandler); + }); + } + + private showRevokeToken(token) { + this.revokeTokenInfo = { + 'token': token, + }; + }; + + private revokeToken(token, callback) { + const errorHandler = this.ApiService.errorDisplay('Could not revoke application token', callback); + const params = { + 'token_uuid': token['uuid'], + }; + + this.ApiService.revokeAppToken(null, params).then((resp) => { + this.loadTokens(); + callback(true); + }, errorHandler); + } + + private showToken(token) { + const errorHandler = this.ApiService.errorDisplay('Could not find application token'); + const params = { + 'token_uuid': token['uuid'], + }; + + this.ApiService.getAppToken(null, params).then((resp) => { + this.tokenCredentials = { + 'title': resp['token']['title'], + 'namespace': this.UserService.currentUser().username, + 'username': '$app', + 'password': resp['token']['token_code'], + }; + }, errorHandler); + } +} diff --git a/static/js/directives/ui/app-specific-token-manager/cog.html b/static/js/directives/ui/app-specific-token-manager/cog.html new file mode 100644 index 000000000..06fa569c5 --- /dev/null +++ b/static/js/directives/ui/app-specific-token-manager/cog.html @@ -0,0 +1,5 @@ + + + Revoke Token + + \ No newline at end of file diff --git a/static/js/directives/ui/app-specific-token-manager/created.html b/static/js/directives/ui/app-specific-token-manager/created.html new file mode 100644 index 000000000..a0747b110 --- /dev/null +++ b/static/js/directives/ui/app-specific-token-manager/created.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/js/directives/ui/app-specific-token-manager/expiration.html b/static/js/directives/ui/app-specific-token-manager/expiration.html new file mode 100644 index 000000000..ce37f4389 --- /dev/null +++ b/static/js/directives/ui/app-specific-token-manager/expiration.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/js/directives/ui/app-specific-token-manager/last-accessed.html b/static/js/directives/ui/app-specific-token-manager/last-accessed.html new file mode 100644 index 000000000..ae35bf6e3 --- /dev/null +++ b/static/js/directives/ui/app-specific-token-manager/last-accessed.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/js/directives/ui/app-specific-token-manager/token-title.html b/static/js/directives/ui/app-specific-token-manager/token-title.html new file mode 100644 index 000000000..6674709d3 --- /dev/null +++ b/static/js/directives/ui/app-specific-token-manager/token-title.html @@ -0,0 +1 @@ +{{ item.title }} \ No newline at end of file diff --git a/static/js/directives/ui/cor-table/cor-table-col.component.ts b/static/js/directives/ui/cor-table/cor-table-col.component.ts index ec4523257..27383d056 100644 --- a/static/js/directives/ui/cor-table/cor-table-col.component.ts +++ b/static/js/directives/ui/cor-table/cor-table-col.component.ts @@ -36,7 +36,7 @@ export class CorTableColumn implements OnInit { } public processColumnForOrdered(value: any): any { - if (this.kindof == 'datetime') { + if (this.kindof == 'datetime' && value) { return this.tableService.getReversedTimestamp(value); } diff --git a/static/js/directives/ui/cor-table/cor-table.component.html b/static/js/directives/ui/cor-table/cor-table.component.html index 6e17d0574..13b07fdb5 100644 --- a/static/js/directives/ui/cor-table/cor-table.component.html +++ b/static/js/directives/ui/cor-table/cor-table.component.html @@ -41,7 +41,8 @@ diff --git a/static/js/directives/ui/credentials-dialog.js b/static/js/directives/ui/credentials-dialog.js index 268f2ab7f..5864d5c7a 100644 --- a/static/js/directives/ui/credentials-dialog.js +++ b/static/js/directives/ui/credentials-dialog.js @@ -170,7 +170,7 @@ angular.module('quay').directive('credentialsDialog', function () { return ''; } - return $scope.getEscapedUsername(credentials).toLowerCase() + '-pull-secret'; + return $scope.getSuffixedFilename(credentials, 'pull-secret'); }; $scope.getKubernetesFile = function(credentials) { @@ -193,8 +193,12 @@ angular.module('quay').directive('credentialsDialog', function () { return $scope.getSuffixedFilename(credentials, 'secret.yml') }; - $scope.getEscapedUsername = function(credentials) { - return credentials.username.replace(/[^a-zA-Z0-9]/g, '-'); + $scope.getEscaped = function(item) { + var escaped = item.replace(/[^a-zA-Z0-9]/g, '-'); + if (escaped[0] == '-') { + escaped = escaped.substr(1); + } + return escaped; }; $scope.getSuffixedFilename = function(credentials, suffix) { @@ -202,7 +206,12 @@ angular.module('quay').directive('credentialsDialog', function () { return ''; } - return $scope.getEscapedUsername(credentials) + '-' + suffix; + var prefix = $scope.getEscaped(credentials.username); + if (credentials.title) { + prefix = $scope.getEscaped(credentials.title); + } + + return prefix + '-' + suffix; }; } }; diff --git a/static/js/directives/ui/logs-view.js b/static/js/directives/ui/logs-view.js index a239d0cc0..8b4e69063 100644 --- a/static/js/directives/ui/logs-view.js +++ b/static/js/directives/ui/logs-view.js @@ -283,6 +283,9 @@ angular.module('quay').directive('logsView', function () { } }, + 'create_app_specific_token': 'Created external application token {app_specific_token_title}', + 'revoke_app_specific_token': 'Revoked external application token {app_specific_token_title}', + // Note: These are deprecated. 'add_repo_webhook': 'Add webhook in repository {repo}', 'delete_repo_webhook': 'Delete webhook in repository {repo}' @@ -345,7 +348,9 @@ angular.module('quay').directive('logsView', function () { 'manifest_label_add': 'Add Manifest Label', 'manifest_label_delete': 'Delete Manifest Label', 'change_tag_expiration': 'Change tag expiration', - + 'create_app_specific_token': 'Create external app token', + 'revoke_app_specific_token': 'Revoke external app token', + // Note: these are deprecated. 'add_repo_webhook': 'Add webhook', 'delete_repo_webhook': 'Delete webhook' diff --git a/static/js/directives/ui/time-ago/time-ago.component.html b/static/js/directives/ui/time-ago/time-ago.component.html index 254f49565..9fa97cd6a 100644 --- a/static/js/directives/ui/time-ago/time-ago.component.html +++ b/static/js/directives/ui/time-ago/time-ago.component.html @@ -2,5 +2,5 @@ - Unknown + Never \ No newline at end of file diff --git a/static/js/quay.module.ts b/static/js/quay.module.ts index f47094cc3..682169e8a 100644 --- a/static/js/quay.module.ts +++ b/static/js/quay.module.ts @@ -39,6 +39,7 @@ import { CorTabsModule } from './directives/ui/cor-tabs/cor-tabs.module'; import { TriggerDescriptionComponent } from './directives/ui/trigger-description/trigger-description.component'; import { TimeAgoComponent } from './directives/ui/time-ago/time-ago.component'; import { TimeDisplayComponent } from './directives/ui/time-display/time-display.component'; +import { AppSpecificTokenManagerComponent } from './directives/ui/app-specific-token-manager/app-specific-token-manager.component'; import { MarkdownModule } from './directives/ui/markdown/markdown.module'; import * as Clipboard from 'clipboard'; @@ -83,6 +84,7 @@ import * as Clipboard from 'clipboard'; TriggerDescriptionComponent, TimeAgoComponent, TimeDisplayComponent, + AppSpecificTokenManagerComponent, ], providers: [ ViewArrayImpl, diff --git a/static/partials/user-view.html b/static/partials/user-view.html index 14f3a3d50..3ff99051d 100644 --- a/static/partials/user-view.html +++ b/static/partials/user-view.html @@ -70,25 +70,8 @@ - -
-

Docker CLI Token

-
- A generated token is required to login via the Docker CLI. -
- -
+ ng-class="$ctrl.tablePredicateClass(col)" style="{{ ::col.style }}" + class="{{ ::col.class }}"> {{ ::col.title }}
- - - - -
CLI Token: - -
- - -
+

Docker CLI Password

The Docker CLI stores passwords entered on the command line in plaintext. It is therefore highly recommended to generate an an encrypted version of your password to use for docker login. @@ -109,6 +92,18 @@
+ +
+

Docker CLI and other Application Tokens

+
+ As an alternative to using your password for Docker and rkt CLIs, an application token can be generated below. +
+
+ An application token is required to login via the Docker or rkt CLIs. +
+ +
+

User Settings

diff --git a/test/registry_tests.py b/test/registry_tests.py index 5d211f8df..72edd4d4e 100644 --- a/test/registry_tests.py +++ b/test/registry_tests.py @@ -1121,6 +1121,27 @@ class RegistryTestsMixin(object): self.assertEquals(1, logs[0]['metadata']['oauth_token_id']) + def test_push_pull_logging_byclitoken(self): + # Push the repository. + self.do_push('devtable', 'newrepo', 'devtable', 'password') + + # Pull the repository. + self.do_pull('devtable', 'newrepo', '$app', 'test') + + # Retrieve the logs and ensure the pull was added. + self.conduct_api_login('devtable', 'password') + result = self.conduct('GET', '/api/v1/repository/devtable/newrepo/logs') + logs = result.json()['logs'] + + self.assertEquals(2, len(logs)) + self.assertEquals('pull_repo', logs[0]['kind']) + self.assertEquals('devtable', logs[0]['metadata']['namespace']) + self.assertEquals('newrepo', logs[0]['metadata']['repo']) + + self.assertEquals('devtable', logs[0]['performer']['name']) + self.assertTrue('app_specific_token' in logs[0]['metadata']) + + def test_pull_publicrepo_anonymous(self): # Add a new repository under the public user, so we have a real repository to pull. self.do_push('public', 'newrepo', 'public', 'password') @@ -2447,6 +2468,10 @@ class LoginTests(object): self.do_login('$oauthtoken', 'test', expect_success=True, scope='repository:devtable/complex:pull') + def test_cli_token(self): + self.do_login('$app', 'test', expect_success=True, + scope='repository:devtable/complex:pull') + class V1LoginTests(V1RegistryLoginMixin, LoginTests, RegistryTestCaseMixin, BaseRegistryMixin, LiveServerTestCase): """ Tests for V1 login. """ @@ -2518,6 +2543,16 @@ class V2LoginTests(V2RegistryLoginMixin, LoginTests, RegistryTestCaseMixin, Base def test_invalidpassword_noscope(self): self.do_logincheck('public', 'invalidpass', expect_success=False, scope=None) + def test_cli_noaccess(self): + self.do_logincheck('$app', 'test', expect_success=True, + scope='repository:freshuser/unknownrepo:pull,push', + expected_actions=[]) + + def test_cli_public(self): + self.do_logincheck('$app', 'test', expect_success=True, + scope='repository:public/publicrepo:pull,push', + expected_actions=['pull']) + def test_oauth_noaccess(self): self.do_logincheck('$oauthtoken', 'test', expect_success=True, scope='repository:freshuser/unknownrepo:pull,push', diff --git a/util/audit.py b/util/audit.py index 903100b72..8de8a4329 100644 --- a/util/audit.py +++ b/util/audit.py @@ -9,7 +9,7 @@ from app import analytics, userevents, ip_resolver from data import model from auth.registry_jwt_auth import get_granted_entity from auth.auth_context import (get_authenticated_user, get_validated_token, - get_validated_oauth_token) + get_validated_oauth_token, get_validated_app_specific_token) logger = logging.getLogger(__name__) @@ -27,20 +27,28 @@ def track_and_log(event_name, repo_obj, analytics_name=None, analytics_sample=1, authenticated_oauth_token = get_validated_oauth_token() authenticated_user = get_authenticated_user() authenticated_token = get_validated_token() if not authenticated_user else None + app_specific_token = get_validated_app_specific_token() - if not authenticated_user and not authenticated_token and not authenticated_oauth_token: + if (not authenticated_user and not authenticated_token and not authenticated_oauth_token and + not app_specific_token): entity = get_granted_entity() if entity: authenticated_user = entity.user authenticated_token = entity.token authenticated_oauth_token = entity.oauth + app_specific_token = entity.app_specific_token logger.debug('Logging the %s to Mixpanel and the log system', event_name) if authenticated_oauth_token: metadata['oauth_token_id'] = authenticated_oauth_token.id metadata['oauth_token_application_id'] = authenticated_oauth_token.application.client_id metadata['oauth_token_application'] = authenticated_oauth_token.application.name + metadata['username'] = authenticated_user.username analytics_id = 'oauth:{0}'.format(authenticated_oauth_token.id) + elif app_specific_token: + metadata['app_specific_token'] = app_specific_token.uuid + metadata['username'] = authenticated_user.username + analytics_id = 'appspecifictoken:{0}'.format(app_specific_token.uuid) elif authenticated_user: metadata['username'] = authenticated_user.username analytics_id = authenticated_user.username diff --git a/util/config/configutil.py b/util/config/configutil.py index ace6e02a5..20ddf6814 100644 --- a/util/config/configutil.py +++ b/util/config/configutil.py @@ -20,6 +20,7 @@ def add_enterprise_config_defaults(config_obj, current_secret_key, hostname): config_obj['FEATURE_CHANGE_TAG_EXPIRATION'] = config_obj.get('FEATURE_CHANGE_TAG_EXPIRATION', True) config_obj['FEATURE_DIRECT_LOGIN'] = config_obj.get('FEATURE_DIRECT_LOGIN', True) + config_obj['FEATURE_APP_SPECIFIC_TOKENS'] = config_obj.get('FEATURE_APP_SPECIFIC_TOKENS', True) config_obj['FEATURE_PARTIAL_USER_AUTOCOMPLETE'] = config_obj.get('FEATURE_PARTIAL_USER_AUTOCOMPLETE', True) # Default features that are off. diff --git a/util/config/validator.py b/util/config/validator.py index c53dc428c..9cf312844 100644 --- a/util/config/validator.py +++ b/util/config/validator.py @@ -23,7 +23,7 @@ from util.config.validators.validate_oidc import OIDCLoginValidator from util.config.validators.validate_timemachine import TimeMachineValidator from util.config.validators.validate_access import AccessSettingsValidator from util.config.validators.validate_actionlog_archiving import ActionLogArchivingValidator -from util.config.validators.validate_oidcauth import OIDCAuthValidator +from util.config.validators.validate_apptokenauth import AppTokenAuthValidator logger = logging.getLogger(__name__) @@ -62,7 +62,7 @@ VALIDATORS = { TimeMachineValidator.name: TimeMachineValidator.validate, AccessSettingsValidator.name: AccessSettingsValidator.validate, ActionLogArchivingValidator.name: ActionLogArchivingValidator.validate, - OIDCAuthValidator.name: OIDCAuthValidator.validate, + AppTokenAuthValidator.name: AppTokenAuthValidator.validate, } def validate_service_for_config(service, config, password=None): diff --git a/util/config/validators/test/test_validate_apptokenauth.py b/util/config/validators/test/test_validate_apptokenauth.py new file mode 100644 index 000000000..61ccd3570 --- /dev/null +++ b/util/config/validators/test/test_validate_apptokenauth.py @@ -0,0 +1,29 @@ +import pytest + +from util.config.validators import ConfigValidationException +from util.config.validators.validate_apptokenauth import AppTokenAuthValidator + +from test.fixtures import * + +@pytest.mark.parametrize('unvalidated_config', [ + ({'AUTHENTICATION_TYPE': 'AppToken'}), + ({'AUTHENTICATION_TYPE': 'AppToken', 'FEATURE_APP_SPECIFIC_TOKENS': False}), + ({'AUTHENTICATION_TYPE': 'AppToken', 'FEATURE_APP_SPECIFIC_TOKENS': True, + 'FEATURE_DIRECT_LOGIN': True}), +]) +def test_validate_invalid_auth_config(unvalidated_config, app): + validator = AppTokenAuthValidator() + + with pytest.raises(ConfigValidationException): + validator.validate(unvalidated_config, None, None) + + +def test_validate_auth(app): + config = { + 'AUTHENTICATION_TYPE': 'AppToken', + 'FEATURE_APP_SPECIFIC_TOKENS': True, + 'FEATURE_DIRECT_LOGIN': False, + } + + validator = AppTokenAuthValidator() + validator.validate(config, None, None) diff --git a/util/config/validators/test/test_validate_oidcauth.py b/util/config/validators/test/test_validate_oidcauth.py deleted file mode 100644 index 9dedbe571..000000000 --- a/util/config/validators/test/test_validate_oidcauth.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest - -from util.config.validators import ConfigValidationException -from util.config.validators.validate_oidcauth import OIDCAuthValidator - -from test.fixtures import * - -@pytest.mark.parametrize('unvalidated_config', [ - ({'AUTHENTICATION_TYPE': 'OIDC'}), - ({'AUTHENTICATION_TYPE': 'OIDC', 'INTERNAL_OIDC_SERVICE_ID': 'someservice'}), - ({'AUTHENTICATION_TYPE': 'OIDC', 'INTERNAL_OIDC_SERVICE_ID': 'someservice', - 'SOMESERVICE_LOGIN_CONFIG': {}, 'FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH': True}), -]) -def test_validate_invalid_oidc_auth_config(unvalidated_config, app): - validator = OIDCAuthValidator() - - with pytest.raises(ConfigValidationException): - validator.validate(unvalidated_config, None, None) - - -def test_validate_oidc_auth(app): - config = { - 'AUTHENTICATION_TYPE': 'OIDC', - 'INTERNAL_OIDC_SERVICE_ID': 'someservice', - 'SOMESERVICE_LOGIN_CONFIG': { - 'CLIENT_ID': 'foo', - 'CLIENT_SECRET': 'bar', - 'OIDC_SERVER': 'http://someserver', - }, - 'HTTPCLIENT': None, - } - - validator = OIDCAuthValidator() - validator.validate(config, None, None) diff --git a/util/config/validators/validate_apptokenauth.py b/util/config/validators/validate_apptokenauth.py new file mode 100644 index 000000000..6d7be1f1b --- /dev/null +++ b/util/config/validators/validate_apptokenauth.py @@ -0,0 +1,19 @@ +from util.config.validators import BaseValidator, ConfigValidationException + +class AppTokenAuthValidator(BaseValidator): + name = "apptoken-auth" + + @classmethod + def validate(cls, config, user, user_password): + if config.get('AUTHENTICATION_TYPE', 'Database') != 'AppToken': + return + + # Ensure that app tokens are enabled, as they are required. + if not config.get('FEATURE_APP_SPECIFIC_TOKENS', False): + msg = 'Application token support must be enabled to use External Application Token auth' + raise ConfigValidationException(msg) + + # Ensure that direct login is disabled. + if config.get('FEATURE_DIRECT_LOGIN', True): + msg = 'Direct login must be disabled to use External Application Token auth' + raise ConfigValidationException(msg) diff --git a/util/config/validators/validate_oidcauth.py b/util/config/validators/validate_oidcauth.py deleted file mode 100644 index 502ca8b9c..000000000 --- a/util/config/validators/validate_oidcauth.py +++ /dev/null @@ -1,25 +0,0 @@ -from app import app -from data.users.oidc import OIDCInternalAuth, UnknownServiceException -from util.config.validators import BaseValidator, ConfigValidationException - -class OIDCAuthValidator(BaseValidator): - name = "oidc-auth" - - @classmethod - def validate(cls, config, user, user_password): - if config.get('AUTHENTICATION_TYPE', 'Database') != 'OIDC': - return - - # Ensure that encrypted passwords are not required, as they do not work with OIDC auth. - if config.get('FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH', False): - raise ConfigValidationException('Encrypted passwords must be disabled to use OIDC auth') - - login_service_id = config.get('INTERNAL_OIDC_SERVICE_ID') - if not login_service_id: - raise ConfigValidationException('Missing OIDC provider') - - # By instantiating the auth engine, it will check if the provider exists and works. - try: - OIDCInternalAuth(config, login_service_id, False) - except UnknownServiceException as use: - raise ConfigValidationException(use.message) diff --git a/util/security/registry_jwt.py b/util/security/registry_jwt.py index 05debf843..b21ad1044 100644 --- a/util/security/registry_jwt.py +++ b/util/security/registry_jwt.py @@ -103,7 +103,8 @@ def _generate_jwt_object(audience, subject, context, access, lifetime_s, issuer, return jwt.encode(token_data, private_key, ALGORITHM, headers=token_headers) -def build_context_and_subject(user=None, token=None, oauthtoken=None, tuf_root=None): +def build_context_and_subject(user=None, token=None, oauthtoken=None, appspecifictoken=None, + tuf_root=None): """ Builds the custom context field for the JWT signed token and returns it, along with the subject for the JWT signed token. """ @@ -123,6 +124,14 @@ def build_context_and_subject(user=None, token=None, oauthtoken=None, tuf_root=N }) return (context, user.username) + if appspecifictoken: + context.update({ + 'kind': 'app_specific_token', + 'user': user.username, + 'ast': appspecifictoken.uuid, + }) + return (context, user.username) + if user: context.update({ 'kind': 'user', @@ -141,5 +150,3 @@ def build_context_and_subject(user=None, token=None, oauthtoken=None, tuf_root=N 'kind': 'anonymous', }) return (context, ANONYMOUS_SUB) - -