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:
josephschorr 2018-01-04 16:00:28 -05:00 committed by GitHub
commit e01fb45dd8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 1016 additions and 292 deletions

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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)

View file

@ -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')

View file

@ -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)

View file

@ -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

View file

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

View file

@ -0,0 +1,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')))

View file

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

View file

@ -0,0 +1,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

View file

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

View file

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

View file

@ -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)

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -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

View 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

View 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)

View file

@ -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),

View file

@ -1083,4 +1083,3 @@ class Users(ApiResource):
abort(404)
return user_view(user)

View file

@ -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)

View file

@ -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:
@ -177,7 +184,7 @@ def generate_registry_jwt(auth_result):
# 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})

View file

@ -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'})

View file

@ -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">

View file

@ -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">

View file

@ -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) {

View file

@ -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) {

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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>

View file

@ -0,0 +1 @@
<time-ago datetime="item.created"></time-ago>

View file

@ -0,0 +1 @@
<expiration-status-view expiration-date="item.expiration"></expiration-status-view>

View file

@ -0,0 +1 @@
<time-ago datetime="item.last_accessed"></time-ago>

View file

@ -0,0 +1 @@
<a ng-click="col.bindModel.showToken(item)">{{ item.title }}</a>

View file

@ -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);
}

View file

@ -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>

View file

@ -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;
};
}
};

View file

@ -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,6 +348,8 @@ 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',

View file

@ -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>

View file

@ -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,

View file

@ -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() {

View file

@ -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.

View file

@ -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',

View file

@ -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

View file

@ -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.

View file

@ -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):

View 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)

View file

@ -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)

View 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)

View file

@ -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)

View file

@ -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)