From 524d77f527d50b6145f381972c9256a82f799b71 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 8 Dec 2017 17:05:59 -0500 Subject: [PATCH 1/5] Add an AppSpecificAuthToken data model for app-specific auth tokens. These will be used for the Docker CLI in place of username+password --- auth/auth_context.py | 9 ++ auth/credentials.py | 41 +++++-- auth/registry_jwt_auth.py | 12 +- auth/test/test_basic.py | 14 ++- auth/test/test_credentials.py | 40 ++++++- auth/validateresult.py | 18 ++- config.py | 6 + data/database.py | 20 ++++ ...8d9_add_support_for_app_specific_tokens.py | 57 +++++++++ data/model/__init__.py | 1 + data/model/appspecifictoken.py | 108 +++++++++++++++++ data/model/test/test_appspecifictoken.py | 77 ++++++++++++ data/users/__init__.py | 14 ++- data/users/apptoken.py | 59 +++++++++ data/users/oidc.py | 92 -------------- data/users/test/test_oidc.py | 38 ------ data/users/test/test_users.py | 8 -- endpoints/api/__init__.py | 1 + endpoints/api/appspecifictokens.py | 112 ++++++++++++++++++ endpoints/api/test/test_appspecifictoken.py | 33 ++++++ endpoints/api/test/test_security.py | 23 +++- endpoints/api/user.py | 1 - endpoints/v1/index.py | 19 ++- endpoints/v2/v2auth.py | 19 ++- initdb.py | 8 ++ .../directives/config/config-setup-tool.html | 49 ++++---- static/js/core-config-setup.js | 4 +- .../app-specific-token-manager.component.html | 33 ++++++ .../app-specific-token-manager.component.ts | 71 +++++++++++ .../ui/app-specific-token-manager/cog.html | 5 + .../app-specific-token-manager/created.html | 1 + .../expiration.html | 1 + .../last-accessed.html | 1 + .../token-title.html | 1 + .../ui/cor-table/cor-table-col.component.ts | 2 +- .../ui/cor-table/cor-table.component.html | 3 +- static/js/directives/ui/credentials-dialog.js | 17 ++- static/js/directives/ui/logs-view.js | 7 +- .../ui/time-ago/time-ago.component.html | 2 +- static/js/quay.module.ts | 2 + static/partials/user-view.html | 31 ++--- test/registry_tests.py | 35 ++++++ util/audit.py | 12 +- util/config/configutil.py | 1 + util/config/validator.py | 4 +- .../test/test_validate_apptokenauth.py | 29 +++++ .../validators/test/test_validate_oidcauth.py | 34 ------ .../validators/validate_apptokenauth.py | 19 +++ util/config/validators/validate_oidcauth.py | 25 ---- util/security/registry_jwt.py | 13 +- 50 files changed, 943 insertions(+), 289 deletions(-) create mode 100644 data/migrations/versions/7367229b38d9_add_support_for_app_specific_tokens.py create mode 100644 data/model/appspecifictoken.py create mode 100644 data/model/test/test_appspecifictoken.py create mode 100644 data/users/apptoken.py delete mode 100644 data/users/oidc.py delete mode 100644 data/users/test/test_oidc.py create mode 100644 endpoints/api/appspecifictokens.py create mode 100644 endpoints/api/test/test_appspecifictoken.py create mode 100644 static/js/directives/ui/app-specific-token-manager/app-specific-token-manager.component.html create mode 100644 static/js/directives/ui/app-specific-token-manager/app-specific-token-manager.component.ts create mode 100644 static/js/directives/ui/app-specific-token-manager/cog.html create mode 100644 static/js/directives/ui/app-specific-token-manager/created.html create mode 100644 static/js/directives/ui/app-specific-token-manager/expiration.html create mode 100644 static/js/directives/ui/app-specific-token-manager/last-accessed.html create mode 100644 static/js/directives/ui/app-specific-token-manager/token-title.html create mode 100644 util/config/validators/test/test_validate_apptokenauth.py delete mode 100644 util/config/validators/test/test_validate_oidcauth.py create mode 100644 util/config/validators/validate_apptokenauth.py delete mode 100644 util/config/validators/validate_oidcauth.py 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) - - From 2214a2c7ad97bac3cd27cf7932582c4e276783aa Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 12 Dec 2017 16:00:38 -0500 Subject: [PATCH 2/5] Disable fresh login check in auth engines that won't support it --- data/users/__init__.py | 5 +++++ data/users/apptoken.py | 4 ++++ data/users/database.py | 4 ++++ data/users/federated.py | 4 ++++ endpoints/api/__init__.py | 5 +++-- 5 files changed, 20 insertions(+), 2 deletions(-) diff --git a/data/users/__init__.py b/data/users/__init__.py index 6d52d5b94..a18ef6ba9 100644 --- a/data/users/__init__.py +++ b/data/users/__init__.py @@ -187,6 +187,11 @@ class UserAuthentication(object): """ Returns whether this auth system supports using encrypted credentials. """ return self.state.supports_encrypted_credentials + @property + def supports_fresh_login(self): + """ Returns whether this auth system supports the fresh login check. """ + return self.state.supports_fresh_login + def query_users(self, query, limit=20): """ Performs a lookup against the user system for the specified query. The returned tuple will be of the form (results, federated_login_id, err_msg). If the method is unsupported, diff --git a/data/users/apptoken.py b/data/users/apptoken.py index 271b8eef7..638b2b040 100644 --- a/data/users/apptoken.py +++ b/data/users/apptoken.py @@ -12,6 +12,10 @@ class AppTokenInternalAuth(object): """ Forces all internal credential login to go through an app token, by disabling all other access. """ + @property + def supports_fresh_login(self): + # Since there is no password. + return False @property def federated_service(self): diff --git a/data/users/database.py b/data/users/database.py index 6c85db3bc..a71669c44 100644 --- a/data/users/database.py +++ b/data/users/database.py @@ -5,6 +5,10 @@ class DatabaseUsers(object): def federated_service(self): return None + @property + def supports_fresh_login(self): + return True + 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/federated.py b/data/users/federated.py index 047234a65..424779eee 100644 --- a/data/users/federated.py +++ b/data/users/federated.py @@ -24,6 +24,10 @@ class FederatedUsers(object): def federated_service(self): return self._federated_service + @property + def supports_fresh_login(self): + return True + @property def supports_encrypted_credentials(self): return True diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 625e1926a..209b2d013 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -10,7 +10,7 @@ from flask_restful import Resource, abort, Api, reqparse from flask_restful.utils.cors import crossdomain from jsonschema import validate, ValidationError -from app import app, metric_queue +from app import app, metric_queue, authentication from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, AdministerRepositoryPermission, UserReadPermission, UserAdminPermission) @@ -300,7 +300,8 @@ def require_fresh_login(func): last_login = session.get('login_time', datetime.datetime.min) valid_span = datetime.datetime.now() - datetime.timedelta(minutes=10) - if not user.password_hash or last_login >= valid_span: + if (not user.password_hash or last_login >= valid_span or + not authentication.supports_fresh_login): return func(*args, **kwargs) raise FreshLoginRequired() From 5b4f5f9859142c21cad2e305cf13297402dd53a6 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 12 Dec 2017 17:23:57 -0500 Subject: [PATCH 3/5] Regenerate test DB for token changes --- test/data/test.db | Bin 1679360 -> 1699840 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/data/test.db b/test/data/test.db index e109172d6a86edc69ff686a6a5063c939d785ec6..c3664e16b3004f098829848b8640f72df4c6ac39 100644 GIT binary patch delta 80721 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

