diff --git a/rhodecode/api/__init__.py b/rhodecode/api/__init__.py --- a/rhodecode/api/__init__.py +++ b/rhodecode/api/__init__.py @@ -209,18 +209,18 @@ def request_view(request): request.rpc_user = auth_u # now check if token is valid for API - role = UserApiKeys.ROLE_API - extra_auth_tokens = [ - x.api_key for x in User.extra_valid_auth_tokens(api_user, role=role)] - active_tokens = [api_user.api_key] + extra_auth_tokens + auth_token = request.rpc_api_key + token_match = api_user.authenticate_by_token( + auth_token, roles=[UserApiKeys.ROLE_API], include_builtin_token=True) + invalid_token = not token_match - log.debug('Checking if API key has proper role') - if request.rpc_api_key not in active_tokens: + log.debug('Checking if API KEY is valid with proper role') + if invalid_token: return jsonrpc_error( request, retid=request.rpc_id, - message='API KEY has bad role for an API call') + message='API KEY invalid or, has bad role for an API call') - except Exception as e: + except Exception: log.exception('Error on API AUTH') return jsonrpc_error( request, retid=request.rpc_id, message='Invalid API KEY') diff --git a/rhodecode/authentication/plugins/auth_token.py b/rhodecode/authentication/plugins/auth_token.py --- a/rhodecode/authentication/plugins/auth_token.py +++ b/rhodecode/authentication/plugins/auth_token.py @@ -122,10 +122,10 @@ class RhodeCodeAuthPlugin(RhodeCodeAuthP log.debug('Authenticating user with args %s', user_attrs) if userobj.active: - role = UserApiKeys.ROLE_VCS - active_tokens = [x.api_key for x in - User.extra_valid_auth_tokens(userobj, role=role)] - if userobj.username == username and password in active_tokens: + token_match = userobj.authenticate_by_token( + password, roles=[UserApiKeys.ROLE_VCS]) + + if userobj.username == username and token_match: log.info( 'user `%s` successfully authenticated via %s', user_attrs['username'], self.name) diff --git a/rhodecode/controllers/feed.py b/rhodecode/controllers/feed.py --- a/rhodecode/controllers/feed.py +++ b/rhodecode/controllers/feed.py @@ -28,12 +28,11 @@ import pytz from pylons import url, response, tmpl_context as c from pylons.i18n.translation import _ -from beaker.cache import cache_region, region_invalidate +from beaker.cache import cache_region from webhelpers.feedgenerator import Atom1Feed, Rss201rev2Feed -from rhodecode.model.db import CacheKey +from rhodecode.model.db import CacheKey, UserApiKeys from rhodecode.lib import helpers as h -from rhodecode.lib import caches from rhodecode.lib.auth import LoginRequired, HasRepoPermissionAnyDecorator from rhodecode.lib.base import BaseRepoController from rhodecode.lib.diffs import DiffProcessor, LimitedDiffContainer @@ -62,7 +61,7 @@ class FeedController(BaseRepoController) safe_int(config.get('rss_cut_off_limit', 32 * 1024)), } - @LoginRequired(auth_token_access=True) + @LoginRequired(auth_token_access=[UserApiKeys.ROLE_FEED]) def __before__(self): super(FeedController, self).__before__() config = self._get_config() diff --git a/rhodecode/controllers/journal.py b/rhodecode/controllers/journal.py --- a/rhodecode/controllers/journal.py +++ b/rhodecode/controllers/journal.py @@ -35,7 +35,7 @@ from pylons import request, tmpl_context from pylons.i18n.translation import _ from rhodecode.controllers.admin.admin import _journal_filter -from rhodecode.model.db import UserLog, UserFollowing, User +from rhodecode.model.db import UserLog, UserFollowing, User, UserApiKeys from rhodecode.model.meta import Session import rhodecode.lib.helpers as h from rhodecode.lib.helpers import Page @@ -211,7 +211,7 @@ class JournalController(BaseController): return render('journal/journal.mako') - @LoginRequired(auth_token_access=True) + @LoginRequired(auth_token_access=[UserApiKeys.ROLE_FEED]) @NotAnonymous() def journal_atom(self): """ @@ -223,7 +223,7 @@ class JournalController(BaseController): .all() return self._atom_feed(following, public=False) - @LoginRequired(auth_token_access=True) + @LoginRequired(auth_token_access=[UserApiKeys.ROLE_FEED]) @NotAnonymous() def journal_rss(self): """ @@ -281,7 +281,7 @@ class JournalController(BaseController): return c.journal_data return render('journal/public_journal.mako') - @LoginRequired(auth_token_access=True) + @LoginRequired(auth_token_access=[UserApiKeys.ROLE_FEED]) def public_journal_atom(self): """ Produce an atom-1.0 feed via feedgenerator module @@ -293,7 +293,7 @@ class JournalController(BaseController): return self._atom_feed(c.following) - @LoginRequired(auth_token_access=True) + @LoginRequired(auth_token_access=[UserApiKeys.ROLE_FEED]) def public_journal_rss(self): """ Produce an rss2 feed via feedgenerator module diff --git a/rhodecode/lib/auth.py b/rhodecode/lib/auth.py --- a/rhodecode/lib/auth.py +++ b/rhodecode/lib/auth.py @@ -99,6 +99,7 @@ class PasswordGenerator(object): class _RhodeCodeCryptoBase(object): + ENC_PREF = None def hash_create(self, str_): """ @@ -139,6 +140,7 @@ class _RhodeCodeCryptoBase(object): class _RhodeCodeCryptoBCrypt(_RhodeCodeCryptoBase): + ENC_PREF = '$2a$10' def hash_create(self, str_): self._assert_bytes(str_) @@ -194,6 +196,7 @@ class _RhodeCodeCryptoBCrypt(_RhodeCodeC class _RhodeCodeCryptoSha256(_RhodeCodeCryptoBase): + ENC_PREF = '_' def hash_create(self, str_): self._assert_bytes(str_) @@ -211,6 +214,7 @@ class _RhodeCodeCryptoSha256(_RhodeCodeC class _RhodeCodeCryptoMd5(_RhodeCodeCryptoBase): + ENC_PREF = '_' def hash_create(self, str_): self._assert_bytes(str_) @@ -831,10 +835,6 @@ class AuthUser(object): self._permissions_scoped_cache[cache_key] = res return self._permissions_scoped_cache[cache_key] - @property - def auth_tokens(self): - return self.get_auth_tokens() - def get_instance(self): return User.get(self.user_id) @@ -925,16 +925,6 @@ class AuthUser(object): log.debug('PERMISSION tree computed %s' % (result_repr,)) return result - def get_auth_tokens(self): - auth_tokens = [self.api_key] - for api_key in UserApiKeys.query()\ - .filter(UserApiKeys.user_id == self.user_id)\ - .filter(or_(UserApiKeys.expires == -1, - UserApiKeys.expires >= time.time())).all(): - auth_tokens.append(api_key.api_key) - - return auth_tokens - @property def is_default(self): return self.username == User.DEFAULT_USER @@ -1171,7 +1161,7 @@ class LoginRequired(object): :param api_access: if enabled this checks only for valid auth token and grants access based on valid token """ - def __init__(self, auth_token_access=False): + def __init__(self, auth_token_access=None): self.auth_token_access = auth_token_access def __call__(self, func): @@ -1191,7 +1181,7 @@ class LoginRequired(object): ip_access_valid = False # check if we used an APIKEY and it's a valid one - # defined whitelist of controllers which API access will be enabled + # defined white-list of controllers which API access will be enabled _auth_token = request.GET.get( 'auth_token', '') or request.GET.get('api_key', '') auth_token_access_valid = allowed_auth_token_access( @@ -1200,8 +1190,20 @@ class LoginRequired(object): # explicit controller is enabled or API is in our whitelist if self.auth_token_access or auth_token_access_valid: log.debug('Checking AUTH TOKEN access for %s' % (cls,)) + db_user = user.get_instance() - if _auth_token and _auth_token in user.auth_tokens: + if db_user: + if self.auth_token_access: + roles = self.auth_token_access + else: + roles = [UserApiKeys.ROLE_HTTP] + token_match = db_user.authenticate_by_token( + _auth_token, roles=roles, include_builtin_token=True) + else: + log.debug('Unable to fetch db instance for auth user: %s', user) + token_match = False + + if _auth_token and token_match: auth_token_access_valid = True log.debug('AUTH TOKEN ****%s is VALID' % (_auth_token[-4:],)) else: diff --git a/rhodecode/model/db.py b/rhodecode/model/db.py --- a/rhodecode/model/db.py +++ b/rhodecode/model/db.py @@ -581,15 +581,16 @@ class User(Base, BaseModel): @property def feed_token(self): + return self.get_feed_token() + + def get_feed_token(self): feed_tokens = UserApiKeys.query()\ .filter(UserApiKeys.user == self)\ .filter(UserApiKeys.role == UserApiKeys.ROLE_FEED)\ .all() if feed_tokens: return feed_tokens[0].api_key - else: - # use the main token so we don't end up with nothing... - return self.api_key + return 'NO_FEED_TOKEN_AVAILABLE' @classmethod def extra_valid_auth_tokens(cls, user, role=None): @@ -601,11 +602,57 @@ class User(Base, BaseModel): UserApiKeys.role == UserApiKeys.ROLE_ALL)) return tokens.all() + def authenticate_by_token(self, auth_token, roles=None, + include_builtin_token=False): + from rhodecode.lib import auth + + log.debug('Trying to authenticate user: %s via auth-token, ' + 'and roles: %s', self, roles) + + if not auth_token: + return False + + crypto_backend = auth.crypto_backend() + + roles = (roles or []) + [UserApiKeys.ROLE_ALL] + tokens_q = UserApiKeys.query()\ + .filter(UserApiKeys.user_id == self.user_id)\ + .filter(or_(UserApiKeys.expires == -1, + UserApiKeys.expires >= time.time())) + + tokens_q = tokens_q.filter(UserApiKeys.role.in_(roles)) + + maybe_builtin = [] + if include_builtin_token: + maybe_builtin = [AttributeDict({'api_key': self.api_key})] + + plain_tokens = [] + hash_tokens = [] + + for token in tokens_q.all() + maybe_builtin: + if token.api_key.startswith(crypto_backend.ENC_PREF): + hash_tokens.append(token.api_key) + else: + plain_tokens.append(token.api_key) + + is_plain_match = auth_token in plain_tokens + if is_plain_match: + return True + + for hashed in hash_tokens: + # marcink: this is expensive to calculate, but the most secure + match = crypto_backend.hash_check(auth_token, hashed) + if match: + return True + + return False + @property def builtin_token_roles(self): - return map(UserApiKeys._get_role_name, [ + roles = [ UserApiKeys.ROLE_API, UserApiKeys.ROLE_FEED, UserApiKeys.ROLE_HTTP - ]) + ] + return map(UserApiKeys._get_role_name, roles) @property def ip_addresses(self): diff --git a/rhodecode/tests/functional/test_feed.py b/rhodecode/tests/functional/test_feed.py --- a/rhodecode/tests/functional/test_feed.py +++ b/rhodecode/tests/functional/test_feed.py @@ -17,7 +17,7 @@ # This program is dual-licensed. If you wish to learn more about the # RhodeCode Enterprise Edition, including its added features, Support services, # and proprietary license terms, please see https://rhodecode.com/licenses/ - +from rhodecode.model.auth_token import AuthTokenModel from rhodecode.model.db import User from rhodecode.tests import * @@ -32,15 +32,36 @@ class TestFeedController(TestController) assert response.content_type == "application/rss+xml" assert """""" in response - def test_rss_with_auth_token(self, backend): - auth_token = User.get_first_super_admin().feed_token + def test_rss_with_auth_token(self, backend, user_admin): + auth_token = user_admin.feed_token assert auth_token != '' - response = self.app.get(url(controller='feed', action='rss', - repo_name=backend.repo_name, auth_token=auth_token)) + response = self.app.get( + url(controller='feed', action='rss', + repo_name=backend.repo_name, auth_token=auth_token, + status=200)) assert response.content_type == "application/rss+xml" assert """""" in response + def test_rss_with_auth_token_of_wrong_type(self, backend, user_util): + user = user_util.create_user() + auth_token = AuthTokenModel().create( + user.user_id, 'test-token', -1, AuthTokenModel.cls.ROLE_API) + auth_token = auth_token.api_key + + self.app.get( + url(controller='feed', action='rss', + repo_name=backend.repo_name, auth_token=auth_token), + status=302) + + auth_token = AuthTokenModel().create( + user.user_id, 'test-token', -1, AuthTokenModel.cls.ROLE_FEED) + auth_token = auth_token.api_key + self.app.get( + url(controller='feed', action='rss', + repo_name=backend.repo_name, auth_token=auth_token), + status=200) + def test_atom(self, backend): self.log_user() response = self.app.get(url(controller='feed', action='atom', diff --git a/rhodecode/tests/functional/test_login.py b/rhodecode/tests/functional/test_login.py --- a/rhodecode/tests/functional/test_login.py +++ b/rhodecode/tests/functional/test_login.py @@ -443,14 +443,15 @@ class TestLoginController: ('fake_number', '123456'), ('proper_auth_token', None) ]) - def test_access_not_whitelisted_page_via_auth_token(self, test_name, - auth_token): + def test_access_not_whitelisted_page_via_auth_token( + self, test_name, auth_token, user_admin): + whitelist = self._get_api_whitelist([]) with mock.patch.dict('rhodecode.CONFIG', whitelist): assert [] == whitelist['api_access_controllers_whitelist'] if test_name == 'proper_auth_token': # use builtin if api_key is None - auth_token = User.get_first_super_admin().api_key + auth_token = user_admin.api_key with fixture.anon_access(False): self.app.get(url(controller='changeset', @@ -465,15 +466,17 @@ class TestLoginController: ('fake_number', '123456', 302), ('proper_auth_token', None, 200) ]) - def test_access_whitelisted_page_via_auth_token(self, test_name, - auth_token, code): - whitelist = self._get_api_whitelist( - ['ChangesetController:changeset_raw']) + def test_access_whitelisted_page_via_auth_token( + self, test_name, auth_token, code, user_admin): + + whitelist_entry = ['ChangesetController:changeset_raw'] + whitelist = self._get_api_whitelist(whitelist_entry) + with mock.patch.dict('rhodecode.CONFIG', whitelist): - assert ['ChangesetController:changeset_raw'] == \ - whitelist['api_access_controllers_whitelist'] + assert whitelist_entry == whitelist['api_access_controllers_whitelist'] + if test_name == 'proper_auth_token': - auth_token = User.get_first_super_admin().api_key + auth_token = user_admin.api_key with fixture.anon_access(False): self.app.get(url(controller='changeset', diff --git a/rhodecode/tests/lib/test_auth.py b/rhodecode/tests/lib/test_auth.py --- a/rhodecode/tests/lib/test_auth.py +++ b/rhodecode/tests/lib/test_auth.py @@ -26,6 +26,7 @@ from mock import patch from rhodecode.lib import auth from rhodecode.lib.utils2 import md5 +from rhodecode.model.auth_token import AuthTokenModel from rhodecode.model.db import User from rhodecode.model.repo import RepoModel from rhodecode.model.user import UserModel @@ -580,3 +581,28 @@ class TestGenerateAuthToken(object): result = auth.generate_auth_token(user_name) expected_result = sha1(user_name + random_salt).hexdigest() assert result == expected_result + + +@pytest.mark.parametrize("test_token, test_roles, auth_result, expected_tokens", [ + ('', None, False, + []), + ('wrongtoken', None, False, + []), + ('abracadabra_vcs', [AuthTokenModel.cls.ROLE_API], False, + [('abracadabra_api', AuthTokenModel.cls.ROLE_API, -1)]), + ('abracadabra_api', [AuthTokenModel.cls.ROLE_API], True, + [('abracadabra_api', AuthTokenModel.cls.ROLE_API, -1)]), + ('abracadabra_api', [AuthTokenModel.cls.ROLE_API], True, + [('abracadabra_api', AuthTokenModel.cls.ROLE_API, -1), + ('abracadabra_http', AuthTokenModel.cls.ROLE_HTTP, -1)]), +]) +def test_auth_by_token(test_token, test_roles, auth_result, expected_tokens, + user_util): + user = user_util.create_user() + user_id = user.user_id + for token, role, expires in expected_tokens: + new_token = AuthTokenModel().create(user_id, 'test-token', expires, role) + new_token.api_key = token # inject known name for testing... + + assert auth_result == user.authenticate_by_token( + test_token, roles=test_roles, include_builtin_token=True)