# HG changeset patch # User Marcin Kuzminski # Date 2019-10-22 18:00:39 # Node ID 09f31efc450ba8703bd78a2d680951aeb7eebfb0 # Parent 5f150e861c7a3e9a86e21eed1a0719d42bc50be9 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. 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', []);