jApK32*{Vxz;0<`gr@4CbSbg{&J&UMj@URB_f|P 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 From 888b564a9bee3749c2eedd42034242f1825de710 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 12 Dec 2017 17:51:54 -0500 Subject: [PATCH 4/5] Add a banner to the Quay UI when an app specific token is about to expire --- auth/test/test_basic.py | 1 - auth/test/test_credentials.py | 4 +-- data/model/appspecifictoken.py | 6 +++- endpoints/api/appspecifictokens.py | 29 +++++++++++++++++-- endpoints/api/test/test_appspecifictoken.py | 4 +++ static/directives/quay-message-bar.html | 7 +++++ static/js/directives/quay-message-bar.js | 4 ++- .../app-specific-token-manager.component.ts | 6 +++- static/js/services/notification-service.js | 8 +++++ 9 files changed, 60 insertions(+), 9 deletions(-) diff --git a/auth/test/test_basic.py b/auth/test/test_basic.py index 0bfb60606..a25fe8b50 100644 --- a/auth/test/test_basic.py +++ b/auth/test/test_basic.py @@ -74,5 +74,4 @@ def test_valid_app_specific_token(app): 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 4b795ed6d..1000258c5 100644 --- a/auth/test/test_credentials.py +++ b/auth/test/test_credentials.py @@ -17,7 +17,7 @@ def test_valid_robot(app): assert result == ValidateResult(AuthKind.credentials, robot=robot) def test_valid_robot_for_disabled_user(app): - user = model.user.get_user('devtable') + user = model.user.get_user('devtable') user.enabled = False user.save() @@ -50,7 +50,7 @@ def test_invalid_user(app): 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) diff --git a/data/model/appspecifictoken.py b/data/model/appspecifictoken.py index 37489326e..a9df58606 100644 --- a/data/model/appspecifictoken.py +++ b/data/model/appspecifictoken.py @@ -11,13 +11,17 @@ 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' +# Define a "unique" value so that callers can specifiy an expiration of None and *not* have it +# use the default. +_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. """ diff --git a/endpoints/api/appspecifictokens.py b/endpoints/api/appspecifictokens.py index 3e3525dc1..a387d82bb 100644 --- a/endpoints/api/appspecifictokens.py +++ b/endpoints/api/appspecifictokens.py @@ -1,19 +1,25 @@ """ Manages app specific tokens for the current user. """ import logging +import math +from datetime import timedelta from flask import request import features +from app import app 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) + path_param, NotFound, format_date, show_if, query_param, parse_args, + truthy_bool) +from util.timedeltastring import convert_to_timedelta logger = logging.getLogger(__name__) + def token_view(token, include_code=False): data = { 'uuid': token.uuid, @@ -30,6 +36,11 @@ def token_view(token, include_code=False): return data + +# The default window to use when looking up tokens that will be expiring. +_DEFAULT_TOKEN_EXPIRATION_WINDOW = '4w' + + @resource('/v1/user/apptoken') @show_if(features.APP_SPECIFIC_TOKENS) class AppTokens(ApiResource): @@ -51,11 +62,23 @@ class AppTokens(ApiResource): @require_user_admin @nickname('listAppTokens') - def get(self): + @parse_args() + @query_param('expiring', 'If true, only returns those tokens expiring soon', type=truthy_bool) + def get(self, parsed_args): """ Lists the app specific tokens for the user. """ - tokens = model.appspecifictoken.list_tokens(get_authenticated_user()) + expiring = parsed_args['expiring'] + if expiring: + expiration = app.config.get('APP_SPECIFIC_TOKEN_EXPIRATION') + token_expiration = convert_to_timedelta(expiration or _DEFAULT_TOKEN_EXPIRATION_WINDOW) + seconds = math.ceil(token_expiration.total_seconds() * 0.1) or 1 + soon = timedelta(seconds=seconds) + tokens = model.appspecifictoken.get_expiring_tokens(get_authenticated_user(), soon) + else: + tokens = model.appspecifictoken.list_tokens(get_authenticated_user()) + return { 'tokens': [token_view(token, include_code=False) for token in tokens], + 'only_expiring': expiring, } @require_user_admin diff --git a/endpoints/api/test/test_appspecifictoken.py b/endpoints/api/test/test_appspecifictoken.py index e9e95fc59..f71c306e7 100644 --- a/endpoints/api/test/test_appspecifictoken.py +++ b/endpoints/api/test/test_appspecifictoken.py @@ -17,6 +17,10 @@ def test_app_specific_tokens(app, client): 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]) + # List the tokens expiring soon and ensure the one added is not present. + resp = conduct_api_call(cl, AppTokens, 'GET', {'expiring': True}, None, 200).json + assert token_uuid not in set([token['uuid'] for token in resp['tokens']]) + # 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 diff --git a/static/directives/quay-message-bar.html b/static/directives/quay-message-bar.html index cc6de4a1c..ccf3ca2ce 100644 --- a/static/directives/quay-message-bar.html +++ b/static/directives/quay-message-bar.html @@ -1,4 +1,11 @@

+
+
+ Your external application token {{ token.title }} + will be expiring . + Please create a new token and revoke this token in user settings. +
+
diff --git a/static/js/directives/quay-message-bar.js b/static/js/directives/quay-message-bar.js index 9d3954d6b..0c4c04a03 100644 --- a/static/js/directives/quay-message-bar.js +++ b/static/js/directives/quay-message-bar.js @@ -9,8 +9,10 @@ angular.module('quay').directive('quayMessageBar', function () { transclude: false, restrict: 'C', scope: {}, - controller: function ($scope, $element, ApiService) { + controller: function ($scope, $element, ApiService, NotificationService) { $scope.messages = []; + $scope.NotificationService = NotificationService; + ApiService.getGlobalMessages().then(function (data) { $scope.messages = data['messages'] || []; }, function (resp) { 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 index 640a96d3d..19431e0c6 100644 --- 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 @@ -14,7 +14,8 @@ export class AppSpecificTokenManagerComponent { private tokenCredentials: any; private revokeTokenInfo: any; - constructor(@Inject('ApiService') private ApiService: any, @Inject('UserService') private UserService: any) { + constructor(@Inject('ApiService') private ApiService: any, @Inject('UserService') private UserService: any, + @Inject('NotificationService') private NotificationService: any) { this.loadTokens(); } @@ -49,6 +50,9 @@ export class AppSpecificTokenManagerComponent { this.ApiService.revokeAppToken(null, params).then((resp) => { this.loadTokens(); + + // Update the notification service so it hides any banners if we revoked an expiring token. + this.NotificationService.update(); callback(true); }, errorHandler); } diff --git a/static/js/services/notification-service.js b/static/js/services/notification-service.js index ab8a118c5..ef856e735 100644 --- a/static/js/services/notification-service.js +++ b/static/js/services/notification-service.js @@ -11,6 +11,7 @@ function($rootScope, $interval, UserService, ApiService, StringBuilderService, P 'notifications': [], 'notificationClasses': [], 'notificationSummaries': [], + 'expiringAppTokens': [], 'additionalNotifications': false }; @@ -272,6 +273,13 @@ function($rootScope, $interval, UserService, ApiService, StringBuilderService, P notificationService.additionalNotifications = resp['additional']; notificationService.notificationClasses = notificationService.getClasses(notificationService.notifications); }); + + var params = { + 'expiring': true + }; + ApiService.listAppTokens(null, params).then(function(resp) { + notificationService.expiringAppTokens = resp['tokens']; + }); }; notificationService.reset = function() { From 6a876a6b73f2a914d3012cea950ed6c724c442ee Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 4 Jan 2018 15:26:17 -0500 Subject: [PATCH 5/5] Change title to be UTF8 --- .../7367229b38d9_add_support_for_app_specific_tokens.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 12d378020..ffb0f00ef 100644 --- a/data/migrations/versions/7367229b38d9_add_support_for_app_specific_tokens.py +++ b/data/migrations/versions/7367229b38d9_add_support_for_app_specific_tokens.py @@ -13,6 +13,7 @@ down_revision = 'd8989249f8f6' from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import mysql +from util.migrate import UTF8CharField def upgrade(tables): # ### commands auto generated by Alembic - please adjust! ### @@ -20,7 +21,7 @@ def upgrade(tables): 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('title', UTF8CharField(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),