commit
aac78cfdbe
|
@ -4,6 +4,7 @@
|
|||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
**/*.egg-info
|
||||
|
||||
# dependencies
|
||||
**/node_modules
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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))
|
|
@ -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))
|
|
@ -8,6 +8,8 @@ pyjwt = "*"
|
|||
pygithub = "*"
|
||||
flask-restful = "*"
|
||||
psycopg2-binary = "*"
|
||||
passlib = "*"
|
||||
pyhamcrest = "*"
|
||||
|
||||
[dev-packages]
|
||||
|
||||
|
|
|
@ -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": {}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -0,0 +1,6 @@
|
|||
INSERT INTO
|
||||
account.account (email_address, password)
|
||||
VALUES
|
||||
(%(email_address)s, %(password)s)
|
||||
RETURNING
|
||||
id
|
|
@ -0,0 +1,4 @@
|
|||
INSERT INTO
|
||||
account.refresh_token (account_id, refresh_token)
|
||||
VALUES
|
||||
(%(account_id)s, %(refresh_token)s)
|
|
@ -0,0 +1,5 @@
|
|||
DELETE FROM
|
||||
account.refresh_token
|
||||
WHERE
|
||||
account_id = %(account_id)s AND
|
||||
refresh_token = %(refresh_token)s
|
|
@ -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}
|
||||
|
|
@ -1 +0,0 @@
|
|||
SELECT * FROM account.account WHERE id = %(account_id)s
|
|
@ -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
|
|
@ -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
|
|
@ -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():
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
from .authentication import get_account, validate_token_cookies
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
]
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue