diff --git a/src/caosdb/configuration.py b/src/caosdb/configuration.py index 8b4af56eb45561526bf8f45d9e4708631901ce6e..9f6b558abd7bd5e291eaa8bd89ca41ebe0c73bf8 100644 --- a/src/caosdb/configuration.py +++ b/src/caosdb/configuration.py @@ -24,20 +24,20 @@ """Created on 20.09.2016.""" -from sys import hexversion -if hexversion < 0x03000000: - from ConfigParser import ConfigParser # @UnusedImport @UnresolvedImport -else: - from configparser import ConfigParser # @UnresolvedImport @Reimport +try: + # python2 + from ConfigParser import ConfigParser +except ImportError: + # python3 + from configparser import ConfigParser _DEFAULTS = {"Connection": {"url": None, - "timeout": "200", - "username": None, - "password": None, - "password_method": "plain", - "debug": "0", - "cacert": None}, + "timeout": "200", + "username": None, + "password_method": "plain", + "debug": "0", + "cacert": None}, "Container": {"debug": "0"}} diff --git a/src/caosdb/connection/authentication/external_credentials_provider.py b/src/caosdb/connection/authentication/external_credentials_provider.py new file mode 100644 index 0000000000000000000000000000000000000000..8164d49b4d50203c4de4d1ecd946bb309e74dd4d --- /dev/null +++ b/src/caosdb/connection/authentication/external_credentials_provider.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2018 Research Group Biomedical Physics, +# Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +"""external_credentials_provider. +""" +from __future__ import absolute_import, unicode_literals +from abc import ABCMeta +import logging +from .plain import PlainTextCredentialsProvider + +# meta class compatible with Python 2 *and* 3: +ABC = ABCMeta(str('ABC'), (object, ), {str('__slots__'): ()}) + +class ExternalCredentialsProvider(PlainTextCredentialsProvider, ABC): + """ExternalCredentialsProvider. + + Abstract subclass of PlainTextCredentialsProvider which should be used to + implement external credentials provider (e.g. pass, keyring, or any other call + to an external program, which presents the plain text password, which is to be + used for the authentication. + + Parameters + ---------- + callback: Function + A function which has **kwargs argument. This funktion will be called + each time a password is needed with the current connection + configuration as parameters. + """ + + def __init__(self, callback): + super(ExternalCredentialsProvider, self).__init__() + self._callback = callback + self._config = None + + def configure(self, **config): + """configure. + + Parameters + ---------- + **config + Keyword arguments containing the necessary arguments for the + concrete implementation of this class. + + Attributes + ---------- + password : str + The password. This password is not stored in this class. A callback + is called to provide the password each time this property is + called. + + Returns + ------- + None + """ + if "password" in config: + if "password_method" in config: + authm = "`{}`".format(config["password_method"]) + else: + authm = "an external credentials provider" + self.logger.log(logging.WARNING, + ("`password` defined. You configured caosdb to " + "use %s as authentication method and yet " + "provided a password yourself. This indicates " + "a misconfiguration (e.g. in your " + "pycaosdb.ini) and should be avoided."), + authm) + self._config = dict(config) + + @property + def password(self): + return self._callback(**self._config) diff --git a/src/caosdb/connection/authentication/interface.py b/src/caosdb/connection/authentication/interface.py index d3512174c405f0d86bc4edc11ca10962936f3093..fe9cb9170933cc12f76747d9d3c62a1a1fd2e8bd 100644 --- a/src/caosdb/connection/authentication/interface.py +++ b/src/caosdb/connection/authentication/interface.py @@ -25,10 +25,6 @@ caosdb server.""" from abc import ABCMeta, abstractmethod, abstractproperty import logging -try: - from urllib.parse import quote -except ImportError: - from urllib import quote from caosdb.connection.utils import urlencode from caosdb.connection.interface import CaosDBServerConnection from caosdb.connection.utils import parse_session_token @@ -46,17 +42,24 @@ class AbstractAuthenticator(ABC): Interface for different authentication mechanisms. e.g. username/password authentication or SSH key authentication. + Attributes + ---------- + logger : Logger + A logger which should be used for all logging which has to do with + authentication. + Methods ------- - login - logout - configure + login (abstract) + logout (abstract) + configure (abstract) on_request on_response """ def __init__(self): self._session_token = None + self.logger = _LOGGER @abstractmethod def login(self): @@ -141,6 +144,7 @@ class AbstractAuthenticator(ABC): headers['Cookie'] = self._session_token + class CredentialsAuthenticator(AbstractAuthenticator): """CredentialsAuthenticator @@ -220,10 +224,21 @@ class CredentialsProvider(ABC): Attributes ---------- - password - username + password (abstract) + username (abstract) + logger : Logger + A logger which should be used for all logging which has to do with the + provision of credentials. This is usually just the "authentication" + logger. + + Methods + ------- + configure (abstract) """ + def __init__(self): + self.logger = _LOGGER + @abstractmethod def configure(self, **config): """configure. diff --git a/src/caosdb/connection/authentication/keyring.py b/src/caosdb/connection/authentication/keyring.py index da9e7d41000009c1ba26c1faa080f84a5ab2e91f..abdbf112e20279d9c7211d01bebd296869edea5d 100644 --- a/src/caosdb/connection/authentication/keyring.py +++ b/src/caosdb/connection/authentication/keyring.py @@ -30,7 +30,8 @@ retrieve the password. import sys import imp from getpass import getpass -from .plain import PlainTextCredentialsProvider +from caosdb.exceptions import ConfigurationException +from .external_credentials_provider import ExternalCredentialsProvider from .interface import CredentialsAuthenticator @@ -46,7 +47,7 @@ def get_authentication_provider(): CredentialsAuthenticator with a 'KeyringCaller' as back-end. """ - return CredentialsAuthenticator(KeyringCaller()) + return CredentialsAuthenticator(KeyringCaller(callback=_call_keyring)) def _get_external_keyring(): @@ -64,7 +65,16 @@ def _get_external_keyring(): fil.close() -def _call_keyring(app, username): +def _call_keyring(**config): + if "username" not in config: + raise ConfigurationException("Your configuration did not provide a " + "`username` which is needed by the " + "`KeyringCaller` to retrieve the " + "password in question.") + url = config.get("url") + username = config.get("username") + app = "PyCaosDB — {}".format(url) + password = _call_keyring(app=app, username=username) external_keyring = _get_external_keyring() password = external_keyring.get_password(app, username) if password is None: @@ -76,7 +86,7 @@ def _call_keyring(app, username): return password -class KeyringCaller(PlainTextCredentialsProvider): +class KeyringCaller(ExternalCredentialsProvider): """KeyringCaller. A class for retrieving the password from the external 'gnome keyring' and @@ -91,22 +101,3 @@ class KeyringCaller(PlainTextCredentialsProvider): password username """ - - def configure(self, **config): - """configure. - - Parameters - ---------- - **config - Keyword arguments which contain at least the keywords "username" and - "url". - - Returns - ------- - None - """ - url = config.get("url") - username = config.get("username") - app = "PyCaosDB — {}".format(url) - password = _call_keyring(app=app, username=username) - super(KeyringCaller, self).configure(password=password, **config) diff --git a/src/caosdb/connection/authentication/pass.py b/src/caosdb/connection/authentication/pass.py index ed8c1991f9e234a7ea00f9597c6d59bc845b1fa5..9399fc4f4a76407ad94618785adcfbb945d4c788 100644 --- a/src/caosdb/connection/authentication/pass.py +++ b/src/caosdb/connection/authentication/pass.py @@ -28,8 +28,9 @@ password. """ from subprocess import check_output, CalledProcessError +from caosdb.exceptions import ConfigurationException from .interface import CredentialsAuthenticator -from .plain import PlainTextCredentialsProvider +from .external_credentials_provider import ExternalCredentialsProvider def get_authentication_provider(): @@ -44,13 +45,19 @@ def get_authentication_provider(): CredentialsAuthenticator with a 'PassCaller' as back-end. """ - return CredentialsAuthenticator(PassCaller()) + return CredentialsAuthenticator(PassCaller(callback=_call_pass)) -def _call_pass(password_identifier): +def _call_pass(**config): + if "password_identifier" not in config: + raise ConfigurationException("Your configuration did not provide a " + "`password_identifier` which is needed " + "by the `PassCaller` to retrieve the " + "password in question.") + try: return check_output( - "pass " + password_identifier, + "pass " + config["password_identifier"], shell=True).splitlines()[0].decode("UTF-8") except CalledProcessError as exc: raise RuntimeError( @@ -59,7 +66,7 @@ def _call_pass(password_identifier): "incorrect or missing.".format(exc.returncode)) -class PassCaller(PlainTextCredentialsProvider): +class PassCaller(ExternalCredentialsProvider): """PassCaller. A class for retrieving the password from the external program 'pass' and @@ -74,21 +81,5 @@ class PassCaller(PlainTextCredentialsProvider): password username """ - - def configure(self, **config): - """configure. - - Parameters - ---------- - **config - Keyword arguments which contain at least the keywords "username" and - "password_identifier". - - Returns - ------- - None - """ - if "password_identifier" in config: - password = _call_pass(config["password_identifier"]) - config["password"] = password - super(PassCaller, self).configure(**config) + # all the work is done in _call_pass and the super class + pass diff --git a/src/caosdb/connection/authentication/plain.py b/src/caosdb/connection/authentication/plain.py index 6cb7b1b896d8d32f71437c527385f269b37cdb51..83dd592940a7010d07112f73b9bd5bcf3741a168 100644 --- a/src/caosdb/connection/authentication/plain.py +++ b/src/caosdb/connection/authentication/plain.py @@ -59,6 +59,7 @@ class PlainTextCredentialsProvider(CredentialsProvider): """ def __init__(self): + super(PlainTextCredentialsProvider, self).__init__() self._password = None self._username = None diff --git a/src/caosdb/exceptions.py b/src/caosdb/exceptions.py index 581acc2cb4a447c423309f66e2a8ae218cb1a7c0..485ad908b18310d9f8346beb2261b528a8a45cce 100644 --- a/src/caosdb/exceptions.py +++ b/src/caosdb/exceptions.py @@ -32,6 +32,29 @@ class CaosDBException(Exception): Exception.__init__(self, msg) self.msg = msg +class ConfigurationException(CaosDBException): + """ConfigurationException. + + Indicates a misconfiguration. + + Parameters + ---------- + msg : str + A descriptin of the misconfiguration. The constructor adds + a few lines with explainingg where to find the configuration. + + Attributes + ---------- + msg : str + A description of the misconfiguration. + """ + def __init__(self, msg): + super(ConfigurationException, self).__init__(msg + + ConfigurationException._INFO) + + _INFO = ("\n\nPlease check your ~/.pycaosdb.ini and your $PWD/" + ".pycaosdb.ini. Do at least one of them exist and are they correct?") + class ClientErrorException(CaosDBException): def __init__(self, msg, status, body): diff --git a/tox.ini b/tox.ini index 5a7cee258b4936038d36d59cadd38534bbe7c941..11866fb924e728c719f14a9440455198f23dcbeb 100644 --- a/tox.ini +++ b/tox.ini @@ -2,5 +2,6 @@ envlist= py27, py34, py35, py36 skip_missing_interpreters = true [testenv] -deps=nose -commands= nosetests {posargs} +deps=pytest + nose +commands=py.test {posargs} diff --git a/unittests/test_authentication_external.py b/unittests/test_authentication_external.py new file mode 100644 index 0000000000000000000000000000000000000000..c595d38f83f6994f62baca924b1225832b9a659b --- /dev/null +++ b/unittests/test_authentication_external.py @@ -0,0 +1,35 @@ +"""test_authentication_external. + +Tests for the external_credentials_provider modul. +""" + +from __future__ import unicode_literals +import logging +from pytest import raises +from caosdb.connection.authentication import ( + external_credentials_provider as ecp +) + +class _TestCaller(ecp.ExternalCredentialsProvider): + pass + +def test_callback(): + def _callback(**config): + assert "opt" in config + return "secret" + t = _TestCaller(callback=_callback) + t.configure(opt=None) + assert t.password == "secret" + +def test_log_password_incident(): + class _mylogger(logging.Logger): + def log(self, level, msg, *args, **kwargs): + assert level == logging.WARNING + assert "`password` defined." in msg + raise Exception("log") + + t = _TestCaller(callback=None) + t.logger = _mylogger("mylogger") + with raises(Exception) as exc_info: + t.configure(password="password") + assert exc_info.value.args[0] == "log" diff --git a/unittests/test_authentication_keyring.py b/unittests/test_authentication_keyring.py new file mode 100644 index 0000000000000000000000000000000000000000..721adefde1df3a819c72ec61433debd9856e6b9f --- /dev/null +++ b/unittests/test_authentication_keyring.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2018 Research Group Biomedical Physics, +# Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +"""test_authentication_keyring + +Tests for the caosdb.connection.authentication.keyring module. +""" +import sys +from pytest import raises +from caosdb.connection.authentication.keyring import KeyringCaller + +def test_initialization(): + def _callback(**config): + assert not config + raise Exception("_callback") + k = KeyringCaller(callback=_callback) + k.configure() + with raises(Exception) as exc_info: + assert k.password is None + assert exc_info.value.args[0] == "_callback" diff --git a/unittests/test_authentication_pass.py b/unittests/test_authentication_pass.py new file mode 100644 index 0000000000000000000000000000000000000000..968fdfd94b75eef27f76bb0e0f07370bca3e5150 --- /dev/null +++ b/unittests/test_authentication_pass.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2018 Research Group Biomedical Physics, +# Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +"""test_authentication_pass + +Tests for the caosdb.connection.authentication.pass module. +""" +import sys +from pytest import raises +_PASSCALLER="caosdb.connection.authentication.pass" +__import__(_PASSCALLER) +PassCaller = sys.modules[_PASSCALLER].PassCaller + +def test_initialization(): + def _callback(**config): + assert not config + raise Exception("_callback") + p = PassCaller(callback=_callback) + p.configure() + with raises(Exception) as exc_info: + p.password + assert exc_info.value.args[0] == "_callback" diff --git a/unittests/test_authentication_plain.py b/unittests/test_authentication_plain.py index 5e9c3c3d338047eb69c58094c6325699bdcff537..6b59a23101111ee3592ee5395560a33ab1382b17 100644 --- a/unittests/test_authentication_plain.py +++ b/unittests/test_authentication_plain.py @@ -52,3 +52,8 @@ def test_subclass_configure(): instance.configure(password="OH NO!") assert exc_info.value.args[0] == ("configure() got multiple values for " "keyword argument 'password'") + +def test_plain_has_logger(): + p = PlainTextCredentialsProvider() + assert hasattr(p, "logger") + assert p.logger.name == "authentication"