Merge pull request #2942 from coreos-inc/joseph.schorr/QS-75/cli-tokens
Add support for app specific tokens to Quay
This commit is contained in:
		
						commit
						e01fb45dd8
					
				
					 56 changed files with 1016 additions and 292 deletions
				
			
		|  | @ -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) | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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: | ||||
|  |  | |||
|  | @ -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,11 @@ 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) | ||||
|   assert result == ValidateResult(AuthKind.basic, appspecifictoken=app_specific_token) | ||||
|  |  | |||
|  | @ -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') | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -1418,6 +1418,26 @@ class BitTorrentPieces(BaseModel): | |||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| class AppSpecificAuthToken(BaseModel): | ||||
|   """ AppSpecificAuthToken represents a token generated by a user for use with an external | ||||
|       application where putting the user's credentials, even encrypted, is deemed too risky. | ||||
|   """ | ||||
|   user = QuayUserField() | ||||
|   uuid = CharField(default=uuid_generator, max_length=36, index=True) | ||||
|   title = CharField() | ||||
|   token_code = CharField(default=random_string_generator(length=120), unique=True, index=True) | ||||
|   created = DateTimeField(default=datetime.now) | ||||
|   expiration = DateTimeField(null=True) | ||||
|   last_accessed = DateTimeField(null=True) | ||||
| 
 | ||||
|   class Meta: | ||||
|     database = db | ||||
|     read_slaves = (read_slave,) | ||||
|     indexes = ( | ||||
|       (('user', 'expiration'), False), | ||||
|     ) | ||||
|    | ||||
| 
 | ||||
