Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refresh token client support. #5662

Merged
merged 18 commits into from Sep 26, 2019
1 change: 1 addition & 0 deletions conans/__init__.py
Expand Up @@ -16,6 +16,7 @@
CHECKSUM_DEPLOY = "checksum_deploy" # Only when v2
REVISIONS = "revisions" # Only when enabled in config, not by default look at server_launcher.py
ONLY_V2 = "only_v2" # Remotes and virtuals from Artifactory returns this capability
REFRESH_TOKEN = "oauth_token"
lasote marked this conversation as resolved.
Show resolved Hide resolved
SERVER_CAPABILITIES = [COMPLEX_SEARCH_CAPABILITY, REVISIONS] # Server is always with revisions
DEFAULT_REVISION_V1 = "0"

Expand Down
10 changes: 5 additions & 5 deletions conans/client/cmd/user.py
Expand Up @@ -10,7 +10,7 @@ def users_list(localdb_file, remotes):
remotes_info = []
for remote in remotes:
user_info = {}
user, token = localdb.get_login(remote.url)
user, token, _ = localdb.get_login(remote.url)
user_info["name"] = remote.name
user_info["user_name"] = user
user_info["authenticated"] = True if token else False
Expand All @@ -20,7 +20,7 @@ def users_list(localdb_file, remotes):

def token_present(localdb_file, remote, user):
localdb = LocalDB.create(localdb_file)
current_user, token = localdb.get_login(remote.url)
current_user, token, _ = localdb.get_login(remote.url)
return token is not None and (user is None or user == current_user)


Expand All @@ -33,10 +33,10 @@ def user_set(localdb_file, user, remote_name=None):

if user.lower() == "none":
user = None
return update_localdb(localdb, user, None, remote_name)
return update_localdb(localdb, user, token=None, refresh_token=None, remote=remote_name)


def update_localdb(localdb, user, token, remote):
def update_localdb(localdb, user, token, refresh_token, remote):
previous_user = localdb.get_username(remote.url)
localdb.set_login((user, token), remote.url)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed set_login to store and not passing a tuple but arguments. It was very confusing. Sorry for the refactor.

localdb.store(user, token, refresh_token, remote.url)
return remote.name, previous_user, user
4 changes: 2 additions & 2 deletions conans/client/conan_api.py
Expand Up @@ -787,8 +787,8 @@ def authenticate(self, name, password, remote_name, skip_auth=False):
if not password:
name, password = self.user_io.request_login(remote_name=remote_name, username=name)

_, remote_name, prev_user, user = self.app.remote_manager.authenticate(remote, name,
password)
_, _, remote_name, prev_user, user = self.app.remote_manager.authenticate(remote, name,
password)
return remote_name, prev_user, user

@api_method
Expand Down
20 changes: 19 additions & 1 deletion conans/client/migrations.py
Expand Up @@ -4,6 +4,7 @@
from conans import DEFAULT_REVISION_V1
from conans.client import migrations_settings
from conans.client.cache.cache import CONAN_CONF, PROFILES_FOLDER
from conans.client.cache.remote_registry import migrate_registry_file
from conans.client.conf.config_installer import _ConfigOrigin, _save_configs
from conans.client.tools import replace_in_file
from conans.errors import ConanException
Expand All @@ -16,7 +17,6 @@
from conans.paths import PACKAGE_METADATA
from conans.paths.package_layouts.package_cache_layout import PackageCacheLayout
from conans.util.files import list_folder_subdirs, load, save
from conans.client.cache.remote_registry import migrate_registry_file


class ClientMigrator(Migrator):
Expand Down Expand Up @@ -102,6 +102,9 @@ def _make_migrations(self, old_version):
if old_version < Version("1.15.0"):
migrate_registry_file(self.cache, self.out)

if old_version < Version("1.19.0"):
migrate_localdb_refresh_token(self.cache, self.out)


def _get_refs(cache):
folders = list_folder_subdirs(cache.store, 4)
Expand All @@ -114,6 +117,21 @@ def _get_prefs(layout):
return [PackageReference(layout.ref, s) for s in folders]


