From a47c67798aa04aa02ac25ceb89e6a750c67e7ced Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 30 Jan 2019 23:14:35 -0600 Subject: [PATCH 01/71] fixed some docstring typos and added an ENV value --- shared/selene/api/base_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shared/selene/api/base_config.py b/shared/selene/api/base_config.py index fa4cc4dd..ba0ba974 100644 --- a/shared/selene/api/base_config.py +++ b/shared/selene/api/base_config.py @@ -13,10 +13,9 @@ Example usage: . . . - @app.teardown_teardown_appcontext + @app.teardown_appcontext def close_db_connections(): - app.config['DB_CONNECTION_POOL].close_all() - + app.config['DB_CONNECTION_POOL'].close_all() """ import os @@ -41,6 +40,7 @@ class BaseConfig(object): ACCESS_SECRET = os.environ['JWT_ACCESS_SECRET'] DB_CONNECTION_POOL = allocate_db_connection_pool(db_connection_config) DEBUG = False + ENV = os.environ['SELENE_ENVIRONMENT'] REFRESH_SECRET = os.environ['JWT_REFRESH_SECRET'] From 5b2d65d54629728c793e9d6a6435aa813c8ae480 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 30 Jan 2019 23:16:59 -0600 Subject: [PATCH 02/71] renamed from "antisocial" to "internal" and changed logic to use new architecture --- api/sso/sso_api/endpoints/__init__.py | 2 +- .../endpoints/authenticate_antisocial.py | 54 ----------------- .../endpoints/authenticate_internal.py | 58 +++++++++++++++++++ 3 files changed, 59 insertions(+), 55 deletions(-) delete mode 100644 api/sso/sso_api/endpoints/authenticate_antisocial.py create mode 100644 api/sso/sso_api/endpoints/authenticate_internal.py diff --git a/api/sso/sso_api/endpoints/__init__.py b/api/sso/sso_api/endpoints/__init__.py index 894858af..ac1c3e05 100644 --- a/api/sso/sso_api/endpoints/__init__.py +++ b/api/sso/sso_api/endpoints/__init__.py @@ -1,4 +1,4 @@ -from .authenticate_antisocial import AuthenticateAntisocialEndpoint +from .authenticate_internal import AuthenticateInternalEndpoint from .social_login_tokens import SocialLoginTokensEndpoint from .facebook import AuthorizeFacebookEndpoint from .github import AuthorizeGithubEndpoint diff --git a/api/sso/sso_api/endpoints/authenticate_antisocial.py b/api/sso/sso_api/endpoints/authenticate_antisocial.py deleted file mode 100644 index bb5a32dd..00000000 --- a/api/sso/sso_api/endpoints/authenticate_antisocial.py +++ /dev/null @@ -1,54 +0,0 @@ -from http import HTTPStatus -import json -from time import time - -import requests as service_request - -from selene.api import SeleneEndpoint, APIError -from selene.util.auth import encode_auth_token, ONE_DAY - - -class AuthenticateAntisocialEndpoint(SeleneEndpoint): - """ - User Login Resource - """ - def __init__(self): - super(AuthenticateAntisocialEndpoint, self).__init__() - self.response_status_code = HTTPStatus.OK - self.tartarus_token = None - self.users_uuid = None - - def get(self): - try: - self._authenticate_credentials() - except APIError: - pass - else: - self._build_response() - - return self.response - - def _authenticate_credentials(self): - basic_credentials = self.request.headers['authorization'] - service_request_headers = {'Authorization': basic_credentials} - auth_service_response = service_request.get( - self.config['TARTARUS_BASE_URL'] + '/auth/login', - headers=service_request_headers - ) - self._check_for_service_errors(auth_service_response) - auth_service_response_content = json.loads( - auth_service_response.content - ) - self.users_uuid = auth_service_response_content['uuid'] - self.tartarus_token = auth_service_response_content['accessToken'] - - def _build_response(self): - self.selene_token = encode_auth_token( - self.config['SECRET_KEY'], self.users_uuid - ) - response_data = dict( - expiration=time() + ONE_DAY, - seleneToken=self.selene_token, - tartarusToken=self.tartarus_token, - ) - self.response = (response_data, HTTPStatus.OK) diff --git a/api/sso/sso_api/endpoints/authenticate_internal.py b/api/sso/sso_api/endpoints/authenticate_internal.py new file mode 100644 index 00000000..6c001a60 --- /dev/null +++ b/api/sso/sso_api/endpoints/authenticate_internal.py @@ -0,0 +1,58 @@ +"""Authenticate a user logging in with a email address and password + +This type of login is considered "internal" because we are storing the email +address and password on our servers. This is as opposed to "external" +authentication, which uses a 3rd party authentication, like Google. + +""" +from binascii import a2b_base64 +from http import HTTPStatus +from time import time + +from selene.api import SeleneEndpoint, APIError +from selene.util.auth import encode_auth_token, SEVEN_DAYS +from selene.util.db.connection_pool import get_db_connection +from selene.account.repository import get_account_id_from_credentials + + +class AuthenticateInternalEndpoint(SeleneEndpoint): + """ + Sign in a user with an email address and password. + """ + def __init__(self): + super(AuthenticateInternalEndpoint, self).__init__() + self.response_status_code = HTTPStatus.OK + self.account_uuid = None + + def get(self): + try: + self._authenticate_credentials() + except APIError: + pass + else: + self._build_response() + + return self.response + + def _authenticate_credentials(self): + """Compare credentials in request to credentials in database.""" + + basic_credentials = self.request.headers['authorization'] + binary_credentials = a2b_base64(basic_credentials.strip('Basic ')) + email_address, password = binary_credentials.decode().split(':') + with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: + self.account_uuid = get_account_id_from_credentials( + db, + email_address, + password + ) + + def _build_response(self): + self.selene_token = encode_auth_token( + self.config['SECRET_KEY'], self.account_uuid + ) + response_data = dict( + expiration=time() + SEVEN_DAYS, + seleneToken=self.selene_token, + ) + self.response = (response_data, HTTPStatus.OK) From 1b4574a3673d6e00b6aeba1f976d877ac2e4bf50 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 30 Jan 2019 23:17:59 -0600 Subject: [PATCH 03/71] changed to use new library config code and changed internal endpoint naming --- api/sso/sso_api/api.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/api/sso/sso_api/api.py b/api/sso/sso_api/api.py index 88a3e76d..4e8f1d41 100644 --- a/api/sso/sso_api/api.py +++ b/api/sso/sso_api/api.py @@ -1,22 +1,27 @@ +import os + from flask import Flask, request from flask_restful import Api +from selene.api.base_config import get_base_config + from .endpoints import ( - AuthenticateAntisocialEndpoint, + AuthenticateInternalEndpoint, SocialLoginTokensEndpoint, AuthorizeFacebookEndpoint, AuthorizeGithubEndpoint, AuthorizeGoogleEndpoint, LogoutEndpoint ) -from .config import get_config_location + # Initialize the Flask application and the Flask Restful API sso = Flask(__name__) -sso.config.from_object(get_config_location()) -sso_api = Api(sso, catch_all_404s=True) +sso.config.from_object(get_base_config()) +sso.config['SSO_BASE_URL'] = os.environ['SSO_BASE_URL'] -# Define the endpoints -sso_api.add_resource(AuthenticateAntisocialEndpoint, '/api/antisocial') +# Initialize the REST API and define the endpoints +sso_api = Api(sso, catch_all_404s=True) +sso_api.add_resource(AuthenticateInternalEndpoint, '/api/antisocial') sso_api.add_resource(AuthorizeFacebookEndpoint, '/api/social/facebook') sso_api.add_resource(AuthorizeGithubEndpoint, '/api/social/github') sso_api.add_resource(AuthorizeGoogleEndpoint, '/api/social/google') @@ -39,3 +44,8 @@ def add_cors_headers(response): sso.after_request(add_cors_headers) + + +@sso.teardown_appcontext +def close_db_connections(): + sso.config['DB_CONNECTION_POOL'].close_all() From db1c1ffee8f92ceaac6a758f7c6b9a34422726c8 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 30 Jan 2019 23:18:28 -0600 Subject: [PATCH 04/71] removed app-specific config file because all configs are in the global file --- api/sso/sso_api/config.py | 49 --------------------------------------- 1 file changed, 49 deletions(-) delete mode 100644 api/sso/sso_api/config.py diff --git a/api/sso/sso_api/config.py b/api/sso/sso_api/config.py deleted file mode 100644 index 6d329e15..00000000 --- a/api/sso/sso_api/config.py +++ /dev/null @@ -1,49 +0,0 @@ -import os - - -class LoginConfigException(Exception): - pass - - -class BaseConfig: - """Base configuration.""" - DEBUG = False - SECRET_KEY = os.environ['JWT_SECRET'] - SERVICE_BASE_URL = os.environ['SERVICE_BASE_URL'] - SSO_BASE_URL = os.environ['SSO_BASE_URL'] - TARTARUS_BASE_URL = os.environ['TARTARUS_BASE_URL'] - - -class DevelopmentConfig(BaseConfig): - """Development configuration.""" - DEBUG = True - - -class TestConfig(BaseConfig): - pass - - -class ProdConfig(BaseConfig): - pass - - -def get_config_location(): - environment_configs = dict( - dev=DevelopmentConfig, - test=TestConfig, - prod=ProdConfig - ) - - try: - environment_name = os.environ['SELENE_ENVIRONMENT'] - except KeyError: - raise LoginConfigException('the SELENE_ENVIRONMENT variable is not set') - - try: - configs_location = environment_configs[environment_name] - except KeyError: - raise LoginConfigException( - 'no configuration defined for the "{}" environment'.format(environment_name) - ) - - return configs_location From a69a262e874d34ae2b743e8ee6f66af63cf4f503 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 30 Jan 2019 23:20:40 -0600 Subject: [PATCH 05/71] added some docstrings --- api/sso/sso_api/api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/sso/sso_api/api.py b/api/sso/sso_api/api.py index 4e8f1d41..3ca8e854 100644 --- a/api/sso/sso_api/api.py +++ b/api/sso/sso_api/api.py @@ -1,3 +1,5 @@ +"""Define the API that will support Mycroft single sign on (SSO).""" + import os from flask import Flask, request @@ -48,4 +50,5 @@ sso.after_request(add_cors_headers) @sso.teardown_appcontext def close_db_connections(): + """Close all pool connections when the app is terminated""" sso.config['DB_CONNECTION_POOL'].close_all() From 6604c8d355ccb07c5a122dfbdb8862434ca4a2f7 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 30 Jan 2019 23:23:22 -0600 Subject: [PATCH 06/71] new function to validate user credentials against the database --- .../selene/account/repository/authenticate.py | 24 +++++++++++++++++++ .../sql/get_account_id_from_credentials.sql | 9 +++++++ 2 files changed, 33 insertions(+) create mode 100644 shared/selene/account/repository/authenticate.py create mode 100644 shared/selene/account/repository/sql/get_account_id_from_credentials.sql diff --git a/shared/selene/account/repository/authenticate.py b/shared/selene/account/repository/authenticate.py new file mode 100644 index 00000000..f29f61f2 --- /dev/null +++ b/shared/selene/account/repository/authenticate.py @@ -0,0 +1,24 @@ +from os import path + +from selene.util.db.cursor import DatabaseQuery, fetch + +SQL_DIR = path.join(path.dirname(__file__), 'sql') + + +def get_account_id_from_credentials(db, email: str, password: str) -> str: + """ + Validate that the provided email/password combination exists on our database + + :param db: database connection object + :param email: the user provided email address + :param password: the user provided password + :return: the uuid of the account + """ + query = DatabaseQuery( + file_path=path.join(SQL_DIR, 'get_account_id_from_credentials.sql'), + args=dict(email_address=email, password=password), + singleton=True + ) + sql_results = fetch(db, query) + + return sql_results['id'] diff --git a/shared/selene/account/repository/sql/get_account_id_from_credentials.sql b/shared/selene/account/repository/sql/get_account_id_from_credentials.sql new file mode 100644 index 00000000..7e2858ca --- /dev/null +++ b/shared/selene/account/repository/sql/get_account_id_from_credentials.sql @@ -0,0 +1,9 @@ +SELECT + a.id +FROM + account.account a +INNER JOIN + account.account_login al ON al.account_id = a.id +WHERE + a.email_address = %(email_address)s AND + al.token = crypt(%(password)s, al.token) From 35a9dcba1bf5dbd99174dbb37223617496145644 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:16:53 -0600 Subject: [PATCH 07/71] enhanced and refactored to add crud abilities --- shared/selene/util/db/cursor.py | 86 ++++++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/shared/selene/util/db/cursor.py b/shared/selene/util/db/cursor.py index 28062973..9eeec995 100644 --- a/shared/selene/util/db/cursor.py +++ b/shared/selene/util/db/cursor.py @@ -6,7 +6,7 @@ Example Usage: query_result = mycroft_db_ro.execute_sql(sql) """ -from dataclasses import dataclass +from dataclasses import dataclass, field from logging import getLogger from os import path @@ -33,29 +33,71 @@ def get_sql_from_file(file_path: str) -> str: @dataclass -class DatabaseQuery(object): +class DatabaseRequest(object): file_path: str - args: dict - singleton: bool + args: dict = field(default=None) -def fetch(db, db_query: DatabaseQuery): - """ - Fetch all or one row from the database. - :param db: connection to the mycroft database. - :param db_query: parameters used to determine how to fetch the data - :return: the query results; will be a results object if a singleton select - was issued, a list of results objects otherwise. - """ - sql = get_sql_from_file(db_query.file_path) - print(db_query.file_path) - with db.cursor() as cursor: - _log.debug(cursor.mogrify(sql, db_query.args)) - cursor.execute(sql, db_query.args) - if db_query.singleton: - execution_result = cursor.fetchone() - else: - execution_result = cursor.fetchall() +class Cursor(object): + def __init__(self, db): + self.db = db + + def _fetch(self, db_request: DatabaseRequest, singleton=False): + """ + Fetch all or one row from the database. + :param db_request: parameters used to determine how to fetch the data + :return: the query results; will be a results object if a singleton select + was issued, a list of results objects otherwise. + """ + sql = get_sql_from_file(db_request.file_path) + with self.db.cursor() as cursor: + _log.debug(cursor.mogrify(sql, db_request.args)) + cursor.execute(sql, db_request.args) + if singleton: + execution_result = cursor.fetchone() + else: + execution_result = cursor.fetchall() + _log.debug('query returned {} rows'.format(cursor.rowcount)) - return execution_result + return execution_result + + def select_one(self, db_request: DatabaseRequest): + """ + Fetch a single row from the database. + + :param db_request: parameters used to determine how to fetch the data + :return: a single results object + """ + return self._fetch(db_request, singleton=True) + + def select_all(self, db_request: DatabaseRequest): + """ + Fetch all rows resulting from the database request. + + :param db_request: parameters used to determine how to fetch the data + :return: a single results object + """ + return self._fetch(db_request) + + def _execute(self, db_request: DatabaseRequest): + """ + Fetch all or one row from the database. + :param db_request: parameters used to determine how to fetch the data + :return: the query results; will be a results object if a singleton select + was issued, a list of results objects otherwise. + """ + sql = get_sql_from_file(db_request.file_path) + with self.db.cursor() as cursor: + _log.debug(cursor.mogrify(sql, db_request.args)) + cursor.execute(sql, db_request.args) + _log.debug(str(cursor.rowcount) + 'rows affected') + + def delete(self, db_request: DatabaseRequest): + self._execute(db_request) + + def insert(self, db_request: DatabaseRequest): + self._execute(db_request) + + def update(self, db_request: DatabaseRequest): + self._execute(db_request) From a7e091b7fe597abedb7ba140156e8d631e999e71 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:17:04 -0600 Subject: [PATCH 08/71] enhanced and refactored to add crud abilities --- shared/selene/util/db/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/selene/util/db/__init__.py b/shared/selene/util/db/__init__.py index 12f1b94d..dc496ec8 100644 --- a/shared/selene/util/db/__init__.py +++ b/shared/selene/util/db/__init__.py @@ -1,3 +1,3 @@ from .connection import DatabaseConnectionConfig from .connection_pool import allocate_db_connection_pool, get_db_connection -from .cursor import DatabaseQuery, fetch +from .cursor import DatabaseRequest, Cursor From cc8f0fb81e8812b5ce0bd59540098b0075456dbb Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:21:44 -0600 Subject: [PATCH 09/71] updated name of repository --- shared/setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shared/setup.py b/shared/setup.py index e62ae044..ff9f9755 100644 --- a/shared/setup.py +++ b/shared/setup.py @@ -5,8 +5,8 @@ environments used in developement of Selene APIs and services. from setuptools import setup setup( - name='selene_util', + name='selene', version='0.0.0', - packages=['selene_util'], + packages=['selene'], install_requires=['flask', 'flask-restful', 'pygithub', 'pyjwt'] -) \ No newline at end of file +) From 4554cbdd0bd2246931b0ecb892001f742143d4bc Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:22:05 -0600 Subject: [PATCH 10/71] updated by pipenv --- shared/Pipfile.lock | 112 ++++++++++++++++++++++---------------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/shared/Pipfile.lock b/shared/Pipfile.lock index 397e4245..80932582 100644 --- a/shared/Pipfile.lock +++ b/shared/Pipfile.lock @@ -18,10 +18,10 @@ "default": { "aniso8601": { "hashes": [ - "sha256:547e7bc88c19742e519fb4ca39f4b8113fdfb8fca322e325f16a8bfc6cfc553c", - "sha256:e7560de91bf00baa712b2550a2fdebf0188c5fce2fcd1162fbac75c19bb29c95" + "sha256:03c0ffeeb04edeca1ed59684cc6836dc377f58e52e315dc7be3af879909889f4", + "sha256:ac30cceff24aec920c37b8d74d7d8a5dd37b1f62a90b4f268a6234cabe147080" ], - "version": "==4.0.1" + "version": "==4.1.0" }, "certifi": { "hashes": [ @@ -60,18 +60,18 @@ }, "flask-restful": { "hashes": [ - "sha256:5795519501347e108c436b693ff9a4d7b373a3ac9069627d64e4001c05dd3407", - "sha256:e2f1b8063de3944b94c7f8be5cee4d2161db0267c54c5b757d875295061776fa" + "sha256:ecd620c5cc29f663627f99e04f17d1f16d095c83dc1d618426e2ad68b03092f8", + "sha256:f8240ec12349afe8df1db168ea7c336c4e5b0271a36982bff7394f93275f2ca9" ], "index": "pypi", - "version": "==0.3.6" + "version": "==0.3.7" }, "idna": { "hashes": [ - "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", - "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" ], - "version": "==2.7" + "version": "==2.8" }, "itsdangerous": { "hashes": [ @@ -122,75 +122,75 @@ }, "psycopg2-binary": { "hashes": [ - "sha256:036bcb198a7cc4ce0fe43344f8c2c9a8155aefa411633f426c8c6ed58a6c0426", - "sha256:1d770fcc02cdf628aebac7404d56b28a7e9ebec8cfc0e63260bd54d6edfa16d4", - "sha256:1fdc6f369dcf229de6c873522d54336af598b9470ccd5300e2f58ee506f5ca13", - "sha256:21f9ddc0ff6e07f7d7b6b484eb9da2c03bc9931dd13e36796b111d631f7135a3", - "sha256:247873cda726f7956f745a3e03158b00de79c4abea8776dc2f611d5ba368d72d", - "sha256:3aa31c42f29f1da6f4fd41433ad15052d5ff045f2214002e027a321f79d64e2c", - "sha256:475f694f87dbc619010b26de7d0fc575a4accf503f2200885cc21f526bffe2ad", - "sha256:4b5e332a24bf6e2fda1f51ca2a57ae1083352293a08eeea1fa1112dc7dd542d1", - "sha256:570d521660574aca40be7b4d532dfb6f156aad7b16b5ed62d1534f64f1ef72d8", - "sha256:59072de7def0690dd13112d2bdb453e20570a97297070f876fbbb7cbc1c26b05", - "sha256:5f0b658989e918ef187f8a08db0420528126f2c7da182a7b9f8bf7f85144d4e4", - "sha256:649199c84a966917d86cdc2046e03d536763576c0b2a756059ae0b3a9656bc20", - "sha256:6645fc9b4705ae8fbf1ef7674f416f89ae1559deec810f6dd15197dfa52893da", - "sha256:6872dd54d4e398d781efe8fe2e2d7eafe4450d61b5c4898aced7610109a6df75", - "sha256:6ce34fbc251fc0d691c8d131250ba6f42fd2b28ef28558d528ba8c558cb28804", - "sha256:73920d167a0a4d1006f5f3b9a3efce6f0e5e883a99599d38206d43f27697df00", - "sha256:8a671732b87ae423e34b51139628123bc0306c2cb85c226e71b28d3d57d7e42a", - "sha256:8d517e8fda2efebca27c2018e14c90ed7dc3f04d7098b3da2912e62a1a5585fe", - "sha256:9475a008eb7279e20d400c76471843c321b46acacc7ee3de0b47233a1e3fa2cf", - "sha256:96947b8cd7b3148fb0e6549fcb31258a736595d6f2a599f8cd450e9a80a14781", - "sha256:abf229f24daa93f67ac53e2e17c8798a71a01711eb9fcdd029abba8637164338", - "sha256:b1ab012f276df584beb74f81acb63905762c25803ece647016613c3d6ad4e432", - "sha256:b22b33f6f0071fe57cb4e9158f353c88d41e739a3ec0d76f7b704539e7076427", - "sha256:b3b2d53274858e50ad2ffdd6d97ce1d014e1e530f82ec8b307edd5d4c921badf", - "sha256:bab26a729befc7b9fab9ded1bba9c51b785188b79f8a2796ba03e7e734269e2e", - "sha256:daa1a593629aa49f506eddc9d23dc7f89b35693b90e1fbcd4480182d1203ea90", - "sha256:dd111280ce40e89fd17b19c1269fd1b74a30fce9d44a550840e86edb33924eb8", - "sha256:e0b86084f1e2e78c451994410de756deba206884d6bed68d5a3d7f39ff5fea1d", - "sha256:eb86520753560a7e89639500e2a254bb6f683342af598088cb72c73edcad21e6", - "sha256:ff18c5c40a38d41811c23e2480615425c97ea81fd7e9118b8b899c512d97c737" + "sha256:19a2d1f3567b30f6c2bb3baea23f74f69d51f0c06c2e2082d0d9c28b0733a4c2", + "sha256:2b69cf4b0fa2716fd977aa4e1fd39af6110eb47b2bb30b4e5a469d8fbecfc102", + "sha256:2e952fa17ba48cbc2dc063ddeec37d7dc4ea0ef7db0ac1eda8906365a8543f31", + "sha256:348b49dd737ff74cfb5e663e18cb069b44c64f77ec0523b5794efafbfa7df0b8", + "sha256:3d72a5fdc5f00ca85160915eb9a973cf9a0ab8148f6eda40708bf672c55ac1d1", + "sha256:4957452f7868f43f32c090dadb4188e9c74a4687323c87a882e943c2bd4780c3", + "sha256:5138cec2ee1e53a671e11cc519505eb08aaaaf390c508f25b09605763d48de4b", + "sha256:587098ca4fc46c95736459d171102336af12f0d415b3b865972a79c03f06259f", + "sha256:5b79368bcdb1da4a05f931b62760bea0955ee2c81531d8e84625df2defd3f709", + "sha256:5cf43807392247d9bc99737160da32d3fa619e0bfd85ba24d1c78db205f472a4", + "sha256:676d1a80b1eebc0cacae8dd09b2fde24213173bf65650d22b038c5ed4039f392", + "sha256:6b0211ecda389101a7d1d3df2eba0cf7ffbdd2480ca6f1d2257c7bd739e84110", + "sha256:79cde4660de6f0bb523c229763bd8ad9a93ac6760b72c369cf1213955c430934", + "sha256:7aba9786ac32c2a6d5fb446002ed936b47d5e1f10c466ef7e48f66eb9f9ebe3b", + "sha256:7c8159352244e11bdd422226aa17651110b600d175220c451a9acf795e7414e0", + "sha256:945f2eedf4fc6b2432697eb90bb98cc467de5147869e57405bfc31fa0b824741", + "sha256:96b4e902cde37a7fc6ab306b3ac089a3949e6ce3d824eeca5b19dc0bedb9f6e2", + "sha256:9a7bccb1212e63f309eb9fab47b6eaef796f59850f169a25695b248ca1bf681b", + "sha256:a3bfcac727538ec11af304b5eccadbac952d4cca1a551a29b8fe554e3ad535dc", + "sha256:b19e9f1b85c5d6136f5a0549abdc55dcbd63aba18b4f10d0d063eb65ef2c68b4", + "sha256:b664011bb14ca1f2287c17185e222f2098f7b4c857961dbcf9badb28786dbbf4", + "sha256:bde7959ef012b628868d69c474ec4920252656d0800835ed999ba5e4f57e3e2e", + "sha256:cb095a0657d792c8de9f7c9a0452385a309dfb1bbbb3357d6b1e216353ade6ca", + "sha256:d16d42a1b9772152c1fe606f679b2316551f7e1a1ce273e7f808e82a136cdb3d", + "sha256:d444b1545430ffc1e7a24ce5a9be122ccd3b135a7b7e695c5862c5aff0b11159", + "sha256:d93ccc7bf409ec0a23f2ac70977507e0b8a8d8c54e5ee46109af2f0ec9e411f3", + "sha256:df6444f952ca849016902662e1a47abf4fa0678d75f92fd9dd27f20525f809cd", + "sha256:e63850d8c52ba2b502662bf3c02603175c2397a9acc756090e444ce49508d41e", + "sha256:ec43358c105794bc2b6fd34c68d27f92bea7102393c01889e93f4b6a70975728", + "sha256:f4c6926d9c03dadce7a3b378b40d2fea912c1344ef9b29869f984fb3d2a2420b" ], "index": "pypi", - "version": "==2.7.6.1" + "version": "==2.7.7" }, "pygithub": { "hashes": [ - "sha256:70d90139f61a3d88417ff15eaca6150d0b3ba7ef0dc59589ea3719c3ce518ef6" + "sha256:263102b43a83e2943900c1313109db7a00b3b78aeeae2c9137ba694982864872" ], "index": "pypi", - "version": "==1.43.3" + "version": "==1.43.5" }, "pyjwt": { "hashes": [ - "sha256:00414bfef802aaecd8cc0d5258b6cb87bd8f553c2986c2c5f29b19dd5633aeb7", - "sha256:ddec8409c57e9d371c6006e388f91daf3b0b43bdf9fcbf99451fb7cf5ce0a86d" + "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", + "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" ], "index": "pypi", - "version": "==1.7.0" + "version": "==1.7.1" }, "pytz": { "hashes": [ - "sha256:31cb35c89bd7d333cd32c5f278fca91b523b0834369e757f4c5641ea252236ca", - "sha256:8e0f8568c118d3077b46be7d654cc8167fa916092e28320cde048e54bfc9f1e6" + "sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9", + "sha256:d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c" ], - "version": "==2018.7" + "version": "==2018.9" }, "requests": { "hashes": [ - "sha256:65b3a120e4329e33c9889db89c80976c5272f56ea92d3e74da8a463992e3ff54", - "sha256:ea881206e59f41dbd0bd445437d792e43906703fff75ca8ff43ccdb11f33f263" + "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", + "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" ], - "version": "==2.20.1" + "version": "==2.21.0" }, "six": { "hashes": [ - "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", - "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" ], - "version": "==1.11.0" + "version": "==1.12.0" }, "urllib3": { "hashes": [ @@ -208,9 +208,9 @@ }, "wrapt": { "hashes": [ - "sha256:d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6" + "sha256:4aea003270831cceb8a90ff27c4031da6ead7ec1886023b80ce0dfe0adf61533" ], - "version": "==1.10.11" + "version": "==1.11.1" } }, "develop": {} From fa94d5b1df07cd4e8895e2a95f610d4f14cce139 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:23:03 -0600 Subject: [PATCH 11/71] added refresh token logic --- shared/selene/util/auth.py | 114 ++++++++++++++++++++++++------------- 1 file changed, 74 insertions(+), 40 deletions(-) diff --git a/shared/selene/util/auth.py b/shared/selene/util/auth.py index a462dd38..4839b203 100644 --- a/shared/selene/util/auth.py +++ b/shared/selene/util/auth.py @@ -1,55 +1,89 @@ from datetime import datetime -from logging import getLogger from time import time import jwt -ONE_DAY = 86400 - -_log = getLogger(__package__) +FIFTEEN_MINUTES = 900 +ONE_MONTH = 2628000 class AuthenticationError(Exception): pass -def encode_auth_token(secret_key, user_uuid): - """ - Generates the Auth Token - :return: string - """ - token_expiration = time() + ONE_DAY - payload = dict(iat=datetime.utcnow(), exp=token_expiration, sub=user_uuid) - selene_token = jwt.encode( - payload, - secret_key, - algorithm='HS256' - ) +class AuthenticationTokenGenerator(object): + _access_token = None + _refresh_token = None - # before returning the token, convert it from bytes to string so that - # it can be included in a JSON response object - return selene_token.decode() + def __init__(self, account_id: str): + self.account_id = account_id + self.access_secret = None + self.refresh_secret = None + + def _generate_token(self, token_duration: int): + """ + Generates a JWT token + """ + token_expiration = time() + token_duration + payload = dict( + iat=datetime.utcnow(), + exp=token_expiration, + sub=self.account_id + ) + + if token_duration == FIFTEEN_MINUTES: + secret = self.access_secret + else: + secret = self.refresh_secret + + if secret is None: + raise ValueError('cannot generate a token without a secret') + + token = jwt.encode( + payload, + secret, + algorithm='HS256' + ) + + # convert the token from byte-array to string so that + # it can be included in a JSON response object + return token.decode() + + @property + def access_token(self): + """ + Generates a JWT access token + """ + if self._access_token is None: + self._access_token = self._generate_token(FIFTEEN_MINUTES) + + return self._access_token + + @property + def refresh_token(self): + """ + Generates a JWT access token + """ + if self._refresh_token is None: + self._refresh_token = self._generate_token(ONE_MONTH) + + return self._refresh_token -def decode_auth_token(auth_token: str, secret_key: str) -> tuple: - """ - Decodes the auth token - :param auth_token: the Selene JSON Web Token extracted from cookies. - :param secret_key: the key needed to decode the token - :return: two-value tuple containing a boolean value indicating if the - token is good and the user UUID extracted from the token. UUID will - be None if token is invalid. - """ - try: - payload = jwt.decode(auth_token, secret_key) - user_uuid = payload['sub'] - except jwt.ExpiredSignatureError: - error_msg = 'Selene token expired' - _log.info(error_msg) - raise AuthenticationError(error_msg) - except jwt.InvalidTokenError: - error_msg = 'Invalid Selene token' - _log.info(error_msg) - raise AuthenticationError(error_msg) +class AuthenticationTokenValidator(object): + def __init__(self, token: str, secret: str): + self.token = token + self.secret = secret + self.account_id = None + self.token_is_expired = False + self.token_is_invalid = False - return user_uuid + def validate_token(self): + """Decodes the auth token""" + try: + payload = jwt.decode(self.token, self.secret) + self.account_id = payload['sub'] + except jwt.ExpiredSignatureError: + self.token_is_expired = True + except jwt.InvalidTokenError: + self.token_is_invalid = True From 1c6e53d3af23178df747333a51999a1cf4ac052c Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:23:37 -0600 Subject: [PATCH 12/71] added domains for cookies --- shared/selene/api/base_config.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shared/selene/api/base_config.py b/shared/selene/api/base_config.py index ba0ba974..a4c6cb02 100644 --- a/shared/selene/api/base_config.py +++ b/shared/selene/api/base_config.py @@ -46,14 +46,15 @@ class BaseConfig(object): class DevelopmentConfig(BaseConfig): DEBUG = True + DOMAIN = 'mycroft.test' class TestConfig(BaseConfig): - pass + DOMAIN = 'mycroft-test.net' class ProdConfig(BaseConfig): - pass + DOMAIN = 'mycroft.ai' def get_base_config(): From 51e706dce6feb5cb750951243794aa927602a9d8 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:24:34 -0600 Subject: [PATCH 13/71] converted function into repository class and added method to update the refresh token --- shared/selene/account/repository/account.py | 44 ++++++++++++++------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/shared/selene/account/repository/account.py b/shared/selene/account/repository/account.py index d3a244f5..b7b00a23 100644 --- a/shared/selene/account/repository/account.py +++ b/shared/selene/account/repository/account.py @@ -1,23 +1,39 @@ from os import path -from selene.util.db import DatabaseQuery, fetch +from selene.util.db import DatabaseRequest, Cursor from ..entity.account import Account SQL_DIR = path.join(path.dirname(__file__), 'sql') -def get_account_by_id(db, account_id: str) -> Account: - """Use a given uuid to query the database for an account +class AccountRepository(object): + def __init__(self, db): + self.db = db - :param db: psycopg2 connection object to mycroft database - :param account_id: uuid - :return: - """ - query = DatabaseQuery( - file_path=path.join(SQL_DIR, 'get_account_by_id.sql'), - args=dict(account_id=account_id), - singleton=True - ) - sql_results = fetch(db, query) + def get_account_by_id(self, account_id: str) -> Account: + """Use a given uuid to query the database for an account - return Account(**sql_results) + :param account_id: uuid + :return: an account entity, if one is found + """ + request = DatabaseRequest( + file_path=path.join(SQL_DIR, 'get_account_by_id.sql'), + args=dict(account_id=account_id), + ) + cursor = Cursor(self.db) + sql_results = cursor.select_one(request) + + if sql_results is not None: + return Account(**sql_results) + + def update_refresh_token(self, account: Account): + """When a new refresh token is generated update the account table""" + request = DatabaseRequest( + file_path=path.join(SQL_DIR, 'update_refresh_token.sql'), + args=dict( + account_id=account.id, + refresh_token=account.refresh_token + ), + ) + cursor = Cursor(self.db) + cursor.update(request) From 9bd0622fc238d17705024657f84342c385bc0521 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:25:27 -0600 Subject: [PATCH 14/71] added refresh token to dataclass --- shared/selene/account/entity/account.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shared/selene/account/entity/account.py b/shared/selene/account/entity/account.py index 1c74816e..f115155e 100644 --- a/shared/selene/account/entity/account.py +++ b/shared/selene/account/entity/account.py @@ -6,3 +6,4 @@ class Account(object): """Representation of a Mycroft user account.""" id: str email_address: str + refresh_token: str From 2dcb851382c21e20c632fe66ea9fc07362cc96e8 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:26:03 -0600 Subject: [PATCH 15/71] added refresh token to query and refactored --- .../selene/account/repository/sql/get_account_by_id.sql | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/shared/selene/account/repository/sql/get_account_by_id.sql b/shared/selene/account/repository/sql/get_account_by_id.sql index ed9788ac..11b57688 100644 --- a/shared/selene/account/repository/sql/get_account_by_id.sql +++ b/shared/selene/account/repository/sql/get_account_by_id.sql @@ -1 +1,8 @@ -SELECT * FROM account.account WHERE id = %(account_id)s +SELECT + id, + email_address, + refresh_token +FROM + account.account +WHERE + id = %(account_id)s From e4a21a25e93de07cfd1124f45fee19706a4da84a Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:26:28 -0600 Subject: [PATCH 16/71] updated to return all columns --- ..._from_credentials.sql => get_account_from_credentials.sql} | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename shared/selene/account/repository/sql/{get_account_id_from_credentials.sql => get_account_from_credentials.sql} (79%) diff --git a/shared/selene/account/repository/sql/get_account_id_from_credentials.sql b/shared/selene/account/repository/sql/get_account_from_credentials.sql similarity index 79% rename from shared/selene/account/repository/sql/get_account_id_from_credentials.sql rename to shared/selene/account/repository/sql/get_account_from_credentials.sql index 7e2858ca..d350978f 100644 --- a/shared/selene/account/repository/sql/get_account_id_from_credentials.sql +++ b/shared/selene/account/repository/sql/get_account_from_credentials.sql @@ -1,5 +1,7 @@ SELECT - a.id + a.id, + a.email_address, + a.refresh_token FROM account.account a INNER JOIN From 2a3c89961eedb7d8dac8dc5c1fe98c74b306d2da Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:30:22 -0600 Subject: [PATCH 17/71] added logger and fixed a couple of minor bugs --- api/sso/sso_api/api.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/api/sso/sso_api/api.py b/api/sso/sso_api/api.py index 3ca8e854..fb150356 100644 --- a/api/sso/sso_api/api.py +++ b/api/sso/sso_api/api.py @@ -1,5 +1,6 @@ """Define the API that will support Mycroft single sign on (SSO).""" +from logging import getLogger import os from flask import Flask, request @@ -16,6 +17,8 @@ from .endpoints import ( LogoutEndpoint ) +_log = getLogger('sso_api') + # Initialize the Flask application and the Flask Restful API sso = Flask(__name__) sso.config.from_object(get_base_config()) @@ -23,7 +26,7 @@ sso.config['SSO_BASE_URL'] = os.environ['SSO_BASE_URL'] # Initialize the REST API and define the endpoints sso_api = Api(sso, catch_all_404s=True) -sso_api.add_resource(AuthenticateInternalEndpoint, '/api/antisocial') +sso_api.add_resource(AuthenticateInternalEndpoint, '/api/internal') sso_api.add_resource(AuthorizeFacebookEndpoint, '/api/social/facebook') sso_api.add_resource(AuthorizeGithubEndpoint, '/api/social/github') sso_api.add_resource(AuthorizeGoogleEndpoint, '/api/social/google') @@ -49,6 +52,7 @@ sso.after_request(add_cors_headers) @sso.teardown_appcontext -def close_db_connections(): +def close_db_connections(_): """Close all pool connections when the app is terminated""" - sso.config['DB_CONNECTION_POOL'].close_all() + _log.info('closing connections') + sso.config['DB_CONNECTION_POOL'].closeall() From ff875fc79fec1da66fe57441d1fffae8d39bab94 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:31:50 -0600 Subject: [PATCH 18/71] added refresh token logic --- .../endpoints/authenticate_internal.py | 63 ++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/api/sso/sso_api/endpoints/authenticate_internal.py b/api/sso/sso_api/endpoints/authenticate_internal.py index 6c001a60..99016ae6 100644 --- a/api/sso/sso_api/endpoints/authenticate_internal.py +++ b/api/sso/sso_api/endpoints/authenticate_internal.py @@ -9,10 +9,17 @@ from binascii import a2b_base64 from http import HTTPStatus from time import time +from flask import after_this_request + +from selene.account import Account, AccountRepository, AuthenticationRepository from selene.api import SeleneEndpoint, APIError -from selene.util.auth import encode_auth_token, SEVEN_DAYS -from selene.util.db.connection_pool import get_db_connection -from selene.account.repository import get_account_id_from_credentials +from selene.util.auth import ( + AuthenticationError, + AuthenticationTokenGenerator, + FIFTEEN_MINUTES, + ONE_MONTH +) +from selene.util.db import get_db_connection class AuthenticateInternalEndpoint(SeleneEndpoint): @@ -22,16 +29,33 @@ class AuthenticateInternalEndpoint(SeleneEndpoint): def __init__(self): super(AuthenticateInternalEndpoint, self).__init__() self.response_status_code = HTTPStatus.OK - self.account_uuid = None + self.account: Account = None def get(self): try: self._authenticate_credentials() + self._generate_tokens() except APIError: pass else: self._build_response() + @after_this_request + def set_cookies(response): + response.set_cookie( + 'seleneAccess', + str(self.access_token), + max_age=FIFTEEN_MINUTES, + httponly=True + ) + response.set_cookie( + 'seleneRefresh', + str(self.refresh_token), + max_age=ONE_MONTH, + httponly=True + ) + return response + return self.response def _authenticate_credentials(self): @@ -41,18 +65,25 @@ class AuthenticateInternalEndpoint(SeleneEndpoint): binary_credentials = a2b_base64(basic_credentials.strip('Basic ')) email_address, password = binary_credentials.decode().split(':') with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: - self.account_uuid = get_account_id_from_credentials( - db, - email_address, - password + auth_repository = AuthenticationRepository(db) + self.account = auth_repository.get_account_from_credentials( + email_address, + password ) + if self.account is None: + raise AuthenticationError('provided credentials not found') + + def _generate_tokens(self): + token_generator = AuthenticationTokenGenerator(self.account_id) + token_generator.access_secret = self.config['ACCESS_SECRET'] + token_generator.refresh_secret = self.config['REFRESH_SECRET'] + self.access_token = token_generator.access_token + self.refresh_token = token_generator.refresh_token + + def _update_refresh_token_on_db(self): + with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: + acct_repository = AccountRepository(db) + acct_repository.update_refresh_token(self.account) def _build_response(self): - self.selene_token = encode_auth_token( - self.config['SECRET_KEY'], self.account_uuid - ) - response_data = dict( - expiration=time() + SEVEN_DAYS, - seleneToken=self.selene_token, - ) - self.response = (response_data, HTTPStatus.OK) + self.response = ({}, HTTPStatus.OK) From f458e809e9c170c1e8cb40cea6eb0854c400a066 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:32:09 -0600 Subject: [PATCH 19/71] added refresh token logic --- shared/selene/api/base_endpoint.py | 114 ++++++++++++++++++----------- 1 file changed, 70 insertions(+), 44 deletions(-) diff --git a/shared/selene/api/base_endpoint.py b/shared/selene/api/base_endpoint.py index 6507112d..be9a3cb6 100644 --- a/shared/selene/api/base_endpoint.py +++ b/shared/selene/api/base_endpoint.py @@ -1,15 +1,13 @@ -"""Reusable code for the API layer""" +"""Base class for Flask API endpoints""" + from http import HTTPStatus -from logging import getLogger from flask import request, current_app from flask_restful import Resource -from ..util.auth import decode_auth_token, AuthenticationError - -# The logger is initialized here but this should be overridden with a -# package-specific logger (e.g. _log = getLogger(__package__) -_log = getLogger() +from selene.account import AccountRepository +from selene.util.auth import AuthenticationError, AuthenticationTokenValidator +from selene.util.db import get_db_connection class APIError(Exception): @@ -29,13 +27,13 @@ class SeleneEndpoint(Resource): authentication_required: bool = True def __init__(self): - self.config = current_app.config + self.config: dict = current_app.config self.authenticated = False self.request = request - self.response = None - self.selene_token: str = None - self.tartarus_token: str = None - self.user_uuid: str = None + self.response: tuple = None + self.access_token: str = None + self.access_token_expired: bool = False + self.account_id: str = None def _authenticate(self): """ @@ -44,8 +42,12 @@ class SeleneEndpoint(Resource): :raises: APIError() """ try: - self._get_auth_token() - self._validate_auth_token() + self._get_access_token() + self._validate_access_token() + if self.access_token_expired: + self._get_refresh_token() + self._validate_refresh_token() + self._compare_token_to_db() except AuthenticationError as ae: if self.authentication_required: self.response = (str(ae), HTTPStatus.UNAUTHORIZED) @@ -53,44 +55,68 @@ class SeleneEndpoint(Resource): else: self.authenticated = True - def _get_auth_token(self): - """Get the Selene JWT (and the tartarus token) from cookies. + def _get_access_token(self): + """Get the Selene JWT access tokens from request cookies. :raises: AuthenticationError """ try: - self.selene_token = request.cookies['seleneToken'] - self.tartarus_token = request.cookies['tartarusToken'] + self.access_token = self.request.cookies['seleneAccess'] except KeyError: - raise AuthenticationError( - 'no authentication token found in request' - ) + raise AuthenticationError('no access token found in request') - def _validate_auth_token(self): - """Decode the Selene JWT. + def _validate_access_token(self): + """Validate the access token is well-formed and not expired :raises: AuthenticationError """ - self.user_uuid = decode_auth_token( - self.selene_token, - self.config['SECRET_KEY'] + validator = AuthenticationTokenValidator( + self.access_token, + self.config['ACCESS_TOKEN_SECRET'] ) + validator.validate_token() + if validator.token_is_expired: + self.access_token_expired = True + elif validator.token_is_invalid: + raise AuthenticationError('access token is invalid') + else: + self.account_id = validator.account_id - def _check_for_service_errors(self, service_response): - """Common logic to handle non-successful returns from service calls.""" - if service_response.status_code != HTTPStatus.OK: - error_message = ( - 'service URL {url} returned HTTP status {status}'.format( - status=service_response.status_code, - url=service_response.request.url - ) - ) - _log.error(error_message) - if service_response.status_code == HTTPStatus.UNAUTHORIZED: - self.response = (error_message, HTTPStatus.UNAUTHORIZED) - else: - self.response = ( - error_message, - HTTPStatus.INTERNAL_SERVER_ERROR - ) - raise APIError() + def _get_refresh_token(self): + """Get the Selene JWT refresh tokens from request cookies. + + :raises: AuthenticationError + """ + try: + self.refresh_token = self.request.cookies['seleneRefresh'] + except KeyError: + raise AuthenticationError('no refresh token found in request') + + def _validate_refresh_token(self): + """Validate the refresh token is well-formed and not expired + + :raises: AuthenticationError + """ + validator = AuthenticationTokenValidator( + self.refresh_token, + self.config['REFRESH_TOKEN_SECRET'] + ) + validator.validate_token() + if validator.token_is_expired: + raise AuthenticationError('refresh token is expired') + elif validator.token_is_invalid: + raise AuthenticationError('access token is invalid') + else: + self.account_id = validator.account_id + + def _compare_token_to_db(self): + """The refresh token in the request must match the database value. + + :raises: AuthenticationError + """ + with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: + account_repository = AccountRepository(db) + account = account_repository.get_account_by_id(self.account_id) + + if account.refresh_token != self.refreshToken: + raise AuthenticationError('refresh token not recognized') From 753c522833f8ee37118dcfef369c1a5a414ee66f Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:32:29 -0600 Subject: [PATCH 20/71] added imports --- shared/selene/account/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/selene/account/__init__.py b/shared/selene/account/__init__.py index 382ddd31..e80223f4 100644 --- a/shared/selene/account/__init__.py +++ b/shared/selene/account/__init__.py @@ -1 +1,3 @@ -from .account import get_account_by_id +from .entity.account import Account +from .repository.account import AccountRepository +from .repository.authentication import AuthenticationRepository From b733f68d7f5f588dae5457e902366ff8d2ba2ea8 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:32:52 -0600 Subject: [PATCH 21/71] new repository for authentication logic --- .../account/repository/authentication.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 shared/selene/account/repository/authentication.py diff --git a/shared/selene/account/repository/authentication.py b/shared/selene/account/repository/authentication.py new file mode 100644 index 00000000..438e7282 --- /dev/null +++ b/shared/selene/account/repository/authentication.py @@ -0,0 +1,31 @@ +from os import path + +from selene.util.db import DatabaseRequest, Cursor +from ..entity.account import Account + +SQL_DIR = path.join(path.dirname(__file__), 'sql') + + +class AuthenticationRepository(object): + def __init__(self, db): + self.db = db + + def get_account_from_credentials( + self, email: str, password: str + ) -> Account: + """ + Validate email/password combination against the database + + :param email: the user provided email address + :param password: the user provided password + :return: the matching account record, if one is found + """ + query = DatabaseRequest( + file_path=path.join(SQL_DIR, 'get_account_from_credentials.sql'), + args=dict(email_address=email, password=password), + ) + cursor = Cursor(self.db) + sql_results = cursor.select_one(query) + + if sql_results is not None: + return Account(**sql_results) From 1085c6ead749ffe9f72061fe1cd6ac49475bc0b2 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:33:49 -0600 Subject: [PATCH 22/71] new sql for updating the refresh token --- .../selene/account/repository/sql/update_refresh_token.sql | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 shared/selene/account/repository/sql/update_refresh_token.sql diff --git a/shared/selene/account/repository/sql/update_refresh_token.sql b/shared/selene/account/repository/sql/update_refresh_token.sql new file mode 100644 index 00000000..3f5ff3ca --- /dev/null +++ b/shared/selene/account/repository/sql/update_refresh_token.sql @@ -0,0 +1,6 @@ +UPDATE + account.account +SET + refresh_token = %(refresh_token)s +WHERE + id = %(account_id)s From 33cfe4c63b649573f9a0ae4fa8c2925ef489eb8f Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 00:43:59 -0600 Subject: [PATCH 23/71] minor refactor --- shared/selene/util/auth.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/shared/selene/util/auth.py b/shared/selene/util/auth.py index 4839b203..ea14f394 100644 --- a/shared/selene/util/auth.py +++ b/shared/selene/util/auth.py @@ -15,10 +15,10 @@ class AuthenticationTokenGenerator(object): _access_token = None _refresh_token = None - def __init__(self, account_id: str): + def __init__(self, account_id: str, access_secret, refresh_secret): self.account_id = account_id - self.access_secret = None - self.refresh_secret = None + self.access_secret = access_secret + self.refresh_secret = refresh_secret def _generate_token(self, token_duration: int): """ @@ -36,9 +36,6 @@ class AuthenticationTokenGenerator(object): else: secret = self.refresh_secret - if secret is None: - raise ValueError('cannot generate a token without a secret') - token = jwt.encode( payload, secret, From eb4a7e5343fcf8dc65bb6d98e3a652304df8f3f8 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 15:26:47 -0600 Subject: [PATCH 24/71] performed some cleanup that was causing virtualenv issues --- api/sso/Pipfile | 2 +- api/sso/Pipfile.lock | 200 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 2 deletions(-) diff --git a/api/sso/Pipfile b/api/sso/Pipfile index 19344750..3c8e5e67 100644 --- a/api/sso/Pipfile +++ b/api/sso/Pipfile @@ -12,7 +12,7 @@ certifi = "*" uwsgi = "*" [dev-packages] -selene = {path = "./../../shared"} +selene = {editable = true,path = "./../../shared"} [requires] python_version = "3.7" diff --git a/api/sso/Pipfile.lock b/api/sso/Pipfile.lock index 73fcdd9f..a4c02306 100644 --- a/api/sso/Pipfile.lock +++ b/api/sso/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "164da3986d2c0c62e788d86365e0f305a6b236ca52adede6077a5751540f7e3d" + "sha256": "48ca62ed518781784ac47dd3096355c49757222721de19fe7c636b100100cec1" }, "pipfile-spec": 6, "requires": { @@ -168,8 +168,206 @@ } }, "develop": { + "aniso8601": { + "hashes": [ + "sha256:03c0ffeeb04edeca1ed59684cc6836dc377f58e52e315dc7be3af879909889f4", + "sha256:ac30cceff24aec920c37b8d74d7d8a5dd37b1f62a90b4f268a6234cabe147080" + ], + "version": "==4.1.0" + }, + "certifi": { + "hashes": [ + "sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", + "sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033" + ], + "index": "pypi", + "version": "==2018.11.29" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "click": { + "hashes": [ + "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", + "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" + ], + "version": "==7.0" + }, + "deprecated": { + "hashes": [ + "sha256:8bfeba6e630abf42b5d111b68a05f7fe3d6de7004391b3cd614947594f87a4ff", + "sha256:b784e0ca85a8c1e694d77e545c10827bd99772392e79d5f5442e761515a1246e" + ], + "version": "==1.2.4" + }, + "flask": { + "hashes": [ + "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", + "sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" + ], + "index": "pypi", + "version": "==1.0.2" + }, + "flask-restful": { + "hashes": [ + "sha256:ecd620c5cc29f663627f99e04f17d1f16d095c83dc1d618426e2ad68b03092f8", + "sha256:f8240ec12349afe8df1db168ea7c336c4e5b0271a36982bff7394f93275f2ca9" + ], + "index": "pypi", + "version": "==0.3.7" + }, + "idna": { + "hashes": [ + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + ], + "version": "==2.8" + }, + "itsdangerous": { + "hashes": [ + "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", + "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" + ], + "version": "==1.1.0" + }, + "jinja2": { + "hashes": [ + "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", + "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" + ], + "version": "==2.10" + }, + "markupsafe": { + "hashes": [ + "sha256:048ef924c1623740e70204aa7143ec592504045ae4429b59c30054cb31e3c432", + "sha256:130f844e7f5bdd8e9f3f42e7102ef1d49b2e6fdf0d7526df3f87281a532d8c8b", + "sha256:19f637c2ac5ae9da8bfd98cef74d64b7e1bb8a63038a3505cd182c3fac5eb4d9", + "sha256:1b8a7a87ad1b92bd887568ce54b23565f3fd7018c4180136e1cf412b405a47af", + "sha256:1c25694ca680b6919de53a4bb3bdd0602beafc63ff001fea2f2fc16ec3a11834", + "sha256:1f19ef5d3908110e1e891deefb5586aae1b49a7440db952454b4e281b41620cd", + "sha256:1fa6058938190ebe8290e5cae6c351e14e7bb44505c4a7624555ce57fbbeba0d", + "sha256:31cbb1359e8c25f9f48e156e59e2eaad51cd5242c05ed18a8de6dbe85184e4b7", + "sha256:3e835d8841ae7863f64e40e19477f7eb398674da6a47f09871673742531e6f4b", + "sha256:4e97332c9ce444b0c2c38dd22ddc61c743eb208d916e4265a2a3b575bdccb1d3", + "sha256:525396ee324ee2da82919f2ee9c9e73b012f23e7640131dd1b53a90206a0f09c", + "sha256:52b07fbc32032c21ad4ab060fec137b76eb804c4b9a1c7c7dc562549306afad2", + "sha256:52ccb45e77a1085ec5461cde794e1aa037df79f473cbc69b974e73940655c8d7", + "sha256:5c3fbebd7de20ce93103cb3183b47671f2885307df4a17a0ad56a1dd51273d36", + "sha256:5e5851969aea17660e55f6a3be00037a25b96a9b44d2083651812c99d53b14d1", + "sha256:5edfa27b2d3eefa2210fb2f5d539fbed81722b49f083b2c6566455eb7422fd7e", + "sha256:7d263e5770efddf465a9e31b78362d84d015cc894ca2c131901a4445eaa61ee1", + "sha256:83381342bfc22b3c8c06f2dd93a505413888694302de25add756254beee8449c", + "sha256:857eebb2c1dc60e4219ec8e98dfa19553dae33608237e107db9c6078b1167856", + "sha256:98e439297f78fca3a6169fd330fbe88d78b3bb72f967ad9961bcac0d7fdd1550", + "sha256:bf54103892a83c64db58125b3f2a43df6d2cb2d28889f14c78519394feb41492", + "sha256:d9ac82be533394d341b41d78aca7ed0e0f4ba5a2231602e2f05aa87f25c51672", + "sha256:e982fe07ede9fada6ff6705af70514a52beb1b2c3d25d4e873e82114cf3c5401", + "sha256:edce2ea7f3dfc981c4ddc97add8a61381d9642dc3273737e756517cc03e84dd6", + "sha256:efdc45ef1afc238db84cb4963aa689c0408912a0239b0721cb172b4016eb31d6", + "sha256:f137c02498f8b935892d5c0172560d7ab54bc45039de8805075e19079c639a9c", + "sha256:f82e347a72f955b7017a39708a3667f106e6ad4d10b25f237396a7115d8ed5fd", + "sha256:fb7c206e01ad85ce57feeaaa0bf784b97fa3cad0d4a5737bc5295785f5c613a1" + ], + "version": "==1.1.0" + }, + "psycopg2-binary": { + "hashes": [ + "sha256:19a2d1f3567b30f6c2bb3baea23f74f69d51f0c06c2e2082d0d9c28b0733a4c2", + "sha256:2b69cf4b0fa2716fd977aa4e1fd39af6110eb47b2bb30b4e5a469d8fbecfc102", + "sha256:2e952fa17ba48cbc2dc063ddeec37d7dc4ea0ef7db0ac1eda8906365a8543f31", + "sha256:348b49dd737ff74cfb5e663e18cb069b44c64f77ec0523b5794efafbfa7df0b8", + "sha256:3d72a5fdc5f00ca85160915eb9a973cf9a0ab8148f6eda40708bf672c55ac1d1", + "sha256:4957452f7868f43f32c090dadb4188e9c74a4687323c87a882e943c2bd4780c3", + "sha256:5138cec2ee1e53a671e11cc519505eb08aaaaf390c508f25b09605763d48de4b", + "sha256:587098ca4fc46c95736459d171102336af12f0d415b3b865972a79c03f06259f", + "sha256:5b79368bcdb1da4a05f931b62760bea0955ee2c81531d8e84625df2defd3f709", + "sha256:5cf43807392247d9bc99737160da32d3fa619e0bfd85ba24d1c78db205f472a4", + "sha256:676d1a80b1eebc0cacae8dd09b2fde24213173bf65650d22b038c5ed4039f392", + "sha256:6b0211ecda389101a7d1d3df2eba0cf7ffbdd2480ca6f1d2257c7bd739e84110", + "sha256:79cde4660de6f0bb523c229763bd8ad9a93ac6760b72c369cf1213955c430934", + "sha256:7aba9786ac32c2a6d5fb446002ed936b47d5e1f10c466ef7e48f66eb9f9ebe3b", + "sha256:7c8159352244e11bdd422226aa17651110b600d175220c451a9acf795e7414e0", + "sha256:945f2eedf4fc6b2432697eb90bb98cc467de5147869e57405bfc31fa0b824741", + "sha256:96b4e902cde37a7fc6ab306b3ac089a3949e6ce3d824eeca5b19dc0bedb9f6e2", + "sha256:9a7bccb1212e63f309eb9fab47b6eaef796f59850f169a25695b248ca1bf681b", + "sha256:a3bfcac727538ec11af304b5eccadbac952d4cca1a551a29b8fe554e3ad535dc", + "sha256:b19e9f1b85c5d6136f5a0549abdc55dcbd63aba18b4f10d0d063eb65ef2c68b4", + "sha256:b664011bb14ca1f2287c17185e222f2098f7b4c857961dbcf9badb28786dbbf4", + "sha256:bde7959ef012b628868d69c474ec4920252656d0800835ed999ba5e4f57e3e2e", + "sha256:cb095a0657d792c8de9f7c9a0452385a309dfb1bbbb3357d6b1e216353ade6ca", + "sha256:d16d42a1b9772152c1fe606f679b2316551f7e1a1ce273e7f808e82a136cdb3d", + "sha256:d444b1545430ffc1e7a24ce5a9be122ccd3b135a7b7e695c5862c5aff0b11159", + "sha256:d93ccc7bf409ec0a23f2ac70977507e0b8a8d8c54e5ee46109af2f0ec9e411f3", + "sha256:df6444f952ca849016902662e1a47abf4fa0678d75f92fd9dd27f20525f809cd", + "sha256:e63850d8c52ba2b502662bf3c02603175c2397a9acc756090e444ce49508d41e", + "sha256:ec43358c105794bc2b6fd34c68d27f92bea7102393c01889e93f4b6a70975728", + "sha256:f4c6926d9c03dadce7a3b378b40d2fea912c1344ef9b29869f984fb3d2a2420b" + ], + "version": "==2.7.7" + }, + "pygithub": { + "hashes": [ + "sha256:263102b43a83e2943900c1313109db7a00b3b78aeeae2c9137ba694982864872" + ], + "version": "==1.43.5" + }, + "pyjwt": { + "hashes": [ + "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", + "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" + ], + "index": "pypi", + "version": "==1.7.1" + }, + "pytz": { + "hashes": [ + "sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9", + "sha256:d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c" + ], + "version": "==2018.9" + }, + "requests": { + "hashes": [ + "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", + "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" + ], + "index": "pypi", + "version": "==2.21.0" + }, "selene": { + "editable": true, "path": "./../../shared" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + }, + "urllib3": { + "hashes": [ + "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", + "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22" + ], + "version": "==1.24.1" + }, + "werkzeug": { + "hashes": [ + "sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c", + "sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b" + ], + "version": "==0.14.1" + }, + "wrapt": { + "hashes": [ + "sha256:4aea003270831cceb8a90ff27c4031da6ead7ec1886023b80ce0dfe0adf61533" + ], + "version": "==1.11.1" } } } From b20396364391f01e095cb1496d132d37934fdcdd Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 15:35:45 -0600 Subject: [PATCH 25/71] ignore python egg-info directories --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7ca4f2be..23363a07 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /dist /tmp /out-tsc +**/*.egg-info # dependencies **/node_modules From c17dcf0a4e5f1076ffd191c171a5f345d2a6a42a Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 21:40:44 -0600 Subject: [PATCH 26/71] added ability to delete an expired refresh token and update a refresh token --- shared/selene/api/base_endpoint.py | 140 ++++++++++++++++++----------- 1 file changed, 88 insertions(+), 52 deletions(-) diff --git a/shared/selene/api/base_endpoint.py b/shared/selene/api/base_endpoint.py index be9a3cb6..981ef1f9 100644 --- a/shared/selene/api/base_endpoint.py +++ b/shared/selene/api/base_endpoint.py @@ -5,8 +5,14 @@ from http import HTTPStatus from flask import request, current_app from flask_restful import Resource -from selene.account import AccountRepository -from selene.util.auth import AuthenticationError, AuthenticationTokenValidator +from selene.account import Account, AccountRepository +from selene.util.auth import ( + AuthenticationError, + AuthenticationTokenGenerator, + AuthenticationTokenValidator, + FIFTEEN_MINUTES, + ONE_MONTH +) from selene.util.db import get_db_connection @@ -31,9 +37,9 @@ class SeleneEndpoint(Resource): self.authenticated = False self.request = request self.response: tuple = None - self.access_token: str = None self.access_token_expired: bool = False - self.account_id: str = None + self.refresh_token_expired: bool = False + self.account: Account = None def _authenticate(self): """ @@ -42,12 +48,8 @@ class SeleneEndpoint(Resource): :raises: APIError() """ try: - self._get_access_token() - self._validate_access_token() - if self.access_token_expired: - self._get_refresh_token() - self._validate_refresh_token() - self._compare_token_to_db() + account_id = self._validate_auth_tokens() + self._validate_account(account_id) except AuthenticationError as ae: if self.authentication_required: self.response = (str(ae), HTTPStatus.UNAUTHORIZED) @@ -55,68 +57,102 @@ class SeleneEndpoint(Resource): else: self.authenticated = True - def _get_access_token(self): - """Get the Selene JWT access tokens from request cookies. + def _validate_auth_tokens(self) -> str: + self.access_token_expired, account_id = self._validate_token( + 'seleneAccess', + self.config['ACCESS_TOKEN_SECRET'] + ) + if self.access_token_expired: + self.refresh_token_expired, account_id = self._validate_token( + 'seleneRefresh', + self.config['REFRESH_TOKEN_SECRET'] + ) - :raises: AuthenticationError - """ - try: - self.access_token = self.request.cookies['seleneAccess'] - except KeyError: - raise AuthenticationError('no access token found in request') + return account_id - def _validate_access_token(self): + def _validate_token(self, cookie_key, jwt_secret): """Validate the access token is well-formed and not expired :raises: AuthenticationError """ - validator = AuthenticationTokenValidator( - self.access_token, - self.config['ACCESS_TOKEN_SECRET'] - ) - validator.validate_token() - if validator.token_is_expired: - self.access_token_expired = True - elif validator.token_is_invalid: - raise AuthenticationError('access token is invalid') - else: - self.account_id = validator.account_id + account_id = None + token_expired = False - def _get_refresh_token(self): - """Get the Selene JWT refresh tokens from request cookies. - - :raises: AuthenticationError - """ try: - self.refresh_token = self.request.cookies['seleneRefresh'] + access_token = self.request.cookies[cookie_key] except KeyError: - raise AuthenticationError('no refresh token found in request') + error_msg = 'no {} token found in request' + raise AuthenticationError(error_msg.format(cookie_key)) - def _validate_refresh_token(self): - """Validate the refresh token is well-formed and not expired - - :raises: AuthenticationError - """ - validator = AuthenticationTokenValidator( - self.refresh_token, - self.config['REFRESH_TOKEN_SECRET'] - ) + validator = AuthenticationTokenValidator(access_token, jwt_secret) validator.validate_token() if validator.token_is_expired: - raise AuthenticationError('refresh token is expired') + token_expired = True elif validator.token_is_invalid: raise AuthenticationError('access token is invalid') else: - self.account_id = validator.account_id + account_id = validator.account_id - def _compare_token_to_db(self): + return token_expired, account_id + + def _validate_account(self, account_id): """The refresh token in the request must match the database value. :raises: AuthenticationError """ with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: account_repository = AccountRepository(db) - account = account_repository.get_account_by_id(self.account_id) + self.account = account_repository.get_account_by_id(account_id) - if account.refresh_token != self.refreshToken: - raise AuthenticationError('refresh token not recognized') + if self.account is None: + raise AuthenticationError('account not found') + + if self.access_token_expired: + if self.refresh_token not in self.account.refresh_tokens: + raise AuthenticationError('refresh token not found') + + def _generate_tokens(self): + token_generator = AuthenticationTokenGenerator( + self.account_id, + self.config['ACCESS_SECRET'], + self.config['REFRESH_SECRET'] + ) + access_token = token_generator.access_token + refresh_token = token_generator.refresh_token + + return access_token, refresh_token + + def _generate_token_cookies(self, access_token, refresh_token): + access_token_cookie = dict( + key='seleneAccess', + value=str(access_token), + domain=self.config['DOMAIN'], + max_age=FIFTEEN_MINUTES, + httponly=True + ) + refresh_token_cookie = dict( + key='seleneRefresh', + value=str(refresh_token), + domain=self.config['DOMAIN'], + max_age=ONE_MONTH, + httponly=True + ) + + return access_token_cookie, refresh_token_cookie + + def _update_refresh_token_on_db(self, new_refresh_token): + old_refresh_token = self.request.cookies['seleneRefresh'] + with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: + acct_repository = AccountRepository(db) + if self.refresh_token_expired: + acct_repository.delete_refresh_token( + self.account, + old_refresh_token + ) + raise AuthenticationError('refresh token expired') + else: + acct_repository.update_refresh_token( + self.account, + new_refresh_token, + old_refresh_token + ) From 901a256d54233c125fcf2b42a01b11aa9a4befb8 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 21:41:32 -0600 Subject: [PATCH 27/71] broke refresh tokens into their own table and added functionality to update/delete a refresh token --- shared/selene/account/entity/account.py | 3 ++- shared/selene/account/repository/account.py | 14 +++++++++-- .../selene/account/repository/authenticate.py | 24 ------------------- .../repository/sql/delete_refresh_token.sql | 5 ++++ .../repository/sql/get_account_by_id.sql | 10 ++++---- .../repository/sql/update_refresh_token.sql | 7 +++--- 6 files changed, 29 insertions(+), 34 deletions(-) delete mode 100644 shared/selene/account/repository/authenticate.py create mode 100644 shared/selene/account/repository/sql/delete_refresh_token.sql diff --git a/shared/selene/account/entity/account.py b/shared/selene/account/entity/account.py index f115155e..96e0544d 100644 --- a/shared/selene/account/entity/account.py +++ b/shared/selene/account/entity/account.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import List @dataclass @@ -6,4 +7,4 @@ class Account(object): """Representation of a Mycroft user account.""" id: str email_address: str - refresh_token: str + refresh_tokens: List[str] diff --git a/shared/selene/account/repository/account.py b/shared/selene/account/repository/account.py index b7b00a23..5e4e5700 100644 --- a/shared/selene/account/repository/account.py +++ b/shared/selene/account/repository/account.py @@ -26,13 +26,23 @@ class AccountRepository(object): if sql_results is not None: return Account(**sql_results) - def update_refresh_token(self, account: Account): + def delete_refresh_token(self, account: Account, token: str): + """When a new refresh token is generated update the account table""" + request = DatabaseRequest( + file_path=path.join(SQL_DIR, 'delete_refresh_token.sql'), + args=dict(account_id=account.id, refresh_token=token), + ) + cursor = Cursor(self.db) + cursor.update(request) + + def update_refresh_token(self, account: Account, old: str, new: str): """When a new refresh token is generated update the account table""" request = DatabaseRequest( file_path=path.join(SQL_DIR, 'update_refresh_token.sql'), args=dict( account_id=account.id, - refresh_token=account.refresh_token + new_refresh_token=new, + old_refresh_token=old ), ) cursor = Cursor(self.db) diff --git a/shared/selene/account/repository/authenticate.py b/shared/selene/account/repository/authenticate.py deleted file mode 100644 index f29f61f2..00000000 --- a/shared/selene/account/repository/authenticate.py +++ /dev/null @@ -1,24 +0,0 @@ -from os import path - -from selene.util.db.cursor import DatabaseQuery, fetch - -SQL_DIR = path.join(path.dirname(__file__), 'sql') - - -def get_account_id_from_credentials(db, email: str, password: str) -> str: - """ - Validate that the provided email/password combination exists on our database - - :param db: database connection object - :param email: the user provided email address - :param password: the user provided password - :return: the uuid of the account - """ - query = DatabaseQuery( - file_path=path.join(SQL_DIR, 'get_account_id_from_credentials.sql'), - args=dict(email_address=email, password=password), - singleton=True - ) - sql_results = fetch(db, query) - - return sql_results['id'] diff --git a/shared/selene/account/repository/sql/delete_refresh_token.sql b/shared/selene/account/repository/sql/delete_refresh_token.sql new file mode 100644 index 00000000..66f9d43b --- /dev/null +++ b/shared/selene/account/repository/sql/delete_refresh_token.sql @@ -0,0 +1,5 @@ +DELETE FROM + account.refresh_token +WHERE + account_id = %(account_id) AND + refresh_token = %(refresh_token)s diff --git a/shared/selene/account/repository/sql/get_account_by_id.sql b/shared/selene/account/repository/sql/get_account_by_id.sql index 11b57688..7b79893e 100644 --- a/shared/selene/account/repository/sql/get_account_by_id.sql +++ b/shared/selene/account/repository/sql/get_account_by_id.sql @@ -1,8 +1,10 @@ SELECT - id, - email_address, - refresh_token + a.id, + a.email_address, + array_agg(rt.refresh_token) as refresh_tokens FROM - account.account + account.account a +INNER JOIN + account.refresh_token rt on a.id = rt.account_id WHERE id = %(account_id)s diff --git a/shared/selene/account/repository/sql/update_refresh_token.sql b/shared/selene/account/repository/sql/update_refresh_token.sql index 3f5ff3ca..a6b2649f 100644 --- a/shared/selene/account/repository/sql/update_refresh_token.sql +++ b/shared/selene/account/repository/sql/update_refresh_token.sql @@ -1,6 +1,7 @@ UPDATE - account.account + account.refresh_token SET - refresh_token = %(refresh_token)s + refresh_token = %(new_refresh_token)s WHERE - id = %(account_id)s + account_id = %(account_id)s AND + refresh_token = %(old_refresh_token)s From ad0d4820f0b764459d2b4456299d17fe27d18d5e Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 22:08:59 -0600 Subject: [PATCH 28/71] added logic to add a refresh token on sign in; moved refresh token functions in the account repository to their own repository; moved authentication repo into account repo --- .../endpoints/authenticate_internal.py | 36 +++++----------- shared/selene/account/__init__.py | 2 +- shared/selene/account/repository/account.py | 34 +++++++-------- .../account/repository/authentication.py | 31 ------------- .../account/repository/refresh_token.py | 43 +++++++++++++++++++ .../repository/sql/add_refresh_token.sql | 4 ++ .../sql/get_account_from_credentials.sql | 4 +- 7 files changed, 78 insertions(+), 76 deletions(-) delete mode 100644 shared/selene/account/repository/authentication.py create mode 100644 shared/selene/account/repository/refresh_token.py create mode 100644 shared/selene/account/repository/sql/add_refresh_token.sql diff --git a/api/sso/sso_api/endpoints/authenticate_internal.py b/api/sso/sso_api/endpoints/authenticate_internal.py index 99016ae6..d0a642d2 100644 --- a/api/sso/sso_api/endpoints/authenticate_internal.py +++ b/api/sso/sso_api/endpoints/authenticate_internal.py @@ -11,7 +11,7 @@ from time import time from flask import after_this_request -from selene.account import Account, AccountRepository, AuthenticationRepository +from selene.account import Account, AccountRepository, RefreshTokenRepository from selene.api import SeleneEndpoint, APIError from selene.util.auth import ( AuthenticationError, @@ -34,7 +34,9 @@ class AuthenticateInternalEndpoint(SeleneEndpoint): def get(self): try: self._authenticate_credentials() - self._generate_tokens() + access_token, refresh_token = self._generate_tokens() + self._add_refresh_token_to_db(refresh_token) + cookies = self._generate_token_cookies(access_token, refresh_token) except APIError: pass else: @@ -42,18 +44,9 @@ class AuthenticateInternalEndpoint(SeleneEndpoint): @after_this_request def set_cookies(response): - response.set_cookie( - 'seleneAccess', - str(self.access_token), - max_age=FIFTEEN_MINUTES, - httponly=True - ) - response.set_cookie( - 'seleneRefresh', - str(self.refresh_token), - max_age=ONE_MONTH, - httponly=True - ) + access_token_cookie, refresh_token_cookie = cookies + response.set_cookie(**access_token_cookie) + response.set_cookie(**refresh_token_cookie) return response return self.response @@ -65,7 +58,7 @@ class AuthenticateInternalEndpoint(SeleneEndpoint): binary_credentials = a2b_base64(basic_credentials.strip('Basic ')) email_address, password = binary_credentials.decode().split(':') with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: - auth_repository = AuthenticationRepository(db) + auth_repository = AccountRepository(db) self.account = auth_repository.get_account_from_credentials( email_address, password @@ -73,17 +66,10 @@ class AuthenticateInternalEndpoint(SeleneEndpoint): if self.account is None: raise AuthenticationError('provided credentials not found') - def _generate_tokens(self): - token_generator = AuthenticationTokenGenerator(self.account_id) - token_generator.access_secret = self.config['ACCESS_SECRET'] - token_generator.refresh_secret = self.config['REFRESH_SECRET'] - self.access_token = token_generator.access_token - self.refresh_token = token_generator.refresh_token - - def _update_refresh_token_on_db(self): + def _add_refresh_token_to_db(self, refresh_token): with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: - acct_repository = AccountRepository(db) - acct_repository.update_refresh_token(self.account) + token_repo = RefreshTokenRepository(db, self.account) + token_repo.add_refresh_token(refresh_token) def _build_response(self): self.response = ({}, HTTPStatus.OK) diff --git a/shared/selene/account/__init__.py b/shared/selene/account/__init__.py index e80223f4..e979f967 100644 --- a/shared/selene/account/__init__.py +++ b/shared/selene/account/__init__.py @@ -1,3 +1,3 @@ from .entity.account import Account from .repository.account import AccountRepository -from .repository.authentication import AuthenticationRepository +from .repository.refresh_token import RefreshTokenRepository diff --git a/shared/selene/account/repository/account.py b/shared/selene/account/repository/account.py index 5e4e5700..25a7cdd4 100644 --- a/shared/selene/account/repository/account.py +++ b/shared/selene/account/repository/account.py @@ -26,24 +26,22 @@ class AccountRepository(object): if sql_results is not None: return Account(**sql_results) - def delete_refresh_token(self, account: Account, token: str): - """When a new refresh token is generated update the account table""" - request = DatabaseRequest( - file_path=path.join(SQL_DIR, 'delete_refresh_token.sql'), - args=dict(account_id=account.id, refresh_token=token), - ) - cursor = Cursor(self.db) - cursor.update(request) + def get_account_from_credentials( + self, email: str, password: str + ) -> Account: + """ + Validate email/password combination against the database - def update_refresh_token(self, account: Account, old: str, new: str): - """When a new refresh token is generated update the account table""" - request = DatabaseRequest( - file_path=path.join(SQL_DIR, 'update_refresh_token.sql'), - args=dict( - account_id=account.id, - new_refresh_token=new, - old_refresh_token=old - ), + :param email: the user provided email address + :param password: the user provided password + :return: the matching account record, if one is found + """ + query = DatabaseRequest( + file_path=path.join(SQL_DIR, 'get_account_from_credentials.sql'), + args=dict(email_address=email, password=password), ) cursor = Cursor(self.db) - cursor.update(request) + sql_results = cursor.select_one(query) + + if sql_results is not None: + return Account(**sql_results) diff --git a/shared/selene/account/repository/authentication.py b/shared/selene/account/repository/authentication.py deleted file mode 100644 index 438e7282..00000000 --- a/shared/selene/account/repository/authentication.py +++ /dev/null @@ -1,31 +0,0 @@ -from os import path - -from selene.util.db import DatabaseRequest, Cursor -from ..entity.account import Account - -SQL_DIR = path.join(path.dirname(__file__), 'sql') - - -class AuthenticationRepository(object): - def __init__(self, db): - self.db = db - - def get_account_from_credentials( - self, email: str, password: str - ) -> Account: - """ - Validate email/password combination against the database - - :param email: the user provided email address - :param password: the user provided password - :return: the matching account record, if one is found - """ - query = DatabaseRequest( - file_path=path.join(SQL_DIR, 'get_account_from_credentials.sql'), - args=dict(email_address=email, password=password), - ) - cursor = Cursor(self.db) - sql_results = cursor.select_one(query) - - if sql_results is not None: - return Account(**sql_results) diff --git a/shared/selene/account/repository/refresh_token.py b/shared/selene/account/repository/refresh_token.py new file mode 100644 index 00000000..f7fa5693 --- /dev/null +++ b/shared/selene/account/repository/refresh_token.py @@ -0,0 +1,43 @@ +from os import path + +from selene.util.db import DatabaseRequest, Cursor +from ..entity.account import Account + +SQL_DIR = path.join(path.dirname(__file__), 'sql') + + +class RefreshTokenRepository(object): + def __init__(self, db, account: Account): + self.db = db + self.account = account + + def add_refresh_token(self, token: str): + """Add a refresh token to an account""" + request = DatabaseRequest( + file_path=path.join(SQL_DIR, 'add_refresh_token.sql'), + args=dict(account_id=self.account.id, refresh_token=token), + ) + cursor = Cursor(self.db) + cursor.insert(request) + + def delete_refresh_token(self, token: str): + """When a refresh token expires, delete it.""" + request = DatabaseRequest( + file_path=path.join(SQL_DIR, 'delete_refresh_token.sql'), + args=dict(account_id=self.account.id, refresh_token=token), + ) + cursor = Cursor(self.db) + cursor.update(request) + + def update_refresh_token(self, old: str, new: str): + """When a new refresh token is generated replace the old one""" + request = DatabaseRequest( + file_path=path.join(SQL_DIR, 'update_refresh_token.sql'), + args=dict( + account_id=self.account.id, + new_refresh_token=new, + old_refresh_token=old + ), + ) + cursor = Cursor(self.db) + cursor.update(request) diff --git a/shared/selene/account/repository/sql/add_refresh_token.sql b/shared/selene/account/repository/sql/add_refresh_token.sql new file mode 100644 index 00000000..d07167e1 --- /dev/null +++ b/shared/selene/account/repository/sql/add_refresh_token.sql @@ -0,0 +1,4 @@ +INSERT INTO + account.refresh_token (account_id, refresh_token) +VALUES + (%(account_id)s, %(refresh_token)s) diff --git a/shared/selene/account/repository/sql/get_account_from_credentials.sql b/shared/selene/account/repository/sql/get_account_from_credentials.sql index d350978f..25233429 100644 --- a/shared/selene/account/repository/sql/get_account_from_credentials.sql +++ b/shared/selene/account/repository/sql/get_account_from_credentials.sql @@ -1,11 +1,13 @@ SELECT a.id, a.email_address, - a.refresh_token + array_agg(rt.refresh_token) as refresh_tokens FROM account.account a INNER JOIN account.account_login al ON al.account_id = a.id +INNER JOIN + account.refresh_token rt ON rt.account_id = a.id WHERE a.email_address = %(email_address)s AND al.token = crypt(%(password)s, al.token) From 0e79c40f99686e63aa7d058a12a1b1a8cdc1f04f Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Fri, 1 Feb 2019 22:09:30 -0600 Subject: [PATCH 29/71] reflect changes to account package --- shared/selene/api/base_endpoint.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/shared/selene/api/base_endpoint.py b/shared/selene/api/base_endpoint.py index 981ef1f9..aab604b0 100644 --- a/shared/selene/api/base_endpoint.py +++ b/shared/selene/api/base_endpoint.py @@ -5,7 +5,7 @@ from http import HTTPStatus from flask import request, current_app from flask_restful import Resource -from selene.account import Account, AccountRepository +from selene.account import Account, AccountRepository, RefreshTokenRepository from selene.util.auth import ( AuthenticationError, AuthenticationTokenGenerator, @@ -143,16 +143,12 @@ class SeleneEndpoint(Resource): def _update_refresh_token_on_db(self, new_refresh_token): old_refresh_token = self.request.cookies['seleneRefresh'] with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: - acct_repository = AccountRepository(db) + token_repository = RefreshTokenRepository(db, self.account) if self.refresh_token_expired: - acct_repository.delete_refresh_token( - self.account, - old_refresh_token - ) + token_repository.delete_refresh_token(old_refresh_token) raise AuthenticationError('refresh token expired') else: - acct_repository.update_refresh_token( - self.account, + token_repository.update_refresh_token( new_refresh_token, old_refresh_token ) From 4fbe6e1c6d08128913f21d9910bece6460ff5d7f Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Sat, 2 Feb 2019 00:44:37 -0600 Subject: [PATCH 30/71] removed print statement --- shared/selene/util/db/cursor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/shared/selene/util/db/cursor.py b/shared/selene/util/db/cursor.py index 9eeec995..4d0322fa 100644 --- a/shared/selene/util/db/cursor.py +++ b/shared/selene/util/db/cursor.py @@ -28,7 +28,6 @@ def get_sql_from_file(file_path: str) -> str: with open(path.join(file_path)) as sql_file: raw_sql = sql_file.read() - print(raw_sql) return raw_sql From aec1f18ad8b58f1094aaab29a574aaa2f88d837c Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Sat, 2 Feb 2019 00:45:00 -0600 Subject: [PATCH 31/71] minor bug fix --- shared/selene/api/base_endpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/selene/api/base_endpoint.py b/shared/selene/api/base_endpoint.py index aab604b0..c382f078 100644 --- a/shared/selene/api/base_endpoint.py +++ b/shared/selene/api/base_endpoint.py @@ -113,7 +113,7 @@ class SeleneEndpoint(Resource): def _generate_tokens(self): token_generator = AuthenticationTokenGenerator( - self.account_id, + self.account.id, self.config['ACCESS_SECRET'], self.config['REFRESH_SECRET'] ) From 604fd6108bd105946859f2e5974a3a432370f907 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Sat, 2 Feb 2019 00:45:35 -0600 Subject: [PATCH 32/71] added group by and changed a join to a left join to fix bugs --- .../account/repository/sql/get_account_from_credentials.sql | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shared/selene/account/repository/sql/get_account_from_credentials.sql b/shared/selene/account/repository/sql/get_account_from_credentials.sql index 25233429..fb5eeab9 100644 --- a/shared/selene/account/repository/sql/get_account_from_credentials.sql +++ b/shared/selene/account/repository/sql/get_account_from_credentials.sql @@ -6,8 +6,11 @@ FROM account.account a INNER JOIN account.account_login al ON al.account_id = a.id -INNER JOIN +LEFT JOIN account.refresh_token rt ON rt.account_id = a.id WHERE a.email_address = %(email_address)s AND al.token = crypt(%(password)s, al.token) +GROUP BY + a.id, + a.email_address From 7fd66423df505391dfda1a35a742dc227bf3f596 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Sat, 2 Feb 2019 18:51:19 -0600 Subject: [PATCH 33/71] changed url of internal login endpoint and removed database close logic as it was being called at the wrong time --- api/sso/sso_api/api.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/api/sso/sso_api/api.py b/api/sso/sso_api/api.py index fb150356..3ca80a6d 100644 --- a/api/sso/sso_api/api.py +++ b/api/sso/sso_api/api.py @@ -26,7 +26,7 @@ sso.config['SSO_BASE_URL'] = os.environ['SSO_BASE_URL'] # Initialize the REST API and define the endpoints sso_api = Api(sso, catch_all_404s=True) -sso_api.add_resource(AuthenticateInternalEndpoint, '/api/internal') +sso_api.add_resource(AuthenticateInternalEndpoint, '/api/login/internal') sso_api.add_resource(AuthorizeFacebookEndpoint, '/api/social/facebook') sso_api.add_resource(AuthorizeGithubEndpoint, '/api/social/github') sso_api.add_resource(AuthorizeGoogleEndpoint, '/api/social/google') @@ -49,10 +49,3 @@ def add_cors_headers(response): sso.after_request(add_cors_headers) - - -@sso.teardown_appcontext -def close_db_connections(_): - """Close all pool connections when the app is terminated""" - _log.info('closing connections') - sso.config['DB_CONNECTION_POOL'].closeall() From 59c1075b7e5cce9aa76838953b0a2561c05d78c4 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Sat, 2 Feb 2019 18:52:43 -0600 Subject: [PATCH 34/71] added tests for internal login endpoint and fixed bugs the test uncovered --- .../endpoints/authenticate_internal.py | 28 ++++---- api/sso/tests/features/environment.py | 15 ++++ api/sso/tests/features/internal_login.feature | 13 ++++ api/sso/tests/features/steps/login.py | 68 +++++++++++++++++++ 4 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 api/sso/tests/features/environment.py create mode 100644 api/sso/tests/features/internal_login.feature create mode 100644 api/sso/tests/features/steps/login.py diff --git a/api/sso/sso_api/endpoints/authenticate_internal.py b/api/sso/sso_api/endpoints/authenticate_internal.py index d0a642d2..4596f329 100644 --- a/api/sso/sso_api/endpoints/authenticate_internal.py +++ b/api/sso/sso_api/endpoints/authenticate_internal.py @@ -3,22 +3,16 @@ This type of login is considered "internal" because we are storing the email address and password on our servers. This is as opposed to "external" authentication, which uses a 3rd party authentication, like Google. - """ + from binascii import a2b_base64 from http import HTTPStatus -from time import time from flask import after_this_request from selene.account import Account, AccountRepository, RefreshTokenRepository -from selene.api import SeleneEndpoint, APIError -from selene.util.auth import ( - AuthenticationError, - AuthenticationTokenGenerator, - FIFTEEN_MINUTES, - ONE_MONTH -) +from selene.api import SeleneEndpoint +from selene.util.auth import AuthenticationError from selene.util.db import get_db_connection @@ -37,16 +31,18 @@ class AuthenticateInternalEndpoint(SeleneEndpoint): access_token, refresh_token = self._generate_tokens() self._add_refresh_token_to_db(refresh_token) cookies = self._generate_token_cookies(access_token, refresh_token) - except APIError: - pass + except AuthenticationError as ae: + cookies = None + self.response = (str(ae), HTTPStatus.UNAUTHORIZED) else: self._build_response() @after_this_request def set_cookies(response): - access_token_cookie, refresh_token_cookie = cookies - response.set_cookie(**access_token_cookie) - response.set_cookie(**refresh_token_cookie) + if cookies is not None: + access_token_cookie, refresh_token_cookie = cookies + response.set_cookie(**access_token_cookie) + response.set_cookie(**refresh_token_cookie) return response return self.response @@ -58,8 +54,8 @@ class AuthenticateInternalEndpoint(SeleneEndpoint): binary_credentials = a2b_base64(basic_credentials.strip('Basic ')) email_address, password = binary_credentials.decode().split(':') with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: - auth_repository = AccountRepository(db) - self.account = auth_repository.get_account_from_credentials( + acct_repository = AccountRepository(db) + self.account = acct_repository.get_account_from_credentials( email_address, password ) diff --git a/api/sso/tests/features/environment.py b/api/sso/tests/features/environment.py new file mode 100644 index 00000000..db72d9ad --- /dev/null +++ b/api/sso/tests/features/environment.py @@ -0,0 +1,15 @@ +from behave import fixture, use_fixture + +from sso_api.api import sso + + +@fixture +def sso_client(context): + sso.testing = True + context.client = sso.test_client() + + yield context.client + + +def before_feature(context, _): + use_fixture(sso_client, context) diff --git a/api/sso/tests/features/internal_login.feature b/api/sso/tests/features/internal_login.feature new file mode 100644 index 00000000..f8ea98f8 --- /dev/null +++ b/api/sso/tests/features/internal_login.feature @@ -0,0 +1,13 @@ +Feature: internal login + User signs into a selene web app with an email address and password (rather + than signing in with a third party authenticator, like Google). + + Scenario: User signs in with valid email/password combination + Given user enters email address "devops@mycroft.ai" and password "devops" + When user attempts to login + Then login succeeds + + Scenario: User signs in with invalid email/password combination + Given user enters email address "devops@mycroft.ai" and password "foo" + When user attempts to login + Then login fails diff --git a/api/sso/tests/features/steps/login.py b/api/sso/tests/features/steps/login.py new file mode 100644 index 00000000..b40fa532 --- /dev/null +++ b/api/sso/tests/features/steps/login.py @@ -0,0 +1,68 @@ +from binascii import b2a_base64 +from http import HTTPStatus +from behave import given, then, when +from hamcrest import assert_that, contains, equal_to, has_item + +from selene.account import Account, AccountRepository + + +# TODO: add a step here when the add account logic is built +@given('user enters email address "{user}" and password "{password}"') +def add_credentials_to_db(context, user, password): + context.user = user + context.password = password + + +@when('user attempts to login') +def call_internal_login_endpoint(context): + credentials = '{}:{}'.format(context.user, context.password).encode() + credentials = b2a_base64(credentials, newline=False).decode() + context.response = context.client.get( + '/api/login/internal', + headers=dict(Authorization='Basic ' + credentials)) + + +@then('login succeeds') +def check_for_login_success(context): + assert_that(context.response.status_code, equal_to(200)) + assert_that( + context.response.headers['Access-Control-Allow-Origin'], + equal_to('*') + ) + for cookie in context.response.headers.getlist('Set-Cookie'): + ingredients = parse_cookie(cookie) + ingredient_names = list(ingredients.keys()) + if cookie.startswith('seleneAccess'): + assert_that(ingredient_names, has_item('seleneAccess')) + elif cookie.startswith('seleneRefresh'): + assert_that(ingredient_names, has_item('seleneRefresh')) + else: + raise ValueError('unexpected cookie found: ' + cookie) + for ingredient_name in ('Domain', 'Expires', 'Max-Age', 'HttpOnly'): + assert_that(ingredient_names, has_item(ingredient_name)) + + +@then('login fails') +def check_for_login_fail(context): + assert_that(context.response.status_code, equal_to(401)) + assert_that( + context.response.headers['Access-Control-Allow-Origin'], + equal_to('*') + ) + assert_that(context.response.is_json, equal_to(True)) + assert_that( + context.response.get_json(), + equal_to('provided credentials not found') + ) + + +def parse_cookie(cookie: str) -> dict: + ingredients = {} + for ingredient in cookie.split('; '): + if '=' in ingredient: + key, value = ingredient.split('=') + ingredients[key] = value + else: + ingredients[ingredient] = None + + return ingredients From c5c287047f63dda55b63f4f5480cb2f69e86688a Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Sat, 2 Feb 2019 18:53:18 -0600 Subject: [PATCH 35/71] added the behave and hamcrest packages for testing --- api/sso/Pipfile | 2 ++ api/sso/Pipfile.lock | 31 ++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/api/sso/Pipfile b/api/sso/Pipfile index 3c8e5e67..ea2b0034 100644 --- a/api/sso/Pipfile +++ b/api/sso/Pipfile @@ -13,6 +13,8 @@ uwsgi = "*" [dev-packages] selene = {editable = true,path = "./../../shared"} +behave = "*" +pyhamcrest = "*" [requires] python_version = "3.7" diff --git a/api/sso/Pipfile.lock b/api/sso/Pipfile.lock index a4c02306..ce7f672a 100644 --- a/api/sso/Pipfile.lock +++ b/api/sso/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "48ca62ed518781784ac47dd3096355c49757222721de19fe7c636b100100cec1" + "sha256": "ded1e716dc407aa28e78eb5131a7d9b2a6c5e52f6ef3ab6d1e30fd8fd4998dc6" }, "pipfile-spec": 6, "requires": { @@ -175,6 +175,14 @@ ], "version": "==4.1.0" }, + "behave": { + "hashes": [ + "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86", + "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c" + ], + "index": "pypi", + "version": "==1.2.6" + }, "certifi": { "hashes": [ "sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", @@ -274,6 +282,19 @@ ], "version": "==1.1.0" }, + "parse": { + "hashes": [ + "sha256:870dd675c1ee8951db3e29b81ebe44fd131e3eb8c03a79483a58ea574f3145c2" + ], + "version": "==1.11.1" + }, + "parse-type": { + "hashes": [ + "sha256:6e906a66f340252e4c324914a60d417d33a4bea01292ea9bbf68b4fc123be8c9", + "sha256:f596bdc75d3dd93036fbfe3d04127da9f6df0c26c36e01e76da85adef4336b3c" + ], + "version": "==0.4.2" + }, "psycopg2-binary": { "hashes": [ "sha256:19a2d1f3567b30f6c2bb3baea23f74f69d51f0c06c2e2082d0d9c28b0733a4c2", @@ -315,6 +336,14 @@ ], "version": "==1.43.5" }, + "pyhamcrest": { + "hashes": [ + "sha256:6b672c02fdf7470df9674ab82263841ce8333fb143f32f021f6cb26f0e512420", + "sha256:8ffaa0a53da57e89de14ced7185ac746227a8894dbd5a3c718bf05ddbd1d56cd" + ], + "index": "pypi", + "version": "==1.9.0" + }, "pyjwt": { "hashes": [ "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", From 70ccab71de45e6afd74cb4cba936de10ca8ebb1a Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Mon, 4 Feb 2019 15:03:19 -0600 Subject: [PATCH 36/71] minor bug fix for cookie domain --- shared/selene/api/base_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shared/selene/api/base_config.py b/shared/selene/api/base_config.py index a4c6cb02..8f1bf377 100644 --- a/shared/selene/api/base_config.py +++ b/shared/selene/api/base_config.py @@ -46,15 +46,15 @@ class BaseConfig(object): class DevelopmentConfig(BaseConfig): DEBUG = True - DOMAIN = 'mycroft.test' + DOMAIN = '.localhost' class TestConfig(BaseConfig): - DOMAIN = 'mycroft-test.net' + DOMAIN = '.mycroft-test.net' class ProdConfig(BaseConfig): - DOMAIN = 'mycroft.ai' + DOMAIN = '.mycroft.ai' def get_base_config(): From aea98511168006fbd007409f91245a775423e91c Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Mon, 4 Feb 2019 15:04:22 -0600 Subject: [PATCH 37/71] moved the hook to add the cookies to the response into the base class to avoid re-coding it in every place it is needed. --- api/sso/sso_api/endpoints/authenticate_internal.py | 13 +------------ shared/selene/api/base_endpoint.py | 9 +++++++-- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/api/sso/sso_api/endpoints/authenticate_internal.py b/api/sso/sso_api/endpoints/authenticate_internal.py index 4596f329..a8fa9aa2 100644 --- a/api/sso/sso_api/endpoints/authenticate_internal.py +++ b/api/sso/sso_api/endpoints/authenticate_internal.py @@ -8,8 +8,6 @@ authentication, which uses a 3rd party authentication, like Google. from binascii import a2b_base64 from http import HTTPStatus -from flask import after_this_request - from selene.account import Account, AccountRepository, RefreshTokenRepository from selene.api import SeleneEndpoint from selene.util.auth import AuthenticationError @@ -30,21 +28,12 @@ class AuthenticateInternalEndpoint(SeleneEndpoint): self._authenticate_credentials() access_token, refresh_token = self._generate_tokens() self._add_refresh_token_to_db(refresh_token) - cookies = self._generate_token_cookies(access_token, refresh_token) + self._generate_token_cookies(access_token, refresh_token) except AuthenticationError as ae: - cookies = None self.response = (str(ae), HTTPStatus.UNAUTHORIZED) else: self._build_response() - @after_this_request - def set_cookies(response): - if cookies is not None: - access_token_cookie, refresh_token_cookie = cookies - response.set_cookie(**access_token_cookie) - response.set_cookie(**refresh_token_cookie) - return response - return self.response def _authenticate_credentials(self): diff --git a/shared/selene/api/base_endpoint.py b/shared/selene/api/base_endpoint.py index c382f078..ea6b5089 100644 --- a/shared/selene/api/base_endpoint.py +++ b/shared/selene/api/base_endpoint.py @@ -2,7 +2,7 @@ from http import HTTPStatus -from flask import request, current_app +from flask import after_this_request, current_app, request from flask_restful import Resource from selene.account import Account, AccountRepository, RefreshTokenRepository @@ -138,7 +138,12 @@ class SeleneEndpoint(Resource): httponly=True ) - return access_token_cookie, refresh_token_cookie + @after_this_request + def set_cookies(response): + response.set_cookie(**access_token_cookie) + response.set_cookie(**refresh_token_cookie) + + return response def _update_refresh_token_on_db(self, new_refresh_token): old_refresh_token = self.request.cookies['seleneRefresh'] From 06fbbae76d74abd52edd811af0f18d5c82a1789e Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 11:29:00 -0600 Subject: [PATCH 38/71] changed how cookies are build to fix issue with accessing them in client code --- shared/selene/api/base_endpoint.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/shared/selene/api/base_endpoint.py b/shared/selene/api/base_endpoint.py index ea6b5089..bce3d6c2 100644 --- a/shared/selene/api/base_endpoint.py +++ b/shared/selene/api/base_endpoint.py @@ -128,14 +128,12 @@ class SeleneEndpoint(Resource): value=str(access_token), domain=self.config['DOMAIN'], max_age=FIFTEEN_MINUTES, - httponly=True ) refresh_token_cookie = dict( key='seleneRefresh', value=str(refresh_token), domain=self.config['DOMAIN'], max_age=ONE_MONTH, - httponly=True ) @after_this_request From ce444f3293e31c6b8fa084b56095a90d9ebd591b Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 11:29:35 -0600 Subject: [PATCH 39/71] changed dev domain setting for https purposes --- shared/selene/api/base_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/selene/api/base_config.py b/shared/selene/api/base_config.py index 8f1bf377..b9120e41 100644 --- a/shared/selene/api/base_config.py +++ b/shared/selene/api/base_config.py @@ -46,7 +46,7 @@ class BaseConfig(object): class DevelopmentConfig(BaseConfig): DEBUG = True - DOMAIN = '.localhost' + DOMAIN = '.mycroft.test' class TestConfig(BaseConfig): From e4a165d2797881f5df798eb021413920b143cbd7 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 11:30:37 -0600 Subject: [PATCH 40/71] new functionality for federated login to ensure the email address exists on our platform --- shared/selene/account/repository/account.py | 14 ++++++++++++++ .../repository/sql/get_account_by_email.sql | 13 +++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 shared/selene/account/repository/sql/get_account_by_email.sql diff --git a/shared/selene/account/repository/account.py b/shared/selene/account/repository/account.py index 25a7cdd4..9ee1a1a3 100644 --- a/shared/selene/account/repository/account.py +++ b/shared/selene/account/repository/account.py @@ -45,3 +45,17 @@ class AccountRepository(object): if sql_results is not None: return Account(**sql_results) + + def get_account_by_email(self, email_address): + account = None + request = DatabaseRequest( + file_path=path.join(SQL_DIR, 'get_account_by_email.sql'), + args=dict(email_address=email_address), + ) + cursor = Cursor(self.db) + db_response = cursor.select_one(request) + + if db_response is not None: + account = Account(**db_response) + + return account diff --git a/shared/selene/account/repository/sql/get_account_by_email.sql b/shared/selene/account/repository/sql/get_account_by_email.sql new file mode 100644 index 00000000..fb838f57 --- /dev/null +++ b/shared/selene/account/repository/sql/get_account_by_email.sql @@ -0,0 +1,13 @@ +SELECT + a.id, + a.email_address, + array_agg(rt.refresh_token) as refresh_tokens +FROM + account.account a +INNER JOIN + account.refresh_token rt on a.id = rt.account_id +WHERE + a.email_address = %(email_address)s +GROUP BY + a.id, + a.email_address From e6b8748296414faa5c89e0490450152b94679e9a Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 11:33:19 -0600 Subject: [PATCH 41/71] replaced previous social login logic with new client-side implementation --- api/sso/sso_api/api.py | 19 +++++----- api/sso/sso_api/endpoints/__init__.py | 5 +-- .../sso_api/endpoints/authenticate_social.py | 38 ------------------- api/sso/sso_api/endpoints/facebook.py | 17 --------- api/sso/sso_api/endpoints/github.py | 18 --------- api/sso/sso_api/endpoints/google.py | 18 --------- .../sso_api/endpoints/social_login_tokens.py | 32 ---------------- .../sso_api/endpoints/validate_federated.py | 37 ++++++++++++++++++ 8 files changed, 47 insertions(+), 137 deletions(-) delete mode 100644 api/sso/sso_api/endpoints/authenticate_social.py delete mode 100644 api/sso/sso_api/endpoints/facebook.py delete mode 100644 api/sso/sso_api/endpoints/github.py delete mode 100644 api/sso/sso_api/endpoints/google.py delete mode 100644 api/sso/sso_api/endpoints/social_login_tokens.py create mode 100644 api/sso/sso_api/endpoints/validate_federated.py diff --git a/api/sso/sso_api/api.py b/api/sso/sso_api/api.py index 3ca80a6d..4c492822 100644 --- a/api/sso/sso_api/api.py +++ b/api/sso/sso_api/api.py @@ -10,11 +10,12 @@ from selene.api.base_config import get_base_config from .endpoints import ( AuthenticateInternalEndpoint, - SocialLoginTokensEndpoint, - AuthorizeFacebookEndpoint, - AuthorizeGithubEndpoint, - AuthorizeGoogleEndpoint, - LogoutEndpoint + # SocialLoginTokensEndpoint, + # AuthorizeFacebookEndpoint, + # AuthorizeGithubEndpoint, + # AuthorizeGoogleEndpoint, + LogoutEndpoint, + ValidateFederatedEndpoint ) _log = getLogger('sso_api') @@ -26,11 +27,9 @@ sso.config['SSO_BASE_URL'] = os.environ['SSO_BASE_URL'] # Initialize the REST API and define the endpoints sso_api = Api(sso, catch_all_404s=True) -sso_api.add_resource(AuthenticateInternalEndpoint, '/api/login/internal') -sso_api.add_resource(AuthorizeFacebookEndpoint, '/api/social/facebook') -sso_api.add_resource(AuthorizeGithubEndpoint, '/api/social/github') -sso_api.add_resource(AuthorizeGoogleEndpoint, '/api/social/google') -sso_api.add_resource(SocialLoginTokensEndpoint, '/api/social/tokens') +sso_api.add_resource(AuthenticateInternalEndpoint, '/api/internal-login') +sso_api.add_resource(ValidateFederatedEndpoint, '/api/validate-federated') + sso_api.add_resource(LogoutEndpoint, '/api/logout') diff --git a/api/sso/sso_api/endpoints/__init__.py b/api/sso/sso_api/endpoints/__init__.py index ac1c3e05..f864d85a 100644 --- a/api/sso/sso_api/endpoints/__init__.py +++ b/api/sso/sso_api/endpoints/__init__.py @@ -1,6 +1,3 @@ from .authenticate_internal import AuthenticateInternalEndpoint -from .social_login_tokens import SocialLoginTokensEndpoint -from .facebook import AuthorizeFacebookEndpoint -from .github import AuthorizeGithubEndpoint -from .google import AuthorizeGoogleEndpoint from .logout import LogoutEndpoint +from .validate_federated import ValidateFederatedEndpoint diff --git a/api/sso/sso_api/endpoints/authenticate_social.py b/api/sso/sso_api/endpoints/authenticate_social.py deleted file mode 100644 index ff42269f..00000000 --- a/api/sso/sso_api/endpoints/authenticate_social.py +++ /dev/null @@ -1,38 +0,0 @@ -from http import HTTPStatus - -from selene.api import SeleneEndpoint -from selene.util.auth import encode_auth_token, ONE_DAY -from time import time -import json - - -class AuthenticateSocialEndpoint(SeleneEndpoint): - def __init__(self): - super(AuthenticateSocialEndpoint, self).__init__() - self.response_status_code = HTTPStatus.OK - self.tartarus_token = None - self.users_uuid = None - - def get(self): - self._get_tartarus_token() - self._build_front_end_response() - return self.response - - def _get_tartarus_token(self): - args = self.request.args - if "data" in args: - self.tartarus_token = args['data'] - token_json = json.loads(self.tartarus_token) - self.users_uuid = token_json["uuid"] - - def _build_front_end_response(self): - self.selene_token = encode_auth_token( - self.config['SECRET_KEY'], self.users_uuid - ) - - response_data = dict( - expiration=time() + ONE_DAY, - seleneToken=self.selene_token, - tartarusToken=self.tartarus_token, - ) - self.response = (response_data, HTTPStatus.OK) diff --git a/api/sso/sso_api/endpoints/facebook.py b/api/sso/sso_api/endpoints/facebook.py deleted file mode 100644 index 6bf8fd59..00000000 --- a/api/sso/sso_api/endpoints/facebook.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Endpoint for single sign on through Facebook""" -from flask import redirect - -from selene.api import SeleneEndpoint - - -class AuthorizeFacebookEndpoint(SeleneEndpoint): - def get(self): - """Call a Tartarus endpoint that will redirect to Facebook login.""" - tartarus_auth_endpoint = ( - '{tartarus_url}/social/auth/facebook' - '?clientUri={login_url}&path=/social/login'.format( - tartarus_url=self.config['TARTARUS_BASE_URL'], - login_url=self.config['SSO_BASE_URL'] - ) - ) - return redirect(tartarus_auth_endpoint) diff --git a/api/sso/sso_api/endpoints/github.py b/api/sso/sso_api/endpoints/github.py deleted file mode 100644 index a4f085a6..00000000 --- a/api/sso/sso_api/endpoints/github.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Endpoint for single sign on through Github""" -from flask import redirect - -from selene.api import SeleneEndpoint - - -class AuthorizeGithubEndpoint(SeleneEndpoint): - - def get(self): - """Call a Tartarus endpoint that will redirect to Github login.""" - tartarus_auth_endpoint = ( - '{tartarus_url}/social/auth/github' - '?clientUri={login_url}&path=/social/login'.format( - tartarus_url=self.config['TARTARUS_BASE_URL'], - login_url=self.config['SSO_BASE_URL'] - ) - ) - return redirect(tartarus_auth_endpoint) diff --git a/api/sso/sso_api/endpoints/google.py b/api/sso/sso_api/endpoints/google.py deleted file mode 100644 index d1f213fe..00000000 --- a/api/sso/sso_api/endpoints/google.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Endpoint for single sign on through Google""" -from flask import redirect - -from selene.api import SeleneEndpoint - - -class AuthorizeGoogleEndpoint(SeleneEndpoint): - - def get(self): - """Call a Tartarus endpoint that will redirect to Google login.""" - tartarus_auth_endpoint = ( - '{tartarus_url}/social/auth/google' - '?clientUri={login_url}&path=/social/login'.format( - login_url=self.config['SSO_BASE_URL'], - tartarus_url=self.config['TARTARUS_BASE_URL'] - ) - ) - return redirect(tartarus_auth_endpoint) diff --git a/api/sso/sso_api/endpoints/social_login_tokens.py b/api/sso/sso_api/endpoints/social_login_tokens.py deleted file mode 100644 index d021031f..00000000 --- a/api/sso/sso_api/endpoints/social_login_tokens.py +++ /dev/null @@ -1,32 +0,0 @@ -from http import HTTPStatus -import json -from time import time - -from selene.api import SeleneEndpoint -from selene.util.auth import encode_auth_token, ONE_DAY - - -class SocialLoginTokensEndpoint(SeleneEndpoint): - def post(self): - self._get_tartarus_token() - self._build_selene_token() - self._build_response() - return self.response - - def _get_tartarus_token(self): - request_data = json.loads(self.request.data) - self.tartarus_token = request_data['accessToken'] - self.user_uuid = request_data["uuid"] - - def _build_selene_token(self): - self.selene_token = encode_auth_token( - self.config['SECRET_KEY'], self.user_uuid - ) - - def _build_response(self): - response_data = dict( - expiration=time() + ONE_DAY, - seleneToken=self.selene_token, - tartarusToken=self.tartarus_token, - ) - self.response = (response_data, HTTPStatus.OK) diff --git a/api/sso/sso_api/endpoints/validate_federated.py b/api/sso/sso_api/endpoints/validate_federated.py new file mode 100644 index 00000000..31e8a3ed --- /dev/null +++ b/api/sso/sso_api/endpoints/validate_federated.py @@ -0,0 +1,37 @@ +from http import HTTPStatus +import json + +from selene.api import SeleneEndpoint +from selene.account import AccountRepository, RefreshTokenRepository +from selene.util.auth import AuthenticationError +from selene.util.db import get_db_connection + + +class ValidateFederatedEndpoint(SeleneEndpoint): + def post(self): + try: + self._get_account() + except AuthenticationError as ae: + self.response = str(ae), HTTPStatus.UNAUTHORIZED + else: + access_token, refresh_token = self._generate_tokens() + self._generate_token_cookies(access_token, refresh_token) + self._update_refresh_token_on_db(refresh_token) + self.response = 'account validated', HTTPStatus.OK + + return self.response + + def _get_account(self): + request_data = json.loads(self.request.data) + email_address = request_data['email'] + with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: + acct_repository = AccountRepository(db) + self.account = acct_repository.get_account_by_email(email_address) + + if self.account is None: + raise AuthenticationError('account not found') + + def _add_refresh_token_to_db(self, refresh_token): + with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: + token_repo = RefreshTokenRepository(db, self.account) + token_repo.add_refresh_token(refresh_token) From 41cbf732f1e62f57efe37f848ff2d50402e45027 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 12:06:19 -0600 Subject: [PATCH 42/71] changed join on refresh token table to outer join in case there are no refresh tokens for an account --- shared/selene/account/repository/sql/get_account_by_email.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/selene/account/repository/sql/get_account_by_email.sql b/shared/selene/account/repository/sql/get_account_by_email.sql index fb838f57..22158fa9 100644 --- a/shared/selene/account/repository/sql/get_account_by_email.sql +++ b/shared/selene/account/repository/sql/get_account_by_email.sql @@ -4,7 +4,7 @@ SELECT array_agg(rt.refresh_token) as refresh_tokens FROM account.account a -INNER JOIN +LEFT JOIN account.refresh_token rt on a.id = rt.account_id WHERE a.email_address = %(email_address)s From f8f3cf3957acbdb03e1127ff8befb5859bc6d969 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 12:06:49 -0600 Subject: [PATCH 43/71] changed join on refresh token table to outer join in case there are no refresh tokens for an account --- shared/selene/account/repository/sql/get_account_by_id.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/selene/account/repository/sql/get_account_by_id.sql b/shared/selene/account/repository/sql/get_account_by_id.sql index 7b79893e..dcb9ec90 100644 --- a/shared/selene/account/repository/sql/get_account_by_id.sql +++ b/shared/selene/account/repository/sql/get_account_by_id.sql @@ -4,7 +4,7 @@ SELECT array_agg(rt.refresh_token) as refresh_tokens FROM account.account a -INNER JOIN +LEFT JOIN account.refresh_token rt on a.id = rt.account_id WHERE id = %(account_id)s From 440bdaabc62230b2658d7a7b297a045226942be3 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 12:28:49 -0600 Subject: [PATCH 44/71] provided ability to specify value of database connection's autocommit attribute --- shared/selene/util/db/connection.py | 7 ++++--- shared/selene/util/db/connection_pool.py | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/shared/selene/util/db/connection.py b/shared/selene/util/db/connection.py index 0bda0ce1..e1890064 100644 --- a/shared/selene/util/db/connection.py +++ b/shared/selene/util/db/connection.py @@ -31,7 +31,7 @@ class DatabaseConnectionConfig(object): @contextmanager -def connect_to_db(connection_config: DatabaseConnectionConfig): +def connect_to_db(connection_config: DatabaseConnectionConfig, autocommit=True): """ Return a connection to the mycroft database for the specified user. @@ -40,6 +40,7 @@ def connect_to_db(connection_config: DatabaseConnectionConfig): python notebook) :param connection_config: data needed to establish a connection + :param autocommit: indicated if transactions should commit automatically :return: database connection """ db = None @@ -50,9 +51,9 @@ def connect_to_db(connection_config: DatabaseConnectionConfig): host=connection_config.host, dbname=connection_config.db_name, user=connection_config.user, - cursor_factory=RealDictCursor + cursor_factory=RealDictCursor, ) - db.autocommit = True + db.autocommit = autocommit yield db finally: if db is not None: diff --git a/shared/selene/util/db/connection_pool.py b/shared/selene/util/db/connection_pool.py index 144597ff..aeaeedab 100644 --- a/shared/selene/util/db/connection_pool.py +++ b/shared/selene/util/db/connection_pool.py @@ -49,15 +49,17 @@ def allocate_db_connection_pool( @contextmanager -def get_db_connection(connection_pool): +def get_db_connection(connection_pool, autocommit=True): """Obtain a database connection from a pool and release it when finished :param connection_pool: pool of connections used by the applications + :param autocommit: indicates if transactions should commit automatically :return: context object containing a database connection from the pool """ db_connection = None try: db_connection = connection_pool.getconn() + db_connection.autocommit = autocommit yield db_connection finally: # return the db connection to the pool when exiting the context From 845dd6b308b48fe6b3ce67ccd8c11d9894251da5 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 12:48:48 -0600 Subject: [PATCH 45/71] fixed minor bugs found in testing --- shared/selene/account/repository/refresh_token.py | 2 +- shared/selene/account/repository/sql/delete_refresh_token.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/selene/account/repository/refresh_token.py b/shared/selene/account/repository/refresh_token.py index f7fa5693..eba25b8b 100644 --- a/shared/selene/account/repository/refresh_token.py +++ b/shared/selene/account/repository/refresh_token.py @@ -27,7 +27,7 @@ class RefreshTokenRepository(object): args=dict(account_id=self.account.id, refresh_token=token), ) cursor = Cursor(self.db) - cursor.update(request) + cursor.delete(request) def update_refresh_token(self, old: str, new: str): """When a new refresh token is generated replace the old one""" diff --git a/shared/selene/account/repository/sql/delete_refresh_token.sql b/shared/selene/account/repository/sql/delete_refresh_token.sql index 66f9d43b..efe7cc43 100644 --- a/shared/selene/account/repository/sql/delete_refresh_token.sql +++ b/shared/selene/account/repository/sql/delete_refresh_token.sql @@ -1,5 +1,5 @@ DELETE FROM account.refresh_token WHERE - account_id = %(account_id) AND + account_id = %(account_id)s AND refresh_token = %(refresh_token)s From 6b6a687a00acb0bdb7129b43e539f5b4d35e4baa Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 12:59:23 -0600 Subject: [PATCH 46/71] updated tests to include the validate_federated endpoint --- .../sso_api/endpoints/validate_federated.py | 5 +-- api/sso/tests/features/environment.py | 10 +++++ api/sso/tests/features/internal_login.feature | 12 +++++- api/sso/tests/features/steps/login.py | 40 +++++++++++++------ 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/api/sso/sso_api/endpoints/validate_federated.py b/api/sso/sso_api/endpoints/validate_federated.py index 31e8a3ed..4967a584 100644 --- a/api/sso/sso_api/endpoints/validate_federated.py +++ b/api/sso/sso_api/endpoints/validate_federated.py @@ -16,14 +16,13 @@ class ValidateFederatedEndpoint(SeleneEndpoint): else: access_token, refresh_token = self._generate_tokens() self._generate_token_cookies(access_token, refresh_token) - self._update_refresh_token_on_db(refresh_token) + self._add_refresh_token_to_db(refresh_token) self.response = 'account validated', HTTPStatus.OK return self.response def _get_account(self): - request_data = json.loads(self.request.data) - email_address = request_data['email'] + email_address = self.request.form['email'] with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: acct_repository = AccountRepository(db) self.account = acct_repository.get_account_by_email(email_address) diff --git a/api/sso/tests/features/environment.py b/api/sso/tests/features/environment.py index db72d9ad..1aeab5c7 100644 --- a/api/sso/tests/features/environment.py +++ b/api/sso/tests/features/environment.py @@ -1,11 +1,14 @@ from behave import fixture, use_fixture from sso_api.api import sso +from selene.account import RefreshTokenRepository +from selene.util.db import get_db_connection @fixture def sso_client(context): sso.testing = True + context.db_pool = sso.config['DB_CONNECTION_POOL'] context.client = sso.test_client() yield context.client @@ -13,3 +16,10 @@ def sso_client(context): def before_feature(context, _): use_fixture(sso_client, context) + + +def after_scenario(context, _): + if hasattr(context, 'refresh_token'): + with get_db_connection(context.db_pool) as db: + token_repository = RefreshTokenRepository(db, context.account) + token_repository.delete_refresh_token(context.refresh_token) diff --git a/api/sso/tests/features/internal_login.feature b/api/sso/tests/features/internal_login.feature index f8ea98f8..a2faad5f 100644 --- a/api/sso/tests/features/internal_login.feature +++ b/api/sso/tests/features/internal_login.feature @@ -10,4 +10,14 @@ Feature: internal login Scenario: User signs in with invalid email/password combination Given user enters email address "devops@mycroft.ai" and password "foo" When user attempts to login - Then login fails + Then login fails with "provided credentials not found" error + + Scenario: User with existing account signs in via Facebook + Given user "devops@mycroft.ai" authenticates through facebook + When single sign on validates the account + Then login succeeds + + Scenario: User without account signs in via Facebook + Given user "foo@mycroft.ai" authenticates through facebook + When single sign on validates the account + Then login fails with "account not found" error diff --git a/api/sso/tests/features/steps/login.py b/api/sso/tests/features/steps/login.py index b40fa532..f797eba1 100644 --- a/api/sso/tests/features/steps/login.py +++ b/api/sso/tests/features/steps/login.py @@ -4,21 +4,35 @@ from behave import given, then, when from hamcrest import assert_that, contains, equal_to, has_item from selene.account import Account, AccountRepository +from selene.util.db import get_db_connection # TODO: add a step here when the add account logic is built -@given('user enters email address "{user}" and password "{password}"') -def add_credentials_to_db(context, user, password): - context.user = user +@given('user enters email address "{email}" and password "{password}"') +def add_credentials_to_db(context, email, password): + context.email = email context.password = password +@given('user "{email}" authenticates through facebook') +def add_credentials_to_db(context, email): + context.email = email + + +@when('single sign on validates the account') +def call_validate_federated_endpoint(context): + context.response = context.client.post( + '/api/validate-federated', + data=dict(email=context.email) + ) + + @when('user attempts to login') def call_internal_login_endpoint(context): - credentials = '{}:{}'.format(context.user, context.password).encode() + credentials = '{}:{}'.format(context.email, context.password).encode() credentials = b2a_base64(credentials, newline=False).decode() context.response = context.client.get( - '/api/login/internal', + '/api/internal-login', headers=dict(Authorization='Basic ' + credentials)) @@ -36,24 +50,26 @@ def check_for_login_success(context): assert_that(ingredient_names, has_item('seleneAccess')) elif cookie.startswith('seleneRefresh'): assert_that(ingredient_names, has_item('seleneRefresh')) + context.refresh_token = ingredients['seleneRefresh'] else: raise ValueError('unexpected cookie found: ' + cookie) - for ingredient_name in ('Domain', 'Expires', 'Max-Age', 'HttpOnly'): + for ingredient_name in ('Domain', 'Expires', 'Max-Age'): assert_that(ingredient_names, has_item(ingredient_name)) + with get_db_connection(context.db_pool) as db: + acct_repository = AccountRepository(db) + context.account = acct_repository.get_account_by_email(context.email) + assert_that(context.account.refresh_tokens, has_item(context.refresh_token)) -@then('login fails') -def check_for_login_fail(context): +@then('login fails with "{error_message}" error') +def check_for_login_fail(context, error_message): assert_that(context.response.status_code, equal_to(401)) assert_that( context.response.headers['Access-Control-Allow-Origin'], equal_to('*') ) assert_that(context.response.is_json, equal_to(True)) - assert_that( - context.response.get_json(), - equal_to('provided credentials not found') - ) + assert_that(context.response.get_json(), equal_to(error_message)) def parse_cookie(cookie: str) -> dict: From dc5a330a59bf007ffeff89f111e7a19ac0405e71 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 13:00:36 -0600 Subject: [PATCH 47/71] removed unused import --- api/sso/sso_api/endpoints/validate_federated.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/sso/sso_api/endpoints/validate_federated.py b/api/sso/sso_api/endpoints/validate_federated.py index 4967a584..cc341953 100644 --- a/api/sso/sso_api/endpoints/validate_federated.py +++ b/api/sso/sso_api/endpoints/validate_federated.py @@ -1,5 +1,4 @@ from http import HTTPStatus -import json from selene.api import SeleneEndpoint from selene.account import AccountRepository, RefreshTokenRepository From 745f7fd7e371de4e40783b21c6e1aab624719235 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 13:25:22 -0600 Subject: [PATCH 48/71] added ability to expire a token cookie --- api/sso/sso_api/endpoints/authenticate_internal.py | 2 +- api/sso/sso_api/endpoints/validate_federated.py | 2 +- shared/selene/api/base_endpoint.py | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/sso/sso_api/endpoints/authenticate_internal.py b/api/sso/sso_api/endpoints/authenticate_internal.py index a8fa9aa2..734a8b13 100644 --- a/api/sso/sso_api/endpoints/authenticate_internal.py +++ b/api/sso/sso_api/endpoints/authenticate_internal.py @@ -28,7 +28,7 @@ class AuthenticateInternalEndpoint(SeleneEndpoint): self._authenticate_credentials() access_token, refresh_token = self._generate_tokens() self._add_refresh_token_to_db(refresh_token) - self._generate_token_cookies(access_token, refresh_token) + self._set_token_cookies(access_token, refresh_token) except AuthenticationError as ae: self.response = (str(ae), HTTPStatus.UNAUTHORIZED) else: diff --git a/api/sso/sso_api/endpoints/validate_federated.py b/api/sso/sso_api/endpoints/validate_federated.py index cc341953..90d64f4f 100644 --- a/api/sso/sso_api/endpoints/validate_federated.py +++ b/api/sso/sso_api/endpoints/validate_federated.py @@ -14,7 +14,7 @@ class ValidateFederatedEndpoint(SeleneEndpoint): self.response = str(ae), HTTPStatus.UNAUTHORIZED else: access_token, refresh_token = self._generate_tokens() - self._generate_token_cookies(access_token, refresh_token) + self._set_token_cookies(access_token, refresh_token) self._add_refresh_token_to_db(refresh_token) self.response = 'account validated', HTTPStatus.OK diff --git a/shared/selene/api/base_endpoint.py b/shared/selene/api/base_endpoint.py index bce3d6c2..f9cd04b4 100644 --- a/shared/selene/api/base_endpoint.py +++ b/shared/selene/api/base_endpoint.py @@ -122,7 +122,7 @@ class SeleneEndpoint(Resource): return access_token, refresh_token - def _generate_token_cookies(self, access_token, refresh_token): + def _set_token_cookies(self, access_token, refresh_token, expire=False): access_token_cookie = dict( key='seleneAccess', value=str(access_token), @@ -136,6 +136,10 @@ class SeleneEndpoint(Resource): max_age=ONE_MONTH, ) + if expire: + for cookie in (access_token_cookie, refresh_token_cookie): + cookie.update(value='', max_age=0) + @after_this_request def set_cookies(response): response.set_cookie(**access_token_cookie) From c9d167549c24652355c0ea823a8e5cf1b00c9ad5 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 16:23:41 -0600 Subject: [PATCH 49/71] added functionality to insert a new account and added the password field back into the account object --- shared/selene/account/entity/account.py | 1 + shared/selene/account/repository/account.py | 30 +++++++++++++++++-- .../account/repository/sql/add_account.sql | 6 ++++ .../sql/get_account_from_credentials.sql | 8 ++--- 4 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 shared/selene/account/repository/sql/add_account.sql diff --git a/shared/selene/account/entity/account.py b/shared/selene/account/entity/account.py index 96e0544d..54729306 100644 --- a/shared/selene/account/entity/account.py +++ b/shared/selene/account/entity/account.py @@ -7,4 +7,5 @@ class Account(object): """Representation of a Mycroft user account.""" id: str email_address: str + password: str refresh_tokens: List[str] diff --git a/shared/selene/account/repository/account.py b/shared/selene/account/repository/account.py index 9ee1a1a3..cad29067 100644 --- a/shared/selene/account/repository/account.py +++ b/shared/selene/account/repository/account.py @@ -1,4 +1,5 @@ -from os import path +from passlib.hash import sha512_crypt +from os import environ, path from selene.util.db import DatabaseRequest, Cursor from ..entity.account import Account @@ -6,10 +7,34 @@ from ..entity.account import Account SQL_DIR = path.join(path.dirname(__file__), 'sql') +def _encrypt_password(password): + salt = environ['SALT'] + hash_result = sha512_crypt.using(salt=salt, rounds=5000).hash(password) + hashed_password_index = hash_result.rindex('$') + 1 + + return hash_result[hashed_password_index:] + + class AccountRepository(object): def __init__(self, db): self.db = db + def add(self, email_address: str, password: str): + encrypted_password = _encrypt_password(password) + request = DatabaseRequest( + file_path=path.join(SQL_DIR, 'add_account.sql'), + args=dict(email_address=email_address, password=encrypted_password) + ) + cursor = Cursor(self.db) + sql_results = cursor.insert_returning(request) + + return Account( + id=sql_results, + email_address=email_address, + password=encrypted_password, + refresh_tokens=[] + ) + def get_account_by_id(self, account_id: str) -> Account: """Use a given uuid to query the database for an account @@ -36,9 +61,10 @@ class AccountRepository(object): :param password: the user provided password :return: the matching account record, if one is found """ + encrypted_password = _encrypt_password(password) query = DatabaseRequest( file_path=path.join(SQL_DIR, 'get_account_from_credentials.sql'), - args=dict(email_address=email, password=password), + args=dict(email_address=email, password=encrypted_password), ) cursor = Cursor(self.db) sql_results = cursor.select_one(query) diff --git a/shared/selene/account/repository/sql/add_account.sql b/shared/selene/account/repository/sql/add_account.sql new file mode 100644 index 00000000..f59cf4d8 --- /dev/null +++ b/shared/selene/account/repository/sql/add_account.sql @@ -0,0 +1,6 @@ +INSERT INTO + account.account (email_address, password) +VALUES + (%(email_address)s, %(password)s) +RETURNING + id diff --git a/shared/selene/account/repository/sql/get_account_from_credentials.sql b/shared/selene/account/repository/sql/get_account_from_credentials.sql index fb5eeab9..c579cede 100644 --- a/shared/selene/account/repository/sql/get_account_from_credentials.sql +++ b/shared/selene/account/repository/sql/get_account_from_credentials.sql @@ -1,16 +1,16 @@ SELECT a.id, a.email_address, + a.password, array_agg(rt.refresh_token) as refresh_tokens FROM account.account a -INNER JOIN - account.account_login al ON al.account_id = a.id LEFT JOIN account.refresh_token rt ON rt.account_id = a.id WHERE a.email_address = %(email_address)s AND - al.token = crypt(%(password)s, al.token) + a.password = %(password)s GROUP BY a.id, - a.email_address + a.email_address, + a.password From f5d2f464ed16a7659643108dc9b62e547d5f29bb Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 16:24:38 -0600 Subject: [PATCH 50/71] get rid of the raise of the API error when authentication fails. there is already an indicator of failed authentication, the self.authenticated flag --- shared/selene/api/base_endpoint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/shared/selene/api/base_endpoint.py b/shared/selene/api/base_endpoint.py index f9cd04b4..bcda37c7 100644 --- a/shared/selene/api/base_endpoint.py +++ b/shared/selene/api/base_endpoint.py @@ -53,7 +53,6 @@ class SeleneEndpoint(Resource): except AuthenticationError as ae: if self.authentication_required: self.response = (str(ae), HTTPStatus.UNAUTHORIZED) - raise APIError() else: self.authenticated = True From 7619e35333f7e4d03c6a9ce1c9ef4c5953ecbbf8 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 17:35:25 -0600 Subject: [PATCH 51/71] fixed a bug where query wasn't returning account --- shared/selene/account/repository/sql/get_account_by_email.sql | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/selene/account/repository/sql/get_account_by_email.sql b/shared/selene/account/repository/sql/get_account_by_email.sql index 22158fa9..9cba8f4f 100644 --- a/shared/selene/account/repository/sql/get_account_by_email.sql +++ b/shared/selene/account/repository/sql/get_account_by_email.sql @@ -1,6 +1,7 @@ SELECT a.id, a.email_address, + a.password, array_agg(rt.refresh_token) as refresh_tokens FROM account.account a @@ -10,4 +11,5 @@ WHERE a.email_address = %(email_address)s GROUP BY a.id, - a.email_address + a.email_address, + a.password From ada2f24b5ee88781943cda65870815753e4f97c9 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 17:36:24 -0600 Subject: [PATCH 52/71] fixed a bug with result of add account and added remove account logic --- shared/selene/account/repository/account.py | 12 ++++++++++-- .../selene/account/repository/sql/remove_account.sql | 7 +++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 shared/selene/account/repository/sql/remove_account.sql diff --git a/shared/selene/account/repository/account.py b/shared/selene/account/repository/account.py index cad29067..2a1b7e17 100644 --- a/shared/selene/account/repository/account.py +++ b/shared/selene/account/repository/account.py @@ -26,15 +26,23 @@ class AccountRepository(object): args=dict(email_address=email_address, password=encrypted_password) ) cursor = Cursor(self.db) - sql_results = cursor.insert_returning(request) + result = cursor.insert_returning(request) return Account( - id=sql_results, + id=result['id'], email_address=email_address, password=encrypted_password, refresh_tokens=[] ) + def remove(self, account: Account): + request = DatabaseRequest( + file_path=path.join(SQL_DIR, 'remove_account.sql'), + args=dict(id=account.id) + ) + cursor = Cursor(self.db) + cursor.delete(request) + def get_account_by_id(self, account_id: str) -> Account: """Use a given uuid to query the database for an account diff --git a/shared/selene/account/repository/sql/remove_account.sql b/shared/selene/account/repository/sql/remove_account.sql new file mode 100644 index 00000000..a6d5dab4 --- /dev/null +++ b/shared/selene/account/repository/sql/remove_account.sql @@ -0,0 +1,7 @@ +-- Perform a cascading delete on an account. All children of the account +-- table should have ON DELETE CASCADE clauses. If this request fails, a missing +-- ON DELETE CASCADE clause may be the culprit. +DELETE FROM + account.account +WHERE + id = %(id)s From 1eb1d6152a41e24a4cc7b3e157e1fd3f9da5f277 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 17:37:26 -0600 Subject: [PATCH 53/71] passlib added --- api/sso/Pipfile.lock | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/api/sso/Pipfile.lock b/api/sso/Pipfile.lock index ce7f672a..7e9b6e71 100644 --- a/api/sso/Pipfile.lock +++ b/api/sso/Pipfile.lock @@ -295,6 +295,13 @@ ], "version": "==0.4.2" }, + "passlib": { + "hashes": [ + "sha256:3d948f64138c25633613f303bcc471126eae67c04d5e3f6b7b8ce6242f8653e0", + "sha256:43526aea08fa32c6b6dbbbe9963c4c767285b78147b7437597f992812f69d280" + ], + "version": "==1.7.1" + }, "psycopg2-binary": { "hashes": [ "sha256:19a2d1f3567b30f6c2bb3baea23f74f69d51f0c06c2e2082d0d9c28b0733a4c2", From 8c38d693213916c57c5e7f6458dc6ad91e8aaee3 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Tue, 5 Feb 2019 17:41:13 -0600 Subject: [PATCH 54/71] changed to add a dummy user at before feature and remove said user after feature --- api/sso/tests/features/environment.py | 19 +++++++++++++------ api/sso/tests/features/internal_login.feature | 11 +++++++---- api/sso/tests/features/steps/login.py | 18 +++++++++--------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/api/sso/tests/features/environment.py b/api/sso/tests/features/environment.py index 1aeab5c7..df028fbf 100644 --- a/api/sso/tests/features/environment.py +++ b/api/sso/tests/features/environment.py @@ -1,7 +1,9 @@ +import os + from behave import fixture, use_fixture from sso_api.api import sso -from selene.account import RefreshTokenRepository +from selene.account import AccountRepository from selene.util.db import get_db_connection @@ -16,10 +18,15 @@ def sso_client(context): def before_feature(context, _): use_fixture(sso_client, context) + os.environ['SALT'] = 'testsalt' + with get_db_connection(context.db_pool) as db: + acct_repository = AccountRepository(db) + account = acct_repository.add('foo@mycroft.ai', 'foo') + + context.account = account -def after_scenario(context, _): - if hasattr(context, 'refresh_token'): - with get_db_connection(context.db_pool) as db: - token_repository = RefreshTokenRepository(db, context.account) - token_repository.delete_refresh_token(context.refresh_token) +def after_feature(context, _): + with get_db_connection(context.db_pool) as db: + acct_repository = AccountRepository(db) + acct_repository.remove(context.account) diff --git a/api/sso/tests/features/internal_login.feature b/api/sso/tests/features/internal_login.feature index a2faad5f..5ee489b7 100644 --- a/api/sso/tests/features/internal_login.feature +++ b/api/sso/tests/features/internal_login.feature @@ -3,21 +3,24 @@ Feature: internal login than signing in with a third party authenticator, like Google). Scenario: User signs in with valid email/password combination - Given user enters email address "devops@mycroft.ai" and password "devops" + Given user enters email address "foo@mycroft.ai" and password "foo" When user attempts to login Then login succeeds Scenario: User signs in with invalid email/password combination - Given user enters email address "devops@mycroft.ai" and password "foo" + Given user enters email address "foo@mycroft.ai" and password "bar" When user attempts to login Then login fails with "provided credentials not found" error Scenario: User with existing account signs in via Facebook - Given user "devops@mycroft.ai" authenticates through facebook + Given user "foo@mycroft.ai" authenticates through facebook When single sign on validates the account Then login succeeds Scenario: User without account signs in via Facebook - Given user "foo@mycroft.ai" authenticates through facebook + Given user "bar@mycroft.ai" authenticates through facebook When single sign on validates the account Then login fails with "account not found" error + +# Scenario: Logged in user requests logout +# Given: user "devops@mycroft.ai" is authenticated diff --git a/api/sso/tests/features/steps/login.py b/api/sso/tests/features/steps/login.py index f797eba1..a225fb39 100644 --- a/api/sso/tests/features/steps/login.py +++ b/api/sso/tests/features/steps/login.py @@ -1,21 +1,21 @@ from binascii import b2a_base64 from http import HTTPStatus from behave import given, then, when -from hamcrest import assert_that, contains, equal_to, has_item +from hamcrest import assert_that, equal_to, has_item -from selene.account import Account, AccountRepository +from selene.account import AccountRepository from selene.util.db import get_db_connection -# TODO: add a step here when the add account logic is built @given('user enters email address "{email}" and password "{password}"') -def add_credentials_to_db(context, email, password): +def save_credentials(context, email, password): context.email = email context.password = password @given('user "{email}" authenticates through facebook') -def add_credentials_to_db(context, email): +@given('user "{email}" is authenticated') +def save_email(context, email): context.email = email @@ -38,7 +38,7 @@ def call_internal_login_endpoint(context): @then('login succeeds') def check_for_login_success(context): - assert_that(context.response.status_code, equal_to(200)) + assert_that(context.response.status_code, equal_to(HTTPStatus.OK)) assert_that( context.response.headers['Access-Control-Allow-Origin'], equal_to('*') @@ -57,13 +57,13 @@ def check_for_login_success(context): assert_that(ingredient_names, has_item(ingredient_name)) with get_db_connection(context.db_pool) as db: acct_repository = AccountRepository(db) - context.account = acct_repository.get_account_by_email(context.email) - assert_that(context.account.refresh_tokens, has_item(context.refresh_token)) + account = acct_repository.get_account_by_email(context.email) + assert_that(account.refresh_tokens, has_item(context.refresh_token)) @then('login fails with "{error_message}" error') def check_for_login_fail(context, error_message): - assert_that(context.response.status_code, equal_to(401)) + assert_that(context.response.status_code, equal_to(HTTPStatus.UNAUTHORIZED)) assert_that( context.response.headers['Access-Control-Allow-Origin'], equal_to('*') From b96385716084ec6abb63e057eb2c15888e3036cd Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 6 Feb 2019 11:06:02 -0600 Subject: [PATCH 55/71] fixed ambiguous where clause error. --- shared/selene/account/repository/sql/get_account_by_id.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/selene/account/repository/sql/get_account_by_id.sql b/shared/selene/account/repository/sql/get_account_by_id.sql index dcb9ec90..70a6fece 100644 --- a/shared/selene/account/repository/sql/get_account_by_id.sql +++ b/shared/selene/account/repository/sql/get_account_by_id.sql @@ -7,4 +7,4 @@ FROM LEFT JOIN account.refresh_token rt on a.id = rt.account_id WHERE - id = %(account_id)s + a.id = %(account_id)s From 977dc99570f29e50314ee182d88a9a2c0048272b Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 6 Feb 2019 13:29:58 -0600 Subject: [PATCH 56/71] added package for common API testing logic --- shared/selene/api/testing/__init__.py | 1 + shared/selene/api/testing/authentication.py | 47 +++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 shared/selene/api/testing/__init__.py create mode 100644 shared/selene/api/testing/authentication.py diff --git a/shared/selene/api/testing/__init__.py b/shared/selene/api/testing/__init__.py new file mode 100644 index 00000000..0f62014c --- /dev/null +++ b/shared/selene/api/testing/__init__.py @@ -0,0 +1 @@ +from .authentication import get_account, validate_token_cookies diff --git a/shared/selene/api/testing/authentication.py b/shared/selene/api/testing/authentication.py new file mode 100644 index 00000000..acbf8387 --- /dev/null +++ b/shared/selene/api/testing/authentication.py @@ -0,0 +1,47 @@ +from hamcrest import assert_that, equal_to, has_item + +from selene.account import Account, AccountRepository +from selene.util.db import get_db_connection + +ACCESS_TOKEN_COOKIE_KEY = 'seleneAccess' +REFRESH_TOKEN_COOKIE_KEY = 'seleneRefresh' + + +def validate_token_cookies(context, expired=False): + for cookie in context.response.headers.getlist('Set-Cookie'): + ingredients = _parse_cookie(cookie) + ingredient_names = list(ingredients.keys()) + if ACCESS_TOKEN_COOKIE_KEY in ingredient_names: + context.access_token = ingredients[ACCESS_TOKEN_COOKIE_KEY] + elif REFRESH_TOKEN_COOKIE_KEY in ingredient_names: + context.refresh_token = ingredients[REFRESH_TOKEN_COOKIE_KEY] + for ingredient_name in ('Domain', 'Expires', 'Max-Age'): + assert_that(ingredient_names, has_item(ingredient_name)) + if expired: + assert_that(ingredients['Max-Age'], equal_to('0')) + + assert hasattr(context, 'access_token'), 'no access token in response' + assert hasattr(context, 'refresh_token'), 'no refresh token in response' + if expired: + assert_that(context.access_token, equal_to('')) + assert_that(context.refresh_token, equal_to('')) + + +def _parse_cookie(cookie: str) -> dict: + ingredients = {} + for ingredient in cookie.split('; '): + if '=' in ingredient: + key, value = ingredient.split('=') + ingredients[key] = value + else: + ingredients[ingredient] = None + + return ingredients + + +def get_account(context) -> Account: + with get_db_connection(context.db_pool) as db: + acct_repository = AccountRepository(db) + account = acct_repository.get_account_by_id(context.account.id) + + return account From 849d65bc21fe435d0248362479b13b5ac74d3d4e Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 6 Feb 2019 13:30:33 -0600 Subject: [PATCH 57/71] minor bug fixes --- shared/selene/api/base_endpoint.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shared/selene/api/base_endpoint.py b/shared/selene/api/base_endpoint.py index bcda37c7..15dda703 100644 --- a/shared/selene/api/base_endpoint.py +++ b/shared/selene/api/base_endpoint.py @@ -59,12 +59,12 @@ class SeleneEndpoint(Resource): def _validate_auth_tokens(self) -> str: self.access_token_expired, account_id = self._validate_token( 'seleneAccess', - self.config['ACCESS_TOKEN_SECRET'] + self.config['ACCESS_SECRET'] ) if self.access_token_expired: self.refresh_token_expired, account_id = self._validate_token( 'seleneRefresh', - self.config['REFRESH_TOKEN_SECRET'] + self.config['REFRESH_SECRET'] ) return account_id @@ -78,12 +78,12 @@ class SeleneEndpoint(Resource): token_expired = False try: - access_token = self.request.cookies[cookie_key] + token = self.request.cookies[cookie_key] except KeyError: error_msg = 'no {} token found in request' raise AuthenticationError(error_msg.format(cookie_key)) - validator = AuthenticationTokenValidator(access_token, jwt_secret) + validator = AuthenticationTokenValidator(token, jwt_secret) validator.validate_token() if validator.token_is_expired: token_expired = True From bea58504d6c922aa4c8b7e201cdaff1cebdad204 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 6 Feb 2019 13:30:53 -0600 Subject: [PATCH 58/71] minor bug --- shared/selene/account/repository/sql/get_account_by_id.sql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shared/selene/account/repository/sql/get_account_by_id.sql b/shared/selene/account/repository/sql/get_account_by_id.sql index 70a6fece..a7abe29d 100644 --- a/shared/selene/account/repository/sql/get_account_by_id.sql +++ b/shared/selene/account/repository/sql/get_account_by_id.sql @@ -1,6 +1,7 @@ SELECT a.id, a.email_address, + a.password, array_agg(rt.refresh_token) as refresh_tokens FROM account.account a @@ -8,3 +9,5 @@ LEFT JOIN account.refresh_token rt on a.id = rt.account_id WHERE a.id = %(account_id)s +GROUP BY + 1, 2, 3 From fa5da2a91471851b3130bd32b4026257c62c70b1 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 6 Feb 2019 13:37:39 -0600 Subject: [PATCH 59/71] added test for logout function --- api/sso/sso_api/endpoints/logout.py | 34 ++++----- api/sso/tests/features/environment.py | 1 + .../tests/features/federated_login.feature | 14 ++++ api/sso/tests/features/internal_login.feature | 17 +---- api/sso/tests/features/logout.feature | 10 +++ api/sso/tests/features/steps/login.py | 43 +++-------- api/sso/tests/features/steps/logout.py | 75 +++++++++++++++++++ 7 files changed, 128 insertions(+), 66 deletions(-) create mode 100644 api/sso/tests/features/federated_login.feature create mode 100644 api/sso/tests/features/logout.feature create mode 100644 api/sso/tests/features/steps/logout.py diff --git a/api/sso/sso_api/endpoints/logout.py b/api/sso/sso_api/endpoints/logout.py index 8c8b93ee..fb933710 100644 --- a/api/sso/sso_api/endpoints/logout.py +++ b/api/sso/sso_api/endpoints/logout.py @@ -3,35 +3,27 @@ from http import HTTPStatus from logging import getLogger -import requests - -from selene.api import SeleneEndpoint, APIError +from selene.account import RefreshTokenRepository +from selene.api import SeleneEndpoint +from selene.util.db import get_db_connection _log = getLogger(__package__) class LogoutEndpoint(SeleneEndpoint): - def __init__(self): - super(LogoutEndpoint, self).__init__() - def get(self): - try: - self._authenticate() + self._authenticate() + if self.authenticated or self.refresh_token_expired: self._logout() - except APIError: - pass return self.response def _logout(self): - service_request_headers = { - 'Authorization': 'Bearer ' + self.tartarus_token - } - service_url = self.config['TARTARUS_BASE_URL'] + '/auth/logout' - auth_service_response = requests.get( - service_url, - headers=service_request_headers - ) - self._check_for_service_errors(auth_service_response) - logout_response = auth_service_response.content.decode() - self.response = (logout_response, HTTPStatus.OK) + request_refresh_token = self.request.cookies['seleneRefresh'] + with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: + token_repository = RefreshTokenRepository(db, self.account) + token_repository.delete_refresh_token(request_refresh_token) + access_token, refresh_token = self._generate_tokens() + self._set_token_cookies(access_token, refresh_token, expire=True) + + self.response = ('logged out', HTTPStatus.OK) diff --git a/api/sso/tests/features/environment.py b/api/sso/tests/features/environment.py index df028fbf..34baa4e0 100644 --- a/api/sso/tests/features/environment.py +++ b/api/sso/tests/features/environment.py @@ -11,6 +11,7 @@ from selene.util.db import get_db_connection def sso_client(context): sso.testing = True context.db_pool = sso.config['DB_CONNECTION_POOL'] + context.client_config = sso.config context.client = sso.test_client() yield context.client diff --git a/api/sso/tests/features/federated_login.feature b/api/sso/tests/features/federated_login.feature new file mode 100644 index 00000000..8cbe5efa --- /dev/null +++ b/api/sso/tests/features/federated_login.feature @@ -0,0 +1,14 @@ +Feature: federated login + User signs into a selene web app after authenticating with a 3rd party. + + Scenario: User with existing account signs in via Facebook + Given user "foo@mycroft.ai" authenticates through facebook + When single sign on validates the account + Then login request succeeds + And response contains authentication tokens + And account has new refresh token + + Scenario: User without account signs in via Facebook + Given user "bar@mycroft.ai" authenticates through facebook + When single sign on validates the account + Then login fails with "account not found" error diff --git a/api/sso/tests/features/internal_login.feature b/api/sso/tests/features/internal_login.feature index 5ee489b7..e23d0c99 100644 --- a/api/sso/tests/features/internal_login.feature +++ b/api/sso/tests/features/internal_login.feature @@ -5,22 +5,11 @@ Feature: internal login Scenario: User signs in with valid email/password combination Given user enters email address "foo@mycroft.ai" and password "foo" When user attempts to login - Then login succeeds + Then login request succeeds + And response contains authentication tokens + And account has new refresh token Scenario: User signs in with invalid email/password combination Given user enters email address "foo@mycroft.ai" and password "bar" When user attempts to login Then login fails with "provided credentials not found" error - - Scenario: User with existing account signs in via Facebook - Given user "foo@mycroft.ai" authenticates through facebook - When single sign on validates the account - Then login succeeds - - Scenario: User without account signs in via Facebook - Given user "bar@mycroft.ai" authenticates through facebook - When single sign on validates the account - Then login fails with "account not found" error - -# Scenario: Logged in user requests logout -# Given: user "devops@mycroft.ai" is authenticated diff --git a/api/sso/tests/features/logout.feature b/api/sso/tests/features/logout.feature new file mode 100644 index 00000000..5ae78e7f --- /dev/null +++ b/api/sso/tests/features/logout.feature @@ -0,0 +1,10 @@ +Feature: logout + Regardless of how a user logs in, logging out consists of expiring the + tokens we use to identify logged-in users. + + Scenario: Logged in user requests logout + Given user "foo@mycroft.ai" is authenticated + When user attempts to logout + Then request is successful + And response contains expired token cookies + And refresh token in request is removed from account diff --git a/api/sso/tests/features/steps/login.py b/api/sso/tests/features/steps/login.py index a225fb39..c0ce45ea 100644 --- a/api/sso/tests/features/steps/login.py +++ b/api/sso/tests/features/steps/login.py @@ -3,8 +3,7 @@ from http import HTTPStatus from behave import given, then, when from hamcrest import assert_that, equal_to, has_item -from selene.account import AccountRepository -from selene.util.db import get_db_connection +from selene.api.testing import get_account, validate_token_cookies @given('user enters email address "{email}" and password "{password}"') @@ -14,7 +13,6 @@ def save_credentials(context, email, password): @given('user "{email}" authenticates through facebook') -@given('user "{email}" is authenticated') def save_email(context, email): context.email = email @@ -36,28 +34,23 @@ def call_internal_login_endpoint(context): headers=dict(Authorization='Basic ' + credentials)) -@then('login succeeds') +@then('login request succeeds') def check_for_login_success(context): assert_that(context.response.status_code, equal_to(HTTPStatus.OK)) assert_that( context.response.headers['Access-Control-Allow-Origin'], equal_to('*') ) - for cookie in context.response.headers.getlist('Set-Cookie'): - ingredients = parse_cookie(cookie) - ingredient_names = list(ingredients.keys()) - if cookie.startswith('seleneAccess'): - assert_that(ingredient_names, has_item('seleneAccess')) - elif cookie.startswith('seleneRefresh'): - assert_that(ingredient_names, has_item('seleneRefresh')) - context.refresh_token = ingredients['seleneRefresh'] - else: - raise ValueError('unexpected cookie found: ' + cookie) - for ingredient_name in ('Domain', 'Expires', 'Max-Age'): - assert_that(ingredient_names, has_item(ingredient_name)) - with get_db_connection(context.db_pool) as db: - acct_repository = AccountRepository(db) - account = acct_repository.get_account_by_email(context.email) + + +@then('response contains authentication tokens') +def check_token_cookies(context): + validate_token_cookies(context) + + +@then('account has new refresh token') +def check_account_has_refresh_token(context): + account = get_account(context) assert_that(account.refresh_tokens, has_item(context.refresh_token)) @@ -70,15 +63,3 @@ def check_for_login_fail(context, error_message): ) assert_that(context.response.is_json, equal_to(True)) assert_that(context.response.get_json(), equal_to(error_message)) - - -def parse_cookie(cookie: str) -> dict: - ingredients = {} - for ingredient in cookie.split('; '): - if '=' in ingredient: - key, value = ingredient.split('=') - ingredients[key] = value - else: - ingredients[ingredient] = None - - return ingredients diff --git a/api/sso/tests/features/steps/logout.py b/api/sso/tests/features/steps/logout.py new file mode 100644 index 00000000..9e699bf3 --- /dev/null +++ b/api/sso/tests/features/steps/logout.py @@ -0,0 +1,75 @@ +from http import HTTPStatus +from behave import given, then, when +from hamcrest import assert_that, equal_to, has_item, is_not + +from selene.account import RefreshTokenRepository +from selene.api.testing import get_account, validate_token_cookies +from selene.util.auth import AuthenticationTokenGenerator +from selene.util.db import get_db_connection + +ACCESS_TOKEN_COOKIE_KEY = 'seleneAccess' +REFRESH_TOKEN_COOKIE_KEY = 'seleneRefresh' + + +@given('user "{email}" is authenticated') +def save_email(context, email): + context.email = email + + +@when('user attempts to logout') +def call_logout_endpoint(context): + token_generator = AuthenticationTokenGenerator( + context.account.id, + context.client_config['ACCESS_SECRET'], + context.client_config['REFRESH_SECRET'] + ) + context.client.set_cookie( + context.client_config['DOMAIN'], + ACCESS_TOKEN_COOKIE_KEY, + token_generator.access_token + ) + context.client.set_cookie( + context.client_config['DOMAIN'], + REFRESH_TOKEN_COOKIE_KEY, + token_generator.refresh_token + ) + context.request_refresh_token = token_generator.refresh_token + with get_db_connection(context.client_config['DB_CONNECTION_POOL']) as db: + token_repository = RefreshTokenRepository(db, context.account) + token_repository.add_refresh_token(token_generator.refresh_token) + + context.response = context.client.get('/api/logout') + + +@then('request is successful') +def check_for_logout_success(context): + assert_that(context.response.status_code, equal_to(HTTPStatus.OK)) + assert_that( + context.response.headers['Access-Control-Allow-Origin'], + equal_to('*') + ) + + +@then('response contains expired token cookies') +def check_response_cookies(context): + validate_token_cookies(context, expired=True) + + +@then('refresh token in request is removed from account') +def check_refresh_token_removed(context): + account = get_account(context) + assert_that( + account.refresh_tokens, + is_not(has_item(context.request_refresh_token)) + ) + + +@then('logout fails with "{error_message}" error') +def check_for_login_fail(context, error_message): + assert_that(context.response.status_code, equal_to(HTTPStatus.UNAUTHORIZED)) + assert_that( + context.response.headers['Access-Control-Allow-Origin'], + equal_to('*') + ) + assert_that(context.response.is_json, equal_to(True)) + assert_that(context.response.get_json(), equal_to(error_message)) From b9d4a5247e1f5132ee569eb1ec1176fe31a72b69 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 6 Feb 2019 13:40:15 -0600 Subject: [PATCH 60/71] added ability to execute an "insert returning" SQL statement, which is useful to return values containing a default (like row IDs) --- shared/selene/util/db/cursor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shared/selene/util/db/cursor.py b/shared/selene/util/db/cursor.py index 4d0322fa..c4e6a3b0 100644 --- a/shared/selene/util/db/cursor.py +++ b/shared/selene/util/db/cursor.py @@ -98,5 +98,8 @@ class Cursor(object): def insert(self, db_request: DatabaseRequest): self._execute(db_request) + def insert_returning(self, db_request: DatabaseRequest): + return self._fetch(db_request, singleton=True) + def update(self, db_request: DatabaseRequest): self._execute(db_request) From 0fa89ef644d124fbf7deb4d6854749d1c3699c1a Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 6 Feb 2019 13:40:52 -0600 Subject: [PATCH 61/71] added pyhamcrest package for common api testing package --- shared/Pipfile | 2 ++ shared/Pipfile.lock | 18 +++++++++++++++++- shared/setup.py | 10 +++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/shared/Pipfile b/shared/Pipfile index b95c8ca4..caa66305 100644 --- a/shared/Pipfile +++ b/shared/Pipfile @@ -8,6 +8,8 @@ pyjwt = "*" pygithub = "*" flask-restful = "*" psycopg2-binary = "*" +passlib = "*" +pyhamcrest = "*" [dev-packages] diff --git a/shared/Pipfile.lock b/shared/Pipfile.lock index 80932582..6f08bb78 100644 --- a/shared/Pipfile.lock +++ b/shared/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f4fada5df37cf383510205b5e1b5016d8430cfa8ad1fa85898aa70d66bd12135" + "sha256": "c76a19553967e3b2b049bb52a83cf9b15ea4a298b53a8e9bb7ab9244ce11fc83" }, "pipfile-spec": 6, "requires": { @@ -120,6 +120,14 @@ ], "version": "==1.1.0" }, + "passlib": { + "hashes": [ + "sha256:3d948f64138c25633613f303bcc471126eae67c04d5e3f6b7b8ce6242f8653e0", + "sha256:43526aea08fa32c6b6dbbbe9963c4c767285b78147b7437597f992812f69d280" + ], + "index": "pypi", + "version": "==1.7.1" + }, "psycopg2-binary": { "hashes": [ "sha256:19a2d1f3567b30f6c2bb3baea23f74f69d51f0c06c2e2082d0d9c28b0733a4c2", @@ -163,6 +171,14 @@ "index": "pypi", "version": "==1.43.5" }, + "pyhamcrest": { + "hashes": [ + "sha256:6b672c02fdf7470df9674ab82263841ce8333fb143f32f021f6cb26f0e512420", + "sha256:8ffaa0a53da57e89de14ced7185ac746227a8894dbd5a3c718bf05ddbd1d56cd" + ], + "index": "pypi", + "version": "==1.9.0" + }, "pyjwt": { "hashes": [ "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", diff --git a/shared/setup.py b/shared/setup.py index 3fbccd26..ed7391f7 100644 --- a/shared/setup.py +++ b/shared/setup.py @@ -8,5 +8,13 @@ setup( name='selene', version='0.0.0', packages=find_packages(), - install_requires=['flask', 'flask-restful', 'pygithub', 'pyjwt', 'psycopg2-binary'] + install_requires=[ + 'flask', + 'flask-restful', + 'passlib', + 'pygithub', + 'pyhamcrest', + 'pyjwt', + 'psycopg2-binary' + ] ) From 5f7974520f8358c48c488ea5b7961c326a659b7b Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 6 Feb 2019 22:45:42 -0600 Subject: [PATCH 62/71] changed to allow for sql to be changed before passing to db utility functions --- shared/selene/util/db/__init__.py | 2 +- shared/selene/util/db/cursor.py | 32 +++++++++++++++---------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/shared/selene/util/db/__init__.py b/shared/selene/util/db/__init__.py index dc496ec8..1f20b2f3 100644 --- a/shared/selene/util/db/__init__.py +++ b/shared/selene/util/db/__init__.py @@ -1,3 +1,3 @@ from .connection import DatabaseConnectionConfig from .connection_pool import allocate_db_connection_pool, get_db_connection -from .cursor import DatabaseRequest, Cursor +from .cursor import DatabaseRequest, Cursor, get_sql_from_file diff --git a/shared/selene/util/db/cursor.py b/shared/selene/util/db/cursor.py index c4e6a3b0..a188a66f 100644 --- a/shared/selene/util/db/cursor.py +++ b/shared/selene/util/db/cursor.py @@ -18,10 +18,10 @@ class DBConnectionError(Exception): def get_sql_from_file(file_path: str) -> str: - """ - Read a .sql file and return its contents as a string. + """Read a .sql file and return its contents as a string. All the SQL to access relational databases will be written in .sql files + :param file_path: absolute file system of the .sql file. :return: raw SQL for use in a database interface, such as psycopg """ @@ -33,7 +33,7 @@ def get_sql_from_file(file_path: str) -> str: @dataclass class DatabaseRequest(object): - file_path: str + sql: str args: dict = field(default=None) @@ -42,16 +42,15 @@ class Cursor(object): self.db = db def _fetch(self, db_request: DatabaseRequest, singleton=False): - """ - Fetch all or one row from the database. + """Fetch all or one row from the database. + :param db_request: parameters used to determine how to fetch the data - :return: the query results; will be a results object if a singleton select - was issued, a list of results objects otherwise. + :return: the query results; will be a results object if a singleton + select was issued, a list of results objects otherwise. """ - sql = get_sql_from_file(db_request.file_path) with self.db.cursor() as cursor: - _log.debug(cursor.mogrify(sql, db_request.args)) - cursor.execute(sql, db_request.args) + _log.debug(cursor.mogrify(db_request.sql, db_request.args)) + cursor.execute(db_request.sql, db_request.args) if singleton: execution_result = cursor.fetchone() else: @@ -80,16 +79,15 @@ class Cursor(object): return self._fetch(db_request) def _execute(self, db_request: DatabaseRequest): - """ - Fetch all or one row from the database. + """Fetch all or one row from the database. + :param db_request: parameters used to determine how to fetch the data - :return: the query results; will be a results object if a singleton select - was issued, a list of results objects otherwise. + :return: the query results; will be a results object if a singleton + select was issued, a list of results objects otherwise. """ - sql = get_sql_from_file(db_request.file_path) with self.db.cursor() as cursor: - _log.debug(cursor.mogrify(sql, db_request.args)) - cursor.execute(sql, db_request.args) + _log.debug(cursor.mogrify(db_request.sql, db_request.args)) + cursor.execute(db_request.sql, db_request.args) _log.debug(str(cursor.rowcount) + 'rows affected') def delete(self, db_request: DatabaseRequest): From fb359343e36aee40f7c4c79b2c4af5c34628581b Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 6 Feb 2019 22:46:36 -0600 Subject: [PATCH 63/71] moved some of the feature setup/teardown to scenario level --- api/sso/tests/features/environment.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/api/sso/tests/features/environment.py b/api/sso/tests/features/environment.py index 34baa4e0..3c7195fc 100644 --- a/api/sso/tests/features/environment.py +++ b/api/sso/tests/features/environment.py @@ -20,14 +20,17 @@ def sso_client(context): def before_feature(context, _): use_fixture(sso_client, context) os.environ['SALT'] = 'testsalt' + + +def before_scenario(context, _): with get_db_connection(context.db_pool) as db: acct_repository = AccountRepository(db) - account = acct_repository.add('foo@mycroft.ai', 'foo') - + account_id = acct_repository.add('foo@mycroft.ai', 'foo') + account = acct_repository.get_account_by_id(account_id) context.account = account -def after_feature(context, _): +def after_scenario(context, _): with get_db_connection(context.db_pool) as db: acct_repository = AccountRepository(db) acct_repository.remove(context.account) From 2e53130ab57cc824d906ed8a7eb65927bcd56b3e Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 6 Feb 2019 22:48:46 -0600 Subject: [PATCH 64/71] enhanced account object to include other account related data. side-effect was to abstract out some of the get account logic that was redundant --- shared/selene/account/repository/account.py | 70 +++++++++++-------- .../account/repository/sql/get_account.sql | 47 +++++++++++++ .../repository/sql/get_account_by_email.sql | 15 ---- .../repository/sql/get_account_by_id.sql | 13 ---- .../sql/get_account_from_credentials.sql | 16 ----- 5 files changed, 86 insertions(+), 75 deletions(-) create mode 100644 shared/selene/account/repository/sql/get_account.sql delete mode 100644 shared/selene/account/repository/sql/get_account_by_email.sql delete mode 100644 shared/selene/account/repository/sql/get_account_by_id.sql delete mode 100644 shared/selene/account/repository/sql/get_account_from_credentials.sql diff --git a/shared/selene/account/repository/account.py b/shared/selene/account/repository/account.py index 2a1b7e17..110bbf29 100644 --- a/shared/selene/account/repository/account.py +++ b/shared/selene/account/repository/account.py @@ -1,7 +1,7 @@ from passlib.hash import sha512_crypt from os import environ, path -from selene.util.db import DatabaseRequest, Cursor +from selene.util.db import DatabaseRequest, Cursor, get_sql_from_file from ..entity.account import Account SQL_DIR = path.join(path.dirname(__file__), 'sql') @@ -19,25 +19,20 @@ class AccountRepository(object): def __init__(self, db): self.db = db - def add(self, email_address: str, password: str): + def add(self, email_address: str, password: str) -> str: encrypted_password = _encrypt_password(password) request = DatabaseRequest( - file_path=path.join(SQL_DIR, 'add_account.sql'), + sql=get_sql_from_file(path.join(SQL_DIR, 'add_account.sql')), args=dict(email_address=email_address, password=encrypted_password) ) cursor = Cursor(self.db) result = cursor.insert_returning(request) - return Account( - id=result['id'], - email_address=email_address, - password=encrypted_password, - refresh_tokens=[] - ) + return result['id'] def remove(self, account: Account): request = DatabaseRequest( - file_path=path.join(SQL_DIR, 'remove_account.sql'), + sql=get_sql_from_file(path.join(SQL_DIR, 'remove_account.sql')), args=dict(id=account.id) ) cursor = Cursor(self.db) @@ -49,15 +44,28 @@ class AccountRepository(object): :param account_id: uuid :return: an account entity, if one is found """ - request = DatabaseRequest( - file_path=path.join(SQL_DIR, 'get_account_by_id.sql'), - args=dict(account_id=account_id), + account_id_resolver = '%(account_id)s' + sql = get_sql_from_file(path.join(SQL_DIR, 'get_account.sql')).format( + account_id_resolver=account_id_resolver, ) - cursor = Cursor(self.db) - sql_results = cursor.select_one(request) + request = DatabaseRequest(sql=sql, args=dict(account_id=account_id)) - if sql_results is not None: - return Account(**sql_results) + return self._get_account(request) + + def get_account_by_email(self, email_address: str) -> Account: + account_id_resolver = ( + '(SELECT id FROM account.account ' + 'WHERE email_address = %(email_address)s)' + ) + sql = get_sql_from_file(path.join(SQL_DIR, 'get_account.sql')).format( + account_id_resolver=account_id_resolver, + ) + request = DatabaseRequest( + sql=sql, + args=dict(email_address=email_address), + ) + + return self._get_account(request) def get_account_from_credentials( self, email: str, password: str @@ -69,27 +77,27 @@ class AccountRepository(object): :param password: the user provided password :return: the matching account record, if one is found """ + account_id_resolver = ( + '(SELECT id FROM account.account ' + 'WHERE email_address = %(email_address)s and password=%(password)s)' + ) + sql = get_sql_from_file( + path.join(SQL_DIR, 'get_account.sql') + ) encrypted_password = _encrypt_password(password) - query = DatabaseRequest( - file_path=path.join(SQL_DIR, 'get_account_from_credentials.sql'), + request = DatabaseRequest( + sql=sql.format(account_id_resolver=account_id_resolver), args=dict(email_address=email, password=encrypted_password), ) - cursor = Cursor(self.db) - sql_results = cursor.select_one(query) - if sql_results is not None: - return Account(**sql_results) + return self._get_account(request) - def get_account_by_email(self, email_address): + def _get_account(self, db_request): account = None - request = DatabaseRequest( - file_path=path.join(SQL_DIR, 'get_account_by_email.sql'), - args=dict(email_address=email_address), - ) cursor = Cursor(self.db) - db_response = cursor.select_one(request) + result = cursor.select_one(db_request) - if db_response is not None: - account = Account(**db_response) + if result is not None: + account = Account(**result['account']) return account diff --git a/shared/selene/account/repository/sql/get_account.sql b/shared/selene/account/repository/sql/get_account.sql new file mode 100644 index 00000000..d0bb9402 --- /dev/null +++ b/shared/selene/account/repository/sql/get_account.sql @@ -0,0 +1,47 @@ +WITH + refresh_tokens AS ( + SELECT + array_agg(refresh_token) + FROM + account.refresh_token + WHERE + account_id = {account_id_resolver} + ), + agreements AS ( + SELECT + array_agg( + json_build_object( + 'agreement', ag.agreement, + 'signature_date', lower(aa.agreement_ts_range)::DATE + ) + ) + FROM + account.account_agreement aa + INNER JOIN account.agreement ag ON ag.id = aa.agreement_id + WHERE + aa.account_id = {account_id_resolver} + AND upper(aa.agreement_ts_range) IS NULL + ), + subscription AS ( + SELECT + s.subscription + FROM + account.account_subscription asub + INNER JOIN account.subscription s ON asub.subscription_id = s.id + WHERE + asub.account_id = {account_id_resolver} + AND upper(asub.subscription_ts_range) IS NULL + ) +SELECT + json_build_object( + 'id', id, + 'email_address', email_address, + 'subscription', (SELECT * FROM subscription), + 'refresh_tokens', (SELECT * FROM refresh_tokens), + 'agreements', (SELECT * FROM agreements) + ) as account +FROM + account.account +WHERE + id = {account_id_resolver} + diff --git a/shared/selene/account/repository/sql/get_account_by_email.sql b/shared/selene/account/repository/sql/get_account_by_email.sql deleted file mode 100644 index 9cba8f4f..00000000 --- a/shared/selene/account/repository/sql/get_account_by_email.sql +++ /dev/null @@ -1,15 +0,0 @@ -SELECT - a.id, - a.email_address, - a.password, - array_agg(rt.refresh_token) as refresh_tokens -FROM - account.account a -LEFT JOIN - account.refresh_token rt on a.id = rt.account_id -WHERE - a.email_address = %(email_address)s -GROUP BY - a.id, - a.email_address, - a.password diff --git a/shared/selene/account/repository/sql/get_account_by_id.sql b/shared/selene/account/repository/sql/get_account_by_id.sql deleted file mode 100644 index a7abe29d..00000000 --- a/shared/selene/account/repository/sql/get_account_by_id.sql +++ /dev/null @@ -1,13 +0,0 @@ -SELECT - a.id, - a.email_address, - a.password, - array_agg(rt.refresh_token) as refresh_tokens -FROM - account.account a -LEFT JOIN - account.refresh_token rt on a.id = rt.account_id -WHERE - a.id = %(account_id)s -GROUP BY - 1, 2, 3 diff --git a/shared/selene/account/repository/sql/get_account_from_credentials.sql b/shared/selene/account/repository/sql/get_account_from_credentials.sql deleted file mode 100644 index c579cede..00000000 --- a/shared/selene/account/repository/sql/get_account_from_credentials.sql +++ /dev/null @@ -1,16 +0,0 @@ -SELECT - a.id, - a.email_address, - a.password, - array_agg(rt.refresh_token) as refresh_tokens -FROM - account.account a -LEFT JOIN - account.refresh_token rt ON rt.account_id = a.id -WHERE - a.email_address = %(email_address)s AND - a.password = %(password)s -GROUP BY - a.id, - a.email_address, - a.password From b964274a4498dd3694b40307f8aa130e53098aff Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 6 Feb 2019 22:49:21 -0600 Subject: [PATCH 65/71] updated to reflect changes in database access utilities --- shared/selene/account/repository/refresh_token.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/shared/selene/account/repository/refresh_token.py b/shared/selene/account/repository/refresh_token.py index eba25b8b..fd00db5d 100644 --- a/shared/selene/account/repository/refresh_token.py +++ b/shared/selene/account/repository/refresh_token.py @@ -1,6 +1,6 @@ from os import path -from selene.util.db import DatabaseRequest, Cursor +from selene.util.db import DatabaseRequest, Cursor, get_sql_from_file from ..entity.account import Account SQL_DIR = path.join(path.dirname(__file__), 'sql') @@ -13,8 +13,9 @@ class RefreshTokenRepository(object): def add_refresh_token(self, token: str): """Add a refresh token to an account""" + sql = get_sql_from_file(path.join(SQL_DIR, 'add_refresh_token.sql')) request = DatabaseRequest( - file_path=path.join(SQL_DIR, 'add_refresh_token.sql'), + sql=sql, args=dict(account_id=self.account.id, refresh_token=token), ) cursor = Cursor(self.db) @@ -22,8 +23,9 @@ class RefreshTokenRepository(object): def delete_refresh_token(self, token: str): """When a refresh token expires, delete it.""" + sql = get_sql_from_file(path.join(SQL_DIR, 'delete_refresh_token.sql')) request = DatabaseRequest( - file_path=path.join(SQL_DIR, 'delete_refresh_token.sql'), + sql=sql, args=dict(account_id=self.account.id, refresh_token=token), ) cursor = Cursor(self.db) @@ -31,8 +33,9 @@ class RefreshTokenRepository(object): def update_refresh_token(self, old: str, new: str): """When a new refresh token is generated replace the old one""" + sql = get_sql_from_file(path.join(SQL_DIR, 'update_refresh_token.sql')) request = DatabaseRequest( - file_path=path.join(SQL_DIR, 'update_refresh_token.sql'), + sql=sql, args=dict( account_id=self.account.id, new_refresh_token=new, From 10f9e03b202d13e4bcffd2463a11574268f7a896 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Wed, 6 Feb 2019 22:49:45 -0600 Subject: [PATCH 66/71] added more account-related attributes --- shared/selene/account/entity/account.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/shared/selene/account/entity/account.py b/shared/selene/account/entity/account.py index 54729306..d16646c5 100644 --- a/shared/selene/account/entity/account.py +++ b/shared/selene/account/entity/account.py @@ -1,11 +1,20 @@ +from datetime import date from dataclasses import dataclass from typing import List +@dataclass +class AccountAgreement(object): + """Representation of a 'signed' agreement""" + agreement: str + signature_date: date + + @dataclass class Account(object): """Representation of a Mycroft user account.""" id: str email_address: str - password: str refresh_tokens: List[str] + agreements: List[AccountAgreement] + subscription: str From 8dccd5e36000fe3e22d17579eefac4dcc88bdb43 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Thu, 7 Feb 2019 11:54:51 -0600 Subject: [PATCH 67/71] removed some unused packages --- api/sso/Pipfile | 2 -- api/sso/Pipfile.lock | 41 +---------------------------------------- 2 files changed, 1 insertion(+), 42 deletions(-) diff --git a/api/sso/Pipfile b/api/sso/Pipfile index ea2b0034..00813151 100644 --- a/api/sso/Pipfile +++ b/api/sso/Pipfile @@ -5,8 +5,6 @@ name = "pypi" [packages] flask = "*" -requests = "*" -pyjwt = "*" flask-restful = "*" certifi = "*" uwsgi = "*" diff --git a/api/sso/Pipfile.lock b/api/sso/Pipfile.lock index 7e9b6e71..d11c31d5 100644 --- a/api/sso/Pipfile.lock +++ b/api/sso/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ded1e716dc407aa28e78eb5131a7d9b2a6c5e52f6ef3ab6d1e30fd8fd4998dc6" + "sha256": "4e9facf7e219b79ce25eb3d96329771119b08a8d97779704f7702332bae24a33" }, "pipfile-spec": 6, "requires": { @@ -31,13 +31,6 @@ "index": "pypi", "version": "==2018.11.29" }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, "click": { "hashes": [ "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", @@ -61,13 +54,6 @@ "index": "pypi", "version": "==0.3.7" }, - "idna": { - "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" - ], - "version": "==2.8" - }, "itsdangerous": { "hashes": [ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", @@ -115,14 +101,6 @@ ], "version": "==1.1.0" }, - "pyjwt": { - "hashes": [ - "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", - "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" - ], - "index": "pypi", - "version": "==1.7.1" - }, "pytz": { "hashes": [ "sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9", @@ -130,14 +108,6 @@ ], "version": "==2018.9" }, - "requests": { - "hashes": [ - "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", - "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" - ], - "index": "pypi", - "version": "==2.21.0" - }, "six": { "hashes": [ "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", @@ -145,13 +115,6 @@ ], "version": "==1.12.0" }, - "urllib3": { - "hashes": [ - "sha256:61bf29cada3fc2fbefad4fdf059ea4bd1b4a86d2b6d15e1c7c0b582b9752fe39", - "sha256:de9529817c93f27c8ccbfead6985011db27bd0ddfcdb2d86f3f663385c6a9c22" - ], - "version": "==1.24.1" - }, "uwsgi": { "hashes": [ "sha256:d2318235c74665a60021a4fc7770e9c2756f9fc07de7b8c22805efe85b5ab277" @@ -356,7 +319,6 @@ "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" ], - "index": "pypi", "version": "==1.7.1" }, "pytz": { @@ -371,7 +333,6 @@ "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" ], - "index": "pypi", "version": "==2.21.0" }, "selene": { From c487bcc7f9ada889fb44a58208fb25449fdf11ff Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Thu, 7 Feb 2019 12:14:33 -0600 Subject: [PATCH 68/71] removed a config that was used for tartarus --- api/sso/sso_api/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/sso/sso_api/api.py b/api/sso/sso_api/api.py index 4c492822..a6e3e175 100644 --- a/api/sso/sso_api/api.py +++ b/api/sso/sso_api/api.py @@ -23,7 +23,6 @@ _log = getLogger('sso_api') # Initialize the Flask application and the Flask Restful API sso = Flask(__name__) sso.config.from_object(get_base_config()) -sso.config['SSO_BASE_URL'] = os.environ['SSO_BASE_URL'] # Initialize the REST API and define the endpoints sso_api = Api(sso, catch_all_404s=True) From 3ef7059e31c7fcb36c523e76322c458f283a860c Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Thu, 7 Feb 2019 12:14:54 -0600 Subject: [PATCH 69/71] added docstrings --- .../endpoints/authenticate_internal.py | 24 ++++++++++++------- api/sso/sso_api/endpoints/logout.py | 5 ++++ .../sso_api/endpoints/validate_federated.py | 16 +++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/api/sso/sso_api/endpoints/authenticate_internal.py b/api/sso/sso_api/endpoints/authenticate_internal.py index 734a8b13..7503e061 100644 --- a/api/sso/sso_api/endpoints/authenticate_internal.py +++ b/api/sso/sso_api/endpoints/authenticate_internal.py @@ -15,15 +15,14 @@ from selene.util.db import get_db_connection class AuthenticateInternalEndpoint(SeleneEndpoint): - """ - Sign in a user with an email address and password. - """ + """Sign in a user with an email address and password.""" def __init__(self): super(AuthenticateInternalEndpoint, self).__init__() self.response_status_code = HTTPStatus.OK self.account: Account = None def get(self): + """Process HTTP GET request.""" try: self._authenticate_credentials() access_token, refresh_token = self._generate_tokens() @@ -32,12 +31,15 @@ class AuthenticateInternalEndpoint(SeleneEndpoint): except AuthenticationError as ae: self.response = (str(ae), HTTPStatus.UNAUTHORIZED) else: - self._build_response() + self.response = ({}, HTTPStatus.OK) return self.response def _authenticate_credentials(self): - """Compare credentials in request to credentials in database.""" + """Compare credentials in request to credentials in database. + + :raises AuthenticationError when no match found on database + """ basic_credentials = self.request.headers['authorization'] binary_credentials = a2b_base64(basic_credentials.strip('Basic ')) @@ -51,10 +53,14 @@ class AuthenticateInternalEndpoint(SeleneEndpoint): if self.account is None: raise AuthenticationError('provided credentials not found') - def _add_refresh_token_to_db(self, refresh_token): + def _add_refresh_token_to_db(self, refresh_token: str): + """Track refresh tokens in the database. + + We need to store the value of the refresh token in the database so + that we can validate it when it is used to request new tokens. + + :param refresh_token: the token to install into the database. + """ with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: token_repo = RefreshTokenRepository(db, self.account) token_repo.add_refresh_token(refresh_token) - - def _build_response(self): - self.response = ({}, HTTPStatus.OK) diff --git a/api/sso/sso_api/endpoints/logout.py b/api/sso/sso_api/endpoints/logout.py index fb933710..9afe4c60 100644 --- a/api/sso/sso_api/endpoints/logout.py +++ b/api/sso/sso_api/endpoints/logout.py @@ -19,6 +19,11 @@ class LogoutEndpoint(SeleneEndpoint): return self.response def _logout(self): + """Delete tokens from database and expire the token cookies. + + An absence of tokens will force the user to re-authenticate next time + they visit the site. + """ request_refresh_token = self.request.cookies['seleneRefresh'] with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: token_repository = RefreshTokenRepository(db, self.account) diff --git a/api/sso/sso_api/endpoints/validate_federated.py b/api/sso/sso_api/endpoints/validate_federated.py index 90d64f4f..1fbc6651 100644 --- a/api/sso/sso_api/endpoints/validate_federated.py +++ b/api/sso/sso_api/endpoints/validate_federated.py @@ -1,3 +1,10 @@ +"""Validate user who logged in using a 3rd party authentication mechanism + +Authenticating with Google, Faceboook, etc. is known as "federated" login. +Users that choose this option have been authenticated by the selected platform +so all we need to to to complete login is validate that the email address exists +on our database and build JWTs for access and refresh. +""" from http import HTTPStatus from selene.api import SeleneEndpoint @@ -8,6 +15,7 @@ from selene.util.db import get_db_connection class ValidateFederatedEndpoint(SeleneEndpoint): def post(self): + """Process a HTTP POST request.""" try: self._get_account() except AuthenticationError as ae: @@ -21,6 +29,7 @@ class ValidateFederatedEndpoint(SeleneEndpoint): return self.response def _get_account(self): + """Use email returned by the authentication platform for validation""" email_address = self.request.form['email'] with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: acct_repository = AccountRepository(db) @@ -30,6 +39,13 @@ class ValidateFederatedEndpoint(SeleneEndpoint): raise AuthenticationError('account not found') def _add_refresh_token_to_db(self, refresh_token): + """Track refresh tokens in the database. + + We need to store the value of the refresh token in the database so + that we can validate it when it is used to request new tokens. + + :param refresh_token: the token to install into the database. + """ with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: token_repo = RefreshTokenRepository(db, self.account) token_repo.add_refresh_token(refresh_token) From 5fa2d6f707432cc16284bb0d681696c9a43f8e14 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Thu, 7 Feb 2019 12:15:24 -0600 Subject: [PATCH 70/71] removed commented-out code --- api/sso/sso_api/api.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/api/sso/sso_api/api.py b/api/sso/sso_api/api.py index a6e3e175..dd4a47fc 100644 --- a/api/sso/sso_api/api.py +++ b/api/sso/sso_api/api.py @@ -1,7 +1,6 @@ """Define the API that will support Mycroft single sign on (SSO).""" from logging import getLogger -import os from flask import Flask, request from flask_restful import Api @@ -10,10 +9,6 @@ from selene.api.base_config import get_base_config from .endpoints import ( AuthenticateInternalEndpoint, - # SocialLoginTokensEndpoint, - # AuthorizeFacebookEndpoint, - # AuthorizeGithubEndpoint, - # AuthorizeGoogleEndpoint, LogoutEndpoint, ValidateFederatedEndpoint ) From c698bb9e050744a1cb294a2ef4de905698725c45 Mon Sep 17 00:00:00 2001 From: Chris Veilleux Date: Thu, 7 Feb 2019 12:19:25 -0600 Subject: [PATCH 71/71] added docstrings --- shared/selene/util/db/cursor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/shared/selene/util/db/cursor.py b/shared/selene/util/db/cursor.py index a188a66f..85bb81f7 100644 --- a/shared/selene/util/db/cursor.py +++ b/shared/selene/util/db/cursor.py @@ -33,6 +33,7 @@ def get_sql_from_file(file_path: str) -> str: @dataclass class DatabaseRequest(object): + """Small data object for the sql and the args needed for a database req""" sql: str args: dict = field(default=None) @@ -91,13 +92,17 @@ class Cursor(object): _log.debug(str(cursor.rowcount) + 'rows affected') def delete(self, db_request: DatabaseRequest): + """Helper function for SQL delete statements""" self._execute(db_request) def insert(self, db_request: DatabaseRequest): + """Helper functions for SQL insert statements""" self._execute(db_request) def insert_returning(self, db_request: DatabaseRequest): + """Helper function for SQL inserts returning values.""" return self._fetch(db_request, singleton=True) def update(self, db_request: DatabaseRequest): + """Helper function for SQL update statements.""" self._execute(db_request)