| beta_classes = set([ManifestLayerScan, Tag, TagKind, BlobPlacementLocation, ManifestLayer, ManifestList, | ||||
|                     BitTorrentPieces, MediaType, Label, ManifestBlob, BlobUploading, Blob, | ||||
|                     ManifestLayerDockerV1, BlobPlacementLocationPreference, ManifestListManifest, | ||||
|  |  | |||
|  | @ -0,0 +1,58 @@ | |||
| """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 | ||||
| from util.migrate import UTF8CharField | ||||
| 
 | ||||
| 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', 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), | ||||
|     sa.Column('last_accessed', sa.DateTime(), nullable=True), | ||||
|     sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_appspecificauthtoken_user_id_user')), | ||||
|     sa.PrimaryKeyConstraint('id', name=op.f('pk_appspecificauthtoken')) | ||||
|     ) | ||||
|     op.create_index('appspecificauthtoken_token_code', 'appspecificauthtoken', ['token_code'], unique=True) | ||||
|     op.create_index('appspecificauthtoken_user_id', 'appspecificauthtoken', ['user_id'], unique=False) | ||||
|     op.create_index('appspecificauthtoken_user_id_expiration', 'appspecificauthtoken', ['user_id', 'expiration'], unique=False) | ||||
|     op.create_index('appspecificauthtoken_uuid', 'appspecificauthtoken', ['uuid'], unique=False) | ||||
|     # ### end Alembic commands ### | ||||
| 
 | ||||
|     op.bulk_insert(tables.logentrykind, [ | ||||
|         {'name': 'create_app_specific_token'}, | ||||
|         {'name': 'revoke_app_specific_token'}, | ||||
|     ]) | ||||
| 
 | ||||
| def downgrade(tables): | ||||
|     # ### commands auto generated by Alembic - please adjust! ### | ||||
|     op.drop_table('appspecificauthtoken') | ||||
|     # ### end Alembic commands ### | ||||
| 
 | ||||
|     op.execute(tables | ||||
|                .logentrykind | ||||
|                .delete() | ||||
|                .where(tables. | ||||
|                       logentrykind.name == op.inline_literal('create_app_specific_token'))) | ||||
| 
 | ||||
|     op.execute(tables | ||||
|                .logentrykind | ||||
|                .delete() | ||||
|                .where(tables. | ||||
|                       logentrykind.name == op.inline_literal('revoke_app_specific_token'))) | ||||
|  | @ -126,6 +126,7 @@ config = Config() | |||
| # There MUST NOT be any circular dependencies between these subsections. If there are fix it by | ||||
| # moving the minimal number of things to _basequery | ||||
| from data.model import ( | ||||
|   appspecifictoken, | ||||
|   blob, | ||||
|   build, | ||||
|   image, | ||||
|  |  | |||
							
								
								
									
										112
									
								
								data/model/appspecifictoken.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								data/model/appspecifictoken.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,112 @@ | |||
| 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 | ||||
| 
 | ||||
| 
 | ||||
| # 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. """ | ||||
|   expiration = expiration if expiration != _default_expiration_opt else _default_expiration() | ||||
|   return AppSpecificAuthToken.create(user=user, title=title, expiration=expiration) | ||||
| 
 | ||||
| 
 | ||||
| def list_tokens(user): | ||||
|   """ Lists all tokens for the given user. """ | ||||
|   return AppSpecificAuthToken.select().where(AppSpecificAuthToken.user == user) | ||||
| 
 | ||||
| 
 | ||||
| def revoke_token(token): | ||||
|   """ Revokes an app specific token by deleting it. """ | ||||
|   token.delete_instance() | ||||
| 
 | ||||
| 
 | ||||
| def get_expiring_tokens(user, soon): | ||||
|   """ Returns all tokens owned by the given user that will be expiring "soon", where soon is defined | ||||
|       by the soon parameter (a timedelta from now). | ||||
|   """ | ||||
|   soon_datetime = datetime.now() + soon | ||||
|   return (AppSpecificAuthToken | ||||
|           .select() | ||||
|           .where(AppSpecificAuthToken.user == user, | ||||
|                  AppSpecificAuthToken.expiration <= soon_datetime)) | ||||
| 
 | ||||
| 
 | ||||
| def gc_expired_tokens(user): | ||||
|   """ Deletes all expired tokens owned by the given user. """ | ||||
|   (AppSpecificAuthToken | ||||
|    .delete() | ||||
|    .where(AppSpecificAuthToken.user == user, AppSpecificAuthToken.expiration < datetime.now()) | ||||
|    .execute()) | ||||
| 
 | ||||
| 
 | ||||
| def get_token_by_uuid(uuid, owner=None): | ||||
|   """ Looks up an unexpired app specific token with the given uuid. Returns it if found or | ||||
|       None if none. If owner is specified, only tokens owned by the owner user will be | ||||
|       returned. | ||||
|   """ | ||||
|   try: | ||||
|     query = (AppSpecificAuthToken | ||||
|              .select() | ||||
|              .where(AppSpecificAuthToken.uuid == uuid, | ||||
|                     ((AppSpecificAuthToken.expiration > datetime.now()) | | ||||
|                      (AppSpecificAuthToken.expiration >> None)))) | ||||
|     if owner is not None: | ||||
|       query = query.where(AppSpecificAuthToken.user == owner) | ||||
| 
 | ||||
|     return query.get() | ||||
|   except AppSpecificAuthToken.DoesNotExist: | ||||
|     return None | ||||
| 
 | ||||
| 
 | ||||
| def access_valid_token(token_code): | ||||
|   """ Looks up an unexpired app specific token with the given token code. If found, the token's | ||||
|       last_accessed field is set to now and the token is returned. If not found, returns None. | ||||
|   """ | ||||
|   with db_transaction(): | ||||
|     try: | ||||
|       token = (AppSpecificAuthToken | ||||
|                .select(AppSpecificAuthToken, User) | ||||
|                .join(User) | ||||
|                .where(AppSpecificAuthToken.token_code == token_code, | ||||
|                       ((AppSpecificAuthToken.expiration > datetime.now()) | | ||||
|                        (AppSpecificAuthToken.expiration >> None))) | ||||
|                .get()) | ||||
|     except AppSpecificAuthToken.DoesNotExist: | ||||
|       return None | ||||
| 
 | ||||
|     token.last_accessed = datetime.now() | ||||
| 
 | ||||
|     try: | ||||
|       token.save() | ||||
|     except PeeweeException as ex: | ||||
|       strict_logging_disabled = config.app_config.get('ALLOW_PULLS_WITHOUT_STRICT_LOGGING') | ||||
|       if strict_logging_disabled: | ||||
|         data = { | ||||
|           'exception': ex, | ||||
|           'token': token.id, | ||||
|         } | ||||
| 
 | ||||
|         logger.exception('update last_accessed for token failed', extra=data) | ||||
|       else: | ||||
|         raise | ||||
| 
 | ||||
|   return token | ||||
							
								
								
									
										77
									
								
								data/model/test/test_appspecifictoken.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								data/model/test/test_appspecifictoken.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,77 @@ | |||
| from datetime import datetime | ||||
| 
 | ||||
| import pytest | ||||
| 
 | ||||
| from data import model | ||||
| from data.model.appspecifictoken import create_token, revoke_token, access_valid_token | ||||
| from data.model.appspecifictoken import gc_expired_tokens, get_expiring_tokens | ||||
| from util.timedeltastring import convert_to_timedelta | ||||
| 
 | ||||
| from test.fixtures import * | ||||
| 
 | ||||
| @pytest.mark.parametrize('expiration', [ | ||||
|   (None), | ||||
|   ('-1m'), | ||||
|   ('-1d'), | ||||
|   ('-1w'), | ||||
|   ('10m'), | ||||
|   ('10d'), | ||||
|   ('10w'), | ||||
| ]) | ||||
| def test_gc(expiration, initialized_db): | ||||
|   user = model.user.get_user('devtable') | ||||
| 
 | ||||
|   expiration_date = None | ||||
|   is_expired = False | ||||
|   if expiration: | ||||
|     if expiration[0] == '-': | ||||
|       is_expired = True | ||||
|       expiration_date = datetime.now() - convert_to_timedelta(expiration[1:]) | ||||
|     else: | ||||
|       expiration_date = datetime.now() + convert_to_timedelta(expiration) | ||||
| 
 | ||||
|   # Create a token. | ||||
|   token = create_token(user, 'Some token', expiration=expiration_date) | ||||
| 
 | ||||
|   # GC tokens. | ||||
|   gc_expired_tokens(user) | ||||
| 
 | ||||
|   # Ensure the token was GCed if expired and not if it wasn't. | ||||
|   assert (access_valid_token(token.token_code) is None) == is_expired | ||||
| 
 | ||||
| 
 | ||||
| def test_access_token(initialized_db): | ||||
|   user = model.user.get_user('devtable') | ||||
| 
 | ||||
|   # Create a token. | ||||
|   token = create_token(user, 'Some token') | ||||
|   assert token.last_accessed is None | ||||
| 
 | ||||
|   # Lookup the token. | ||||
|   token = access_valid_token(token.token_code) | ||||
|   assert token.last_accessed is not None | ||||
| 
 | ||||
|   # Revoke the token. | ||||
|   revoke_token(token) | ||||
| 
 | ||||
|   # Ensure it cannot be accessed | ||||
|   assert access_valid_token(token.token_code) is None | ||||
| 
 | ||||
| 
 | ||||
| def test_expiring_soon(initialized_db): | ||||
|   user = model.user.get_user('devtable') | ||||
| 
 | ||||
|   # Create some tokens. | ||||
|   create_token(user, 'Some token') | ||||
|   exp_token = create_token(user, 'Some expiring token', datetime.now() + convert_to_timedelta('1d')) | ||||
|   create_token(user, 'Some other token', expiration=datetime.now() + convert_to_timedelta('2d')) | ||||
| 
 | ||||
|   # Get the token expiring soon. | ||||
|   expiring_soon = get_expiring_tokens(user, convert_to_timedelta('25h')) | ||||
|   assert expiring_soon | ||||
|   assert len(expiring_soon) == 1 | ||||
|   assert expiring_soon[0].id == exp_token.id | ||||
| 
 | ||||
|   expiring_soon = get_expiring_tokens(user, convert_to_timedelta('49h')) | ||||
|   assert expiring_soon | ||||
|   assert len(expiring_soon) == 2 | ||||
|  | @ -10,7 +10,7 @@ from data.users.database import DatabaseUsers | |||
| from data.users.externalldap import LDAPUsers | ||||
| from data.users.externaljwt import ExternalJWTAuthN | ||||
| from data.users.keystone import get_keystone_users | ||||
| from data.users.oidc import OIDCInternalAuth | ||||
| from data.users.apptoken import AppTokenInternalAuth | ||||
| from util.security.aes import AESCipher | ||||
| 
 | ||||
| logger = logging.getLogger(__name__) | ||||
|  | @ -25,7 +25,7 @@ def get_federated_service_name(authentication_type): | |||
|   if authentication_type == 'Keystone': | ||||
|     return 'keystone' | ||||
| 
 | ||||
|   if authentication_type == 'OIDC': | ||||
|   if authentication_type == 'AppToken': | ||||
|     return None | ||||
| 
 | ||||
|   if authentication_type == 'Database': | ||||
|  | @ -84,12 +84,14 @@ def get_users_handler(config, _, override_config_dir): | |||
|                               keystone_admin_password, keystone_admin_tenant, timeout, | ||||
|                               requires_email=features.MAILING) | ||||
| 
 | ||||
|   if authentication_type == 'OIDC': | ||||
|   if authentication_type == 'AppToken': | ||||
|     if features.DIRECT_LOGIN: | ||||
|       raise Exception('Direct login feature must be disabled to use OIDC internal auth') | ||||
|       raise Exception('Direct login feature must be disabled to use AppToken internal auth') | ||||
| 
 | ||||
|     login_service = config.get('INTERNAL_OIDC_SERVICE_ID') | ||||
|     return OIDCInternalAuth(config, login_service, requires_email=features.MAILING) | ||||
|     if not features.APP_SPECIFIC_TOKENS: | ||||
|       raise Exception('AppToken internal auth requires app specific token support to be enabled') | ||||
| 
 | ||||
|     return AppTokenInternalAuth() | ||||
| 
 | ||||
|   raise RuntimeError('Unknown authentication type: %s' % authentication_type) | ||||
| 
 | ||||
|  | @ -185,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, | ||||
|  |  | |||
							
								
								
									
										63
									
								
								data/users/apptoken.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								data/users/apptoken.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,63 @@ | |||
| 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 supports_fresh_login(self): | ||||
|     # Since there is no password. | ||||
|     return False | ||||
| 
 | ||||
|   @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) | ||||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -1,92 +0,0 @@ | |||
| import logging | ||||
| 
 | ||||