def migrate_localdb_refresh_token(cache, out):
from conans.client.store.localdb import LocalDB
from sqlite3 import OperationalError

localdb = LocalDB.create(cache.localdb)
with localdb._connect() as connection:
try:
statement = connection.cursor()
statement.execute("ALTER TABLE users_remotes ADD refresh_token TEXT;")
except OperationalError:
# This likely means the column is already there (fresh created table)
# In the worst scenario the user will be requested to remove the file by hand
pass


def _migrate_full_metadata(cache, out):
# Fix for https://github.com/conan-io/conan/issues/4898
out.warn("Running a full revision metadata migration")
Expand Down
40 changes: 29 additions & 11 deletions conans/client/rest/auth_manager.py
Expand Up @@ -45,12 +45,21 @@ def wrapper(self, *args, **kwargs):
self._user_io.out.info('If you don\'t have an account sign up here: '
'https://bintray.com/signup/oss')
return retry_with_new_token(self, *args, **kwargs)
elif self._rest_client.token and self._rest_client.refresh_token:
# If we have a refresh token try to refresh the access token
try:
self.authenticate(self.user, None)
except AuthenticationException as exc:
logger.info("Cannot refresh the token, cleaning and retrying: {}".format(exc))
self._clear_user_tokens(self.user)
# Set custom headers of mac_digest and username
self.set_custom_headers(self.user)
return wrapper(self, *args, **kwargs)
else:
# Token expired or not valid, so clean the token and repeat the call
# (will be anonymous call but exporting who is calling)
logger.info("Token expired or not valid, cleaning the saved token and retrying")
self._store_login((self.user, None))
self._rest_client.token = None
self._clear_user_tokens(self.user)
# Set custom headers of mac_digest and username
self.set_custom_headers(self.user)
return wrapper(self, *args, **kwargs)
Expand All @@ -62,7 +71,7 @@ def retry_with_new_token(self, *args, **kwargs):
for _ in range(LOGIN_RETRIES):
user, password = self._user_io.request_login(self._remote.name, self.user)
try:
token, _, _, _ = self.authenticate(user, password)
self.authenticate(user, password)
except AuthenticationException:
if self.user is None:
self._user_io.out.error('Wrong user or password')
Expand All @@ -72,8 +81,6 @@ def retry_with_new_token(self, *args, **kwargs):
self._user_io.out.info(
'You can change username with "conan user <username>"')
else:
logger.debug("Got token")
self._rest_client.token = token
self.user = user
# Set custom headers of mac_digest and username
self.set_custom_headers(user)
Expand All @@ -100,11 +107,14 @@ def remote(self, remote):
self._remote = remote
self._rest_client.remote_url = remote.url
self._rest_client.verify_ssl = remote.verify_ssl
self.user, self._rest_client.token = self._localdb.get_login(remote.url)
tmp = self._localdb.get_login(remote.url)
self.user, self._rest_client.token, self._rest_client.refresh_token = tmp

def _store_login(self, login):
def _clear_user_tokens(self, user):
self._rest_client.refresh_token = None
self._rest_client.token = None
try:
self._localdb.set_login(login, self._remote.url)
self._localdb.store(user, token=None, refresh_token=None, remote_url=self._remote.url)
except Exception as e:
self._user_io.out.error(
'Your credentials could not be stored in local cache\n')
Expand Down Expand Up @@ -221,10 +231,18 @@ def authenticate(self, user, password):
user = prev_user

try:
token = self._rest_client.authenticate(user, password)
token, refresh_token = self._rest_client.authenticate(user, password)
except UnicodeDecodeError:
raise ConanException("Password contains not allowed symbols")

logger.debug("Got token")
lasote marked this conversation as resolved.
Show resolved Hide resolved
if refresh_token:
logger.debug("Got refresh token")

# Store result in DB
remote_name, prev_user, user = update_localdb(self._localdb, user, token, self._remote)
return token, remote_name, prev_user, user
remote_name, prev_user, user = update_localdb(self._localdb, user, token, refresh_token,
self._remote)
self._rest_client.token = token
self._rest_client.refresh_token = refresh_token

