artifacts: expose a special auth-token based artifacts download urls.

This will allow sharing download to external locations used new generated artifact download
tokens. This feature allows also serving downloads using secret urls with all the fancy logic
of our auth tokens.
This commit is contained in:
Marcin Kuzminski 2019-10-22 20:00:39 +02:00
parent a73fb57d29
commit c6f649e139
5 changed files with 78 additions and 18 deletions

View file

@ -44,6 +44,9 @@ def includeme(config):
config.add_route(
name='download_file',
pattern='/_file_store/download/{fid}')
config.add_route(
name='download_file_by_token',
pattern='/_file_store/token-download/{_auth_token}/{fid}')
# Scan module for configuration decorators.
config.scan('.views', ignore='.tests')

View file

@ -30,8 +30,10 @@ from rhodecode.apps.file_store.exceptions import (
from rhodecode.lib import helpers as h
from rhodecode.lib import audit_logger
from rhodecode.lib.auth import (CSRFRequired, NotAnonymous, HasRepoPermissionAny, HasRepoGroupPermissionAny)
from rhodecode.model.db import Session, FileStore
from rhodecode.lib.auth import (
CSRFRequired, NotAnonymous, HasRepoPermissionAny, HasRepoGroupPermissionAny,
LoginRequired)
from rhodecode.model.db import Session, FileStore, UserApiKeys
log = logging.getLogger(__name__)
@ -44,6 +46,7 @@ class FileStoreView(BaseAppView):
self.storage = utils.get_file_storage(self.request.registry.settings)
return c
@LoginRequired()
@NotAnonymous()
@CSRFRequired()
@view_config(route_name='upload_file', request_method='POST', renderer='json_ext')
@ -99,11 +102,7 @@ class FileStoreView(BaseAppView):
return {'store_fid': store_uid,
'access_path': h.route_path('download_file', fid=store_uid)}
@view_config(route_name='download_file')
def download_file(self):
self.load_default_context()
file_uid = self.request.matchdict['fid']
log.debug('Requesting FID:%s from store %s', file_uid, self.storage)
def _serve_file(self, file_uid):
if not self.storage.exists(file_uid):
store_path = self.storage.store_path(file_uid)
@ -128,7 +127,7 @@ class FileStoreView(BaseAppView):
perm_set = ['repository.read', 'repository.write', 'repository.admin']
has_perm = HasRepoPermissionAny(*perm_set)(repo.repo_name, 'FileStore check')
if not has_perm:
log.warning('Access to file store object forbidden')
log.warning('Access to file store object `%s` forbidden', file_uid)
raise HTTPNotFound()
# scoped to repository group permissions
@ -137,10 +136,31 @@ class FileStoreView(BaseAppView):
perm_set = ['group.read', 'group.write', 'group.admin']
has_perm = HasRepoGroupPermissionAny(*perm_set)(repo_group.group_name, 'FileStore check')
if not has_perm:
log.warning('Access to file store object forbidden')
log.warning('Access to file store object `%s` forbidden', file_uid)
raise HTTPNotFound()
FileStore.bump_access_counter(file_uid)
file_path = self.storage.store_path(file_uid)
return FileResponse(file_path)
# ACL is checked by scopes, if no scope the file is accessible to all
@view_config(route_name='download_file')
def download_file(self):
self.load_default_context()
file_uid = self.request.matchdict['fid']
log.debug('Requesting FID:%s from store %s', file_uid, self.storage)
return self._serve_file(file_uid)
@LoginRequired(auth_token_access=[UserApiKeys.ROLE_ARTIFACT_DOWNLOAD])
@view_config(route_name='download_file_by_token')
def download_file_by_token(self):
"""
Special view that allows to access the download file by special URL that
is stored inside the URL.
http://example.com/_file_store/token-download/TOKEN/FILE_UID
"""
self.load_default_context()
file_uid = self.request.matchdict['fid']
return self._serve_file(file_uid)

View file

@ -1140,7 +1140,7 @@ class AuthUser(object):
# try go get user by api key
elif self._api_key and self._api_key != anon_user.api_key:
log.debug('Trying Auth User lookup by API KEY: `%s`', self._api_key)
log.debug('Trying Auth User lookup by API KEY: `...%s`', self._api_key[-4:])
is_user_loaded = user_model.fill_data(self, api_key=self._api_key)
# lookup by username
@ -1366,6 +1366,10 @@ class AuthUser(object):
def feed_token(self):
return self.get_instance().feed_token
@LazyProperty
def artifact_token(self):
return self.get_instance().artifact_token
@classmethod
def check_ip_allowed(cls, user_id, ip_addr, inherit_from_default):
allowed_ips = AuthUser.get_allowed_ips(
@ -1597,6 +1601,11 @@ class LoginRequired(object):
"""
def __init__(self, auth_token_access=None):
self.auth_token_access = auth_token_access
if self.auth_token_access:
valid_type = set(auth_token_access).intersection(set(UserApiKeys.ROLES))
if not valid_type:
raise ValueError('auth_token_access must be on of {}, got {}'.format(
UserApiKeys.ROLES, auth_token_access))
def __call__(self, func):
return get_cython_compat_decorator(self.__wrapper, func)
@ -1616,19 +1625,25 @@ class LoginRequired(object):
# check if our IP is allowed
ip_access_valid = True
if not user.ip_allowed:
h.flash(h.literal(_('IP %s not allowed' % (user.ip_addr,))),
h.flash(h.literal(_('IP {} not allowed'.format(user.ip_addr))),
category='warning')
ip_access_valid = False
# check if we used an APIKEY and it's a valid one
# we used stored token that is extract from GET or URL param (if any)
_auth_token = request.user_auth_token
# check if we used an AUTH_TOKEN and it's a valid one
# defined white-list of controllers which API access will be enabled
_auth_token = request.GET.get(
'auth_token', '') or request.GET.get('api_key', '')
whitelist = None
if self.auth_token_access:
# since this location is allowed by @LoginRequired decorator it's our
# only whitelist
whitelist = [loc]
auth_token_access_valid = allowed_auth_token_access(
loc, auth_token=_auth_token)
loc, whitelist=whitelist, auth_token=_auth_token)
# explicit controller is enabled or API is in our whitelist
if self.auth_token_access or auth_token_access_valid:
if auth_token_access_valid:
log.debug('Checking AUTH TOKEN access for %s', cls)
db_user = user.get_instance()
@ -1637,6 +1652,8 @@ class LoginRequired(object):
roles = self.auth_token_access
else:
roles = [UserApiKeys.ROLE_HTTP]
log.debug('AUTH TOKEN: checking auth for user %s and roles %s',
db_user, roles)
token_match = db_user.authenticate_by_token(
_auth_token, roles=roles)
else:

View file

@ -718,6 +718,23 @@ class User(Base, BaseModel):
return feed_tokens[0].api_key
return 'NO_FEED_TOKEN_AVAILABLE'
@LazyProperty
def artifact_token(self):
return self.get_artifact_token()
def get_artifact_token(self, cache=True):
artifacts_tokens = UserApiKeys.query()\
.filter(UserApiKeys.user == self)\
.filter(UserApiKeys.role == UserApiKeys.ROLE_ARTIFACT_DOWNLOAD)
if cache:
artifacts_tokens = artifacts_tokens.options(
FromCache("sql_cache_short", "get_user_artifact_token_%s" % self.user_id))
artifacts_tokens = artifacts_tokens.all()
if artifacts_tokens:
return artifacts_tokens[0].api_key
return 'NO_ARTIFACT_TOKEN_AVAILABLE'
@classmethod
def get(cls, user_id, cache=False):
if not user_id:
@ -765,7 +782,7 @@ class User(Base, BaseModel):
else:
plain_token_map[token.api_key] = token
log.debug(
'Found %s plain and %s encrypted user tokens to check for authentication',
'Found %s plain and %s encrypted tokens to check for authentication for this user',
len(plain_token_map), len(enc_token_map))
# plain token match comes first
@ -1075,9 +1092,10 @@ class UserApiKeys(Base, BaseModel):
ROLE_VCS = 'token_role_vcs'
ROLE_API = 'token_role_api'
ROLE_FEED = 'token_role_feed'
ROLE_ARTIFACT_DOWNLOAD = 'role_artifact_download'
ROLE_PASSWORD_RESET = 'token_password_reset'
ROLES = [ROLE_ALL, ROLE_HTTP, ROLE_VCS, ROLE_API, ROLE_FEED]
ROLES = [ROLE_ALL, ROLE_HTTP, ROLE_VCS, ROLE_API, ROLE_FEED, ROLE_ARTIFACT_DOWNLOAD]
user_api_key_id = Column("user_api_key_id", Integer(), nullable=False, unique=True, default=None, primary_key=True)
user_id = Column("user_id", Integer(), ForeignKey('users.user_id'), nullable=True, unique=None, default=None)
@ -1139,6 +1157,7 @@ class UserApiKeys(Base, BaseModel):
cls.ROLE_VCS: _('vcs (git/hg/svn protocol)'),
cls.ROLE_API: _('api calls'),
cls.ROLE_FEED: _('feed access'),
cls.ROLE_ARTIFACT_DOWNLOAD: _('artifacts downloads'),
}.get(role, role)
@property

View file

@ -140,6 +140,7 @@ function registerRCRoutes() {
pyroutes.register('channelstream_proxy', '/_channelstream', []);
pyroutes.register('upload_file', '/_file_store/upload', []);
pyroutes.register('download_file', '/_file_store/download/%(fid)s', ['fid']);
pyroutes.register('download_file_by_token', '/_file_store/token-download/%(_auth_token)s/%(fid)s', ['_auth_token', 'fid']);
pyroutes.register('logout', '/_admin/logout', []);
pyroutes.register('reset_password', '/_admin/password_reset', []);
pyroutes.register('reset_password_confirmation', '/_admin/password_reset_confirmation', []);