| from data import model | ||||
| from oauth.loginmanager import OAuthLoginManager | ||||
| from oauth.oidc import PublicKeyLoadException | ||||
| from util.security.jwtutil import InvalidTokenError | ||||
| 
 | ||||
| 
 | ||||
| logger = logging.getLogger(__name__) | ||||
| 
 | ||||
| 
 | ||||
| class UnknownServiceException(Exception): | ||||
|   pass | ||||
| 
 | ||||
| 
 | ||||
| class OIDCInternalAuth(object): | ||||
|   """ Handles authentication by delegating authentication to a signed OIDC JWT produced by the | ||||
|       configured OIDC service. | ||||
|   """ | ||||
|   def __init__(self, config, login_service_id, requires_email): | ||||
|     login_manager = OAuthLoginManager(config) | ||||
| 
 | ||||
|     self.login_service_id = login_service_id | ||||
|     self.login_service = login_manager.get_service(login_service_id) | ||||
|     if self.login_service is None: | ||||
|       raise UnknownServiceException('Unknown OIDC login service %s' % login_service_id) | ||||
| 
 | ||||
|   @property | ||||
|   def federated_service(self): | ||||
|     return None | ||||
| 
 | ||||
|   @property | ||||
|   def requires_distinct_cli_password(self): | ||||
|     # Since the "password" is the generated ID token. | ||||
|     return False | ||||
| 
 | ||||
