Merge pull request #39 from MycroftAI/single-sign-on-api

Single sign on api
pull/40/head
Chris Veilleux 2019-02-07 12:49:56 -06:00 committed by GitHub
commit aac78cfdbe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1220 additions and 486 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@
/dist
/tmp
/out-tsc
**/*.egg-info
# dependencies
**/node_modules

View File

@ -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"

225
api/sso/Pipfile.lock generated
View File

@ -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"
}
}
}

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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))

View File

@ -8,6 +8,8 @@ pyjwt = "*"
pygithub = "*"
flask-restful = "*"
psycopg2-binary = "*"
passlib = "*"
pyhamcrest = "*"
[dev-packages]

132
shared/Pipfile.lock generated
View File

@ -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": {}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1,6 @@
INSERT INTO
account.account (email_address, password)
VALUES
(%(email_address)s, %(password)s)
RETURNING
id

View File

@ -0,0 +1,4 @@
INSERT INTO
account.refresh_token (account_id, refresh_token)
VALUES
(%(account_id)s, %(refresh_token)s)

View File

@ -0,0 +1,5 @@
DELETE FROM
account.refresh_token
WHERE
account_id = %(account_id)s AND
refresh_token = %(refresh_token)s

View File

@ -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}

View File

@ -1 +0,0 @@
SELECT * FROM account.account WHERE id = %(account_id)s

View File

@ -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

View File

@ -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

View File

@ -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():

View File

@ -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()

View File

@ -0,0 +1 @@
from .authentication import get_account, validate_token_cookies

View File

@ -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

View File

@ -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

View File

@ -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

View 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:

View File

@ -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

View File

@ -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)

View File

@ -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'
]
)