diff --git a/rhodecode/apps/file_store/__init__.py b/rhodecode/apps/file_store/__init__.py --- a/rhodecode/apps/file_store/__init__.py +++ b/rhodecode/apps/file_store/__init__.py @@ -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') diff --git a/rhodecode/apps/file_store/views.py b/rhodecode/apps/file_store/views.py --- a/rhodecode/apps/file_store/views.py +++ b/rhodecode/apps/file_store/views.py @@ -30,8 +30,10 @@ from rhodecode.apps.file_store.exception 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) diff --git a/rhodecode/lib/auth.py b/rhodecode/lib/auth.py --- a/rhodecode/lib/auth.py +++ b/rhodecode/lib/auth.py @@ -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: diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -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 diff --git a/rhodecode/public/js/rhodecode/routes.js b/rhodecode/public/js/rhodecode/routes.js --- a/rhodecode/public/js/rhodecode/routes.js +++ b/rhodecode/public/js/rhodecode/routes.js @@ -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', []);