|   @property | ||||
|   def supports_encrypted_credentials(self): | ||||
|     # Since the "password" is already a signed JWT. | ||||
|     return False | ||||
| 
 | ||||
|   def verify_credentials(self, username_or_email, id_token): | ||||
|     # Parse the ID token. | ||||
|     try: | ||||
|       payload = self.login_service.decode_user_jwt(id_token) | ||||
|     except InvalidTokenError as ite: | ||||
|       logger.exception('Got invalid token error on OIDC decode: %s. Token: %s', ite.message, id_token) | ||||
|       return (None, 'Could not validate OIDC token') | ||||
|     except PublicKeyLoadException as pke: | ||||
|       logger.exception('Could not load public key during OIDC decode: %s. Token: %s', pke.message, id_token) | ||||
|       return (None, 'Could not validate OIDC token') | ||||
| 
 | ||||
|     # Find the user ID. | ||||
|     user_id = payload['sub'] | ||||
| 
 | ||||
|     # Lookup the federated login and user record with that matching ID and service. | ||||
|     user_found = model.user.verify_federated_login(self.login_service_id, user_id) | ||||
|     if user_found is None: | ||||
|       return (None, 'User does not exist') | ||||
| 
 | ||||
|     if not user_found.enabled: | ||||
|       return (None, 'User account is disabled. Please contact your administrator.') | ||||
| 
 | ||||
|     return (user_found, None) | ||||
| 
 | ||||
|   def verify_and_link_user(self, username_or_email, password): | ||||
|     return self.verify_credentials(username_or_email, password) | ||||
| 
 | ||||
|   def confirm_existing_user(self, username, password): | ||||
|     return self.verify_credentials(username, password) | ||||
| 
 | ||||
|   def link_user(self, username_or_email): | ||||
|     return (None, 'Unsupported for this authentication system') | ||||
| 
 | ||||
|   def get_and_link_federated_user_info(self, user_info): | ||||
|     return (None, 'Unsupported for this authentication system') | ||||
| 
 | ||||
|   def query_users(self, query, limit): | ||||
|     return (None, '', '') | ||||
| 
 | ||||
|   def check_group_lookup_args(self, group_lookup_args): | ||||
|     return (False, 'Not supported') | ||||
| 
 | ||||
|   def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False): | ||||
|     return (None, 'Not supported') | ||||
| 
 | ||||
|   def service_metadata(self): | ||||
|     return {} | ||||
| 
 | ||||
|   def ping(self): | ||||
|     """ Always assumed to be working. If the DB is broken, other checks will handle it. """ | ||||
|     return (True, None) | ||||
|  | @ -1,38 +0,0 @@ | |||
| import pytest | ||||
| 
 | ||||
| from httmock import HTTMock | ||||
| 
 | ||||
| from data import model | ||||
| from data.users.oidc import OIDCInternalAuth | ||||
| from oauth.test.test_oidc import * | ||||
| from test.fixtures import * | ||||
| 
 | ||||
| @pytest.mark.parametrize('username, expect_success', [ | ||||
|   ('devtable', True), | ||||
|   ('disabled', False) | ||||
| ]) | ||||
| def test_oidc_login(username, expect_success, app_config, id_token, jwks_handler, | ||||
|                     discovery_handler, app): | ||||
|   internal_auth = OIDCInternalAuth(app_config, 'someoidc', False) | ||||
|   with HTTMock(jwks_handler, discovery_handler): | ||||
|     # Try an invalid token. | ||||
|     (user, err) = internal_auth.verify_credentials('someusername', 'invalidtoken') | ||||
|     assert err is not None | ||||
|     assert user is None | ||||
| 
 | ||||