return token, refresh_token, remote_name, prev_user, user
3 changes: 3 additions & 0 deletions conans/client/rest/client_routes.py
Expand Up @@ -49,6 +49,9 @@ def search_packages(self, ref, query=None):
url += "?%s" % urlencode({"q": query})
return self.base_url + url

def oauth_authenticate(self):
return self.base_url + routes.oauth_authenticate

def common_authenticate(self):
return self.base_url + routes.common_authenticate

Expand Down
21 changes: 15 additions & 6 deletions conans/client/rest/rest_client.py
@@ -1,6 +1,6 @@
from collections import defaultdict

from conans import CHECKSUM_DEPLOY, REVISIONS, ONLY_V2
from conans import CHECKSUM_DEPLOY, REVISIONS, ONLY_V2, REFRESH_TOKEN
from conans.client.rest.rest_client_v1 import RestV1Methods
from conans.client.rest.rest_client_v2 import RestV2Methods
from conans.errors import OnlyV2Available
Expand All @@ -16,6 +16,7 @@ def __init__(self, output, requester, revisions_enabled, put_headers=None):

# Set to instance
self.token = None
self.refresh_token = None
self.remote_url = None
self.custom_headers = {} # Can set custom headers to each request
self._output = output
Expand Down Expand Up @@ -85,11 +86,19 @@ def upload_package(self, pref, files_to_upload, deleted, retry, retry_wait):
return self._get_api().upload_package(pref, files_to_upload, deleted, retry, retry_wait)

def authenticate(self, user, password):
apiv1 = RestV1Methods(self.remote_url, self.token, self.custom_headers, self._output,
self.requester, self.verify_ssl, self._put_headers)
# Use v1 for the auth because the "ping" could be also protected so we don't know if
# we can use v2
return apiv1.authenticate(user, password)
api_v1 = RestV1Methods(self.remote_url, self.token, self.custom_headers, self._output,
self.requester, self.verify_ssl, self._put_headers)

if not self.refresh_token or not self.token:
if False and REFRESH_TOKEN in self._cached_capabilities[self.remote_url]:
lasote marked this conversation as resolved.
Show resolved Hide resolved
# Artifactory >= 6.13.X
token, refresh_token = api_v1.authenticate_oauth(user, password)
else:
token, refresh_token = api_v1.authenticate(user, password)
else:
token, refresh_token = api_v1.refresh_token(self.token, self.refresh_token)

return token, refresh_token

