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 diff --git a/api/sso/Pipfile b/api/sso/Pipfile index 19344750..00813151 100644 --- a/api/sso/Pipfile +++ b/api/sso/Pipfile @@ -5,14 +5,14 @@ name = "pypi" [packages] flask = "*" -requests = "*" -pyjwt = "*" flask-restful = "*" certifi = "*" uwsgi = "*" [dev-packages] -selene = {path = "./../../shared"} +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 73fcdd9f..d11c31d5 100644 --- a/api/sso/Pipfile.lock +++ b/api/sso/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "164da3986d2c0c62e788d86365e0f305a6b236ca52adede6077a5751540f7e3d" + "sha256": "4e9facf7e219b79ce25eb3d96329771119b08a8d97779704f7702332bae24a33" }, "pipfile-spec": 6, "requires": { @@ -31,6 +31,129 @@ "index": "pypi", "version": "==2018.11.29" }, + "click": { + "hashes": [ + "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", + "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" + ], + "version": "==7.0" + }, + "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" + }, + "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" + }, + "pytz": { + "hashes": [ + "sha256:32b0891edff07e28efe91284ed9c31e123d84bea3fd98e1f72be2508f43ef8d9", + "sha256:d5f05e487007e29e03409f9398d074e158d920d36eb82eaf66fb1136b0c5374c" + ], + "version": "==2018.9" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + }, + "uwsgi": { + "hashes": [ + "sha256:d2318235c74665a60021a4fc7770e9c2756f9fc07de7b8c22805efe85b5ab277" + ], + "index": "pypi", + "version": "==2.0.17.1" + }, + "werkzeug": { + "hashes": [ + "sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c", + "sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b" + ], + "version": "==0.14.1" + } + }, + "develop": { + "aniso8601": { + "hashes": [ + "sha256:03c0ffeeb04edeca1ed59684cc6836dc377f58e52e315dc7be3af879909889f4", + "sha256:ac30cceff24aec920c37b8d74d7d8a5dd37b1f62a90b4f268a6234cabe147080" + ], + "version": "==4.1.0" + }, + "behave": { + "hashes": [ + "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86", + "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c" + ], + "index": "pypi", + "version": "==1.2.6" + }, + "certifi": { + "hashes": [ + "sha256:47f9c83ef4c0c621eaef743f133f09fa8a74a9b75f037e8624f83bd1b6626cb7", + "sha256:993f830721089fef441cdfeb4b2c8c9df86f0c63239f06bd025a76a7daddb033" + ], + "index": "pypi", + "version": "==2018.11.29" + }, "chardet": { "hashes": [ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", @@ -45,6 +168,13 @@ ], "version": "==7.0" }, + "deprecated": { + "hashes": [ + "sha256:8bfeba6e630abf42b5d111b68a05f7fe3d6de7004391b3cd614947594f87a4ff", + "sha256:b784e0ca85a8c1e694d77e545c10827bd99772392e79d5f5442e761515a1246e" + ], + "version": "==1.2.4" + }, "flask": { "hashes": [ "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", @@ -115,12 +245,80 @@ ], "version": "==1.1.0" }, + "parse": { + "hashes": [ + "sha256:870dd675c1ee8951db3e29b81ebe44fd131e3eb8c03a79483a58ea574f3145c2" + ], + "version": "==1.11.1" + }, + "parse-type": { + "hashes": [ + "sha256:6e906a66f340252e4c324914a60d417d33a4bea01292ea9bbf68b4fc123be8c9", + "sha256:f596bdc75d3dd93036fbfe3d04127da9f6df0c26c36e01e76da85adef4336b3c" + ], + "version": "==0.4.2" + }, + "passlib": { + "hashes": [ + "sha256:3d948f64138c25633613f303bcc471126eae67c04d5e3f6b7b8ce6242f8653e0", + "sha256:43526aea08fa32c6b6dbbbe9963c4c767285b78147b7437597f992812f69d280" + ], + "version": "==1.7.1" + }, + "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" + }, + "pyhamcrest": { + "hashes": [ + "sha256:6b672c02fdf7470df9674ab82263841ce8333fb143f32f021f6cb26f0e512420", + "sha256:8ffaa0a53da57e89de14ced7185ac746227a8894dbd5a3c718bf05ddbd1d56cd" + ], + "index": "pypi", + "version": "==1.9.0" + }, "pyjwt": { "hashes": [ "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" ], - "index": "pypi", "version": "==1.7.1" }, "pytz": { @@ -135,9 +333,12 @@ "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" ], - "index": "pypi", "version": "==2.21.0" }, + "selene": { + "editable": true, + "path": "./../../shared" + }, "six": { "hashes": [ "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", @@ -152,24 +353,18 @@ ], "version": "==1.24.1" }, - "uwsgi": { - "hashes": [ - "sha256:d2318235c74665a60021a4fc7770e9c2756f9fc07de7b8c22805efe85b5ab277" - ], - "index": "pypi", - "version": "==2.0.17.1" - }, "werkzeug": { "hashes": [ "sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c", "sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b" ], "version": "==0.14.1" - } - }, - "develop": { - "selene": { - "path": "./../../shared" + }, + "wrapt": { + "hashes": [ + "sha256:4aea003270831cceb8a90ff27c4031da6ead7ec1886023b80ce0dfe0adf61533" + ], + "version": "==1.11.1" } } } diff --git a/api/sso/sso_api/api.py b/api/sso/sso_api/api.py index 88a3e76d..dd4a47fc 100644 --- a/api/sso/sso_api/api.py +++ b/api/sso/sso_api/api.py @@ -1,26 +1,29 @@ +"""Define the API that will support Mycroft single sign on (SSO).""" + +from logging import getLogger + from flask import Flask, request from flask_restful import Api +from selene.api.base_config import get_base_config + from .endpoints import ( - AuthenticateAntisocialEndpoint, - SocialLoginTokensEndpoint, - AuthorizeFacebookEndpoint, - AuthorizeGithubEndpoint, - AuthorizeGoogleEndpoint, - LogoutEndpoint + AuthenticateInternalEndpoint, + LogoutEndpoint, + ValidateFederatedEndpoint ) -from .config import get_config_location + +_log = getLogger('sso_api') + # 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()) + +# Initialize the REST API and define the endpoints +sso_api = Api(sso, catch_all_404s=True) +sso_api.add_resource(AuthenticateInternalEndpoint, '/api/internal-login') +sso_api.add_resource(ValidateFederatedEndpoint, '/api/validate-federated') -# Define the endpoints -sso_api.add_resource(AuthenticateAntisocialEndpoint, '/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') -sso_api.add_resource(SocialLoginTokensEndpoint, '/api/social/tokens') sso_api.add_resource(LogoutEndpoint, '/api/logout') 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 diff --git a/api/sso/sso_api/endpoints/__init__.py b/api/sso/sso_api/endpoints/__init__.py index 894858af..f864d85a 100644 --- a/api/sso/sso_api/endpoints/__init__.py +++ b/api/sso/sso_api/endpoints/__init__.py @@ -1,6 +1,3 @@ -from .authenticate_antisocial import AuthenticateAntisocialEndpoint -from .social_login_tokens import SocialLoginTokensEndpoint -from .facebook import AuthorizeFacebookEndpoint -from .github import AuthorizeGithubEndpoint -from .google import AuthorizeGoogleEndpoint +from .authenticate_internal import AuthenticateInternalEndpoint from .logout import LogoutEndpoint +from .validate_federated import ValidateFederatedEndpoint 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..7503e061 --- /dev/null +++ b/api/sso/sso_api/endpoints/authenticate_internal.py @@ -0,0 +1,66 @@ +"""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 selene.account import Account, AccountRepository, RefreshTokenRepository +from selene.api import SeleneEndpoint +from selene.util.auth import AuthenticationError +from selene.util.db import get_db_connection + + +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: Account = None + + def get(self): + """Process HTTP GET request.""" + try: + self._authenticate_credentials() + access_token, refresh_token = self._generate_tokens() + self._add_refresh_token_to_db(refresh_token) + self._set_token_cookies(access_token, refresh_token) + except AuthenticationError as ae: + self.response = (str(ae), HTTPStatus.UNAUTHORIZED) + else: + self.response = ({}, HTTPStatus.OK) + + return self.response + + def _authenticate_credentials(self): + """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 ')) + email_address, password = binary_credentials.decode().split(':') + with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: + acct_repository = AccountRepository(db) + self.account = acct_repository.get_account_from_credentials( + email_address, + password + ) + if self.account is None: + raise AuthenticationError('provided credentials not found') + + 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) 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/logout.py b/api/sso/sso_api/endpoints/logout.py index 8c8b93ee..9afe4c60 100644 --- a/api/sso/sso_api/endpoints/logout.py +++ b/api/sso/sso_api/endpoints/logout.py @@ -3,35 +3,32 @@ 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) + """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) + 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/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..1fbc6651 --- /dev/null +++ b/api/sso/sso_api/endpoints/validate_federated.py @@ -0,0 +1,51 @@ +"""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 +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): + """Process a HTTP POST request.""" + try: + self._get_account() + except AuthenticationError as ae: + self.response = str(ae), HTTPStatus.UNAUTHORIZED + else: + access_token, refresh_token = self._generate_tokens() + self._set_token_cookies(access_token, refresh_token) + self._add_refresh_token_to_db(refresh_token) + self.response = 'account validated', HTTPStatus.OK + + 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) + 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): + """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) diff --git a/api/sso/tests/features/environment.py b/api/sso/tests/features/environment.py new file mode 100644 index 00000000..3c7195fc --- /dev/null +++ b/api/sso/tests/features/environment.py @@ -0,0 +1,36 @@ +import os + +from behave import fixture, use_fixture + +from sso_api.api import sso +from selene.account import AccountRepository +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_config = sso.config + context.client = sso.test_client() + + yield context.client + + +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_id = acct_repository.add('foo@mycroft.ai', 'foo') + account = acct_repository.get_account_by_id(account_id) + context.account = account + + +def after_scenario(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/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 new file mode 100644 index 00000000..e23d0c99 --- /dev/null +++ b/api/sso/tests/features/internal_login.feature @@ -0,0 +1,15 @@ +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 "foo@mycroft.ai" and password "foo" + When user attempts to login + 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 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 new file mode 100644 index 00000000..c0ce45ea --- /dev/null +++ b/api/sso/tests/features/steps/login.py @@ -0,0 +1,65 @@ +from binascii import b2a_base64 +from http import HTTPStatus +from behave import given, then, when +from hamcrest import assert_that, equal_to, has_item + +from selene.api.testing import get_account, validate_token_cookies + + +@given('user enters email address "{email}" and password "{password}"') +def save_credentials(context, email, password): + context.email = email + context.password = password + + +@given('user "{email}" authenticates through facebook') +def save_email(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.email, context.password).encode() + credentials = b2a_base64(credentials, newline=False).decode() + context.response = context.client.get( + '/api/internal-login', + headers=dict(Authorization='Basic ' + credentials)) + + +@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('*') + ) + + +@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)) + + +@then('login 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)) 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)) 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 397e4245..6f08bb78 100644 --- a/shared/Pipfile.lock +++ b/shared/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f4fada5df37cf383510205b5e1b5016d8430cfa8ad1fa85898aa70d66bd12135" + "sha256": "c76a19553967e3b2b049bb52a83cf9b15ea4a298b53a8e9bb7ab9244ce11fc83" }, "pipfile-spec": 6, "requires": { @@ -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": [ @@ -120,77 +120,93 @@ ], "version": "==1.1.0" }, - "psycopg2-binary": { + "passlib": { "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:3d948f64138c25633613f303bcc471126eae67c04d5e3f6b7b8ce6242f8653e0", + "sha256:43526aea08fa32c6b6dbbbe9963c4c767285b78147b7437597f992812f69d280" ], "index": "pypi", - "version": "==2.7.6.1" + "version": "==1.7.1" + }, + "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" + ], + "index": "pypi", + "version": "==2.7.7" }, "pygithub": { "hashes": [ - "sha256:70d90139f61a3d88417ff15eaca6150d0b3ba7ef0dc59589ea3719c3ce518ef6" + "sha256:263102b43a83e2943900c1313109db7a00b3b78aeeae2c9137ba694982864872" ], "index": "pypi", - "version": "==1.43.3" + "version": "==1.43.5" + }, + "pyhamcrest": { + "hashes": [ + "sha256:6b672c02fdf7470df9674ab82263841ce8333fb143f32f021f6cb26f0e512420", + "sha256:8ffaa0a53da57e89de14ced7185ac746227a8894dbd5a3c718bf05ddbd1d56cd" + ], + "index": "pypi", + "version": "==1.9.0" }, "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 +224,9 @@ }, "wrapt": { "hashes": [ - "sha256:d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6" + "sha256:4aea003270831cceb8a90ff27c4031da6ead7ec1886023b80ce0dfe0adf61533" ], - "version": "==1.10.11" + "version": "==1.11.1" } }, "develop": {} diff --git a/shared/selene/account/__init__.py b/shared/selene/account/__init__.py index 382ddd31..e979f967 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.refresh_token import RefreshTokenRepository diff --git a/shared/selene/account/entity/account.py b/shared/selene/account/entity/account.py index 1c74816e..d16646c5 100644 --- a/shared/selene/account/entity/account.py +++ b/shared/selene/account/entity/account.py @@ -1,4 +1,13 @@ +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 @@ -6,3 +15,6 @@ class Account(object): """Representation of a Mycroft user account.""" id: str email_address: str + refresh_tokens: List[str] + agreements: List[AccountAgreement] + subscription: str diff --git a/shared/selene/account/repository/account.py b/shared/selene/account/repository/account.py index d3a244f5..110bbf29 100644 --- a/shared/selene/account/repository/account.py +++ b/shared/selene/account/repository/account.py @@ -1,23 +1,103 @@ -from os import path +from passlib.hash import sha512_crypt +from os import environ, path -from selene.util.db import DatabaseQuery, fetch +from selene.util.db import DatabaseRequest, Cursor, get_sql_from_file 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 +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 - :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) + return hash_result[hashed_password_index:] - return Account(**sql_results) + +class AccountRepository(object): + def __init__(self, db): + self.db = db + + def add(self, email_address: str, password: str) -> str: + encrypted_password = _encrypt_password(password) + request = DatabaseRequest( + 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 result['id'] + + def remove(self, account: Account): + request = DatabaseRequest( + sql=get_sql_from_file(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 + + :param account_id: uuid + :return: an account entity, if one is found + """ + 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, + ) + request = DatabaseRequest(sql=sql, args=dict(account_id=account_id)) + + 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 + ) -> 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 + """ + 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) + request = DatabaseRequest( + sql=sql.format(account_id_resolver=account_id_resolver), + args=dict(email_address=email, password=encrypted_password), + ) + + return self._get_account(request) + + def _get_account(self, db_request): + account = None + cursor = Cursor(self.db) + result = cursor.select_one(db_request) + + if result is not None: + account = Account(**result['account']) + + return account diff --git a/shared/selene/account/repository/refresh_token.py b/shared/selene/account/repository/refresh_token.py new file mode 100644 index 00000000..fd00db5d --- /dev/null +++ b/shared/selene/account/repository/refresh_token.py @@ -0,0 +1,46 @@ +from os import path + +from selene.util.db import DatabaseRequest, Cursor, get_sql_from_file +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""" + sql = get_sql_from_file(path.join(SQL_DIR, 'add_refresh_token.sql')) + request = DatabaseRequest( + sql=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.""" + sql = get_sql_from_file(path.join(SQL_DIR, 'delete_refresh_token.sql')) + request = DatabaseRequest( + sql=sql, + args=dict(account_id=self.account.id, refresh_token=token), + ) + cursor = Cursor(self.db) + cursor.delete(request) + + 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( + sql=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_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/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/delete_refresh_token.sql b/shared/selene/account/repository/sql/delete_refresh_token.sql new file mode 100644 index 00000000..efe7cc43 --- /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)s AND + refresh_token = %(refresh_token)s 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_id.sql b/shared/selene/account/repository/sql/get_account_by_id.sql deleted file mode 100644 index ed9788ac..00000000 --- a/shared/selene/account/repository/sql/get_account_by_id.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT * FROM account.account WHERE id = %(account_id)s 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 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..a6b2649f --- /dev/null +++ b/shared/selene/account/repository/sql/update_refresh_token.sql @@ -0,0 +1,7 @@ +UPDATE + account.refresh_token +SET + refresh_token = %(new_refresh_token)s +WHERE + account_id = %(account_id)s AND + refresh_token = %(old_refresh_token)s diff --git a/shared/selene/api/base_config.py b/shared/selene/api/base_config.py index fa4cc4dd..b9120e41 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,19 +40,21 @@ 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'] 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(): diff --git a/shared/selene/api/base_endpoint.py b/shared/selene/api/base_endpoint.py index 6507112d..15dda703 100644 --- a/shared/selene/api/base_endpoint.py +++ b/shared/selene/api/base_endpoint.py @@ -1,15 +1,19 @@ -"""Reusable code for the API layer""" -from http import HTTPStatus -from logging import getLogger +"""Base class for Flask API endpoints""" -from flask import request, current_app +from http import HTTPStatus + +from flask import after_this_request, current_app, request 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 Account, AccountRepository, RefreshTokenRepository +from selene.util.auth import ( + AuthenticationError, + AuthenticationTokenGenerator, + AuthenticationTokenValidator, + FIFTEEN_MINUTES, + ONE_MONTH +) +from selene.util.db import get_db_connection class APIError(Exception): @@ -29,13 +33,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_expired: bool = False + self.refresh_token_expired: bool = False + self.account: Account = None def _authenticate(self): """ @@ -44,53 +48,113 @@ class SeleneEndpoint(Resource): :raises: APIError() """ try: - self._get_auth_token() - self._validate_auth_token() + 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) - raise APIError() else: self.authenticated = True - def _get_auth_token(self): - """Get the Selene JWT (and the tartarus token) from cookies. - - :raises: AuthenticationError - """ - try: - self.selene_token = request.cookies['seleneToken'] - self.tartarus_token = request.cookies['tartarusToken'] - except KeyError: - raise AuthenticationError( - 'no authentication token found in request' + def _validate_auth_tokens(self) -> str: + self.access_token_expired, account_id = self._validate_token( + 'seleneAccess', + self.config['ACCESS_SECRET'] + ) + if self.access_token_expired: + self.refresh_token_expired, account_id = self._validate_token( + 'seleneRefresh', + self.config['REFRESH_SECRET'] ) - def _validate_auth_token(self): - """Decode the Selene JWT. + return account_id + + def _validate_token(self, cookie_key, jwt_secret): + """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'] + account_id = None + token_expired = False + + try: + token = self.request.cookies[cookie_key] + except KeyError: + error_msg = 'no {} token found in request' + raise AuthenticationError(error_msg.format(cookie_key)) + + validator = AuthenticationTokenValidator(token, jwt_secret) + validator.validate_token() + if validator.token_is_expired: + token_expired = True + elif validator.token_is_invalid: + raise AuthenticationError('access token is invalid') + else: + account_id = validator.account_id + + 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) + self.account = account_repository.get_account_by_id(account_id) + + 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 _set_token_cookies(self, access_token, refresh_token, expire=False): + access_token_cookie = dict( + key='seleneAccess', + value=str(access_token), + domain=self.config['DOMAIN'], + max_age=FIFTEEN_MINUTES, + ) + refresh_token_cookie = dict( + key='seleneRefresh', + value=str(refresh_token), + domain=self.config['DOMAIN'], + max_age=ONE_MONTH, ) - 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) + 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) + 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'] + with get_db_connection(self.config['DB_CONNECTION_POOL']) as db: + token_repository = RefreshTokenRepository(db, self.account) + if self.refresh_token_expired: + token_repository.delete_refresh_token(old_refresh_token) + raise AuthenticationError('refresh token expired') else: - self.response = ( - error_message, - HTTPStatus.INTERNAL_SERVER_ERROR + token_repository.update_refresh_token( + new_refresh_token, + old_refresh_token ) - raise APIError() 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 diff --git a/shared/selene/util/auth.py b/shared/selene/util/auth.py index a462dd38..ea14f394 100644 --- a/shared/selene/util/auth.py +++ b/shared/selene/util/auth.py @@ -1,55 +1,86 @@ 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, access_secret, refresh_secret): + self.account_id = account_id + self.access_secret = access_secret + self.refresh_secret = refresh_secret + + 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 + + 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 diff --git a/shared/selene/util/db/__init__.py b/shared/selene/util/db/__init__.py index 12f1b94d..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 DatabaseQuery, fetch +from .cursor import DatabaseRequest, Cursor, get_sql_from_file 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 diff --git a/shared/selene/util/db/cursor.py b/shared/selene/util/db/cursor.py index 28062973..85bb81f7 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 @@ -18,44 +18,91 @@ 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 """ with open(path.join(file_path)) as sql_file: raw_sql = sql_file.read() - print(raw_sql) return raw_sql @dataclass -class DatabaseQuery(object): - file_path: str - args: dict - singleton: bool +class DatabaseRequest(object): + """Small data object for the sql and the args needed for a database req""" + sql: str + 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. + """ + with self.db.cursor() as cursor: + _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: + 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. + """ + with self.db.cursor() as cursor: + _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): + """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) diff --git a/shared/setup.py b/shared/setup.py index cd8475dd..6235e76e 100644 --- a/shared/setup.py +++ b/shared/setup.py @@ -9,5 +9,13 @@ setup( version='0.0.0', packages=find_packages(), include_package_data=True, - install_requires=['flask', 'flask-restful', 'pygithub', 'pyjwt', 'psycopg2-binary'] + install_requires=[ + 'flask', + 'flask-restful', + 'passlib', + 'pygithub', + 'pyhamcrest', + 'pyjwt', + 'psycopg2-binary' + ] )