|     # Try a valid token for an unlinked user. | ||||
|     (user, err) = internal_auth.verify_credentials('someusername', id_token) | ||||
|     assert err is not None | ||||
|     assert user is None | ||||
| 
 | ||||
|     # Link the user to the service. | ||||
|     model.user.attach_federated_login(model.user.get_user(username), 'someoidc', 'cooluser') | ||||
| 
 | ||||
|     # Try a valid token for a linked user. | ||||
|     (user, err) = internal_auth.verify_credentials('someusername', id_token) | ||||
|     if expect_success: | ||||
|       assert err is None | ||||
|       assert user.username == username | ||||
|     else: | ||||
|       assert err is not None | ||||
|       assert user is None | ||||
|  | @ -5,7 +5,6 @@ from mock import patch | |||
| 
 | ||||
| from data.database import model | ||||
| from data.users.federated import DISABLED_MESSAGE | ||||
| from data.users.oidc import OIDCInternalAuth | ||||
| from test.test_ldap import mock_ldap | ||||
| from test.test_keystone_auth import fake_keystone | ||||
| from test.test_external_jwt_authn import fake_jwt | ||||
|  | @ -38,18 +37,11 @@ def test_auth_createuser(auth_system_builder, user1, user2, config, app): | |||
|       assert new_user is None | ||||
|       assert err == DISABLED_MESSAGE | ||||
| 
 | ||||
| @contextmanager | ||||
| def fake_oidc(app_config): | ||||
|   yield OIDCInternalAuth(app_config, 'someoidc', False) | ||||
| 
 | ||||
| @pytest.mark.parametrize('auth_system_builder,auth_kwargs', [ | ||||
|   (mock_ldap, {}), | ||||
|   (fake_keystone, {'version': 3}), | ||||
|   (fake_keystone, {'version': 2}), | ||||
|   (fake_jwt, {}), | ||||
|   (fake_oidc, {'app_config': { | ||||
|     'SOMEOIDC_LOGIN_CONFIG': {}, | ||||
|   }}), | ||||
| ]) | ||||
| def test_ping(auth_system_builder, auth_kwargs, app): | ||||
|   with auth_system_builder(**auth_kwargs) as auth: | ||||
|  |  | |||
|  | @ -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() | ||||
|  | @ -382,6 +383,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 | ||||
|  |  | |||
							
								
								
									
										135
									
								
								endpoints/api/appspecifictokens.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								endpoints/api/appspecifictokens.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,135 @@ | |||
