From cc47b42cf70b6968b22a3819bf0b9714135271c1 Mon Sep 17 00:00:00 2001 From: milenk <32734752+milenk@users.noreply.github.com> Date: Mon, 21 Oct 2019 23:44:33 +0200 Subject: [PATCH] Issue #679 ldap3 library implemented instead of python_ldap (#703) --- components/server/requirements.txt | 2 +- components/server/setup.cfg | 6 +- components/server/src/routes/auth.py | 59 +++-- components/server/src/utilities/ldap.py | 14 - .../tests/unittests/database/test_sessions.py | 68 +++++ .../tests/unittests/routes/test_auth.py | 247 ++++++++++++++---- docs/CHANGELOG.md | 3 +- 7 files changed, 303 insertions(+), 96 deletions(-) delete mode 100644 components/server/src/utilities/ldap.py create mode 100644 components/server/tests/unittests/database/test_sessions.py diff --git a/components/server/requirements.txt b/components/server/requirements.txt index e963ed1ebb..c30db6d63c 100644 --- a/components/server/requirements.txt +++ b/components/server/requirements.txt @@ -3,4 +3,4 @@ bottle-mongo==0.3.0 gevent==1.4.0 lxml==4.4.1 pymongo==3.9.0 -python-ldap==3.2.0 +ldap3==2.6.1 diff --git a/components/server/setup.cfg b/components/server/setup.cfg index 8d51f465f1..7389032325 100644 --- a/components/server/setup.cfg +++ b/components/server/setup.cfg @@ -13,7 +13,10 @@ ignore_missing_imports = true [mypy-gevent] ignore_missing_imports = true -[mypy-ldap] +[mypy-ldap3] +ignore_missing_imports = true + +[mypy-ldap3.core] ignore_missing_imports = true [mypy-lxml.html.clean] @@ -24,4 +27,3 @@ ignore_missing_imports = true [mypy-pymongo.database] ignore_missing_imports = true - diff --git a/components/server/src/routes/auth.py b/components/server/src/routes/auth.py index 4645cebca9..e8859c91bd 100644 --- a/components/server/src/routes/auth.py +++ b/components/server/src/routes/auth.py @@ -6,14 +6,16 @@ import re from typing import cast, Dict, Tuple import urllib.parse +import hashlib +import base64 from pymongo.database import Database -import ldap -import bottle +from ldap3 import Server, Connection, ALL +from ldap3.core import exceptions +import bottle from database import sessions from utilities.functions import uuid -from utilities.ldap import LDAPObject from utilities.type import SessionId @@ -31,12 +33,27 @@ def set_session_cookie(session_id: str, expires_datetime: datetime) -> None: options["domain"] = domain bottle.response.set_cookie("session_id", session_id, **options) +def check_password(ssha_ldap_salted_password, password): + """Checks the OpenLDAP tagged digest against the given password""" + + if ssha_ldap_salted_password[:6] != b'{SSHA}': + logging.warning("Only SSHA LDAP password digest supported!") + raise exceptions.LDAPInvalidAttributeSyntaxResult + + digest_salt_b64 = ssha_ldap_salted_password[6:] # strip {SSHA} + + digest_salt = base64.b64decode(digest_salt_b64) + digest = digest_salt[:20] + salt = digest_salt[20:] + + sha = hashlib.sha1(bytes(password, 'utf-8')) #nosec + sha.update(salt) #nosec + + return digest == sha.digest() @bottle.post("/login") def login(database: Database) -> Dict[str, bool]: """Log the user in.""" - # Pylint can't find the ldap.* constants for some reason, turn off the error message: - # pylint: disable=no-member credentials = dict(bottle.request.json) unsafe_characters = re.compile(r"[^\w ]+", re.UNICODE) username = re.sub(unsafe_characters, "", credentials.get("username", "no username given")) @@ -44,23 +61,25 @@ def login(database: Database) -> Dict[str, bool]: ldap_url = os.environ.get("LDAP_URL", "ldap://localhost:389") ldap_lookup_user = os.environ.get("LDAP_LOOKUP_USER", "admin") ldap_lookup_user_password = os.environ.get("LDAP_LOOKUP_USER_PASSWORD", "admin") - ldap_server = ldap.initialize(ldap_url) + try: - ldap_server.simple_bind_s(f"cn={ldap_lookup_user},{ldap_root_dn}", ldap_lookup_user_password) - result = ldap_server.search_s( - ldap_root_dn, ldap.SCOPE_SUBTREE, f"(|(uid={username})(cn={username}))", ['dn', 'uid', 'cn']) - if result: - logging.info("LDAP search result: %s", result) - username = LDAPObject(result[0][1]).cn - else: - raise ldap.INVALID_CREDENTIALS - ldap_server.simple_bind_s(f"cn={username},{ldap_root_dn}", credentials.get("password")) - except (ldap.INVALID_CREDENTIALS, ldap.UNWILLING_TO_PERFORM, ldap.INVALID_DN_SYNTAX, - ldap.SERVER_DOWN) as reason: - logging.warning("Couldn't bind cn=%s,%s: %s", username, ldap_root_dn, reason) + ldap_server = Server(ldap_url, get_info=ALL) + with Connection(ldap_server, + user=f"cn={ldap_lookup_user},{ldap_root_dn}", password=ldap_lookup_user_password) as conn: + if not conn.bind(): + username = ldap_lookup_user + raise exceptions.LDAPBindError + + conn.search(ldap_root_dn, f"(|(uid={username})(cn={username}))", attributes=['userPassword']) + result = conn.entries[0] + password = credentials.get("password", "no password given") + if not check_password(result.userPassword.value, password): + return dict(ok=False) + + except Exception as reason: # pylint: disable=broad-except + logging.warning("LDAP error for cn=%s,%s: %s", username, ldap_root_dn, reason) return dict(ok=False) - finally: - ldap_server.unbind_s() + session_id, session_expiration_datetime = generate_session() sessions.upsert(database, username, session_id, session_expiration_datetime) set_session_cookie(session_id, session_expiration_datetime) diff --git a/components/server/src/utilities/ldap.py b/components/server/src/utilities/ldap.py deleted file mode 100644 index 390ee4c0c2..0000000000 --- a/components/server/src/utilities/ldap.py +++ /dev/null @@ -1,14 +0,0 @@ -"""LDAP utility classes.""" - - -class LDAPObject: # pylint: disable=too-few-public-methods - """Class helper that unpacks a python-ldap search result.""" - - def __init__(self, entry) -> None: - for key, values in entry.items(): - string_values = [value.decode('utf-8') for value in values] - setattr(self, key, string_values if len(string_values) > 1 else string_values[0]) - - def __getattr__(self, key: str) -> str: - # Return a default value for non-existing keys - return "" # pragma: nocover diff --git a/components/server/tests/unittests/database/test_sessions.py b/components/server/tests/unittests/database/test_sessions.py new file mode 100644 index 0000000000..2af65e5000 --- /dev/null +++ b/components/server/tests/unittests/database/test_sessions.py @@ -0,0 +1,68 @@ +"""Test the sessions.""" + +import unittest +from datetime import datetime, timedelta +from unittest.mock import patch, MagicMock +from src.database import sessions + +class SessionsTest(unittest.TestCase): + """Unit tests for sessions class.""" + + def test_upsert(self): + """Test upsert function.""" + database = MagicMock() + database.sessions = MagicMock() + database.sessions.update = MagicMock() + self.assertIsNone( + sessions.upsert(database=database, username='un', session_id='5', + session_expiration_datetime=datetime(2019, 10, 18, 19, 22, 5, 99))) + database.sessions.update.assert_called_with( + {'user': 'un'}, {'user': 'un', 'session_id': '5', + 'session_expiration_datetime': datetime(2019, 10, 18, 19, 22, 5, 99)}, upsert=True) + + def test_delete(self): + """Test delete function.""" + database = MagicMock() + database.sessions = MagicMock() + database.sessions.delete_one = MagicMock() + self.assertIsNone(sessions.delete(database=database, session_id='5')) + database.sessions.delete_one.assert_called_with({'session_id': '5'}) + + def test_valid(self): + """Test valid function.""" + session_obj = MagicMock() + session_obj.get = MagicMock(return_value=(datetime.now() + timedelta(seconds=5))) + database = MagicMock() + database.sessions = MagicMock() + database.sessions.find_one = MagicMock(return_value=session_obj) + self.assertTrue(sessions.valid(database=database, session_id='5')) + database.sessions.find_one.assert_called_with({'session_id': '5'}) + + def test_valid_min_date(self): + """Test valid function with min date.""" + session_obj = MagicMock() + session_obj.get = MagicMock(return_value=datetime.min) + database = MagicMock() + database.sessions = MagicMock() + database.sessions.find_one = MagicMock(return_value=session_obj) + self.assertFalse(sessions.valid(database=database, session_id='5')) + database.sessions.find_one.assert_called_with({'session_id': '5'}) + + def test_valid_session_not_found(self): + """Test valid function when the session is not found.""" + database = MagicMock() + database.sessions = MagicMock() + database.sessions.find_one = MagicMock(return_value=None) + self.assertFalse(sessions.valid(database=database, session_id='5')) + database.sessions.find_one.assert_called_with({'session_id': '5'}) + + @patch('bottle.request') + def test_user(self, bottle_mock): + """Test user function.""" + bottle_mock.get_cookie = MagicMock(return_value=5) + database = MagicMock() + database.sessions = MagicMock() + database.sessions.find_one = MagicMock(return_value={"user": "OK"}) + + self.assertEqual('OK', sessions.user(database=database)) + database.sessions.find_one.assert_called_with({'session_id': 5}) diff --git a/components/server/tests/unittests/routes/test_auth.py b/components/server/tests/unittests/routes/test_auth.py index 172472eef7..72c436e57e 100644 --- a/components/server/tests/unittests/routes/test_auth.py +++ b/components/server/tests/unittests/routes/test_auth.py @@ -2,91 +2,222 @@ import logging import unittest -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch -import bottle -import ldap # pylint: disable=import-error,wrong-import-order +import ldap3 +from ldap3.core import exceptions +import bottle +from database import sessions from src.routes import auth +# pylint: disable=too-many-arguments class LoginTests(unittest.TestCase): """Unit tests for the login route.""" def setUp(self): - logging.disable() self.database = Mock() - self.ldap_server = Mock() - self.ldap_server.search_s.return_value = [ - ('cn=John Doe,ou=users,dc=example,dc=org', {'cn': [b'John Doe'], 'uid': [b'jodoe', b'jodoe1']})] - self.lookup_json = Mock(json=dict(username="admin", password="admin")) - self.invalid_creds_json = Mock(json=dict(username="wrong", password="wrong")) - self.valid_creds_uid_json = Mock(json=dict(username="jodoe", password="secret")) - self.valid_creds_cn_json = Mock(json=dict(username="John Doe", password="secret")) + self.environ_get = MagicMock( + side_effect=["dc=example,dc=org", "ldap://localhost:389", "admin", "admin", "ldap://localhost:389"]) def tearDown(self): bottle.response._cookies = None # pylint: disable=protected-access logging.disable(logging.NOTSET) - def test_successful_login(self): + @patch.object(ldap3.Connection, '__exit__') + @patch.object(ldap3.Connection, '__enter__') + @patch.object(ldap3.Connection, '__init__') + @patch.object(ldap3.Server, '__init__') + @patch('bottle.request') + def test_successful_login(self, request_json_mock, server_mock, connection_mock, connection_enter, connection_exit): """Test successful login.""" - with patch("os.environ.get", Mock(return_value="http://www.quality-time.my-org.org:5001")): - with patch("bottle.request", return_value=self.valid_creds_uid_json): - with patch("ldap.initialize", return_value=self.ldap_server): - self.assertEqual(dict(ok=True), auth.login(self.database)) + environ_get = MagicMock(side_effect=["dc=example,dc=org", 'http://www.quality-time.my-org.org:5001', + "admin", "admin", 'http://www.quality-time.my-org.org:5001']) + request_json_mock.json = dict(username="jodoe", password="secret") + server_mock.return_value = None + connection_mock.return_value = None + connection_exit.return_value = None + fake_con = MagicMock() + fake_con.bind = MagicMock(return_value=True) + fake_con.search = MagicMock() + ldap_entry = Mock() + ldap_entry.userPassword = Mock() + ldap_entry.userPassword.value = b'{SSHA}W841/YybjO4TmqcNTqnBxFKd3SJggaPr' + fake_con.entries = [ldap_entry] + connection_enter.return_value = fake_con + with patch("os.environ.get", environ_get): + self.assertEqual(dict(ok=True), auth.login(self.database)) + cookie = str(bottle.response._cookies) # pylint: disable=protected-access self.assertTrue(cookie.startswith("Set-Cookie: session_id=")) self.assertTrue("domain=" in cookie.lower()) + fake_con.search.assert_called_with("dc=example,dc=org", '(|(uid=jodoe)(cn=jodoe))', attributes=['userPassword']) - def test_successful_login_localhost_uid(self): - """Test successful login on localhost.""" - with patch("bottle.request", return_value=self.valid_creds_uid_json): - with patch("ldap.initialize", return_value=self.ldap_server): - self.assertEqual(dict(ok=True), auth.login(self.database)) - cookie = str(bottle.response._cookies) # pylint: disable=protected-access - self.assertTrue(cookie.startswith("Set-Cookie: session_id=")) - self.assertFalse("domain=" in cookie.lower()) + @patch.object(ldap3.Connection, '__exit__') + @patch.object(ldap3.Connection, '__enter__') + @patch.object(ldap3.Connection, '__init__') + @patch.object(ldap3.Server, '__init__') + @patch('bottle.request') + def test_successful_login_local(self, request_json_mock, server_mock, connection_mock, connection_enter, + connection_exit): + """Test successful login.""" + request_json_mock.json = dict(username="jodoe", password="secret") + server_mock.return_value = None + connection_mock.return_value = None + connection_exit.return_value = None + fake_con = MagicMock() + fake_con.bind = MagicMock(return_value=True) + fake_con.search = MagicMock() + ldap_entry = Mock() + ldap_entry.userPassword = Mock() + ldap_entry.userPassword.value = b'{SSHA}W841/YybjO4TmqcNTqnBxFKd3SJggaPr' + fake_con.entries = [ldap_entry] + connection_enter.return_value = fake_con + with patch("os.environ.get", self.environ_get): + self.assertEqual(dict(ok=True), auth.login(self.database)) - def test_successful_login_localhost_cn(self): - """Test successful login on localhost.""" - with patch("bottle.request", return_value=self.valid_creds_cn_json): - with patch("ldap.initialize", return_value=self.ldap_server): - self.assertEqual(dict(ok=True), auth.login(self.database)) cookie = str(bottle.response._cookies) # pylint: disable=protected-access self.assertTrue(cookie.startswith("Set-Cookie: session_id=")) self.assertFalse("domain=" in cookie.lower()) + fake_con.search.assert_called_with("dc=example,dc=org", '(|(uid=jodoe)(cn=jodoe))', attributes=['userPassword']) - def test_lookup_user_simple_bind_s_failure(self): - """Test lookup user invalid credentials.""" - self.ldap_server.simple_bind_s.side_effect = ldap.INVALID_CREDENTIALS # pylint: disable=no-member - with patch("bottle.request", return_value=self.valid_creds_uid_json): - with patch("ldap.initialize", return_value=self.ldap_server): - self.assertEqual(dict(ok=False), auth.login(self.database)) - - def test_simple_search_s_failure(self): - """Test user invalid credentials.""" - self.ldap_server.search_s.side_effect = ldap.INVALID_CREDENTIALS # pylint: disable=no-member - with patch("bottle.request", return_value=self.invalid_creds_json): - with patch("ldap.initialize", return_value=self.ldap_server): - self.assertEqual(dict(ok=False), auth.login(self.database)) - - def test_unsuccessful_login_localhost_cn(self): - """Test unsuccessful login on localhost.""" - self.ldap_server.simple_bind_s.side_effect = [None, ldap.INVALID_CREDENTIALS] # pylint: disable=no-member - with patch("bottle.request", return_value=self.invalid_creds_json): - with patch("ldap.initialize", return_value=self.ldap_server): - self.assertEqual(dict(ok=False), auth.login(self.database)) - - def test_failed_login(self): - """Test failed login.""" - self.ldap_server.search_s.return_value = [] - with patch("bottle.request", return_value=self.lookup_json): - with patch("ldap.initialize", return_value=self.ldap_server): - self.assertEqual(dict(ok=False), auth.login(self.database)) + @patch.object(logging, 'warning') + @patch.object(ldap3.Connection, '__init__') + @patch.object(ldap3.Server, '__init__') + @patch('bottle.request') + def test_login_server_error(self, request_json_mock, server_mock, connection_mock, logging_mock): + """Test login when a server creation error occurs.""" + request_json_mock.json = dict(username="jodoe", password="secret") + server_mock.side_effect = exceptions.LDAPServerPoolError + connection_mock.return_value = None + with patch("os.environ.get", self.environ_get): + self.assertEqual(dict(ok=False), auth.login(self.database)) + connection_mock.assert_not_called() + self.assertEqual('LDAP error for cn=%s,%s: %s', logging_mock.call_args[0][0]) + self.assertEqual('jodoe', logging_mock.call_args[0][1]) + self.assertEqual("dc=example,dc=org", logging_mock.call_args[0][2]) + self.assertIsInstance(logging_mock.call_args[0][3], exceptions.LDAPServerPoolError) + @patch.object(logging, 'warning') + @patch.object(ldap3.Connection, '__exit__') + @patch.object(ldap3.Connection, '__enter__') + @patch.object(ldap3.Connection, '__init__') + @patch.object(ldap3.Server, '__init__') + @patch('bottle.request') + def test_login_bind_error(self, request_json_mock, server_mock, connection_mock, connection_enter, + connection_exit, logging_mock): + """Test login when an error of binding dn reader occurs.""" + request_json_mock.json = dict(username="jodoe", password="secret") + server_mock.return_value = None + connection_mock.return_value = None + connection_exit.return_value = None + fake_con = MagicMock() + fake_con.bind = MagicMock(return_value=False) + connection_enter.return_value = fake_con + with patch("os.environ.get", self.environ_get): + self.assertEqual(dict(ok=False), auth.login(self.database)) + + connection_mock.assert_called_once() + fake_con.bind.assert_called_once() + self.assertEqual('LDAP error for cn=%s,%s: %s', logging_mock.call_args[0][0]) + self.assertEqual('admin', logging_mock.call_args[0][1]) + self.assertEqual("dc=example,dc=org", logging_mock.call_args[0][2]) + self.assertIsInstance(logging_mock.call_args[0][3], exceptions.LDAPBindError) + + @patch.object(logging, 'warning') + @patch.object(ldap3.Connection, '__exit__') + @patch.object(ldap3.Connection, '__enter__') + @patch.object(ldap3.Connection, '__init__') + @patch.object(ldap3.Server, '__init__') + @patch('bottle.request') + def test_login_search_error(self, request_json_mock, server_mock, connection_mock, connection_enter, + connection_exit, logging_mock): + """Test login when search error of the login user occurs.""" + request_json_mock.json = dict(username="jodoe", password="secret") + server_mock.return_value = None + connection_mock.return_value = None + connection_exit.return_value = None + fake_con = MagicMock() + fake_con.bind = MagicMock(return_value=True) + fake_con.search = MagicMock(side_effect=exceptions.LDAPResponseTimeoutError) + connection_enter.return_value = fake_con + with patch("os.environ.get", self.environ_get): + self.assertEqual(dict(ok=False), auth.login(self.database)) + + connection_mock.assert_called_once() + fake_con.bind.assert_called_once() + self.assertEqual('LDAP error for cn=%s,%s: %s', logging_mock.call_args[0][0]) + self.assertEqual('jodoe', logging_mock.call_args[0][1]) + self.assertEqual("dc=example,dc=org", logging_mock.call_args[0][2]) + self.assertIsInstance(logging_mock.call_args[0][3], exceptions.LDAPResponseTimeoutError) + + @patch.object(logging, 'warning') + @patch.object(ldap3.Connection, '__exit__') + @patch.object(ldap3.Connection, '__enter__') + @patch.object(ldap3.Connection, '__init__') + @patch.object(ldap3.Server, '__init__') + @patch('bottle.request') + def test_login_password_hash_error(self, request_json_mock, server_mock, connection_mock, connection_enter, + connection_exit, logging_mock): + """Test login fails when LDAP password hash is not salted SHA1.""" + request_json_mock.json = dict(username="jodoe", password="secret") + server_mock.return_value = None + connection_mock.return_value = None + connection_exit.return_value = None + fake_con = MagicMock() + fake_con.bind = MagicMock(return_value=True) + fake_con.search = MagicMock() + ldap_entry = Mock() + ldap_entry.userPassword = Mock() + ldap_entry.userPassword.value = b'{XSHA}whatever-here' + fake_con.entries = [ldap_entry] + connection_enter.return_value = fake_con + with patch("os.environ.get", self.environ_get): + self.assertEqual(dict(ok=False), auth.login(self.database)) + fake_con.search.assert_called_with("dc=example,dc=org", '(|(uid=jodoe)(cn=jodoe))', attributes=['userPassword']) + self.assertEqual('Only SSHA LDAP password digest supported!', logging_mock.call_args_list[0][0][0]) + self.assertEqual('LDAP error for cn=%s,%s: %s', logging_mock.call_args_list[1][0][0]) + self.assertEqual('jodoe', logging_mock.call_args_list[1][0][1]) + self.assertEqual("dc=example,dc=org", logging_mock.call_args_list[1][0][2]) + self.assertIsInstance(logging_mock.call_args_list[1][0][3], exceptions.LDAPInvalidAttributeSyntaxResult) + + @patch.object(logging, 'warning') + @patch.object(ldap3.Connection, '__exit__') + @patch.object(ldap3.Connection, '__enter__') + @patch.object(ldap3.Connection, '__init__') + @patch.object(ldap3.Server, '__init__') + @patch('bottle.request') + def test_login_wrong_password(self, request_json_mock, server_mock, connection_mock, connection_enter, + connection_exit, logging_mock): + """Test login when search error of the login user occurs.""" + request_json_mock.json = dict(username="jodoe", password="wrong password!") + server_mock.return_value = None + connection_mock.return_value = None + connection_exit.return_value = None + fake_con = MagicMock() + fake_con.bind = MagicMock(return_value=True) + fake_con.search = MagicMock() + ldap_entry = Mock() + ldap_entry.userPassword = Mock() + ldap_entry.userPassword.value = b'{SSHA}W841/YybjO4TmqcNTqnBxFKd3SJggaPr' + fake_con.entries = [ldap_entry] + connection_enter.return_value = fake_con + with patch("os.environ.get", self.environ_get): + self.assertEqual(dict(ok=False), auth.login(self.database)) + fake_con.search.assert_called_with("dc=example,dc=org", '(|(uid=jodoe)(cn=jodoe))', attributes=['userPassword']) + logging_mock.assert_not_called() class LogoutTests(unittest.TestCase): """Unit tests for the logout route.""" - def test_logout(self): + @patch.object(sessions, 'delete') + @patch('bottle.request') + def test_logout(self, request_mock, delete_mock): """Test successful logout.""" - database = Mock() + request_mock.get_cookie = MagicMock(return_value='the session id') + database = MagicMock() self.assertEqual(dict(ok=True), auth.logout(database)) + cookie = str(bottle.response._cookies) # pylint: disable=protected-access + self.assertTrue(cookie.startswith("Set-Cookie: session_id=")) + self.assertTrue(cookie.find("the session id") > 0) + self.assertRegex(cookie.upper(), r".+MON,\s*0*1\s*JAN\S*\s*0*1") + delete_mock.assert_called_with(database, 'the session id') diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f5b9f6b1e1..4ad3f2e949 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- When measuring unmerged branches, have the metric landing url point to the list of branches in GitLab or Azure DevOps. When measuring the source up-to-dateness of a folder or file in GitLab or Azure DevOps, have the metric landing url point to the folder or file. Closes [#711](https://github.com/ICTU/quality-time/issues/711). +- Instead of python_ldap, ldap3 library introduced. Closes [#679](https://github.com/ICTU/quality-time/issues/679). +- When measuring unmerged branches, have the metric landing url point to the list of branches in GitLab or Azure DevOps. When measuring the source up-to-dateness of a folder or file in GitLab or Azure DevOps, have the metric landing url point to the folder or file. Closes [#711](https://github.com/ICTU/quality-time/issues/711). - When SonarQube is the source for a metric, users can now select the branch to use. Note that only the commercial editions of SonarQube support branch analysis. Closes [#712](https://github.com/ICTU/quality-time/issues/712). ## [0.13.0] - [2019-10-20]