def check_credentials(self):
return self._get_api().check_credentials()
Expand Down
71 changes: 62 additions & 9 deletions conans/client/rest/rest_client_common.py
Expand Up @@ -78,21 +78,73 @@ def __init__(self, remote_url, token, custom_headers, output, requester, verify_
def auth(self):
return JWTAuth(self.token)

@handle_return_deserializer()
def authenticate(self, user, password):
"""Sends user + password to get a token"""
auth = HTTPBasicAuth(user, password)
url = self.router.common_authenticate()
logger.debug("REST: Authenticate: %s" % url)
ret = self.requester.get(url, auth=auth, headers=self.custom_headers,
verify=self.verify_ssl)
@staticmethod
def _check_error_response(ret):
if ret.status_code == 401:
raise AuthenticationException("Wrong user or password")
# Cannot check content-type=text/html, conan server is doing it wrong
if not ret.ok or "html>" in str(ret.content):
raise ConanException("%s\n\nInvalid server response, check remote URL and "
"try again" % str(ret.content))
return ret

def authenticate(self, user, password):
"""Sends user + password to get:
- A plain response with a regular token (not supported refresh in the remote) and None
"""
auth = HTTPBasicAuth(user, password)
url = self.router.common_authenticate()
logger.debug("REST: Authenticate to get access_token: %s" % url)
ret = self.requester.get(url, auth=auth, headers=self.custom_headers,
verify=self.verify_ssl)

self._check_error_response(ret)
return decode_text(ret.content), None

def authenticate_oauth(self, user, password):
"""Sends user + password to get:
- A json with an access_token and a refresh token (if supported in the remote)
Artifactory >= 6.13.X
"""
url = self.router.oauth_authenticate()
auth = HTTPBasicAuth(user, password)
headers = {}
headers.update(self.custom_headers)
headers["Content-type"] = "application/x-www-form-urlencoded"
logger.debug("REST: Authenticating with OAUTH: %s" % url)
ret = self.requester.post(url, auth=auth, headers=headers, verify=self.verify_ssl)
self._check_error_response(ret)

data = ret.json()
access_token = data["access_token"]
refresh_token = data["refresh_token"]
logger.debug("REST: Obtained refresh and access tokens")
return access_token, refresh_token

def refresh_token(self, token, refresh_token):
"""Sends access_token and the refresh_token to get a pair of
access_token and refresh token

Artifactory >= 6.13.X
"""
url = self.router.oauth_authenticate()
logger.debug("REST: Refreshing Token: %s" % url)
headers = {}
headers.update(self.custom_headers)
headers["Content-type"] = "application/x-www-form-urlencoded"
payload = {'access_token': token, 'refresh_token': refresh_token,
'grant_type': 'refresh_token'}
ret = self.requester.post(url, headers=headers, verify=self.verify_ssl, data=payload)
self._check_error_response(ret)

data = ret.json()
if "access_token" not in data:
logger.debug("REST: unexpected data from server: {}".format(data))
raise ConanException("Error refreshing the token")

new_access_token = data["access_token"]
new_refresh_token = data["refresh_token"]
logger.debug("REST: Obtained new refresh and access tokens")
return new_access_token, new_refresh_token

@handle_return_deserializer()
def check_credentials(self):
Expand All @@ -114,6 +166,7 @@ def server_info(self):

if not ret.ok:
raise get_exception_from_error(ret.status_code)("")

version_check = ret.headers.get('X-Conan-Client-Version-Check', None)
server_version = ret.headers.get('X-Conan-Server-Version', None)
server_capabilities = ret.headers.get('X-Conan-Server-Capabilities', "")
Expand Down
20 changes: 12 additions & 8 deletions conans/client/store/localdb.py
Expand Up @@ -37,7 +37,8 @@ def create(dbfile, clean=False):
pass

cursor.execute("create table if not exists %s "
"(remote_url TEXT UNIQUE, user TEXT, token TEXT)" % REMOTES_USER_TABLE)
"(remote_url TEXT UNIQUE, user TEXT, "
"token TEXT, refresh_token TEXT)" % REMOTES_USER_TABLE)
except Exception as e:
message = "Could not initialize local sqlite database"
raise ConanException(message, e)
Expand All @@ -60,28 +61,31 @@ def get_login(self, remote_url):
with self._connect() as connection:
try:
statement = connection.cursor()
statement.execute('select user, token from %s where remote_url="%s"'
statement.execute('select user, token, refresh_token from %s where remote_url="%s"'
% (REMOTES_USER_TABLE, remote_url))
rs = statement.fetchone()
if not rs:
return None, None
return None, None, None
name = rs[0]
token = rs[1]
return name, token
refresh_token = rs[2]
return name, token, refresh_token
except Exception:
raise ConanException("Couldn't read login\n Try removing '%s' file" % self.dbfile)

def get_username(self, remote_url):
return self.get_login(remote_url)[0]

def set_login(self, login, remote_url):
def store(self, user, token, refresh_token, remote_url):
""" Login is a tuple of (user, token) """
with self._connect() as connection:
try:
statement = connection.cursor()
statement.execute("INSERT OR REPLACE INTO %s (remote_url, user, token) "
"VALUES (?, ?, ?)" % REMOTES_USER_TABLE,
(remote_url, login[0], login[1]))
statement.execute("INSERT OR REPLACE INTO %s (remote_url, user, token, "
"refresh_token) "
"VALUES (?, ?, ?, ?)" % REMOTES_USER_TABLE,
(remote_url, user, token, refresh_token))
connection.commit()
except Exception as e:
raise ConanException("Could not store credentials %s" % str(e))

4 changes: 4 additions & 0 deletions conans/model/rest_routes.py
Expand Up @@ -124,6 +124,10 @@ def common_search_packages_revision(self):
def common_authenticate(self):
return "users/authenticate"

@property
def oauth_authenticate(self):
return "users/token"

@property
def common_check_credentials(self):
return "users/check_credentials"