| """ 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, 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, | ||||
|     '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 | ||||
| 
 | ||||
| 
 | ||||
| # 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): | ||||
|   """ 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') | ||||
|   @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. """ | ||||
|     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 | ||||
|   @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/<token_uuid>') | ||||
| @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 | ||||
							
								
								
									
										37
									
								
								endpoints/api/test/test_appspecifictoken.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								endpoints/api/test/test_appspecifictoken.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,37 @@ | |||
| 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]) | ||||
| 
 | ||||
|     # 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 | ||||
|     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) | ||||
|  | @ -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), | ||||
|  |  | |||
|  | @ -1083,4 +1083,3 @@ class Users(ApiResource): | |||
|       abort(404) | ||||
| 
 | ||||
|     return user_view(user) | ||||
| 
 | ||||
|  |  | |||
|  | @ -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) | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -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}) | ||||
|  |  | |||
|  | @ -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'}) | ||||
| 
 | ||||
|  |  | |||
|  | @ -622,14 +622,14 @@ | |||
|       <div class="co-panel-body"> | ||||
|         <div class="description"> | ||||
|           <p> | ||||
|             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. | ||||
|           </p> | ||||
|           <p> | ||||
|             Additional <strong>external</strong> authentication providers (such as GitHub) can be used in addition for <strong>login into the UI</strong>. | ||||
|           </p> | ||||
|         </div> | ||||
| 
 | ||||
|         <div ng-if="config.AUTHENTICATION_TYPE != 'OIDC'"> | ||||
|         <div ng-if="config.AUTHENTICATION_TYPE != 'AppToken'"> | ||||
|           <div class="co-alert co-alert-warning" ng-if="config.AUTHENTICATION_TYPE != 'Database' && !config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH"> | ||||
|             It is <strong>highly recommended</strong> to require encrypted client passwords. External passwords used in the Docker client will be stored in <strong>plaintext</strong>! | ||||
|             <a ng-click="config.FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH = true">Enable this requirement now</a>. | ||||
|  | @ -650,7 +650,7 @@ | |||
|                 <option value="LDAP">LDAP</option> | ||||
|                 <option value="Keystone">Keystone (OpenStack Identity)</option> | ||||
|                 <option value="JWT">JWT Custom Authentication</option> | ||||
|                 <option value="OIDC">OIDC Token Authentication</option> | ||||
|                 <option value="AppToken" ng-if="config.FEATURE_APP_SPECIFIC_TOKENS">External Application Token</option> | ||||
|               </select> | ||||
|             </td> | ||||
|           </tr> | ||||
|  | @ -690,21 +690,6 @@ | |||
|           </tr> | ||||
|         </table> | ||||
| 
 | ||||
|         <!--  OIDC Token Authentication --> | ||||
|         <table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'OIDC'"> | ||||
|           <tr> | ||||
|             <td>OIDC Provider:</td> | ||||
|             <td> | ||||
|               <select class="form-control" ng-model="config.INTERNAL_OIDC_SERVICE_ID" ng-if="getOIDCProviders(config).length"> | ||||
|                 <option value="{{ getOIDCProviderId(provider) }}" ng-repeat="provider in getOIDCProviders(config)">{{ config[provider]['SERVICE_NAME'] || getOIDCProviderId(provider) }}</option> | ||||
|               </select> | ||||
|               <div class="co-alert co-alert-danger" ng-if="!getOIDCProviders(config).length"> | ||||
|                 An OIDC provider must be configured to use this authentication system | ||||
|               </div> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </table> | ||||
| 
 | ||||
|         <!--  Keystone Authentication --> | ||||
|         <table class="config-table" ng-if="config.AUTHENTICATION_TYPE == 'Keystone'"> | ||||
|           <tr> | ||||
|  | @ -782,7 +767,7 @@ | |||
|               <div class="help-text"> | ||||
|                 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. | ||||
|               </div | ||||
|               </div> | ||||
|             </td> | ||||
|           </tr> | ||||
|           <tr> | ||||
|  | @ -1091,7 +1076,7 @@ | |||
|             <span style="display: inline-block; margin-left: 10px">(<a href="javascript:void(0)" ng-click="removeOIDCProvider(provider)">Delete</a>)</span> | ||||
|           </div> | ||||
|           <div class="co-panel-body"> | ||||
|             <div class="co-alert co-alert-warning" ng-if="config.AUTHENTICATION_TYPE && config.AUTHENTICATION_TYPE != 'Database' && config.AUTHENTICATION_TYPE != 'OIDC' && !(config[provider].LOGIN_BINDING_FIELD)"> | ||||
|             <div class="co-alert co-alert-warning" ng-if="config.AUTHENTICATION_TYPE && config.AUTHENTICATION_TYPE != 'Database' && config.AUTHENTICATION_TYPE != 'AppToken' && !(config[provider].LOGIN_BINDING_FIELD)"> | ||||
|               Warning: This OIDC provider is not bound to your <strong>{{ config.AUTHENTICATION_TYPE }}</strong> authentication. Logging in via this provider will create a <strong><span class="registry-name"></span>-only user</strong>, which is not the recommended approach. It is <strong>highly</strong> recommended to choose a "Binding Field" below. | ||||
|             </div> | ||||
| 
 | ||||
|  | @ -1152,7 +1137,7 @@ | |||
|                   </div> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr ng-if="config.AUTHENTICATION_TYPE != 'Database' && config.AUTHENTICATION_TYPE != 'OIDC'"> | ||||
|               <tr ng-if="config.AUTHENTICATION_TYPE != 'Database' && config.AUTHENTICATION_TYPE != 'AppToken'"> | ||||
|                 <td>Binding Field:</td> | ||||
|                 <td> | ||||
|                   <select class="form-control" ng-model="config[provider].LOGIN_BINDING_FIELD"> | ||||
|  | @ -1234,6 +1219,28 @@ | |||
|               </div> | ||||
|             </td> | ||||
|           </tr> | ||||
|           <tr> | ||||
|             <td class="non-input">External Application tokens</td> | ||||
|             <td colspan="2"> | ||||
|               <div class="config-bool-field" binding="config.FEATURE_APP_SPECIFIC_TOKENS"> | ||||
|                 Allow external application tokens | ||||
|               </div> | ||||
|               <div class="help-text"> | ||||
|                   If enabled, users will be able to generate external application tokens for use on the Docker and rkt CLI. Note | ||||
|                   that these tokens will <strong>not be required</strong> unless "App Token" is chosen as the Internal Authentication method above. | ||||
|                </div> | ||||
|              </td> | ||||
|           </tr> | ||||
|           <tr ng-if="config.FEATURE_APP_SPECIFIC_TOKENS"> | ||||
|               <td>External application token expiration</td> | ||||
|               <td colspan="2"> | ||||
|                 <span class="config-string-field" binding="config.APP_SPECIFIC_TOKEN_EXPIRATION" | ||||
|                       pattern="[0-9]+(m|w|h|d|s)" is-optional="true"></span>               | ||||
|                 <div class="help-text"> | ||||
|                   The expiration time for user generated external application tokens. If none, tokens will never expire. | ||||
|                 </div> | ||||
|                </td> | ||||
|             </tr> | ||||
|           <tr> | ||||
|             <td class="non-input">Anonymous Access:</td> | ||||
|             <td colspan="2"> | ||||
|  |  | |||
|  | @ -1,4 +1,11 @@ | |||
| <div class="announcement inline quay-message-bar-element" ng-show="messages.length"> | ||||
|   <div ng-repeat="token in NotificationService.expiringAppTokens"> | ||||
|     <div class="quay-service-status-description warning"> | ||||
|        Your external application token <strong style="display: inline-block; padding: 4px;">{{ token.title }}</strong> | ||||
|        will be expiring <strong style="display: inline-block; padding: 4px;"><time-ago datetime="token.expiration"></time-ago></strong>. | ||||
|        Please create a new token and revoke this token in user settings. | ||||
|     </div> | ||||
|   </div> | ||||
|   <div ng-repeat="message in messages"> | ||||
|       <div class="quay-service-status-description" ng-class="message.severity"> | ||||
|           <span ng-switch on="message.media_type"> | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -0,0 +1,33 @@ | |||
| <div class="resource-view" resource="$ctrl.appTokensResource"> | ||||
|     <div style="float: right; margin-left: 10px;"> | ||||
|         <button class="btn btn-primary" ng-click="$ctrl.askCreateToken()">Create Application Token</button> | ||||
|     </div> | ||||
|     <cor-table table-data="$ctrl.appTokens" table-item-title="tokens" filter-fields="['title']"> | ||||
|         <cor-table-col datafield="title" sortfield="title" title="Title" selected="true" | ||||
|                        bind-model="$ctrl" | ||||
|                        templateurl="/static/js/directives/ui/app-specific-token-manager/token-title.html"></cor-table-col> | ||||
|         <cor-table-col datafield="last_accessed" sortfield="last_accessed" title="Last Accessed" | ||||
|                        kindof="datetime" templateurl="/static/js/directives/ui/app-specific-token-manager/last-accessed.html"></cor-table-col> | ||||
|         <cor-table-col datafield="expiration" sortfield="expiration" title="Expiration" | ||||
|                        kindof="datetime" templateurl="/static/js/directives/ui/app-specific-token-manager/expiration.html"></cor-table-col> | ||||
|         <cor-table-col datafield="created" sortfield="created" title="Created" | ||||
|                        kindof="datetime" templateurl="/static/js/directives/ui/app-specific-token-manager/created.html"></cor-table-col> | ||||
|         <cor-table-col templateurl="/static/js/directives/ui/app-specific-token-manager/cog.html" | ||||
|                        bind-model="$ctrl" class="options-col"></cor-table-col> | ||||
|     </cor-table> | ||||
|      | ||||
|     <div class="credentials-dialog" credentials="$ctrl.tokenCredentials" secret-title="Application Token" entity-title="application token" entity-icon="fa-key"></div> | ||||
| 
 | ||||
|   <!-- Revoke token confirm --> | ||||
|   <div class="cor-confirm-dialog" | ||||
|        dialog-context="$ctrl.revokeTokenInfo" | ||||
|        dialog-action="$ctrl.revokeToken(info.token, callback)" | ||||
|        dialog-title="Revoke Application Token" | ||||
|        dialog-action-title="Revoke Token"> | ||||
|     <div class="co-alert co-alert-warning" style="margin-bottom: 10px;"> | ||||
|         Application token "{{ $ctrl.revokeTokenInfo.token.title }}" will be revoked and <strong>all</strong> applications and CLIs making use of the token will no longer operate. | ||||
|     </div> | ||||
| 
 | ||||
|     Proceed with revocation of this token? | ||||
|   </div> | ||||
| </div> | ||||
|  | @ -0,0 +1,75 @@ | |||
| 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<any>; | ||||
|   private tokenCredentials: any; | ||||
|   private revokeTokenInfo: any; | ||||
| 
 | ||||
|   constructor(@Inject('ApiService') private ApiService: any, @Inject('UserService') private UserService: any, | ||||
|               @Inject('NotificationService') private NotificationService: 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(); | ||||
| 
 | ||||
|       // Update the notification service so it hides any banners if we revoked an expiring token.
 | ||||
|       this.NotificationService.update(); | ||||
|       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); | ||||
|   } | ||||
| } | ||||
|  | @ -0,0 +1,5 @@ | |||
| <span class="cor-options-menu"> | ||||
|     <span class="cor-option" option-click="col.bindModel.showRevokeToken(item)"> | ||||
|         <i class="fa fa-times"></i> Revoke Token | ||||
|     </span> | ||||
| </span> | ||||
|  | @ -0,0 +1 @@ | |||
| <time-ago datetime="item.created"></time-ago> | ||||
|  | @ -0,0 +1 @@ | |||
| <expiration-status-view expiration-date="item.expiration"></expiration-status-view> | ||||
|  | @ -0,0 +1 @@ | |||
| <time-ago datetime="item.last_accessed"></time-ago> | ||||
|  | @ -0,0 +1 @@ | |||
| <a ng-click="col.bindModel.showToken(item)">{{ item.title }}</a> | ||||
|  | @ -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); | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -41,7 +41,8 @@ | |||
|   <table class="co-table co-fixed-table" ng-show="$ctrl.tableData.length"> | ||||
|     <thead> | ||||
|       <td ng-repeat="col in $ctrl.columns" | ||||
|           ng-class="$ctrl.tablePredicateClass(col)" style="{{ ::col.style }}"> | ||||
|           ng-class="$ctrl.tablePredicateClass(col)" style="{{ ::col.style }}" | ||||
|           class="{{ ::col.class }}"> | ||||
|         <a ng-click="$ctrl.setOrder(col)">{{ ::col.title }}</a> | ||||
|       </td> | ||||
|     </thead> | ||||
|  |  | |||
|  | @ -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; | ||||
|       }; | ||||
|     } | ||||
|   }; | ||||
|  |  | |||
|  | @ -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' | ||||
|  |  | |||
|  | @ -2,5 +2,5 @@ | |||
|   <span ng-if="$ctrl.datetime" data-title="{{ $ctrl.datetime | amDateFormat:'llll' }}" bs-tooltip> | ||||
|     <span am-time-ago="$ctrl.datetime"></span> | ||||
|   </span> | ||||
|   <span ng-if="!$ctrl.datetime">Unknown</span> | ||||
|   <span ng-if="!$ctrl.datetime">Never</span> | ||||
| </span> | ||||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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() { | ||||
|  |  | |||
|  | @ -70,25 +70,8 @@ | |||
| 
 | ||||
|         <!-- Settings --> | ||||
|         <cor-tab-pane id="settings"> | ||||
|           <!-- OIDC Token --> | ||||
|           <div class="settings-section" ng-if="Config.AUTHENTICATION_TYPE == 'OIDC'"> | ||||
|             <h3>Docker CLI Token</h3> | ||||
|             <div> | ||||
|               A generated token is <strong>required</strong> to login via the Docker CLI. | ||||
|             </div> | ||||
| 
 | ||||
|             <table class="co-list-table" style="margin-top: 10px;"> | ||||
|               <tr> | ||||
|                 <td>CLI Token:</td> | ||||
|                 <td> | ||||
|                   <span class="external-login-button" is-link="true" action="cli" provider="oidcLoginProvider"></span> | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </table> | ||||
|           </div> | ||||
| 
 | ||||
|           <!-- Encrypted Password --> | ||||
|           <div class="settings-section" ng-if="Config.AUTHENTICATION_TYPE != 'OIDC'"> | ||||
|           <div class="settings-section" ng-if="Config.AUTHENTICATION_TYPE != 'AppToken'"> | ||||
|             <h3>Docker CLI Password</h3> | ||||
|             <div ng-if="!Features.REQUIRE_ENCRYPTED_BASIC_AUTH"> | ||||
|               The Docker CLI stores passwords entered on the command line in <strong>plaintext</strong>. It is therefore highly recommended to generate an an encrypted version of your password to use for <code>docker login</code>. | ||||
|  | @ -109,6 +92,18 @@ | |||
|             </table> | ||||
|           </div> | ||||
| 
 | ||||
|           <!-- App Specific tokens --> | ||||
|           <div class="settings-section" ng-if="Features.APP_SPECIFIC_TOKENS"> | ||||
|             <h3>Docker CLI and other Application Tokens</h3> | ||||
|             <div ng-if="Config.AUTHENTICATION_TYPE != 'AppToken'"> | ||||
|               As an alternative to using your password for Docker and rkt CLIs, an application token can be generated below. | ||||
|             </div> | ||||
|             <div ng-if="Config.AUTHENTICATION_TYPE == 'AppToken'"> | ||||
|               An application token is <strong>required</strong> to login via the Docker or rkt CLIs. | ||||
|             </div> | ||||
|             <app-specific-token-manager></app-specific-token-manager> | ||||
|           </div> | ||||
| 
 | ||||
|           <!-- User Settings --> | ||||
|           <div class="settings-section"> | ||||
|             <h3>User Settings</h3> | ||||
|  |  | |||
										
											Binary file not shown.
										
									
								
							|  | @ -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', | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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. | ||||
|  |  | |||
|  | @ -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): | ||||
|  |  | |||
							
								
								
									
										29
									
								
								util/config/validators/test/test_validate_apptokenauth.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								util/config/validators/test/test_validate_apptokenauth.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -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) | ||||
|  | @ -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) | ||||
							
								
								
									
										19
									
								
								util/config/validators/validate_apptokenauth.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								util/config/validators/validate_apptokenauth.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -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) | ||||
|  | @ -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) | ||||
|  | @ -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) | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
		Reference in a new issue