Add an AppSpecificAuthToken data model for app-specific auth tokens. These will be used for the Docker CLI in place of username+password
This commit is contained in:
parent
53b762a875
commit
524d77f527
50 changed files with 943 additions and 289 deletions
|
@ -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,12 @@ def test_valid_oauth(app):
|
|||
token = _token(OAUTH_TOKEN_USERNAME, oauth_token.access_token)
|
||||
result = validate_basic_auth(token)
|
||||
assert result == ValidateResult(AuthKind.basic, oauthtoken=oauth_token)
|
||||
|
||||
|
||||
def test_valid_app_specific_token(app):
|
||||
user = model.user.get_user('devtable')
|
||||
app_specific_token = model.appspecifictoken.create_token(user, 'some token')
|
||||
token = _token(APP_SPECIFIC_TOKEN_USERNAME, app_specific_token.token_code)
|
||||
result = validate_basic_auth(token)
|
||||
print result.tuple()
|
||||
assert result == ValidateResult(AuthKind.basic, appspecifictoken=app_specific_token)
|
||||
|
|
|
@ -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,57 @@
|
|||
"""Add support for app specific tokens
|
||||
|
||||
Revision ID: 7367229b38d9
|
||||
Revises: d8989249f8f6
|
||||
Create Date: 2017-12-12 13:15:42.419764
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7367229b38d9'
|
||||
down_revision = 'd8989249f8f6'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
def upgrade(tables):
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('appspecificauthtoken',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('uuid', sa.String(length=36), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('token_code', sa.String(length=255), nullable=False),
|
||||
sa.Column('created', sa.DateTime(), nullable=False),
|
||||
sa.Column('expiration', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_accessed', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_appspecificauthtoken_user_id_user')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_appspecificauthtoken'))
|
||||
)
|
||||
op.create_index('appspecificauthtoken_token_code', 'appspecificauthtoken', ['token_code'], unique=True)
|
||||
op.create_index('appspecificauthtoken_user_id', 'appspecificauthtoken', ['user_id'], unique=False)
|
||||
op.create_index('appspecificauthtoken_user_id_expiration', 'appspecificauthtoken', ['user_id', 'expiration'], unique=False)
|
||||
op.create_index('appspecificauthtoken_uuid', 'appspecificauthtoken', ['uuid'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
op.bulk_insert(tables.logentrykind, [
|
||||
{'name': 'create_app_specific_token'},
|
||||
{'name': 'revoke_app_specific_token'},
|
||||
])
|
||||
|
||||
def downgrade(tables):
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('appspecificauthtoken')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
op.execute(tables
|
||||
.logentrykind
|
||||
.delete()
|
||||
.where(tables.
|
||||
logentrykind.name == op.inline_literal('create_app_specific_token')))
|
||||
|
||||
op.execute(tables
|
||||
.logentrykind
|
||||
.delete()
|
||||
.where(tables.
|
||||
logentrykind.name == op.inline_literal('revoke_app_specific_token')))
|
|
@ -126,6 +126,7 @@ config = Config()
|
|||
# There MUST NOT be any circular dependencies between these subsections. If there are fix it by
|
||||
# moving the minimal number of things to _basequery
|
||||
from data.model import (
|
||||
appspecifictoken,
|
||||
blob,
|
||||
build,
|
||||
image,
|
||||
|
|
108
data/model/appspecifictoken.py
Normal file
108
data/model/appspecifictoken.py
Normal file
|
@ -0,0 +1,108 @@
|
|||
import logging
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from cachetools import lru_cache
|
||||
from peewee import PeeweeException
|
||||
|
||||
from data.database import AppSpecificAuthToken, User, db_transaction
|
||||
from data.model import config
|
||||
from util.timedeltastring import convert_to_timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _default_expiration():
|
||||
expiration_str = config.app_config.get('APP_SPECIFIC_TOKEN_EXPIRATION')
|
||||
return datetime.now() + convert_to_timedelta(expiration_str) if expiration_str else expiration_str
|
||||
|
||||
|
||||
_default_expiration_opt = 'deo'
|
||||
def create_token(user, title, expiration=_default_expiration_opt):
|
||||
""" Creates and returns an app specific token for the given user. If no expiration is specified
|
||||
(including `None`), then the default from config is used. """
|
||||
expiration = expiration if expiration != _default_expiration_opt else _default_expiration()
|
||||
return AppSpecificAuthToken.create(user=user, title=title, expiration=expiration)
|
||||
|
||||
|
||||
def list_tokens(user):
|
||||
""" Lists all tokens for the given user. """
|
||||
return AppSpecificAuthToken.select().where(AppSpecificAuthToken.user == user)
|
||||
|
||||
|
||||
def revoke_token(token):
|
||||
""" Revokes an app specific token by deleting it. """
|
||||
token.delete_instance()
|
||||
|
||||
|
||||
def get_expiring_tokens(user, soon):
|
||||
""" Returns all tokens owned by the given user that will be expiring "soon", where soon is defined
|
||||
by the soon parameter (a timedelta from now).
|
||||
"""
|
||||
soon_datetime = datetime.now() + soon
|
||||
return (AppSpecificAuthToken
|
||||
.select()
|
||||
.where(AppSpecificAuthToken.user == user,
|
||||
AppSpecificAuthToken.expiration <= soon_datetime))
|
||||
|
||||
|
||||
def gc_expired_tokens(user):
|
||||
""" Deletes all expired tokens owned by the given user. """
|
||||
(AppSpecificAuthToken
|
||||
.delete()
|
||||
.where(AppSpecificAuthToken.user == user, AppSpecificAuthToken.expiration < datetime.now())
|
||||
.execute())
|
||||
|
||||
|
||||
def get_token_by_uuid(uuid, owner=None):
|
||||
""" Looks up an unexpired app specific token with the given uuid. Returns it if found or
|
||||
None if none. If owner is specified, only tokens owned by the owner user will be
|
||||
returned.
|
||||
"""
|
||||
try:
|
||||
query = (AppSpecificAuthToken
|
||||
.select()
|
||||
.where(AppSpecificAuthToken.uuid == uuid,
|
||||
((AppSpecificAuthToken.expiration > datetime.now()) |
|
||||
(AppSpecificAuthToken.expiration >> None))))
|
||||
if owner is not None:
|
||||
query = query.where(AppSpecificAuthToken.user == owner)
|
||||
|
||||
return query.get()
|
||||
except AppSpecificAuthToken.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def access_valid_token(token_code):
|
||||
""" Looks up an unexpired app specific token with the given token code. If found, the token's
|
||||
last_accessed field is set to now and the token is returned. If not found, returns None.
|
||||
"""
|
||||
with db_transaction():
|
||||
try:
|
||||
token = (AppSpecificAuthToken
|
||||
.select(AppSpecificAuthToken, User)
|
||||
.join(User)
|
||||
.where(AppSpecificAuthToken.token_code == token_code,
|
||||
((AppSpecificAuthToken.expiration > datetime.now()) |
|
||||
(AppSpecificAuthToken.expiration >> None)))
|
||||
.get())
|
||||
except AppSpecificAuthToken.DoesNotExist:
|
||||
return None
|
||||
|
||||
token.last_accessed = datetime.now()
|
||||
|
||||
try:
|
||||
token.save()
|
||||
except PeeweeException as ex:
|
||||
strict_logging_disabled = config.app_config.get('ALLOW_PULLS_WITHOUT_STRICT_LOGGING')
|
||||
if strict_logging_disabled:
|
||||
data = {
|
||||
'exception': ex,
|
||||
'token': token.id,
|
||||
}
|
||||
|
||||
logger.exception('update last_accessed for token failed', extra=data)
|
||||
else:
|
||||
raise
|
||||
|
||||
return token
|
77
data/model/test/test_appspecifictoken.py
Normal file
77
data/model/test/test_appspecifictoken.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from data import model
|
||||
from data.model.appspecifictoken import create_token, revoke_token, access_valid_token
|
||||
from data.model.appspecifictoken import gc_expired_tokens, get_expiring_tokens
|
||||
from util.timedeltastring import convert_to_timedelta
|
||||
|
||||
from test.fixtures import *
|
||||
|
||||
@pytest.mark.parametrize('expiration', [
|
||||
(None),
|
||||
('-1m'),
|
||||
('-1d'),
|
||||
('-1w'),
|
||||
('10m'),
|
||||
('10d'),
|
||||
('10w'),
|
||||
])
|
||||
def test_gc(expiration, initialized_db):
|
||||
user = model.user.get_user('devtable')
|
||||
|
||||
expiration_date = None
|
||||
is_expired = False
|
||||
if expiration:
|
||||
if expiration[0] == '-':
|
||||
is_expired = True
|
||||
expiration_date = datetime.now() - convert_to_timedelta(expiration[1:])
|
||||
else:
|
||||
expiration_date = datetime.now() + convert_to_timedelta(expiration)
|
||||
|
||||
# Create a token.
|
||||
token = create_token(user, 'Some token', expiration=expiration_date)
|
||||
|
||||
# GC tokens.
|
||||
gc_expired_tokens(user)
|
||||
|
||||
# Ensure the token was GCed if expired and not if it wasn't.
|
||||
assert (access_valid_token(token.token_code) is None) == is_expired
|
||||
|
||||
|
||||
def test_access_token(initialized_db):
|
||||
user = model.user.get_user('devtable')
|
||||
|
||||
# Create a token.
|
||||
token = create_token(user, 'Some token')
|
||||
assert token.last_accessed is None
|
||||
|
||||
# Lookup the token.
|
||||
token = access_valid_token(token.token_code)
|
||||
assert token.last_accessed is not None
|
||||
|
||||
# Revoke the token.
|
||||
revoke_token(token)
|
||||
|
||||
# Ensure it cannot be accessed
|
||||
assert access_valid_token(token.token_code) is None
|
||||
|
||||
|
||||
def test_expiring_soon(initialized_db):
|
||||
user = model.user.get_user('devtable')
|
||||
|
||||
# Create some tokens.
|
||||
create_token(user, 'Some token')
|
||||
exp_token = create_token(user, 'Some expiring token', datetime.now() + convert_to_timedelta('1d'))
|
||||
create_token(user, 'Some other token', expiration=datetime.now() + convert_to_timedelta('2d'))
|
||||
|
||||
# Get the token expiring soon.
|
||||
expiring_soon = get_expiring_tokens(user, convert_to_timedelta('25h'))
|
||||
assert expiring_soon
|
||||
assert len(expiring_soon) == 1
|
||||
assert expiring_soon[0].id == exp_token.id
|
||||
|
||||
expiring_soon = get_expiring_tokens(user, convert_to_timedelta('49h'))
|
||||
assert expiring_soon
|
||||
assert len(expiring_soon) == 2
|
|
@ -10,7 +10,7 @@ from data.users.database import DatabaseUsers
|
|||
from data.users.externalldap import LDAPUsers
|
||||
from data.users.externaljwt import ExternalJWTAuthN
|
||||
from data.users.keystone import get_keystone_users
|
||||
from data.users.oidc import OIDCInternalAuth
|
||||
from data.users.apptoken import AppTokenInternalAuth
|
||||
from util.security.aes import AESCipher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -25,7 +25,7 @@ def get_federated_service_name(authentication_type):
|
|||
if authentication_type == 'Keystone':
|
||||
return 'keystone'
|
||||
|
||||
if authentication_type == 'OIDC':
|
||||
if authentication_type == 'AppToken':
|
||||
return None
|
||||
|
||||
if authentication_type == 'Database':
|
||||
|
@ -84,12 +84,14 @@ def get_users_handler(config, _, override_config_dir):
|
|||
keystone_admin_password, keystone_admin_tenant, timeout,
|
||||
requires_email=features.MAILING)
|
||||
|
||||
if authentication_type == 'OIDC':
|
||||
if authentication_type == 'AppToken':
|
||||
if features.DIRECT_LOGIN:
|
||||
raise Exception('Direct login feature must be disabled to use OIDC internal auth')
|
||||
raise Exception('Direct login feature must be disabled to use AppToken internal auth')
|
||||
|
||||
login_service = config.get('INTERNAL_OIDC_SERVICE_ID')
|
||||
return OIDCInternalAuth(config, login_service, requires_email=features.MAILING)
|
||||
if not features.APP_SPECIFIC_TOKENS:
|
||||
raise Exception('AppToken internal auth requires app specific token support to be enabled')
|
||||
|
||||
return AppTokenInternalAuth()
|
||||
|
||||
raise RuntimeError('Unknown authentication type: %s' % authentication_type)
|
||||
|
||||
|
|
59
data/users/apptoken.py
Normal file
59
data/users/apptoken.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
import logging
|
||||
|
||||
from data import model
|
||||
from oauth.loginmanager import OAuthLoginManager
|
||||
from oauth.oidc import PublicKeyLoadException
|
||||
from util.security.jwtutil import InvalidTokenError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AppTokenInternalAuth(object):
|
||||
""" Forces all internal credential login to go through an app token, by disabling all other
|
||||
access.
|
||||
"""
|
||||
|
||||
@property
|
||||
def federated_service(self):
|
||||
return None
|
||||
|
||||
@property
|
||||
def requires_distinct_cli_password(self):
|
||||
# Since there is no supported "password".
|
||||
return False
|
||||
|
||||
@property
|
||||
def supports_encrypted_credentials(self):
|
||||
# Since there is no supported "password".
|
||||
return False
|
||||
|
||||
def verify_credentials(self, username_or_email, id_token):
|
||||
return (None, 'An application specific token is required to login')
|
||||
|
||||
def verify_and_link_user(self, username_or_email, password):
|
||||
return self.verify_credentials(username_or_email, password)
|
||||
|
||||
def confirm_existing_user(self, username, password):
|
||||
return self.verify_credentials(username, password)
|
||||
|
||||
def link_user(self, username_or_email):
|
||||
return (None, 'Unsupported for this authentication system')
|
||||
|
||||
def get_and_link_federated_user_info(self, user_info):
|
||||
return (None, 'Unsupported for this authentication system')
|
||||
|
||||
def query_users(self, query, limit):
|
||||
return (None, '', '')
|
||||
|
||||
def check_group_lookup_args(self, group_lookup_args):
|
||||
return (False, 'Not supported')
|
||||
|
||||
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
|
||||
return (None, 'Not supported')
|
||||
|
||||
def service_metadata(self):
|
||||
return {}
|
||||
|
||||
def ping(self):
|
||||
""" Always assumed to be working. If the DB is broken, other checks will handle it. """
|
||||
return (True, None)
|
|
@ -1,92 +0,0 @@
|
|||
import logging
|
||||
|
||||
from data import model
|
||||
from oauth.loginmanager import OAuthLoginManager
|
||||
from oauth.oidc import PublicKeyLoadException
|
||||
from util.security.jwtutil import InvalidTokenError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnknownServiceException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OIDCInternalAuth(object):
|
||||
""" Handles authentication by delegating authentication to a signed OIDC JWT produced by the
|
||||
configured OIDC service.
|
||||
"""
|
||||
def __init__(self, config, login_service_id, requires_email):
|
||||
login_manager = OAuthLoginManager(config)
|
||||
|
||||
self.login_service_id = login_service_id
|
||||
self.login_service = login_manager.get_service(login_service_id)
|
||||
if self.login_service is None:
|
||||
raise UnknownServiceException('Unknown OIDC login service %s' % login_service_id)
|
||||
|
||||
@property
|
||||
def federated_service(self):
|
||||
return None
|
||||
|
||||
@property
|
||||
def requires_distinct_cli_password(self):
|
||||
# Since the "password" is the generated ID token.
|
||||
return False
|
||||
|
||||
@property
|
||||
def supports_encrypted_credentials(self):
|
||||
# Since the "password" is already a signed JWT.
|
||||
return False
|
||||
|
||||
def verify_credentials(self, username_or_email, id_token):
|
||||
# Parse the ID token.
|
||||
try:
|
||||
payload = self.login_service.decode_user_jwt(id_token)
|
||||
except InvalidTokenError as ite:
|
||||
logger.exception('Got invalid token error on OIDC decode: %s. Token: %s', ite.message, id_token)
|
||||
return (None, 'Could not validate OIDC token')
|
||||
except PublicKeyLoadException as pke:
|
||||
logger.exception('Could not load public key during OIDC decode: %s. Token: %s', pke.message, id_token)
|
||||
return (None, 'Could not validate OIDC token')
|
||||
|
||||
# Find the user ID.
|
||||
user_id = payload['sub']
|
||||
|
||||
# Lookup the federated login and user record with that matching ID and service.
|
||||
user_found = model.user.verify_federated_login(self.login_service_id, user_id)
|
||||
if user_found is None:
|
||||
return (None, 'User does not exist')
|
||||
|
||||
if not user_found.enabled:
|
||||
return (None, 'User account is disabled. Please contact your administrator.')
|
||||
|
||||
return (user_found, None)
|
||||
|
||||
def verify_and_link_user(self, username_or_email, password):
|
||||
return self.verify_credentials(username_or_email, password)
|
||||
|
||||
def confirm_existing_user(self, username, password):
|
||||
return self.verify_credentials(username, password)
|
||||
|
||||
def link_user(self, username_or_email):
|
||||
return (None, 'Unsupported for this authentication system')
|
||||
|
||||
def get_and_link_federated_user_info(self, user_info):
|
||||
return (None, 'Unsupported for this authentication system')
|
||||
|
||||
def query_users(self, query, limit):
|
||||
return (None, '', '')
|
||||
|
||||
def check_group_lookup_args(self, group_lookup_args):
|
||||
return (False, 'Not supported')
|
||||
|
||||
def iterate_group_members(self, group_lookup_args, page_size=None, disable_pagination=False):
|
||||
return (None, 'Not supported')
|
||||
|
||||
def service_metadata(self):
|
||||
return {}
|
||||
|
||||
def ping(self):
|
||||
""" Always assumed to be working. If the DB is broken, other checks will handle it. """
|
||||
return (True, None)
|
|
@ -1,38 +0,0 @@
|
|||
import pytest
|
||||
|
||||
from httmock import HTTMock
|
||||
|
||||
from data import model
|
||||
from data.users.oidc import OIDCInternalAuth
|
||||
from oauth.test.test_oidc import *
|
||||
from test.fixtures import *
|
||||
|
||||
@pytest.mark.parametrize('username, expect_success', [
|
||||
('devtable', True),
|
||||
('disabled', False)
|
||||
])
|
||||
def test_oidc_login(username, expect_success, app_config, id_token, jwks_handler,
|
||||
discovery_handler, app):
|
||||
internal_auth = OIDCInternalAuth(app_config, 'someoidc', False)
|
||||
with HTTMock(jwks_handler, discovery_handler):
|
||||
# Try an invalid token.
|
||||
(user, err) = internal_auth.verify_credentials('someusername', 'invalidtoken')
|
||||
assert err is not None
|
||||
assert user is None
|
||||
|
||||
# Try a valid token for an unlinked user.
|
||||
(user, err) = internal_auth.verify_credentials('someusername', id_token)
|
||||
assert err is not None
|
||||
assert user is None
|
||||
|
||||
# Link the user to the service.
|
||||
model.user.attach_federated_login(model.user.get_user(username), 'someoidc', 'cooluser')
|
||||
|
||||
# Try a valid token for a linked user.
|
||||
(user, err) = internal_auth.verify_credentials('someusername', id_token)
|
||||
if expect_success:
|
||||
assert err is None
|
||||
assert user.username == username
|
||||
else:
|
||||
assert err is not None
|
||||
assert user is None
|
|
@ -5,7 +5,6 @@ from mock import patch
|
|||
|
||||
from data.database import model
|
||||
from data.users.federated import DISABLED_MESSAGE
|
||||
from data.users.oidc import OIDCInternalAuth
|
||||
from test.test_ldap import mock_ldap
|
||||
from test.test_keystone_auth import fake_keystone
|
||||
from test.test_external_jwt_authn import fake_jwt
|
||||
|
@ -38,18 +37,11 @@ def test_auth_createuser(auth_system_builder, user1, user2, config, app):
|
|||
assert new_user is None
|
||||
assert err == DISABLED_MESSAGE
|
||||
|
||||
@contextmanager
|
||||
def fake_oidc(app_config):
|
||||
yield OIDCInternalAuth(app_config, 'someoidc', False)
|
||||
|
||||
@pytest.mark.parametrize('auth_system_builder,auth_kwargs', [
|
||||
(mock_ldap, {}),
|
||||
(fake_keystone, {'version': 3}),
|
||||
(fake_keystone, {'version': 2}),
|
||||
(fake_jwt, {}),
|
||||
(fake_oidc, {'app_config': {
|
||||
'SOMEOIDC_LOGIN_CONFIG': {},
|
||||
}}),
|
||||
])
|
||||
def test_ping(auth_system_builder, auth_kwargs, app):
|
||||
with auth_system_builder(**auth_kwargs) as auth:
|
||||
|
|
|
@ -382,6 +382,7 @@ def define_json_response(schema_name):
|
|||
return wrapper
|
||||
|
||||
|
||||
import endpoints.api.appspecifictokens
|
||||
import endpoints.api.billing
|
||||
import endpoints.api.build
|
||||
import endpoints.api.discovery
|
||||
|
|
112
endpoints/api/appspecifictokens.py
Normal file
112
endpoints/api/appspecifictokens.py
Normal file
|
@ -0,0 +1,112 @@
|
|||
""" Manages app specific tokens for the current user. """
|
||||
|
||||
import logging
|
||||
|
||||
from flask import request
|
||||
|
||||
import features
|
||||
|
||||
from auth.auth_context import get_authenticated_user
|
||||
from data import model
|
||||
from endpoints.api import (ApiResource, nickname, resource, validate_json_request,
|
||||
log_action, require_user_admin, require_fresh_login,
|
||||
path_param, NotFound, format_date, show_if)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def token_view(token, include_code=False):
|
||||
data = {
|
||||
'uuid': token.uuid,
|
||||
'title': token.title,
|
||||
'last_accessed': format_date(token.last_accessed),
|
||||
'created': format_date(token.created),
|
||||
'expiration': format_date(token.expiration),
|
||||
}
|
||||
|
||||
if include_code:
|
||||
data.update({
|
||||
'token_code': token.token_code,
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
@resource('/v1/user/apptoken')
|
||||
@show_if(features.APP_SPECIFIC_TOKENS)
|
||||
class AppTokens(ApiResource):
|
||||
""" Lists all app specific tokens for a user """
|
||||
schemas = {
|
||||
'NewToken': {
|
||||
'type': 'object',
|
||||
'required': [
|
||||
'title',
|
||||
],
|
||||
'properties': {
|
||||
'title': {
|
||||
'type': 'string',
|
||||
'description': 'The user-defined title for the token',
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@require_user_admin
|
||||
@nickname('listAppTokens')
|
||||
def get(self):
|
||||
""" Lists the app specific tokens for the user. """
|
||||
tokens = model.appspecifictoken.list_tokens(get_authenticated_user())
|
||||
return {
|
||||
'tokens': [token_view(token, include_code=False) for token in tokens],
|
||||
}
|
||||
|
||||
@require_user_admin
|
||||
@require_fresh_login
|
||||
@nickname('createAppToken')
|
||||
@validate_json_request('NewToken')
|
||||
def post(self):
|
||||
""" Create a new app specific token for user. """
|
||||
title = request.get_json()['title']
|
||||
token = model.appspecifictoken.create_token(get_authenticated_user(), title)
|
||||
|
||||
log_action('create_app_specific_token', get_authenticated_user().username,
|
||||
{'app_specific_token_title': token.title,
|
||||
'app_specific_token': token.uuid})
|
||||
|
||||
return {
|
||||
'token': token_view(token, include_code=True),
|
||||
}
|
||||
|
||||
|
||||
@resource('/v1/user/apptoken/<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
|
33
endpoints/api/test/test_appspecifictoken.py
Normal file
33
endpoints/api/test/test_appspecifictoken.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
from endpoints.api.appspecifictokens import AppTokens, AppToken
|
||||
from endpoints.api.test.shared import conduct_api_call
|
||||
from endpoints.test.shared import client_with_identity
|
||||
from test.fixtures import *
|
||||
|
||||
def test_app_specific_tokens(app, client):
|
||||
with client_with_identity('devtable', client) as cl:
|
||||
# Add an app specific token.
|
||||
token_data = {'title': 'Testing 123'}
|
||||
resp = conduct_api_call(cl, AppTokens, 'POST', None, token_data, 200).json
|
||||
token_uuid = resp['token']['uuid']
|
||||
assert 'token_code' in resp['token']
|
||||
|
||||
# List the tokens and ensure we have the one added.
|
||||
resp = conduct_api_call(cl, AppTokens, 'GET', None, None, 200).json
|
||||
assert len(resp['tokens'])
|
||||
assert token_uuid in set([token['uuid'] for token in resp['tokens']])
|
||||
assert not set([token['token_code'] for token in resp['tokens'] if 'token_code' in token])
|
||||
|
||||
# Get the token and ensure we have its code.
|
||||
resp = conduct_api_call(cl, AppToken, 'GET', {'token_uuid': token_uuid}, None, 200).json
|
||||
assert resp['token']['uuid'] == token_uuid
|
||||
assert 'token_code' in resp['token']
|
||||
|
||||
# Delete the token.
|
||||
conduct_api_call(cl, AppToken, 'DELETE', {'token_uuid': token_uuid}, None, 204)
|
||||
|
||||
# Ensure the token no longer exists.
|
||||
resp = conduct_api_call(cl, AppTokens, 'GET', None, None, 200).json
|
||||
assert len(resp['tokens'])
|
||||
assert token_uuid not in set([token['uuid'] for token in resp['tokens']])
|
||||
|
||||
conduct_api_call(cl, AppToken, 'GET', {'token_uuid': token_uuid}, None, 404)
|
|
@ -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:
|
||||
|
@ -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})
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,71 @@
|
|||
import { Input, Component, Inject } from 'ng-metadata/core';
|
||||
import * as bootbox from "bootbox";
|
||||
|
||||
/**
|
||||
* A component that displays and manage all app specific tokens for a user.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-specific-token-manager',
|
||||
templateUrl: '/static/js/directives/ui/app-specific-token-manager/app-specific-token-manager.component.html',
|
||||
})
|
||||
export class AppSpecificTokenManagerComponent {
|
||||
private appTokensResource: any;
|
||||
private appTokens: Array<any>;
|
||||
private tokenCredentials: any;
|
||||
private revokeTokenInfo: any;
|
||||
|
||||
constructor(@Inject('ApiService') private ApiService: any, @Inject('UserService') private UserService: any) {
|
||||
this.loadTokens();
|
||||
}
|
||||
|
||||
private loadTokens() {
|
||||
this.appTokensResource = this.ApiService.listAppTokensAsResource().get((resp) => {
|
||||
this.appTokens = resp['tokens'];
|
||||
});
|
||||
}
|
||||
|
||||
private askCreateToken() {
|
||||
bootbox.prompt('Please enter a descriptive title for the new application token', (title) => {
|
||||
if (!title) { return; }
|
||||
|
||||
const errorHandler = this.ApiService.errorDisplay('Could not create the application token');
|
||||
this.ApiService.createAppToken({title}).then((resp) => {
|
||||
this.loadTokens();
|
||||
}, errorHandler);
|
||||
});
|
||||
}
|
||||
|
||||
private showRevokeToken(token) {
|
||||
this.revokeTokenInfo = {
|
||||
'token': token,
|
||||
};
|
||||
};
|
||||
|
||||
private revokeToken(token, callback) {
|
||||
const errorHandler = this.ApiService.errorDisplay('Could not revoke application token', callback);
|
||||
const params = {
|
||||
'token_uuid': token['uuid'],
|
||||
};
|
||||
|
||||
this.ApiService.revokeAppToken(null, params).then((resp) => {
|
||||
this.loadTokens();
|
||||
callback(true);
|
||||
}, errorHandler);
|
||||
}
|
||||
|
||||
private showToken(token) {
|
||||
const errorHandler = this.ApiService.errorDisplay('Could not find application token');
|
||||
const params = {
|
||||
'token_uuid': token['uuid'],
|
||||
};
|
||||
|
||||
this.ApiService.getAppToken(null, params).then((resp) => {
|
||||
this.tokenCredentials = {
|
||||
'title': resp['token']['title'],
|
||||
'namespace': this.UserService.currentUser().username,
|
||||
'username': '$app',
|
||||
'password': resp['token']['token_code'],
|
||||
};
|
||||
}, errorHandler);
|
||||
}
|
||||
}
|
|
@ -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,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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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