├── CONTRIBUTING.md ├── AUTHORS.md ├── docs ├── _static │ └── .gitkeep ├── _templates │ └── .gitkeep ├── index.rst ├── Makefile ├── classes.rst ├── getting_started.rst └── conf.py ├── setup.cfg ├── tests ├── requirements.txt ├── __init__.py ├── yourname_mauth.pub.key ├── yourname_mauth.pub_pkcs15.key ├── yourname_mauth.pub_pkcs15.key ├── common.py ├── test_rsa_public_key.py ├── yourname_mauth.priv.key ├── yourname_mauth.priv_pkcs15.key ├── yourname_mauth.priv_pkcs15.key ├── test_security_token_cacher.py ├── test_mauth_authenticator.py ├── test_signature.py └── test_authenticators.py ├── flask_mauth ├── __version__.py ├── __init__.py ├── cacher │ ├── __init__.py │ └── security_token_cacher.py ├── rsa_public_decrypt │ ├── __init__.py │ └── rsa_decrypt.py ├── mauth │ ├── __init__.py │ ├── signature.py │ └── authenticators.py ├── settings.py ├── exceptions.py └── auth.py ├── requirements.txt ├── .coveragerc ├── MANIFEST.in ├── tox.ini ├── LICENSE ├── setup.py ├── .gitignore └── README.md /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | * glow-mdsol -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_templates/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = True -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | mock 2 | coverage 3 | -------------------------------------------------------------------------------- /flask_mauth/__version__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | __version__ = '1.1' -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'glow' 4 | -------------------------------------------------------------------------------- /flask_mauth/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'glow' 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | six 3 | rsa 4 | cachetools 5 | requests 6 | requests-mauth 7 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | omit = 4 | */venv/* 5 | */tests/* 6 | */.tox/* 7 | -------------------------------------------------------------------------------- /flask_mauth/cacher/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'glow' 4 | 5 | from .security_token_cacher import SecurityTokenCacher -------------------------------------------------------------------------------- /flask_mauth/rsa_public_decrypt/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'glow' 4 | 5 | from .rsa_decrypt import RSAPublicKey 6 | 7 | -------------------------------------------------------------------------------- /flask_mauth/mauth/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'glow' 4 | 5 | from .signature import Signature 6 | from .authenticators import RemoteAuthenticator, LocalAuthenticator 7 | 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE CONTRIBUTING.md README.md AUTHORS.md 2 | recursive-include tests * 3 | recursive-include examples * 4 | recursive-include docs * 5 | recursive-exclude docs *.pyc 6 | recursive-exclude docs *.pyo 7 | recursive-exclude tests *.pyc 8 | recursive-exclude tests *.pyo 9 | recursive-exclude examples *.pyc 10 | recursive-exclude examples *.pyo 11 | prune docs/_build 12 | prune docs/_themes -------------------------------------------------------------------------------- /tests/yourname_mauth.pub.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PUBLIC KEY----- 2 | MIIBCgKCAQEAldlbtWGBrq3N/kQAsw3eTRmU91XXQe6BIuHhWgbLxCTUCpXGA6G9 3 | QpNNo+b5o/ArA7Fie+y94D2NX4ISbi0tEcrepLJXfXiFKAyjLdOEW7sW3Qacj0Ni 4 | nF/WvHBgA7WEfMod57zF7y4+e2R68ur58nqzte8Lh8Kwf75qpLho3XwuLFNAhpuN 5 | R7c1aCffpHldtEOeSEXzeGS1wubF3ZetQpChDxagsSx6zP6pCss5Fh/pvo0tN3U0 6 | /eC99n79bg+hBKWT2DsMvaHUBB67KSA0PN/ANOsbheUKu5qaGkqCXqgP1j78ZRw2 7 | eD1Hn1ALVJ19xYXsPXq0EjAWTHTAEii+6QIDAQAB 8 | -----END RSA PUBLIC KEY----- 9 | -------------------------------------------------------------------------------- /tests/yourname_mauth.pub_pkcs15.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsgGNzJQyalauKRcclxt0 3 | eyLyDvtDMPeeQokbmJQGlvh596osgWYvq6R8xF/1lI6iChftQC7Z2OLEwsK1QkPm 4 | swoFK3cSkyBbdDMqNM1RWttdgL3k+sRDrlJYpJ7a2+GoxJcwgXcZjJCdjwJJFoKa 5 | 0GIfM34xd2rhWX5sH8Y9AqJh0ByfzJNz0boQ/lOJD0EbryW49tvfvybSHczeYIOH 6 | U2M4D7jIOkSfCLNYw+l8mi/yGJaTBombvVU+7ci8EWwtI9jQpmEjjYTNPScBrlPL 7 | tyx/fHheA8XDme9ptHJ2VPiUPMJpBkFPs5CpwXRshx8PToE5qve8XB1Vk5MSZBKu 8 | IwIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /tests/yourname_mauth.pub_pkcs15.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsgGNzJQyalauKRcclxt0 3 | eyLyDvtDMPeeQokbmJQGlvh596osgWYvq6R8xF/1lI6iChftQC7Z2OLEwsK1QkPm 4 | swoFK3cSkyBbdDMqNM1RWttdgL3k+sRDrlJYpJ7a2+GoxJcwgXcZjJCdjwJJFoKa 5 | 0GIfM34xd2rhWX5sH8Y9AqJh0ByfzJNz0boQ/lOJD0EbryW49tvfvybSHczeYIOH 6 | U2M4D7jIOkSfCLNYw+l8mi/yGJaTBombvVU+7ci8EWwtI9jQpmEjjYTNPScBrlPL 7 | tyx/fHheA8XDme9ptHJ2VPiUPMJpBkFPs5CpwXRshx8PToE5qve8XB1Vk5MSZBKu 8 | IwIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /flask_mauth/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | 5 | # DEFAULT FLAGS 6 | mws_token = "MWS" 7 | 8 | x_mws_time = 'X-MWS-Time' 9 | x_mws_authentication = 'X-MWS-Authentication' 10 | x_mcc_impersonate = "MCC-Impersonate" 11 | 12 | # Parser for Signature 13 | signature_info = re.compile(r'\A([^ ]+) *([^:]+):([^:]+)\Z') 14 | 15 | # regex for a UUID, to avoid issues with wags passing ../../ statements 16 | uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE) 17 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = clean, py27, py35, py36, stats 8 | 9 | [testenv] 10 | commands = 11 | coverage run -a setup.py test 12 | deps = 13 | -r{toxinidir}/tests/requirements.txt 14 | 15 | [testenv:clean] 16 | commands= 17 | coverage erase 18 | 19 | [testenv:stats] 20 | commands= 21 | coverage report 22 | coverage html -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Flask MAuth documentation master file, created by 2 | sphinx-quickstart on Wed Jan 4 22:55:48 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Flask MAuth's documentation! 7 | ======================================= 8 | Flask MAuth is an implementation of MAuth authentication for the Flask Web Application Framework. 9 | 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :caption: Contents: 14 | 15 | getting_started 16 | classes 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = FlaskMAuth 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'glow' 4 | 5 | 6 | import os 7 | from hashlib import sha512 8 | 9 | def load_key(keytype='pub'): 10 | """ 11 | Load the sample keys 12 | :param keytype: type of key to load 13 | :return: key content 14 | :rtype: str 15 | """ 16 | assert keytype in ('pub', 'priv', 'pub_pkcs15', 'priv_pkcs15') 17 | content = "" 18 | with open(os.path.join(os.path.dirname(__file__), 19 | 'yourname_mauth.%s.key' % keytype), 'r') as key: 20 | content = key.read() 21 | return content 22 | 23 | 24 | def get_hash(str_to_hash): 25 | return sha512(str_to_hash.encode('US-ASCII')).hexdigest() -------------------------------------------------------------------------------- /flask_mauth/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'glow' 4 | 5 | 6 | class InauthenticError(Exception): 7 | # used to indicate that an object was expected to be validly signed but its signature does not 8 | # match its contents, and so is inauthentic. 9 | pass 10 | 11 | 12 | class UnableToAuthenticateError(Exception): 13 | # the response from the MAuth service encountered when attempting to retrieve mauth 14 | def __init__(self, message, response=None): 15 | super(UnableToAuthenticateError, self).__init__(message) 16 | self.response = response 17 | 18 | 19 | class UnableToSignError(Exception): 20 | # required information for signing was missing 21 | pass 22 | -------------------------------------------------------------------------------- /docs/classes.rst: -------------------------------------------------------------------------------- 1 | Class Reference 2 | *************** 3 | 4 | flask_mauth 5 | =========== 6 | 7 | .. automodule:: flask_mauth.auth 8 | :members: 9 | :undoc-members: 10 | 11 | flask_mauth.cacher 12 | ================== 13 | 14 | .. automodule:: flask_mauth.cacher.security_token_cacher 15 | :members: 16 | :undoc-members: 17 | 18 | flask_mauth.mauth 19 | ================= 20 | 21 | .. automodule:: flask_mauth.mauth.authenticators 22 | :members: 23 | :undoc-members: 24 | 25 | .. automodule:: flask_mauth.mauth.signature 26 | :members: 27 | :undoc-members: 28 | 29 | flask_mauth.rsa_public_decrypt 30 | ============================== 31 | 32 | .. automodule:: flask_mauth.rsa_public_decrypt.rsa_decrypt 33 | :members: 34 | :undoc-members: 35 | 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Medidata Solutions Worldwide 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /flask_mauth/rsa_public_decrypt/rsa_decrypt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = 'glow' 4 | 5 | import base64 6 | 7 | import six 8 | from rsa import PublicKey, common, core, transform 9 | 10 | 11 | class RSAPublicKey(PublicKey): 12 | 13 | def public_decrypt(self, message): 14 | """ 15 | Decrypt a String encrypted with a private key, returns the hash 16 | 17 | :param str message: encrypted message 18 | :return: message hash 19 | :rtype: str 20 | """ 21 | # base64 decode 22 | decoded = base64.b64decode(six.b(message)) 23 | # transform the decoded message to int 24 | encrypted = transform.bytes2int(decoded) 25 | """:type : int""" 26 | payload = core.decrypt_int(encrypted, self.e, self.n) 27 | """:type : int""" 28 | padded = transform.int2bytes(payload, common.byte_size(self.n)) 29 | """:type : str""" 30 | return padded 31 | 32 | def unpad_message(self, padded): 33 | """ 34 | Removes the padding from the string 35 | 36 | :param padded: padded string 37 | :rtype: str 38 | """ 39 | return padded[padded.index(b'\x00', 2) + 1:] -------------------------------------------------------------------------------- /tests/test_rsa_public_key.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | from hashlib import sha512 5 | 6 | import six 7 | from requests_mauth.rsa_sign import RSARawSigner 8 | 9 | from flask_mauth.rsa_public_decrypt.rsa_decrypt import RSAPublicKey 10 | from tests.common import load_key 11 | 12 | 13 | class TestRSAPublicKey(unittest.TestCase): 14 | 15 | def test_round_trip(self): 16 | """We can encrypt a message with a priv key and decrypt with a public key""" 17 | string_to_sign = "Hello world" 18 | # we compare the hash, rather than the message itself.... 19 | hashed = sha512(string_to_sign.encode('US-ASCII')).hexdigest() 20 | # load the public key 21 | pubkey = load_key() 22 | priv_key = load_key('priv') 23 | signer = RSARawSigner(private_key_data=priv_key) 24 | encrypted = signer.sign(string_to_sign) 25 | unsigner = RSAPublicKey.load_pkcs1(pubkey) 26 | padded = unsigner.public_decrypt(encrypted) 27 | actual = unsigner.unpad_message(padded) 28 | self.assertEqual(six.b(hashed), actual, 29 | "Expected {}, got {}".format(hashed, actual)) 30 | 31 | 32 | if __name__ == '__main__': 33 | unittest.main() 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | from os import path 5 | 6 | from setuptools import find_packages, setup 7 | 8 | version_file = path.join( 9 | path.dirname(__file__), 10 | 'flask_mauth', 11 | '__version__.py' 12 | ) 13 | with open(version_file, 'r') as fp: 14 | m = re.search( 15 | r"^__version__ = ['\"]([^'\"]*)['\"]", 16 | fp.read(), 17 | re.M 18 | ) 19 | version = m.groups(1)[0] 20 | 21 | 22 | setup( 23 | name='Flask-MAuth', 24 | version=version, 25 | license='MIT', 26 | url='https://www.github.com/mdsol/flask-mauth/', 27 | author='Geoff Low', 28 | author_email='glow@mdsol.com', 29 | description='MAuth Client and Server Library for MAuth', 30 | long_description=open('README.md').read(), 31 | packages=find_packages(exclude=['tests']), 32 | classifiers=[ 33 | 'Framework :: Flask', 34 | 'Programming Language :: Python :: 2', 35 | 'Programming Language :: Python :: 2.7', 36 | 'Programming Language :: Python :: 3', 37 | 'Programming Language :: Python :: 3.5', 38 | 'Programming Language :: Python :: 3.6', 39 | 'License :: OSI Approved :: MIT License', 40 | ], 41 | zip_safe=False, 42 | include_package_data=True, 43 | platforms='any', 44 | tests_require=['mock'], 45 | test_suite='tests', 46 | install_requires=["Flask", 47 | "six", 48 | "rsa", 49 | "cachetools", 50 | "requests", 51 | "requests-mauth"], 52 | extras_require={ 53 | 'docs': ['sphinx', ], 54 | } 55 | ) 56 | -------------------------------------------------------------------------------- /tests/yourname_mauth.priv.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAldlbtWGBrq3N/kQAsw3eTRmU91XXQe6BIuHhWgbLxCTUCpXG 3 | A6G9QpNNo+b5o/ArA7Fie+y94D2NX4ISbi0tEcrepLJXfXiFKAyjLdOEW7sW3Qac 4 | j0NinF/WvHBgA7WEfMod57zF7y4+e2R68ur58nqzte8Lh8Kwf75qpLho3XwuLFNA 5 | hpuNR7c1aCffpHldtEOeSEXzeGS1wubF3ZetQpChDxagsSx6zP6pCss5Fh/pvo0t 6 | N3U0/eC99n79bg+hBKWT2DsMvaHUBB67KSA0PN/ANOsbheUKu5qaGkqCXqgP1j78 7 | ZRw2eD1Hn1ALVJ19xYXsPXq0EjAWTHTAEii+6QIDAQABAoIBABWrQdS/zeX5ojEj 8 | mkpKNhxGF8GQezlAiRIHQfQnyW2KudLyB5lc2SZ4cKDD9NOdFktJamlrOaXkoL3v 9 | 7ERcagkJohqE45v0QqUg9rPYw2oUauD5ZMhewTLFtSK4yVmf9RGqlJp/NMw6jrR/ 10 | OjxIeG51CenEMJGoOKewXHGHbTqh0tIcLUvRydm0P5Uczinsi5egWVanTHbGzoaK 11 | tXv5qNisk3TKBw2aC5gm5z+7IfbDsXsi/3LaIPXjJwsE11RnYS4Pmar0kZLhHoTP 12 | HLWwo7OXjMwSpe9cT7mB29CaJqGe+rLegIi1/XkeU1CNemL6UeRBglRpCoVwsSVD 13 | isH4GNECgYEAxFXZ3kXhZZNa3alGhSjXnqKyHALccqHMzuz9RNngr4MTRGr9ry7N 14 | VGQBze6ajem52ywq556ObF+/nBhUZd5mnHNWgYkaOTtXPLGVZcroMrpRuLjOrXJq 15 | MxOtQ92yi3O3tnVLn9VQrVC7Oxif23S80STSlLsBCpoaxP/3fuEw/X0CgYEAw2MM 16 | dRSxrpfOOIk/3RjTtL6/s5+XLb607qjt9dpvd1m4n9bCpFEZBQRaCgOgDO93lOJG 17 | UylnekDTA2SO2qCT/HOuir537ZdXtWO5/xg4IWD4nHGCkKb2gSp9th6cQNSw3Elj 18 | 2937nQ9PXHy54uA1r+F3Ynqhq3R8HMKY+Buhst0CgYAQh+H2MxANS2DlNPF5GL0+ 19 | 4Bf6/8qr5C+oZI/Wkjm2zWR76D4/18L2Dg2Q1zwwIrPBXnCmW9VGDrHFZM8GXLlr 20 | BtMLyQ1qMDLiK1mW3oS6cLGcygKs2+tRLaDzC+GSmEWpmSqq5H0MerWo/iPHiIa1 21 | XVJVr4Eg8WS7nYmrJy7GyQKBgE7r61Q/j6XeW8Yqakl8hcc0ZWrAw+gOaDcetT/h 22 | g7TJ4PFvZh/JQjnskBILdNLEx6Cz5YQh7VJMbO+p5qoYwq3ubEpOtVKbFyqFpdOM 23 | jN+us709fGfBiUCTUUQHCUaGownX+yYMfF3smTnah5tExWrNv9NfhX4kBx323KMb 24 | Ri1hAoGBAJXG2kLu38oyr//C0VlQLN0EnRfqqoeyYJ5wzKs6joye+7dk/CDRu9Iv 25 | Or1/HWOnHdDaMzGLfQB+oMLqAn7SMz1aMJsp06las8uBWPt9YkaUUlWxoH1bbGvO 26 | OZMWKYz45c5+bdKtYU56+psS5hLHGH0bUzCf2uoF64WMwObSz1JH 27 | -----END RSA PRIVATE KEY----- -------------------------------------------------------------------------------- /tests/yourname_mauth.priv_pkcs15.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAsgGNzJQyalauKRcclxt0eyLyDvtDMPeeQokbmJQGlvh596os 3 | gWYvq6R8xF/1lI6iChftQC7Z2OLEwsK1QkPmswoFK3cSkyBbdDMqNM1RWttdgL3k 4 | +sRDrlJYpJ7a2+GoxJcwgXcZjJCdjwJJFoKa0GIfM34xd2rhWX5sH8Y9AqJh0Byf 5 | zJNz0boQ/lOJD0EbryW49tvfvybSHczeYIOHU2M4D7jIOkSfCLNYw+l8mi/yGJaT 6 | BombvVU+7ci8EWwtI9jQpmEjjYTNPScBrlPLtyx/fHheA8XDme9ptHJ2VPiUPMJp 7 | BkFPs5CpwXRshx8PToE5qve8XB1Vk5MSZBKuIwIDAQABAoIBAE1oFZSYAVByvyuJ 8 | LFqdisqdSKuxIefiVgTTAJgaDr+J7+f+LXpbyHEYh1UR8YYXfGltPDOD7CG/gxa6 9 | ev4E7wZ+Xf8nuYEXOg3OzhTGEBG1gPSiHzfZLyFYF8oGdl/VwoEyydoSw553qLxu 10 | nv/V7aKeeLj4sGQgqzwymKw8lX1WC44tVqBs3ohrK/iKAhe1wkT5iUwYGJGoTNRJ 11 | zVRGTZxgU2S+P5foOXKYxUNOBv3zlhvQor2aRvLzwcjQQZop3KvzPogKJaX4FeHb 12 | OO2FjMfsogD1DdVXUGOsWZdFj4iN9wOSiqid+UxPyhfUyr9mN5MJMRNV0AMUP+FM 13 | VbDlCJkCgYEA5djx4TYcRQA+xxSNgSMlg5OY9XUpEl4WZIL9/EZSXrb4o5pAPYP2 14 | m/KZzQ7nTZOVr9JznccgdrUnnV9if6m5V/5Omi27gh6fpbovAqF+bQT3AUgmZ8s5 15 | nUore6ByV2y/cRHA0wzM8F9Azgbaw5yHMnQWqgdba8vskiAF9USW2T0CgYEAxkKP 16 | cW/Vzso1TOqAWI3e8VOvWEzytW7s/uedOI1DvrmXytJthFeJwyO70h0EdnBjMab5 17 | HsgqDb8dck0/OFUE6yyQr14rLiWyaXj86WwCuKCbpU0Tf1t4nVMOLeCOBfhMQa/G 18 | ij3TZJTqTfBz9oIB0t79lxAoz6S7UrZxqZo8Wt8CgYBLa8nLDddu6OqwptTcGC1a 19 | JJefi8djaI5OgxFWs7iZrc6e2KHVzbShbZT8TbSmpxQKMrOPhWTorv8Fy+PlYksY 20 | TbF7NCCATQ4z8ok0gsuaeHOY7xTzICOSsmDcW0TJ3TxgnOO6HUwuYANC18r3Pyi5 21 | 7I/3URy1nZ/OP0XVOqGJPQKBgEqNVOtPJpMwLoLR25lxH9iXo2QM62eWYsAn0FMn 22 | q8XYfF/kaRJO6JrcoRANoVP4RxSwuRT+J/IrX6NPsOo57jOQ+oc3Xf1oZ4KJ4HDW 23 | EN/kZSLvrNlDSEPAq6BMxJsyF1rMAliRjyBPbxwHw1N657yn5awcg3wxcc9Uk8E3 24 | ImL5AoGBAKF8De6v1e9GBXHooDRa/EFYs2VbKftKsUxmPfqcPXxM7+ix1O8qwvjz 25 | DDaQLhxhDD/1OHo1L4Jgym/Q//rx8+UnPf4VNto+uegdqew4PwMMYGvKZVcwapWe 26 | lONN7NPhb6MZg6XUCS5qWu9kbFCgBlRS603nB8uHl+2v8CUmjH59 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /tests/yourname_mauth.priv_pkcs15.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAsgGNzJQyalauKRcclxt0eyLyDvtDMPeeQokbmJQGlvh596os 3 | gWYvq6R8xF/1lI6iChftQC7Z2OLEwsK1QkPmswoFK3cSkyBbdDMqNM1RWttdgL3k 4 | +sRDrlJYpJ7a2+GoxJcwgXcZjJCdjwJJFoKa0GIfM34xd2rhWX5sH8Y9AqJh0Byf 5 | zJNz0boQ/lOJD0EbryW49tvfvybSHczeYIOHU2M4D7jIOkSfCLNYw+l8mi/yGJaT 6 | BombvVU+7ci8EWwtI9jQpmEjjYTNPScBrlPLtyx/fHheA8XDme9ptHJ2VPiUPMJp 7 | BkFPs5CpwXRshx8PToE5qve8XB1Vk5MSZBKuIwIDAQABAoIBAE1oFZSYAVByvyuJ 8 | LFqdisqdSKuxIefiVgTTAJgaDr+J7+f+LXpbyHEYh1UR8YYXfGltPDOD7CG/gxa6 9 | ev4E7wZ+Xf8nuYEXOg3OzhTGEBG1gPSiHzfZLyFYF8oGdl/VwoEyydoSw553qLxu 10 | nv/V7aKeeLj4sGQgqzwymKw8lX1WC44tVqBs3ohrK/iKAhe1wkT5iUwYGJGoTNRJ 11 | zVRGTZxgU2S+P5foOXKYxUNOBv3zlhvQor2aRvLzwcjQQZop3KvzPogKJaX4FeHb 12 | OO2FjMfsogD1DdVXUGOsWZdFj4iN9wOSiqid+UxPyhfUyr9mN5MJMRNV0AMUP+FM 13 | VbDlCJkCgYEA5djx4TYcRQA+xxSNgSMlg5OY9XUpEl4WZIL9/EZSXrb4o5pAPYP2 14 | m/KZzQ7nTZOVr9JznccgdrUnnV9if6m5V/5Omi27gh6fpbovAqF+bQT3AUgmZ8s5 15 | nUore6ByV2y/cRHA0wzM8F9Azgbaw5yHMnQWqgdba8vskiAF9USW2T0CgYEAxkKP 16 | cW/Vzso1TOqAWI3e8VOvWEzytW7s/uedOI1DvrmXytJthFeJwyO70h0EdnBjMab5 17 | HsgqDb8dck0/OFUE6yyQr14rLiWyaXj86WwCuKCbpU0Tf1t4nVMOLeCOBfhMQa/G 18 | ij3TZJTqTfBz9oIB0t79lxAoz6S7UrZxqZo8Wt8CgYBLa8nLDddu6OqwptTcGC1a 19 | JJefi8djaI5OgxFWs7iZrc6e2KHVzbShbZT8TbSmpxQKMrOPhWTorv8Fy+PlYksY 20 | TbF7NCCATQ4z8ok0gsuaeHOY7xTzICOSsmDcW0TJ3TxgnOO6HUwuYANC18r3Pyi5 21 | 7I/3URy1nZ/OP0XVOqGJPQKBgEqNVOtPJpMwLoLR25lxH9iXo2QM62eWYsAn0FMn 22 | q8XYfF/kaRJO6JrcoRANoVP4RxSwuRT+J/IrX6NPsOo57jOQ+oc3Xf1oZ4KJ4HDW 23 | EN/kZSLvrNlDSEPAq6BMxJsyF1rMAliRjyBPbxwHw1N657yn5awcg3wxcc9Uk8E3 24 | ImL5AoGBAKF8De6v1e9GBXHooDRa/EFYs2VbKftKsUxmPfqcPXxM7+ix1O8qwvjz 25 | DDaQLhxhDD/1OHo1L4Jgym/Q//rx8+UnPf4VNto+uegdqew4PwMMYGvKZVcwapWe 26 | lONN7NPhb6MZg6XUCS5qWu9kbFCgBlRS603nB8uHl+2v8CUmjH59 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | An important component is that the Authenticator needs its own set of credentials, as it needs access to the MAuth Server to authenticate requests. 5 | 6 | You will need to raise a ticket to register a public key and get an *APP_UUID* for the environment of your application. Note that the *MAUTH_BASE_URL* will probably 7 | include the environment, e.g. *https://mauth-sandbox.imedidata.net* 8 | 9 | 10 | Installation 11 | ------------ 12 | 13 | Install using pip:: 14 | 15 | $ pip install flask-mauth 16 | 17 | 18 | Or directly from GitHub:: 19 | 20 | $ pip install git+https://github.com/mdsol/flask-mauth.git 21 | 22 | This will also install the dependencies 23 | 24 | Usage 25 | ----- 26 | 27 | To use *Flask-MAuth* you will need to create an application instance and supply the required configuration options:: 28 | 29 | from flask import Flask 30 | from flask_mauth import MAuthAuthenticator 31 | 32 | app = Flask("Some Sample App") 33 | app.config['MAUTH_APP_UUID'] = '671785CD-15CE-458A-9779-8132C8F60F04' # This will be the APP UUID for your application 34 | app.config['MAUTH_KEY_DATA'] = key_text # This will be the content of the Private Key 35 | app.config['MAUTH_BASE_URL'] = "https://mauth-sandbox.imedidata.net" # The MAuth Server Base URL 36 | app.config['MAUTH_VERSION'] = "v2" # This defaults to v2 and can be left out 37 | app.config['MAUTH_MODE'] = "local" # This should be either 'local' or 'remote' 38 | mauth = MAuthAuthenticator() 39 | mauth.init_app(app) 40 | 41 | To specify routes that need to be authenticated use the `requires_authentication` decorator:: 42 | 43 | from flask_mauth import MAuthAuthenticator, requires_authentication 44 | @app.route("/some/private/route", methods=["GET"]) 45 | @requires_authentication 46 | def private_route(): 47 | return 'Wibble' 48 | 49 | @app.route("/app_status", methods=["GET"]) 50 | def app_status(): 51 | return 'OK' 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # IPython Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # dotenv 81 | .env 82 | 83 | # virtualenv 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | ### JetBrains template 93 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 94 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 95 | 96 | # User-specific stuff: 97 | .idea/workspace.xml 98 | .idea/tasks.xml 99 | .idea/dictionaries 100 | .idea/vcs.xml 101 | .idea/jsLibraryMappings.xml 102 | 103 | # Sensitive or high-churn files: 104 | .idea/dataSources.ids 105 | .idea/dataSources.xml 106 | .idea/dataSources.local.xml 107 | .idea/sqlDataSources.xml 108 | .idea/dynamic.xml 109 | .idea/uiDesigner.xml 110 | 111 | # Gradle: 112 | .idea/gradle.xml 113 | .idea/libraries 114 | 115 | # Mongo Explorer plugin: 116 | .idea/mongoSettings.xml 117 | 118 | ## File-based project format: 119 | *.iws 120 | 121 | ## Plugin-specific files: 122 | 123 | # IntelliJ 124 | /out/ 125 | 126 | # mpeltonen/sbt-idea plugin 127 | .idea_modules/ 128 | 129 | # JIRA plugin 130 | atlassian-ide-plugin.xml 131 | 132 | # Crashlytics plugin (for Android Studio and IntelliJ) 133 | com_crashlytics_export_strings.xml 134 | crashlytics.properties 135 | crashlytics-build.properties 136 | fabric.properties 137 | 138 | -------------------------------------------------------------------------------- /flask_mauth/cacher/security_token_cacher.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import cachetools 4 | import requests 5 | import requests_mauth 6 | from six.moves.urllib.parse import urljoin 7 | 8 | from flask_mauth.exceptions import InauthenticError, UnableToAuthenticateError 9 | from flask_mauth.settings import uuid_pattern 10 | 11 | 12 | class SecurityTokenCacher(object): 13 | """ 14 | Cache the retrieved tokens from the remote site 15 | """ 16 | 17 | def __init__(self, mauth_auth=None, mauth_base_url=None, mauth_api_version=None, cache_life=60): 18 | # type: (requests_mauth.MAuth, str, str) -> None 19 | self.auth = mauth_auth 20 | self._cache = cachetools.TTLCache(100, 21 | cache_life) 22 | self.mauth_base_url = mauth_base_url 23 | self.mauth_api_version = mauth_api_version 24 | 25 | def flush(self, app_uuid): 26 | """ 27 | Expire a token 28 | 29 | :param app_uuid: APP UUID to expire 30 | """ 31 | if app_uuid in self._cache: 32 | del self._cache[app_uuid] 33 | 34 | def get(self, app_uuid): 35 | """ 36 | Get a credential set from the cache 37 | 38 | :param app_uuid: APP_UUID to retrieve 39 | """ 40 | if app_uuid not in self._cache: 41 | # pull the remote credentials, if this fails it raises an InauthenticError 42 | self._remote_get(app_uuid) 43 | return self._cache.get(app_uuid) 44 | 45 | def _remote_get(self, app_uuid): 46 | # type: (str) -> None 47 | """ 48 | Attempt to retrieve a credential set from the remote store 49 | 50 | :param app_uuid: APP_UUID to retrieve 51 | """ 52 | if not uuid_pattern.match(app_uuid): 53 | raise UnableToAuthenticateError("APP UUID format is not conformant") 54 | url = urljoin(self.mauth_base_url, "/mauth/{mauth_api_version}/security_tokens" \ 55 | "/{app_uuid}.json".format(mauth_api_version=self.mauth_api_version, 56 | app_uuid=app_uuid)) 57 | response = requests.get(url, auth=self.auth) 58 | if response.status_code == 404: 59 | raise InauthenticError("mAuth service responded with 404 looking up public " 60 | "key for {app_uuid}".format(app_uuid=app_uuid)) 61 | elif response.status_code == 200: 62 | self._cache[app_uuid] = response.json() 63 | else: 64 | raise UnableToAuthenticateError("The mAuth service responded " 65 | "with {status}: {body}".format(status=response.status_code, 66 | body=response.content), 67 | response) 68 | -------------------------------------------------------------------------------- /flask_mauth/mauth/signature.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import six 3 | 4 | __author__ = 'glow' 5 | 6 | from six.moves.urllib.parse import urlparse 7 | from flask_mauth import settings 8 | from hashlib import sha512 9 | 10 | 11 | class Signature(object): 12 | """ 13 | Represents a Signature for the purposes of comparison 14 | """ 15 | 16 | def __init__(self, verb=None, url_path=None, body=None, app_uuid=None, seconds_since_epoch=None): 17 | """ 18 | :param str verb: HTTP Verb 19 | :param str url_path: URL path 20 | :param str body: Content of the Request 21 | :param str app_uuid: APP UUID for the requesting client 22 | :param str seconds_since_epoch: Seconds since epoch as declared in the MWS-TIME header 23 | """ 24 | self.verb = verb 25 | self.url_path = url_path 26 | self.body = body 27 | self.app_uuid = app_uuid 28 | self.seconds_since_epoch = seconds_since_epoch 29 | 30 | @classmethod 31 | def from_request(cls, request): 32 | """ 33 | Build a Signature from a Request Object 34 | 35 | :param request: Request 36 | :type request: werkzeug.wrappers.BaseRequest 37 | :return: Signature object 38 | :rtype: Signature 39 | """ 40 | token, app_uuid, signature = settings.signature_info. \ 41 | match(request.headers.get(settings.x_mws_authentication)).groups() 42 | seconds_since_epoch = request.headers.get(settings.x_mws_time) 43 | 44 | return cls(verb=request.method, 45 | url_path=urlparse(request.path).path, 46 | body=request.data or '', 47 | app_uuid=app_uuid, 48 | seconds_since_epoch=seconds_since_epoch) 49 | 50 | @classmethod 51 | def from_signature(cls, signature): 52 | """ 53 | Build a Signature from a signature string 54 | 55 | :param signature: signature string 56 | :type signature: str 57 | :return: Signature object 58 | :rtype: Signature 59 | """ 60 | verb, url_path, body, app_uuid, seconds_since_epoch = signature.split('\n') 61 | return cls(verb, 62 | url_path, 63 | body, 64 | app_uuid, 65 | seconds_since_epoch) 66 | 67 | def matches(self, other): 68 | """ 69 | Confirms that the hash of this matches the passed hash 70 | 71 | :param other: hexdigest hash 72 | """ 73 | if isinstance(other, (six.binary_type,)): 74 | # Python 3 returns a bytes, Python2 returns a string 75 | return six.b(self.hash) == other 76 | return self.hash == other 77 | 78 | @property 79 | def hash(self): 80 | """ 81 | Generate the SHA512 Hash of this object for comparison 82 | 83 | :return: 84 | """ 85 | return sha512('\n'.join([self.verb, self.url_path, 86 | self.body, self.app_uuid, 87 | self.seconds_since_epoch]).encode('US-ASCII')).hexdigest() 88 | 89 | def __eq__(self, other): 90 | """ 91 | Compare Signature Objects 92 | 93 | :param other: Signature object 94 | :type other: Signature 95 | :return: if the objects match 96 | :rtype: bool 97 | """ 98 | assert isinstance(other, (Signature,)) 99 | return self.matches(other.hash) 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask MAuth 2 | 3 | **NOTE** This library is deprecated, you should be using [mauth-client-python](https://github.com/mdsol/mauth-client-python). It will be archived 4 | 5 | Flask-MAuth is a authentication library for Python server applications receiving MAuth signed requests. 6 | 7 | It is a partial Python port of the code in the [mauth-client-ruby](https://github.com/mdsol/mauth-client-ruby) repository. 8 | 9 | It uses the upstream [requests-mauth](https://github.com/mdsol/requests-mauth) client library. We need to decide whether to move the code into the local repository. 10 | 11 | Getting Started 12 | =============== 13 | 14 | An important component is that the Authenticator needs its own set of credentials, as it needs access to the MAuth Server to authenticate requests. 15 | 16 | You will need to raise a ticket to register a public key and get an *APP_UUID* for the environment of your application. Note that the *MAUTH_BASE_URL* will probably 17 | include the environment, e.g. *https://mauth-sandbox.imedidata.net* 18 | 19 | 20 | Installation 21 | ------------ 22 | 23 | Install using pip:: 24 | 25 | $ pip install flask-mauth 26 | 27 | 28 | Or directly from GitHub:: 29 | 30 | $ pip install git+https://github.com/mdsol/flask-mauth.git 31 | 32 | This will also install the dependencies 33 | 34 | Usage 35 | ----- 36 | 37 | To use *Flask-MAuth* you will need to create an application instance and supply the required configuration options:: 38 | 39 | ```python 40 | from flask import Flask 41 | from flask_mauth import MAuthAuthenticator 42 | 43 | app = Flask("Some Sample App") 44 | app.config['MAUTH_APP_UUID'] = '671785CD-15CE-458A-9779-8132C8F60F04' # This will be the APP UUID for your application 45 | app.config['MAUTH_KEY_DATA'] = key_text # This will be the content of the Private Key 46 | app.config['MAUTH_BASE_URL'] = "https://mauth-sandbox.imedidata.net" # The MAuth Server Base URL 47 | app.config['MAUTH_VERSION'] = "v2" # This defaults to v2 and can be left out 48 | app.config['MAUTH_MODE'] = "local" # This should be either 'local' or 'remote' 49 | mauth = MAuthAuthenticator() 50 | mauth.init_app(app) 51 | ``` 52 | To specify routes that need to be authenticated use the `requires_authentication` decorator:: 53 | 54 | ```python 55 | from flask_mauth import MAuthAuthenticator, requires_authentication 56 | 57 | @app.route("/some/private/route", methods=["GET"]) 58 | @requires_authentication 59 | def private_route(): 60 | return 'Wibble' 61 | 62 | @app.route("/app_status", methods=["GET"]) 63 | def app_status(): 64 | return 'OK' 65 | 66 | ``` 67 | 68 | 69 | Development and Testing 70 | ----------------------- 71 | We recommend the use of `virtualenv` or `pyenv` for development. 72 | 73 | We use [tox](https://tox.readthedocs.io/en/latest/) and [pyenv](https://github.com/yyuu/pyenv) to run the tests:: 74 | 75 | $ brew install pyenv pyenv-virtualenv # Follow the instructions to configure the enviroment 76 | $ pip install tox tox-pyenv 77 | $ pyenv local 2.7.13 3.5.2 3.6.0 # take the most recent versions for these 78 | $ tox 79 | 80 | Tox will output the status of the tests, as well as coverage data. 81 | 82 | Build Status (Travis-CI) 83 | ------------ 84 | * develop - [![Build Status](https://travis-ci.org/mdsol/flask-mauth.svg?branch=develop)](https://travis-ci.org/mdsol/flask-mauth.svg?branch=develop) 85 | * master - [![Build Status](https://travis-ci.org/mdsol/flask-mauth.svg?branch=master)](https://travis-ci.org/mdsol/flask-mauth.svg?branch=master) 86 | 87 | -------------------------------------------------------------------------------- /flask_mauth/auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import logging 4 | from functools import wraps 5 | 6 | from flask import Response, current_app, request 7 | from requests_mauth import MAuth 8 | 9 | from flask_mauth.mauth import LocalAuthenticator, RemoteAuthenticator 10 | 11 | logger = logging.getLogger("flask_mauth") 12 | 13 | 14 | class MAuthAuthenticator(object): 15 | """ 16 | The MAuth Authenticator instance 17 | """ 18 | state_key = 'flask_mauth.client' 19 | 20 | def __init__(self, app=None): 21 | # backwards compatibility support 22 | self._app = app 23 | self._authenticator = None 24 | if app: 25 | self.init_app(app) 26 | 27 | @property 28 | def app_uuid(self): 29 | """ 30 | Get the MAuth APP UUID 31 | 32 | :return: MAuth APP UUID 33 | :rtype: str 34 | """ 35 | return self._app.config.get('MAUTH_APP_UUID') 36 | 37 | @property 38 | def mauth_key(self): 39 | """ 40 | Get the MAuth Key Text 41 | 42 | :return: MAuth Key Text 43 | :rtype: str 44 | """ 45 | return self._app.config.get('MAUTH_KEY_DATA') 46 | 47 | @property 48 | def mauth_base_url(self): 49 | """ 50 | Get the MAuth Base URL 51 | 52 | :return: MAuth Base URL 53 | :rtype: str 54 | """ 55 | return self._app.config.get('MAUTH_BASE_URL') 56 | 57 | @property 58 | def mauth_version(self): 59 | """ 60 | Get the MAuth Version (defaults to v2) 61 | 62 | :return: MAuth Version 63 | :rtype: str 64 | """ 65 | return self._app.config.get('MAUTH_VERSION', 'v2') 66 | 67 | @property 68 | def mauth_mode(self): 69 | """ 70 | Get the MAuth Authentication Mode 71 | 72 | :return: Defined MAuth Authentication Mode (defaults to local) 73 | :rtype: str 74 | """ 75 | return self._app.config.get('MAUTH_MODE', 'local') 76 | 77 | def _create_authenticator(self): 78 | # Validate the client settings (MAUTH_APP_UUID, MAUTH_KEY_DATA) 79 | if None in (self.app_uuid, self.mauth_key) or '' in (self.app_uuid, self.mauth_key): 80 | raise TypeError("MAuthAuthenticator requires both a MAUTH_APP_UUID and MAUTH_KEY_DATA to be set") 81 | # Validate the mauth settings (MAUTH_BASE_URL, MAUTH_VERSION) 82 | if None in (self.mauth_base_url, self.mauth_version) or '' in (self.mauth_base_url, self.mauth_version): 83 | raise TypeError("MAuthAuthenticator requires a MAUTH_BASE_URL and MAUTH_VERSION") 84 | # Validate MAUTH_MODE 85 | if self.mauth_mode not in ("local", "remote"): 86 | raise TypeError("MAuthAuthenticator MAUTH_MODE must be one of local or remote") 87 | # create the mauth_client 88 | mauth_client = MAuth(self.app_uuid, self.mauth_key) 89 | if self.mauth_mode == 'local': 90 | authenticator = LocalAuthenticator(mauth_auth=mauth_client, 91 | logger=logger, 92 | mauth_base_url=self.mauth_base_url, 93 | mauth_api_version=self.mauth_version) 94 | else: 95 | authenticator = RemoteAuthenticator(mauth_auth=mauth_client, 96 | logger=logger, 97 | mauth_base_url=self.mauth_base_url, 98 | mauth_api_version=self.mauth_version) 99 | return authenticator 100 | 101 | def authenticate(self, request): 102 | """ 103 | Authenticates a request 104 | 105 | :param request: Request object 106 | :type request: werkzeug.wrappers.BaseRequest 107 | :return: Is the request authentic, Status Code, Message 108 | :rtype: bool, int, str 109 | """ 110 | return self._authenticator.is_authentic(request) 111 | 112 | def init_app(self, app): 113 | """ 114 | Init app with Flask instance. 115 | 116 | :param app: Flask Application instance 117 | """ 118 | self._app = app 119 | app.authenticator = self 120 | app.extensions = getattr(app, 'extensions', {}) 121 | app.extensions[self.state_key] = self 122 | # initialise the authenticator 123 | self._authenticator = self._create_authenticator() 124 | 125 | 126 | def requires_authentication(f): 127 | """ 128 | A Decorator for routes requiring mauth authentication 129 | """ 130 | @wraps(f) 131 | def wrapper(*args, **kwargs): 132 | authenticator = current_app.authenticator 133 | authentic, status, message = authenticator.authenticate(request) 134 | if not authentic: 135 | # TODO: do we return the underlying error? Currently going into the log 136 | _message = json.dumps(dict(errors=dict(mauth=[message]))) 137 | return Response(response=_message, 138 | status=status, 139 | mimetype="application/json") 140 | return f(*args, **kwargs) 141 | return wrapper 142 | -------------------------------------------------------------------------------- /tests/test_security_token_cacher.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | import uuid 5 | from time import sleep 6 | from unittest import TestCase 7 | 8 | import mock 9 | 10 | from flask_mauth.cacher.security_token_cacher import SecurityTokenCacher 11 | from flask_mauth.exceptions import InauthenticError, UnableToAuthenticateError 12 | from tests.common import load_key 13 | 14 | 15 | class TestSecurityTokenCacher(TestCase): 16 | def setUp(self): 17 | self.app_uuid = 'b0603e5c-c344-488e-83ba-9290ea8dc17d' 18 | self.public_key = load_key() 19 | 20 | def test_request_for_known_app_uses_cache(self): 21 | """A previously downloaded token is returned """ 22 | ticket = dict(app_name="Some App", 23 | app_uuid=self.app_uuid, 24 | public_key_str=self.public_key, 25 | created_at=datetime.datetime.now()) 26 | cacher = SecurityTokenCacher() 27 | with mock.patch("flask_mauth.cacher.security_token_cacher.requests.get") as req: 28 | req.return_value = mock.Mock(status_code=200) 29 | req.json.return_value = ticket 30 | # this will fetch 31 | token = cacher.get(self.app_uuid) 32 | # this will retrieve 33 | token = cacher.get(self.app_uuid) 34 | self.assertEqual(1, req.call_count) 35 | 36 | def test_flush(self): 37 | """ We can manually flush an element from the cache """ 38 | ticket = dict(app_name="Some App", 39 | app_uuid=self.app_uuid, 40 | public_key_str=self.public_key, 41 | created_at=datetime.datetime.now()) 42 | cacher = SecurityTokenCacher() 43 | with mock.patch("flask_mauth.cacher.security_token_cacher.requests.get") as req: 44 | req.return_value = mock.Mock(status_code=200) 45 | req.json.return_value = ticket 46 | # this will fetch 47 | token = cacher.get(self.app_uuid) 48 | cacher.flush(self.app_uuid) 49 | # this will fetch 50 | token = cacher.get(self.app_uuid) 51 | # because we flush the element, we need to get calls 52 | self.assertEqual(2, req.call_count) 53 | 54 | def test_flush_missing_app(self): 55 | """ Trying to flush something that doesn't exist, no problem """ 56 | ticket = dict(app_name="Some App", 57 | app_uuid=self.app_uuid, 58 | public_key_str=self.public_key, 59 | created_at=datetime.datetime.now()) 60 | cacher = SecurityTokenCacher() 61 | self.assertIsNone(cacher.flush(str(uuid.uuid4()))) 62 | 63 | def test_request_for_expired_app_fetches(self): 64 | """ An expired cache token will be refetched """ 65 | ticket = dict(app_name="Some App", 66 | app_uuid=self.app_uuid, 67 | public_key_str=self.public_key, 68 | created_at=datetime.datetime.now()) 69 | cacher = SecurityTokenCacher(cache_life=0.001) 70 | with mock.patch("flask_mauth.cacher.security_token_cacher.requests.get") as req: 71 | req.return_value = mock.Mock(status_code=200) 72 | req.json.return_value = ticket 73 | # this will fetch 74 | token = cacher.get(self.app_uuid) 75 | sleep(0.1) 76 | # this will fetch 77 | token = cacher.get(self.app_uuid) 78 | self.assertEqual(2, req.call_count) 79 | 80 | def test_request_for_unknown_app_raises(self): 81 | """ An unknown token returns an InauthenticError """ 82 | ticket = dict(app_name="Some App", 83 | app_uuid=self.app_uuid, 84 | public_key_str=self.public_key, 85 | created_at=datetime.datetime.now()) 86 | cacher = SecurityTokenCacher() 87 | with mock.patch("flask_mauth.cacher.security_token_cacher.requests.get") as req: 88 | req.return_value = mock.Mock(status_code=404) 89 | # this will fetch 90 | with self.assertRaises(InauthenticError) as exc: 91 | token = cacher.get(self.app_uuid) 92 | self.assertEqual(str(exc.exception), 93 | "mAuth service responded with 404 looking up public " 94 | "key for {app_uuid}".format(app_uuid=self.app_uuid)) 95 | self.assertEqual(1, req.call_count) 96 | 97 | def test_mauth_error_raises(self): 98 | """ A MAuth Service error raises an UnableToAuthenticateError """ 99 | cacher = SecurityTokenCacher() 100 | with mock.patch("flask_mauth.cacher.security_token_cacher.requests.get") as req: 101 | req.return_value = mock.Mock(status_code=500, content="GULP") 102 | # this will fetch 103 | with self.assertRaises(UnableToAuthenticateError) as exc: 104 | token = cacher.get(self.app_uuid) 105 | self.assertEqual(str(exc.exception), 106 | "The mAuth service responded " 107 | "with 500: GULP") 108 | self.assertEqual(1, req.call_count) 109 | 110 | def test_incorrect_app_uuid_raises(self): 111 | """ A invalid UUID raises an UnableToAuthenticateError """ 112 | cacher = SecurityTokenCacher() 113 | # pass in attempts to escape using paths 114 | for garbage in ('%s/../../' % self.app_uuid, '../../%s' % self.app_uuid, 115 | 'horse', '1234'): 116 | with self.assertRaises(UnableToAuthenticateError) as exc: 117 | token = cacher.get(garbage) 118 | self.assertEqual(str(exc.exception), 119 | "APP UUID format is not conformant") 120 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Flask MAuth documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Jan 4 22:55:48 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | import re 22 | 23 | sys.path.insert(0, os.path.abspath('..')) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = ['sphinx.ext.autodoc', 35 | 'sphinx.ext.doctest', 36 | 'sphinx.ext.intersphinx', 37 | 'sphinx.ext.todo', 38 | 'sphinx.ext.coverage', 39 | 'sphinx.ext.ifconfig', 40 | 'sphinx.ext.viewcode'] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # 48 | # source_suffix = ['.rst', '.md'] 49 | source_suffix = '.rst' 50 | 51 | # The master toctree document. 52 | master_doc = 'index' 53 | 54 | # General information about the project. 55 | project = u'Flask MAuth' 56 | copyright = u'2017, Geoff Low' 57 | author = u'Geoff Low' 58 | 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | def get_version(): 65 | with open(os.path.join(os.path.dirname(__file__), '..', 'flask_mauth', '__version__.py'), 'r') as fp: 66 | m = re.search( 67 | r"^__version__ = ['\"]([^'\"]*)['\"]", 68 | fp.read(), 69 | re.M 70 | ) 71 | 72 | return m.groups(1)[0] 73 | 74 | 75 | # The short X.Y version. 76 | version = get_version() 77 | # The full version, including alpha/beta/rc tags. 78 | release = get_version() 79 | 80 | # The language for content autogenerated by Sphinx. Refer to documentation 81 | # for a list of supported languages. 82 | # 83 | # This is also used if you do content translation via gettext catalogs. 84 | # Usually you set "language" from the command line for these cases. 85 | language = None 86 | 87 | # List of patterns, relative to source directory, that match files and 88 | # directories to ignore when looking for source files. 89 | # This patterns also effect to html_static_path and html_extra_path 90 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 91 | 92 | # The name of the Pygments (syntax highlighting) style to use. 93 | pygments_style = 'sphinx' 94 | 95 | # If true, `todo` and `todoList` produce output, else they produce nothing. 96 | todo_include_todos = True 97 | 98 | # try to set class attributes 99 | autoclass_content = 'both' 100 | 101 | # -- Options for HTML output ---------------------------------------------- 102 | 103 | # The theme to use for HTML and HTML Help pages. See the documentation for 104 | # a list of builtin themes. 105 | # 106 | html_theme = 'alabaster' 107 | 108 | # Theme options are theme-specific and customize the look and feel of a theme 109 | # further. For a list of options available for each theme, see the 110 | # documentation. 111 | # 112 | # html_theme_options = {} 113 | 114 | # Add any paths that contain custom static files (such as style sheets) here, 115 | # relative to this directory. They are copied after the builtin static files, 116 | # so a file named "default.css" will overwrite the builtin "default.css". 117 | html_static_path = ['_static'] 118 | 119 | # -- Options for HTMLHelp output ------------------------------------------ 120 | 121 | # Output file base name for HTML help builder. 122 | htmlhelp_basename = 'FlaskMAuthdoc' 123 | 124 | # -- Options for LaTeX output --------------------------------------------- 125 | 126 | latex_elements = { 127 | # The paper size ('letterpaper' or 'a4paper'). 128 | # 129 | # 'papersize': 'letterpaper', 130 | 131 | # The font size ('10pt', '11pt' or '12pt'). 132 | # 133 | # 'pointsize': '10pt', 134 | 135 | # Additional stuff for the LaTeX preamble. 136 | # 137 | # 'preamble': '', 138 | 139 | # Latex figure (float) alignment 140 | # 141 | # 'figure_align': 'htbp', 142 | } 143 | 144 | # Grouping the document tree into LaTeX files. List of tuples 145 | # (source start file, target name, title, 146 | # author, documentclass [howto, manual, or own class]). 147 | latex_documents = [ 148 | (master_doc, 'FlaskMAuth.tex', u'Flask MAuth Documentation', 149 | u'Geoff Low', 'manual'), 150 | ] 151 | 152 | # -- Options for manual page output --------------------------------------- 153 | 154 | # One entry per manual page. List of tuples 155 | # (source start file, name, description, authors, manual section). 156 | man_pages = [ 157 | (master_doc, 'flaskmauth', u'Flask MAuth Documentation', 158 | [author], 1) 159 | ] 160 | 161 | # -- Options for Texinfo output ------------------------------------------- 162 | 163 | # Grouping the document tree into Texinfo files. List of tuples 164 | # (source start file, target name, title, author, 165 | # dir menu entry, description, category) 166 | texinfo_documents = [ 167 | (master_doc, 'FlaskMAuth', u'Flask MAuth Documentation', 168 | author, 'FlaskMAuth', 'One line description of project.', 169 | 'Miscellaneous'), 170 | ] 171 | 172 | # Example configuration for intersphinx: refer to the Python standard library. 173 | intersphinx_mapping = {'https://docs.python.org/': None} 174 | -------------------------------------------------------------------------------- /tests/test_mauth_authenticator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import unittest 4 | 5 | import mock 6 | from flask import Flask 7 | 8 | from flask_mauth.auth import MAuthAuthenticator, requires_authentication 9 | from tests.common import load_key 10 | 11 | 12 | class MAuthAthenticatorTestCase(unittest.TestCase): 13 | 14 | def setUp(self): 15 | self.app = Flask("Test App") 16 | 17 | def test_app_configuration(self): 18 | """With everything present, initialisation of app is ok""" 19 | key_text = load_key('priv') 20 | self.app.config['MAUTH_APP_UUID'] = '671785CD-15CE-458A-9779-8132C8F60F04' 21 | self.app.config['MAUTH_KEY_DATA'] = key_text 22 | self.app.config['MAUTH_BASE_URL'] = "https://mauth-sandbox.imedidata.net" 23 | self.app.config['MAUTH_VERSION'] = "v2" 24 | self.app.config['MAUTH_MODE'] = "local" 25 | try: 26 | authenticator = MAuthAuthenticator(self.app) 27 | except TypeError as exc: 28 | self.fail("Shouldn't raise an exception") 29 | 30 | def test_app_configuration_missing_uuid(self): 31 | """With APP_UUID missing, initialisation of app is wrong""" 32 | key_text = load_key('priv') 33 | self.app.config['MAUTH_KEY_DATA'] = key_text 34 | self.app.config['MAUTH_BASE_URL'] = "https://mauth-sandbox.imedidata.net" 35 | self.app.config['MAUTH_VERSION'] = "v2" 36 | self.app.config['MAUTH_MODE'] = "local" 37 | with self.assertRaises(TypeError) as exc: 38 | authenticator = MAuthAuthenticator(self.app) 39 | self.assertEqual(str(exc.exception), 40 | "MAuthAuthenticator requires both a MAUTH_APP_UUID and MAUTH_KEY_DATA to be set") 41 | 42 | def test_app_configuration_missing_key(self): 43 | """With Key Text missing, initialisation of app is wrong""" 44 | key_text = load_key('priv') 45 | self.app.config['MAUTH_APP_UUID'] = '671785CD-15CE-458A-9779-8132C8F60F04' 46 | self.app.config['MAUTH_BASE_URL'] = "https://mauth-sandbox.imedidata.net" 47 | self.app.config['MAUTH_VERSION'] = "v2" 48 | self.app.config['MAUTH_MODE'] = "local" 49 | with self.assertRaises(TypeError) as exc: 50 | authenticator = MAuthAuthenticator(self.app) 51 | self.assertEqual(str(exc.exception), 52 | "MAuthAuthenticator requires both a MAUTH_APP_UUID and MAUTH_KEY_DATA to be set") 53 | 54 | def test_app_configuration_missing_base_url(self): 55 | """With BASE_URL missing, initialisation of app is wrong""" 56 | key_text = load_key('priv') 57 | self.app.config['MAUTH_APP_UUID'] = '671785CD-15CE-458A-9779-8132C8F60F04' 58 | self.app.config['MAUTH_KEY_DATA'] = key_text 59 | self.app.config['MAUTH_VERSION'] = "v2" 60 | self.app.config['MAUTH_MODE'] = "local" 61 | with self.assertRaises(TypeError) as exc: 62 | authenticator = MAuthAuthenticator(self.app) 63 | self.assertEqual(str(exc.exception), 64 | "MAuthAuthenticator requires a MAUTH_BASE_URL and MAUTH_VERSION") 65 | 66 | def test_app_configuration_missing_version(self): 67 | """With VERSION missing, initialisation of app is ok""" 68 | key_text = load_key('priv') 69 | self.app.config['MAUTH_APP_UUID'] = '671785CD-15CE-458A-9779-8132C8F60F04' 70 | self.app.config['MAUTH_KEY_DATA'] = key_text 71 | self.app.config['MAUTH_BASE_URL'] = "https://mauth-sandbox.imedidata.net" 72 | self.app.config['MAUTH_MODE'] = "local" 73 | try: 74 | authenticator = MAuthAuthenticator(self.app) 75 | except TypeError as exc: 76 | self.fail("Shouldn't raise an exception") 77 | self.assertEqual('v2', authenticator.mauth_version) 78 | 79 | def test_app_configuration_wrong_mode(self): 80 | """With incorrect mode, initialisation of app is wrong""" 81 | key_text = load_key('priv') 82 | self.app.config['MAUTH_APP_UUID'] = '671785CD-15CE-458A-9779-8132C8F60F04' 83 | self.app.config['MAUTH_KEY_DATA'] = key_text 84 | self.app.config['MAUTH_BASE_URL'] = "https://mauth-sandbox.imedidata.net" 85 | self.app.config['MAUTH_MODE'] = "banana" 86 | with self.assertRaises(TypeError) as exc: 87 | authenticator = MAuthAuthenticator(self.app) 88 | self.assertEqual(str(exc.exception), 89 | "MAuthAuthenticator MAUTH_MODE must be one of local or remote") 90 | 91 | def test_app_configuration_remote(self): 92 | """With remote mode, initialisation of app is ok""" 93 | key_text = load_key('priv') 94 | self.app.config['MAUTH_APP_UUID'] = '671785CD-15CE-458A-9779-8132C8F60F04' 95 | self.app.config['MAUTH_KEY_DATA'] = key_text 96 | self.app.config['MAUTH_BASE_URL'] = "https://mauth-sandbox.imedidata.net" 97 | self.app.config['MAUTH_MODE'] = "remote" 98 | try: 99 | authenticator = MAuthAuthenticator(self.app) 100 | except TypeError as exc: 101 | self.fail("Shouldn't raise an exception") 102 | self.assertEqual('remote', authenticator.mauth_mode) 103 | 104 | def test_app_configuration_and_call_protected_url(self): 105 | """A protected route will raise if the call is inauthentic""" 106 | key_text = load_key('priv') 107 | self.app.config['MAUTH_APP_UUID'] = '671785CD-15CE-458A-9779-8132C8F60F04' 108 | self.app.config['MAUTH_KEY_DATA'] = key_text 109 | self.app.config['MAUTH_BASE_URL'] = "https://mauth-sandbox.imedidata.net" 110 | self.app.config['MAUTH_VERSION'] = "v2" 111 | self.app.config['MAUTH_MODE'] = "local" 112 | authenticator = MAuthAuthenticator() 113 | authenticator.init_app(self.app) 114 | 115 | @self.app.route("/", methods=['GET']) 116 | @requires_authentication 117 | def test_url_closed(): 118 | return "Ping" 119 | 120 | client = self.app.test_client() 121 | 122 | # protected URL 123 | rv = client.get("/") 124 | self.assertEqual(401, rv.status_code) 125 | self.assertEqual(dict(errors=dict(mauth=["Authentication Failed. No mAuth signature present; " 126 | "X-MWS-Authentication header is blank."])), 127 | json.loads(rv.data.decode('utf-8'))) 128 | 129 | def test_app_configuration_and_call_open_url(self): 130 | """An open route will pass""" 131 | key_text = load_key('priv') 132 | self.app.config['MAUTH_APP_UUID'] = '671785CD-15CE-458A-9779-8132C8F60F04' 133 | self.app.config['MAUTH_KEY_DATA'] = key_text 134 | self.app.config['MAUTH_BASE_URL'] = "https://mauth-sandbox.imedidata.net" 135 | self.app.config['MAUTH_VERSION'] = "v2" 136 | self.app.config['MAUTH_MODE'] = "local" 137 | authenticator = MAuthAuthenticator() 138 | authenticator.init_app(self.app) 139 | 140 | @self.app.route("/lemon", methods=['GET']) 141 | def test_url_open(): 142 | return "Ping" 143 | 144 | client = self.app.test_client() 145 | 146 | # open URL 147 | rv = client.get("/lemon") 148 | self.assertEqual(200, rv.status_code) 149 | self.assertEqual(b'Ping', rv.data) 150 | 151 | def test_app_configuration_with_valid_call(self): 152 | """If the call is authenticated then the call will get passed""" 153 | key_text = load_key('priv') 154 | self.app.config['MAUTH_APP_UUID'] = '671785CD-15CE-458A-9779-8132C8F60F04' 155 | self.app.config['MAUTH_KEY_DATA'] = key_text 156 | self.app.config['MAUTH_BASE_URL'] = "https://mauth-sandbox.imedidata.net" 157 | self.app.config['MAUTH_VERSION'] = "v2" 158 | self.app.config['MAUTH_MODE'] = "local" 159 | 160 | @self.app.route("/", methods=['GET']) 161 | @requires_authentication 162 | def test_url_closed(): 163 | return "Ping" 164 | 165 | client = self.app.test_client() 166 | 167 | with mock.patch("flask_mauth.auth.LocalAuthenticator") as local_auth: 168 | m_auth = local_auth.return_value 169 | m_auth.is_authentic.return_value = True, 200, "" 170 | authenticator = MAuthAuthenticator() 171 | authenticator.init_app(self.app) 172 | # protected URL 173 | rv = client.get("/") 174 | self.assertEqual(200, rv.status_code) 175 | self.assertEqual(b'Ping', rv.data) 176 | -------------------------------------------------------------------------------- /tests/test_signature.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from unittest import TestCase 4 | import mock 5 | from flask_mauth.mauth.signature import Signature 6 | from flask_mauth import settings 7 | from tests.common import get_hash 8 | 9 | 10 | class TestSignature(TestCase): 11 | def setUp(self): 12 | self.mws_time = "1479392498" 13 | self.app_uuid = 'b0603e5c-c344-488e-83ba-9290ea8dc17d' 14 | 15 | def test_create_from_request(self): 16 | """Create a Signature from a request""" 17 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 18 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 19 | path="/mauth/v2/mauth.json?open=1", 20 | method="GET", 21 | data="") 22 | signature = Signature.from_request(request) 23 | self.assertEqual("GET", signature.verb) 24 | self.assertEqual(self.app_uuid, signature.app_uuid) 25 | self.assertEqual("/mauth/v2/mauth.json", signature.url_path) 26 | self.assertEqual(self.mws_time, signature.seconds_since_epoch) 27 | 28 | def test_equality_of_req(self): 29 | """Create a duplicate Signature from a request and then check they are equal""" 30 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 31 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 32 | path="/mauth/v2/mauth.json?open=1", 33 | method="GET", 34 | data="") 35 | signature = Signature.from_request(request) 36 | signature_1 = Signature.from_request(request) 37 | self.assertEqual(signature, signature_1) 38 | 39 | def test_creates_from_string(self): 40 | """Create a Signature from a string""" 41 | # expected string 42 | sig_string = "GET" + "\n" + \ 43 | "/mauth/v2/mauth.json" + "\n" + \ 44 | "" + "\n" + \ 45 | self.app_uuid + "\n" + \ 46 | self.mws_time 47 | signature = Signature.from_signature(sig_string) 48 | self.assertEqual("GET", signature.verb) 49 | self.assertEqual(self.app_uuid, signature.app_uuid) 50 | self.assertEqual("/mauth/v2/mauth.json", signature.url_path) 51 | self.assertEqual(self.mws_time, signature.seconds_since_epoch) 52 | 53 | def test_equality_of_str(self): 54 | """Create a duplicate Signature from a string and then check they are equal""" 55 | sig_string = "GET" + "\n" + \ 56 | "/mauth/v2/mauth.json" + "\n" + \ 57 | "" + "\n" + \ 58 | self.app_uuid + "\n" + \ 59 | self.mws_time 60 | signature = Signature.from_signature(sig_string) 61 | signature_1 = Signature.from_signature(sig_string) 62 | self.assertEqual(signature, signature_1) 63 | 64 | def test_equality_of_str_and_req(self): 65 | """Create a Signature from a string and request and then check they are equal""" 66 | sig_string = "GET" + "\n" + \ 67 | "/mauth/v2/mauth.json" + "\n" + \ 68 | "" + "\n" + \ 69 | self.app_uuid + "\n" + \ 70 | self.mws_time 71 | signature = Signature.from_signature(sig_string) 72 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 73 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 74 | path="/mauth/v2/mauth.json?open=1", 75 | method="GET", 76 | data="") 77 | signature_1 = Signature.from_request(request) 78 | self.assertEqual(signature, signature_1) 79 | 80 | def test_inequality_of_str_and_req(self): 81 | """Create a Signature from a string and request and then check they are not equal with different path""" 82 | sig_string = "GET" + "\n" + \ 83 | "/mauth/v2/authentication_ticket.json" + "\n" + \ 84 | "" + "\n" + \ 85 | self.app_uuid + "\n" + \ 86 | self.mws_time 87 | signature = Signature.from_signature(sig_string) 88 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 89 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 90 | path="/mauth/v2/mauth.json?open=1", 91 | method="GET", 92 | data="") 93 | signature_1 = Signature.from_request(request) 94 | self.assertNotEqual(signature, signature_1) 95 | 96 | def test_inequality_of_str_and_req_ignores_query(self): 97 | """Create a Signature from a string and request and then check they are equal with different paths w. query""" 98 | sig_string = "GET" + "\n" + \ 99 | "/mauth/v2/mauth.json" + "\n" + \ 100 | "" + "\n" + \ 101 | self.app_uuid + "\n" + \ 102 | self.mws_time 103 | signature = Signature.from_signature(sig_string) 104 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 105 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 106 | path="/mauth/v2/mauth.json?open=1", 107 | method="GET", 108 | data="") 109 | signature_1 = Signature.from_request(request) 110 | self.assertEqual(signature, signature_1) 111 | 112 | def test_equality_of_str_and_req_with_escaping(self): 113 | """Create a Signature from a string and request and then check they are not equal with different paths""" 114 | sig_string = "GET" + "\n" + \ 115 | "/mauth/v2/authentication_ticket.json" + "\n" + \ 116 | "" + "\n" + \ 117 | self.app_uuid + "\n" + \ 118 | self.mws_time 119 | signature = Signature.from_signature(sig_string) 120 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 121 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 122 | path="/mauth/v2/mauth.json?open=1", 123 | method="GET", 124 | data="") 125 | signature_1 = Signature.from_request(request) 126 | self.assertNotEqual(signature, signature_1) 127 | self.assertFalse(signature == signature_1) 128 | 129 | def test_matches_when_it_should(self): 130 | """When supplied with a valid hash we match""" 131 | str_to_sign = "GET" + "\n" + \ 132 | "/mauth/v2/mauth.json" + "\n" + \ 133 | "" + "\n" + \ 134 | self.app_uuid + "\n" + \ 135 | self.mws_time 136 | hashed = get_hash(str_to_sign) 137 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 138 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 139 | path="/mauth/v2/mauth.json?open=1", 140 | method="GET", 141 | data="") 142 | signature_1 = Signature.from_request(request) 143 | self.assertTrue(signature_1.matches(hashed)) 144 | 145 | def test_does_not_match(self): 146 | """When supplied with an invalid hash we don't match""" 147 | str_to_sign = "GET" + "\n" + \ 148 | "/mauth/v1/mauth.json" + "\n" + \ 149 | "" + "\n" + \ 150 | self.app_uuid + "\n" + \ 151 | self.mws_time 152 | hashed = get_hash(str_to_sign) 153 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 154 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 155 | path="/mauth/v2/mauth.json?open=1", 156 | method="GET", 157 | data="") 158 | signature_1 = Signature.from_request(request) 159 | self.assertFalse(signature_1.matches(hashed)) 160 | 161 | 162 | # 163 | # def test_creates_expected_string_without_query(self): 164 | # """We create the expected string""" 165 | # mws_time = "1479392498" 166 | # app_uuid = 'b0603e5c-c344-488e-83ba-9290ea8dc17d' 167 | # # expected string 168 | # request = mock.Mock(headers=dict(X_MWS_TIME=mws_time, 169 | # X_MWS_AUTHENTICATION="MWS %s:somethingelse" % app_uuid), 170 | # path="/mauth/v2/mauth.json", 171 | # method="GET", 172 | # data='') 173 | # self.assertEqual(expected, make_signature_string(request)) 174 | -------------------------------------------------------------------------------- /flask_mauth/mauth/authenticators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import abc 4 | import datetime 5 | import json 6 | from base64 import b64encode 7 | 8 | import requests 9 | from six.moves.urllib.parse import urljoin 10 | 11 | from flask_mauth import settings 12 | from flask_mauth.cacher import SecurityTokenCacher 13 | from flask_mauth.exceptions import InauthenticError, UnableToAuthenticateError 14 | from flask_mauth.mauth.signature import Signature 15 | from flask_mauth.rsa_public_decrypt import RSAPublicKey 16 | 17 | 18 | def mws_attr(request): 19 | """ 20 | Extract the MWS Headers from a Request 21 | 22 | :param request: Request object 23 | :type request: werkzeug.wrappers.BaseRequest 24 | :return: Token, APP_UUID, Time since Epoch 25 | :rtype: str, str, str 26 | """ 27 | token, app_uuid, signature, mws_time = "", "", "", "" 28 | if settings.x_mws_authentication in request.headers: 29 | token, app_uuid, signature = settings.signature_info.match( 30 | request.headers.get(settings.x_mws_authentication)).groups() 31 | if settings.x_mws_time in request.headers: 32 | mws_time = request.headers.get(settings.x_mws_time) 33 | return token, app_uuid, signature, mws_time 34 | 35 | 36 | class AbstractMAuthAuthenticator(object): 37 | __metaclass__ = abc.ABCMeta 38 | """ 39 | Abstract Base Class for the Local and Remote Authentication classes 40 | """ 41 | 42 | ALLOWED_DRIFT_SECONDS = 300 43 | 44 | def __init__(self, mauth_auth, logger, mauth_base_url, mauth_api_version): 45 | """ 46 | :param mauth_auth: MAuth object 47 | :type mauth_auth: requests_mauth.client.MAuth 48 | :param logger: Logger for messages (TBD) 49 | :param mauth_base_url: MAuth Base URL 50 | :type mauth_base_url: str 51 | :param mauth_api_version: MAuth API version 52 | :type mauth_api_version: str 53 | """ 54 | self._mauth_auth = mauth_auth 55 | self._logger = logger 56 | self._mauth_base_url = mauth_base_url 57 | self._mauth_api_version = mauth_api_version 58 | 59 | def authenticate(self, request): 60 | """ 61 | Authenticate the request, by checking all the sub-category of issues 62 | 63 | :param request: Request object 64 | :type request: werkzeug.wrappers.BaseRequest 65 | """ 66 | if self.authentication_present(request) and self.time_valid(request) and \ 67 | self.token_valid(request) and self.signature_valid(request): 68 | return True 69 | return False 70 | 71 | def is_authentic(self, request): 72 | """ 73 | Overall Wrapper for mauth 74 | 75 | :param request: Request object 76 | :type request: werkzeug.wrappers.BaseRequest 77 | :return: Is the request authentic?, Status Code, Message 78 | :rtype: (bool, int, str) 79 | """ 80 | self.log_authentication_request(request) 81 | authentic = False 82 | try: 83 | authentic = self.authenticate(request) 84 | except InauthenticError as exc: 85 | self.log_authentication_error(request, str(exc)) 86 | return False, 401, str(exc) 87 | except UnableToAuthenticateError as exc: 88 | self.log_authentication_error(request, str(exc)) 89 | return False, 500, str(exc) 90 | return authentic, 200 if authentic else 401, '' 91 | 92 | def authentication_present(self, request): 93 | """ 94 | Is the mauth header present (assuming request has a headers attribute) that 95 | can be treated like a dict 96 | 97 | :param request: Request object 98 | :type request: werkzeug.wrappers.BaseRequest 99 | :rtype: bool 100 | :return: success 101 | """ 102 | if request.headers.get(settings.x_mws_authentication, '') == '': 103 | raise InauthenticError( 104 | "Authentication Failed. No mAuth signature present; X-MWS-Authentication header is blank.") 105 | return True 106 | 107 | def time_valid(self, request): 108 | """ 109 | Is the time of the request within the allowed drift? 110 | 111 | :param request: Request like object 112 | :type request: werkzeug.wrappers.BaseRequest 113 | :rtype: bool 114 | :return: success 115 | """ 116 | if request.headers.get(settings.x_mws_time, '') == '': 117 | raise InauthenticError( 118 | "Time verification failed for {}. No x-mws-time present.".format(request.__class__.__name__)) 119 | if not str(request.headers.get(settings.x_mws_time, '')).isdigit(): 120 | raise InauthenticError( 121 | "Time verification failed for {}. X-MWS-Time Header format incorrect.".format( 122 | request.__class__.__name__)) 123 | now = datetime.datetime.now() 124 | # this needs a float 125 | signature_time = datetime.datetime.fromtimestamp(float(request.headers.get(settings.x_mws_time))) 126 | if now > signature_time + datetime.timedelta(seconds=self.ALLOWED_DRIFT_SECONDS): 127 | raise InauthenticError("Time verification failed for {}. {} " 128 | "not within {}s of {}".format(request.__class__.__name__, 129 | signature_time, 130 | self.ALLOWED_DRIFT_SECONDS, 131 | now.strftime("%Y-%m-%d %H:%M:%S"))) 132 | return True 133 | 134 | def token_valid(self, request): 135 | """ 136 | Is the message signed correctly? 137 | 138 | :param request: Request object 139 | :type request: werkzeug.wrappers.BaseRequest 140 | :rtype: bool 141 | :return: success 142 | """ 143 | if not settings.signature_info.match(request.headers.get(settings.x_mws_authentication)): 144 | raise InauthenticError("Token verification failed for {}. Misformatted " 145 | "Signature.".format(request.__class__.__name__)) 146 | token, app_uuid, signature, mws_time = mws_attr(request) 147 | if not token == settings.mws_token: 148 | raise InauthenticError("Token verification failed for {}. " 149 | "Expected {}; token was {}".format(request.__class__.__name__, 150 | settings.mws_token, token)) 151 | return True 152 | 153 | @abc.abstractmethod 154 | def signature_valid(self, request): # pragma: no cover 155 | """ 156 | This should be implemented by the child classes 157 | 158 | :param request: the Request Object 159 | :type request: werkzeug.wrappers.BaseRequest 160 | """ 161 | return 162 | 163 | def log_mauth_service_response_error(self, request, response): 164 | """ 165 | Upstream MAuth Service error 166 | 167 | :param request: Original Request Object 168 | :type request: werkzeug.wrappers.BaseRequest 169 | :param response: Returned Response Object 170 | :type response: werkzeug.wrappers.BaseResponse 171 | """ 172 | token, app_uuid, signature, mws_time = mws_attr(request) 173 | message = "MAuth Service: App UUID: {app_uuid}; URL: {url}; " \ 174 | "MAuth service responded with {status}: {body}".format( 175 | app_uuid=app_uuid, 176 | url=request.path, 177 | status=response.status_code, 178 | body=response.data) 179 | self._logger.error(message) 180 | raise UnableToAuthenticateError(message, response) 181 | 182 | def log_authentication_error(self, request, message=""): 183 | """ 184 | Log an error with an authenticated request 185 | 186 | :param request: request object 187 | :type request: werkzeug.wrappers.BaseRequest 188 | :param message: any message that is exposed 189 | :type message: str 190 | """ 191 | token, app_uuid, signature, mws_time = mws_attr(request) 192 | if app_uuid == "": 193 | app_uuid = "MISSING" 194 | self._logger.error("MAuth Authentication Error: App UUID: {}; URL: {}; Error: {}".format(app_uuid, 195 | request.path, 196 | message)) 197 | 198 | def log_authentication_request(self, request): 199 | """ 200 | Log an authenticated request 201 | 202 | :param request: request object 203 | :type request: werkzeug.wrappers.BaseRequest 204 | """ 205 | token, app_uuid, signature, mws_time = mws_attr(request) 206 | if app_uuid == "": 207 | app_uuid = "MISSING" 208 | self._logger.info("MAuth Request: App UUID: {}; URL: {}".format(app_uuid, 209 | request.path)) 210 | 211 | @property 212 | def authenticator_type(self): 213 | """ 214 | Return the Authenticator Type 215 | """ 216 | return self.AUTHENTICATION_TYPE 217 | 218 | 219 | class RemoteAuthenticator(AbstractMAuthAuthenticator): 220 | """ 221 | Remote Authentication object, passes through the authentication to the upstream MAuth Server 222 | """ 223 | AUTHENTICATION_TYPE = "REMOTE" 224 | 225 | def __init__(self, mauth_auth, logger, mauth_base_url, mauth_api_version): 226 | """ 227 | :param mauth_auth: Configured MAuth Client 228 | :type mauth_auth: requests_mauth.client.MAuth 229 | :param logger: configured Flask Logger 230 | :param str mauth_base_url: The Base URL for the mauth server 231 | :param str mauth_api_version: API Version for the mauth server 232 | """ 233 | super(RemoteAuthenticator, self).__init__(mauth_auth=mauth_auth, logger=logger, 234 | mauth_base_url=mauth_base_url, 235 | mauth_api_version=mauth_api_version) 236 | 237 | def signature_valid(self, request): 238 | """ 239 | Is the signature valid? 240 | 241 | :param request: Request instance 242 | :type request: werkzeug.wrappers.BaseRequest 243 | """ 244 | token, app_uuid, signature, mws_time = mws_attr(request) 245 | url = urljoin(self._mauth_base_url, "/mauth/{mauth_api_version}/" \ 246 | "authentication_tickets.json".format( 247 | mauth_api_version=self._mauth_api_version)) 248 | authentication_ticket = dict(verb=request.method, 249 | app_uuid=app_uuid, 250 | client_signature=signature, 251 | request_url=request.path, 252 | request_time=mws_time, 253 | b64encoded_body=b64encode(request.data.encode('utf-8')).decode('utf-8') 254 | ) 255 | response = requests.post(url, 256 | data=json.dumps(dict(authentication_ticket=authentication_ticket)), 257 | auth=self._mauth_auth) 258 | if response.status_code in (412, 404): 259 | # the mAuth service responds with 412 when the given request is not authentically signed. 260 | # older versions of the mAuth service respond with 404 when the given app_uuid 261 | # does not exist, which is also considered to not be authentically signed. newer 262 | # versions of the service respond 412 in all cases, so the 404 check may be removed 263 | # when the old version of the mAuth service is out of service. 264 | raise InauthenticError("The mAuth service responded with {status}: {body}".format( 265 | status=response.status_code, 266 | body=response.content)) 267 | elif 200 <= response.status_code <= 299: 268 | return True 269 | else: 270 | # e.g. 500 error 271 | # NOTE: this raises the underlying UnableToAuthenticateError 272 | self.log_mauth_service_response_error(request=request, 273 | response=response) 274 | 275 | 276 | class LocalAuthenticator(AbstractMAuthAuthenticator): 277 | """ 278 | Local Authentication object, authenticates the request locally, retrieving the necessary credentials from the 279 | upstream MAuth Server 280 | """ 281 | AUTHENTICATION_TYPE = "LOCAL" 282 | 283 | def __init__(self, mauth_auth, logger, mauth_base_url, mauth_api_version): 284 | """ 285 | :param mauth_auth: Configured MAuth Client 286 | :type mauth_auth: requests_mauth.client.MAuth 287 | :param logger: configured Flask Logger 288 | :param str mauth_base_url: The Base URL for the mauth server 289 | :param str mauth_api_version: API Version for the mauth server 290 | """ 291 | super(LocalAuthenticator, self).__init__(mauth_auth=mauth_auth, logger=logger, 292 | mauth_base_url=mauth_base_url, 293 | mauth_api_version=mauth_api_version) 294 | self.secure_token_cacher = SecurityTokenCacher(mauth_auth=mauth_auth, 295 | mauth_api_version=mauth_api_version, 296 | mauth_base_url=mauth_base_url) 297 | 298 | def signature_valid(self, request): 299 | """ 300 | Is the signature valid? 301 | 302 | :param request: request object 303 | :type request: werkzeug.wrappers.BaseRequest 304 | """ 305 | 306 | token, app_uuid, signature, mws_time = mws_attr(request) 307 | 308 | expected = Signature.from_request(request=request) 309 | try: 310 | token = self.secure_token_cacher.get(app_uuid=app_uuid) 311 | key_text = token.get('security_token').get('public_key_str') 312 | if "BEGIN PUBLIC KEY" in key_text: 313 | # Load a PKCS#1 PEM-encoded public key 314 | rsakey = RSAPublicKey.load_pkcs1_openssl_pem(keyfile=key_text) 315 | elif "BEGIN RSA PUBLIC KEY" in key_text: 316 | # Loads a PKCS#1.5 PEM-encoded public key 317 | rsakey = RSAPublicKey.load_pkcs1(keyfile=key_text, format='PEM') 318 | else: 319 | # Unable to identify the key type 320 | self.secure_token_cacher.flush(app_uuid) 321 | raise UnableToAuthenticateError("Unable to identify Public Key type from Signature") 322 | padded = rsakey.public_decrypt(signature) 323 | signature_hash = rsakey.unpad_message(padded) 324 | except ValueError as exc: 325 | self.secure_token_cacher.flush(app_uuid) 326 | # importKey raises 327 | raise InauthenticError("Public key decryption of signature " 328 | "failed!: {}".format(exc)) 329 | if not expected.matches(signature_hash): 330 | raise InauthenticError("Signature verification failed for {}".format(request.__class__.__name__)) 331 | return True 332 | -------------------------------------------------------------------------------- /tests/test_authenticators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import datetime 4 | import json 5 | import time 6 | from unittest import TestCase 7 | 8 | import requests_mauth 9 | import mock 10 | from mock import patch 11 | from six import assertRegex 12 | 13 | from flask_mauth.mauth.authenticators import LocalAuthenticator, AbstractMAuthAuthenticator, RemoteAuthenticator, \ 14 | mws_attr 15 | from flask_mauth import settings 16 | from flask_mauth.exceptions import InauthenticError, UnableToAuthenticateError 17 | from tests.common import load_key 18 | 19 | 20 | class _TestAuthenticator(object): 21 | """ 22 | Pseudo-abstract base class for the Test Cases 23 | """ 24 | 25 | def test_authentication_present_happy_path(self): 26 | """With the header present, we are ok""" 27 | request = mock.Mock(headers={settings.x_mws_authentication: 'MWS 1234'}) 28 | self.assertTrue(self.authenticator.authentication_present(request)) 29 | 30 | def test_authentication_present_missing(self): 31 | """With the header missing we throw an exception""" 32 | request = mock.Mock(headers={}) 33 | with self.assertRaises(InauthenticError) as exc: 34 | self.authenticator.authentication_present(request) 35 | self.assertEqual(str(exc.exception), 36 | "Authentication Failed. No mAuth signature present; X-MWS-Authentication header is blank.", 37 | ) 38 | 39 | def test_authentication_present_blank(self): 40 | """With the header present but blank we throw an exception""" 41 | request = mock.Mock(headers={settings.x_mws_authentication: ''}) 42 | with self.assertRaises(InauthenticError) as exc: 43 | self.authenticator.authentication_present(request) 44 | self.assertEqual(str(exc.exception), 45 | "Authentication Failed. No mAuth signature present; X-MWS-Authentication header is blank." 46 | ) 47 | 48 | def test_time_valid_happy_path(self): 49 | """With an ok time, we are ok""" 50 | now = int(time.time()) 51 | request = mock.Mock(headers={settings.x_mws_time: '%s' % now}) 52 | self.assertTrue(self.authenticator.time_valid(request=request)) 53 | 54 | def test_time_valid_missing_header(self): 55 | """With a missing header, we get an exception""" 56 | request = mock.Mock(headers={}) 57 | with self.assertRaises(InauthenticError) as exc: 58 | self.authenticator.time_valid(request=request) 59 | self.assertEqual(str(exc.exception), 60 | "Time verification failed for Mock. No x-mws-time present.", 61 | ) 62 | 63 | def test_time_valid_invalid_header(self): 64 | """With an invalid header, we get an exception""" 65 | request = mock.Mock(headers={settings.x_mws_time: 'apple'}) 66 | with self.assertRaises(InauthenticError) as exc: 67 | self.authenticator.time_valid(request=request) 68 | self.assertEqual(str(exc.exception), 69 | "Time verification failed for Mock. X-MWS-Time Header format incorrect.", 70 | ) 71 | 72 | def test_time_valid_empty_header(self): 73 | """With an empty header, we get an exception""" 74 | request = mock.Mock(headers={settings.x_mws_time: ''}) 75 | with self.assertRaises(InauthenticError) as exc: 76 | self.authenticator.time_valid(request=request) 77 | self.assertEqual(str(exc.exception), 78 | "Time verification failed for Mock. No x-mws-time present.", 79 | ) 80 | 81 | def test_time_valid_expired_header(self): 82 | """With an empty header, we get an exception""" 83 | now = int(time.time()) - (AbstractMAuthAuthenticator.ALLOWED_DRIFT_SECONDS * 100 + 1) 84 | request = mock.Mock(headers={settings.x_mws_time: str(now)}) 85 | with self.assertRaises(InauthenticError) as exc: 86 | self.authenticator.time_valid(request=request) 87 | assertRegex(self, 88 | str(exc.exception), 89 | r"Time verification failed for Mock. %s " 90 | "not within %ss of [0-9\-]{10} [0-9\:]{7}" % (datetime.datetime.fromtimestamp(now), 91 | AbstractMAuthAuthenticator.ALLOWED_DRIFT_SECONDS), 92 | ) 93 | 94 | def test_token_valid_happy_path(self): 95 | """With an expected header, all good""" 96 | request = mock.Mock(headers={settings.x_mws_authentication: 'MWS some-uuid:some hash'}) 97 | self.assertTrue(self.authenticator.token_valid(request)) 98 | 99 | def test_token_valid_invalid_token(self): 100 | """Invalid token leads to exception""" 101 | request = mock.Mock(headers={settings.x_mws_authentication: 'RWS some-uuid:some hash'}) 102 | with self.assertRaises(InauthenticError) as exc: 103 | self.authenticator.token_valid(request) 104 | self.assertEqual(str(exc.exception), 105 | "Token verification failed for Mock. Expected MWS; token was RWS" 106 | ) 107 | 108 | def test_token_valid_bad_format(self): 109 | """Badly formatted signature leads to exception""" 110 | request = mock.Mock(headers={settings.x_mws_authentication: 'MWS'}) 111 | with self.assertRaises(InauthenticError) as exc: 112 | self.authenticator.token_valid(request) 113 | self.assertEqual(str(exc.exception), 114 | "Token verification failed for Mock. Misformatted Signature.") 115 | 116 | def test_log_mauth_service_response_error(self): 117 | """We log an error for a service error""" 118 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 119 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 120 | path="/mauth/v2/mauth.json?open=1", 121 | method="GET", 122 | data="") 123 | response = mock.Mock(status_code=500, data="Upstream Resource unavailable") 124 | with self.assertRaises(UnableToAuthenticateError) as exc: 125 | self.authenticator.log_mauth_service_response_error(request, response) 126 | error = self.logger.error 127 | error.assert_called_with('MAuth Service: App UUID: {app_uuid}; URL: {url}; ' 128 | 'MAuth service responded with {status}: {body}'.format(app_uuid=self.app_uuid, 129 | url="/mauth/v2/mauth" 130 | ".json?open=1", 131 | status=500, 132 | body="Upstream Resource " 133 | "unavailable")) 134 | 135 | def test_log_inauthentic_error(self): 136 | """We log an error for an InAuthentic error""" 137 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 138 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 139 | path="/mauth/v2/mauth.json?open=1", 140 | method="GET", 141 | data="") 142 | self.authenticator.log_authentication_error(request, message="X-MWS-Time too old") 143 | error = self.logger.error 144 | error.assert_called_with('MAuth Authentication Error: App UUID: {app_uuid}; URL: {url}; ' 145 | 'Error: {message}'.format(app_uuid=self.app_uuid, 146 | url="/mauth/v2/mauth" 147 | ".json?open=1", 148 | message="X-MWS-Time too old")) 149 | 150 | def test_log_inauthentic_error_missing_app_uuid(self): 151 | """We log an error for an InAuthentic error""" 152 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 153 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 154 | path="/mauth/v2/mauth.json?open=1", 155 | method="GET", 156 | data="") 157 | with mock.patch("flask_mauth.mauth.authenticators.mws_attr") as matt: 158 | matt.return_value = "", "", "", "" 159 | self.authenticator.log_authentication_error(request, message="X-MWS-Time too old") 160 | error = self.logger.error 161 | error.assert_called_with('MAuth Authentication Error: App UUID: {app_uuid}; URL: {url}; ' 162 | 'Error: {message}'.format(app_uuid="MISSING", 163 | url="/mauth/v2/mauth" 164 | ".json?open=1", 165 | message="X-MWS-Time too old")) 166 | 167 | def test_log_authorisation_request_info(self): 168 | """We log an info for a request""" 169 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 170 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 171 | path="/mauth/v2/mauth.json?open=1", 172 | method="GET", 173 | data="") 174 | self.authenticator.log_authentication_request(request) 175 | info = self.logger.info 176 | info.assert_called_with('MAuth Request: App UUID: {app_uuid}; URL: {url}'.format(app_uuid=self.app_uuid, 177 | url="/mauth/v2/mauth" 178 | ".json?open=1")) 179 | 180 | def test_log_authorisation_request_missing_app_uuid(self): 181 | """We log an info for a request, if the APP_UUID is missing we flag""" 182 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 183 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 184 | path="/mauth/v2/mauth.json?open=1", 185 | method="GET", 186 | data="") 187 | with mock.patch("flask_mauth.mauth.authenticators.mws_attr") as matt: 188 | matt.return_value = "", "", "", "" 189 | self.authenticator.log_authentication_request(request) 190 | info = self.logger.info 191 | info.assert_called_with('MAuth Request: App UUID: {app_uuid}; URL: {url}'.format(app_uuid="MISSING", 192 | url="/mauth/v2/mauth" 193 | ".json?open=1")) 194 | 195 | 196 | class TestRemoteAuthenticator(_TestAuthenticator, TestCase): 197 | """ 198 | Remotely authenticate a request 199 | """ 200 | 201 | def setUp(self): 202 | self.logger = mock.Mock() 203 | self.authenticator = RemoteAuthenticator(mauth_auth=mock.Mock(), 204 | logger=self.logger, 205 | mauth_api_version='v2', 206 | mauth_base_url='https://mauth-sandbox.imedidata.net') 207 | self.mws_time = "1479392498" 208 | self.app_uuid = 'b0603e5c-c344-488e-83ba-9290ea8dc17d' 209 | 210 | def test_signature_valid(self): 211 | """ With a valid request we get a 200 response """ 212 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 213 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 214 | path="/mauth/v2/mauth.json?open=1", 215 | method="GET", 216 | data="") 217 | with mock.patch("flask_mauth.mauth.authenticators.requests") as req: 218 | req.post.return_value = mock.Mock(status_code=200) 219 | result = self.authenticator.signature_valid(request=request) 220 | self.assertTrue(result) 221 | 222 | def test_signature_invalid_412(self): 223 | """ With a valid request we get a 412 response """ 224 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 225 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 226 | path="/mauth/v2/mauth.json?open=1", 227 | method="GET", 228 | data="") 229 | with mock.patch("flask_mauth.mauth.authenticators.requests") as req: 230 | req.post.return_value = mock.Mock(status_code=412, content="Blurgle") 231 | with self.assertRaises(InauthenticError) as exc: 232 | result = self.authenticator.signature_valid(request=request) 233 | self.assertEqual(str(exc.exception), 234 | "The mAuth service responded with 412: Blurgle") 235 | 236 | def test_signature_invalid_404(self): 237 | """ With a valid request we get a 412 response """ 238 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 239 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 240 | path="/mauth/v2/mauth.json?open=1", 241 | method="GET", 242 | data="") 243 | with mock.patch("flask_mauth.mauth.authenticators.requests") as req: 244 | req.post.return_value = mock.Mock(status_code=404, content="Blargle") 245 | with self.assertRaises(InauthenticError) as exc: 246 | result = self.authenticator.signature_valid(request=request) 247 | self.assertEqual(str(exc.exception), 248 | "The mAuth service responded with 404: Blargle") 249 | 250 | def test_upstream_error(self): 251 | """ With a mauth server problem """ 252 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 253 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 254 | path="/mauth/v2/mauth.json?open=1", 255 | method="GET", 256 | data="") 257 | with mock.patch("flask_mauth.mauth.authenticators.requests") as req: 258 | req.post.return_value = mock.Mock(status_code=500, data="Urgle") 259 | with self.assertRaises(UnableToAuthenticateError) as exc: 260 | result = self.authenticator.signature_valid(request=request) 261 | self.assertEqual(str(exc.exception), 262 | "MAuth Service: App UUID: b0603e5c-c344-488e-83ba-9290ea8dc17d; " 263 | "URL: /mauth/v2/mauth.json?open=1; MAuth service responded with 500: Urgle") 264 | 265 | @patch.object(RemoteAuthenticator, "authenticate") 266 | def test_is_authentic_all_ok(self, authenticate): 267 | """We get a True back if all tests pass""" 268 | authenticate.return_value = True 269 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 270 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 271 | path="/mauth/v2/mauth.json?open=1", 272 | method="GET", 273 | data="") 274 | authentic, status, message = self.authenticator.is_authentic(request) 275 | self.assertTrue(authentic) 276 | self.assertEqual(200, status) 277 | self.assertEqual('', message) 278 | 279 | @patch.object(RemoteAuthenticator, "authenticate") 280 | def test_is_authentic_fails(self, authenticate): 281 | """We get a False back if one or more tests fail""" 282 | authenticate.return_value = False 283 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 284 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 285 | path="/mauth/v2/mauth.json?open=1", 286 | method="GET", 287 | data="") 288 | authentic, status, message = self.authenticator.is_authentic(request) 289 | self.assertFalse(authentic) 290 | self.assertEqual(401, status) 291 | 292 | @patch.object(RemoteAuthenticator, "authenticate") 293 | def test_authenticate_error_conditions_inauthentic(self, authenticate): 294 | """ We get a False back if we raise a InauthenticError """ 295 | authenticate.side_effect = InauthenticError("Authentication Failed. No mAuth signature present; " 296 | "X-MWS-Authentication header is blank.") 297 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 298 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 299 | path="/mauth/v2/mauth.json?open=1", 300 | method="GET", 301 | data="") 302 | authentic, status, message = self.authenticator.is_authentic(request) 303 | self.assertFalse(authentic) 304 | self.assertEqual(401, status) 305 | self.assertEqual("Authentication Failed. No mAuth signature present; " 306 | "X-MWS-Authentication header is blank.", message) 307 | 308 | @patch.object(RemoteAuthenticator, "authenticate") 309 | def test_authenticate_error_conditions_unable(self, authenticate): 310 | """ We get a False back if we raise a UnableToAuthenticateError """ 311 | authenticate.side_effect = UnableToAuthenticateError("") 312 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 313 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 314 | path="/mauth/v2/mauth.json?open=1", 315 | method="GET", 316 | data="") 317 | authentic, status, message = self.authenticator.is_authentic(request) 318 | self.assertFalse(authentic) 319 | self.assertEqual(500, status) 320 | self.assertEqual("", message) 321 | 322 | @patch.object(RemoteAuthenticator, "signature_valid") 323 | @patch.object(RemoteAuthenticator, "authentication_present") 324 | @patch.object(RemoteAuthenticator, "time_valid") 325 | @patch.object(RemoteAuthenticator, "token_valid") 326 | def test_is_authentic_some_token_invalid(self, token_valid, time_valid, authentication_present, signature_valid): 327 | """RemoteAuthenticator: We get a False back if token invalid""" 328 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 329 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 330 | path="/mauth/v2/mauth.json?open=1", 331 | method="GET", 332 | data="") 333 | token_valid.side_effect = InauthenticError("") 334 | time_valid.return_value = True 335 | authentication_present.return_value = True 336 | signature_valid.return_value = True 337 | authentic, status, message = self.authenticator.is_authentic(request) 338 | self.assertFalse(authentic) 339 | self.assertEqual(401, status) 340 | self.assertEqual("", message) 341 | 342 | @patch.object(RemoteAuthenticator, "signature_valid") 343 | @patch.object(RemoteAuthenticator, "authentication_present") 344 | @patch.object(RemoteAuthenticator, "time_valid") 345 | @patch.object(RemoteAuthenticator, "token_valid") 346 | def test_is_authentic_some_time_invalid(self, token_valid, time_valid, authentication_present, signature_valid): 347 | """RemoteAuthenticator: We get a False back if time invalid""" 348 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 349 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 350 | path="/mauth/v2/mauth.json?open=1", 351 | method="GET", 352 | data="") 353 | token_valid.return_value = True 354 | time_valid.side_effect = InauthenticError("") 355 | authentication_present.return_value = True 356 | signature_valid.return_value = True 357 | authentic, status, message = self.authenticator.is_authentic(request) 358 | self.assertFalse(authentic) 359 | self.assertEqual(401, status) 360 | self.assertEqual("", message) 361 | 362 | @patch.object(RemoteAuthenticator, "signature_valid") 363 | @patch.object(RemoteAuthenticator, "authentication_present") 364 | @patch.object(RemoteAuthenticator, "time_valid") 365 | @patch.object(RemoteAuthenticator, "token_valid") 366 | def test_is_authentic_some_authentication_missing(self, token_valid, time_valid, authentication_present, 367 | signature_valid): 368 | """RemoteAuthenticator: We get a False back if mauth missing""" 369 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 370 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 371 | path="/mauth/v2/mauth.json?open=1", 372 | method="GET", 373 | data="") 374 | 375 | token_valid.return_value = True 376 | time_valid.return_value = True 377 | authentication_present.side_effect = InauthenticError("") 378 | signature_valid.return_value = True 379 | authentic, status, message = self.authenticator.is_authentic(request) 380 | self.assertFalse(authentic) 381 | self.assertEqual(401, status) 382 | self.assertEqual("", message) 383 | 384 | @patch.object(RemoteAuthenticator, "signature_valid") 385 | @patch.object(RemoteAuthenticator, "authentication_present") 386 | @patch.object(RemoteAuthenticator, "time_valid") 387 | @patch.object(RemoteAuthenticator, "token_valid") 388 | def test_is_authentic_some_signature_invalid(self, token_valid, time_valid, authentication_present, 389 | signature_valid): 390 | """RemoteAuthenticator: We get a False back if signature invalid""" 391 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 392 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 393 | path="/mauth/v2/mauth.json?open=1", 394 | method="GET", 395 | data="") 396 | token_valid.return_value = True 397 | time_valid.return_value = True 398 | authentication_present.return_value = True 399 | signature_valid.side_effect = InauthenticError("") 400 | authentic, status, message = self.authenticator.is_authentic(request) 401 | self.assertFalse(authentic) 402 | self.assertEqual(401, status) 403 | self.assertEqual("", message) 404 | 405 | @patch.object(RemoteAuthenticator, "signature_valid") 406 | @patch.object(RemoteAuthenticator, "authentication_present") 407 | @patch.object(RemoteAuthenticator, "time_valid") 408 | @patch.object(RemoteAuthenticator, "token_valid") 409 | def test_authenticate_is_ok(self, token_valid, time_valid, authentication_present, signature_valid): 410 | """RemoteAuthenticator: We get a True back if all tests pass""" 411 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 412 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 413 | path="/mauth/v2/mauth.json?open=1", 414 | method="GET", 415 | data="") 416 | token_valid.return_value = True 417 | time_valid.return_value = True 418 | authentication_present.return_value = True 419 | signature_valid.return_value = True 420 | authentic = self.authenticator.authenticate(request) 421 | self.assertTrue(authentic) 422 | 423 | @patch.object(RemoteAuthenticator, "signature_valid") 424 | @patch.object(RemoteAuthenticator, "authentication_present") 425 | @patch.object(RemoteAuthenticator, "time_valid") 426 | @patch.object(RemoteAuthenticator, "token_valid") 427 | def test_authenticate_fails(self, token_valid, time_valid, authentication_present, signature_valid): 428 | """RemoteAuthenticator: We get a False back if any tests fail""" 429 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 430 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 431 | path="/mauth/v2/mauth.json?open=1", 432 | method="GET", 433 | data="") 434 | token_valid.return_value = True 435 | time_valid.return_value = True 436 | authentication_present.return_value = True 437 | signature_valid.return_value = False 438 | authentic = self.authenticator.authenticate(request) 439 | self.assertFalse(authentic) 440 | 441 | def test_authentication_type(self): 442 | """We self-describe""" 443 | self.assertEqual('REMOTE', self.authenticator.authenticator_type) 444 | 445 | 446 | class TestLocalAuthenticator(_TestAuthenticator, TestCase): 447 | def setUp(self): 448 | self.logger = mock.Mock() 449 | self.authenticator = LocalAuthenticator(mauth_auth=mock.Mock(), 450 | logger=self.logger, 451 | mauth_api_version='v2', 452 | mauth_base_url='https://mauth-sandbox.imedidata.net') 453 | self.mws_time = "1479392498" 454 | self.app_uuid = 'b0603e5c-c344-488e-83ba-9290ea8dc17d' 455 | 456 | def generate_headers(self, verb, path, body, mws_time=None, app_uuid=None, keytype='pkcs1'): 457 | """ 458 | Generates a Signature String 459 | :param verb: HTTP verb, eg GET 460 | :param path: URL Path (without query strings) 461 | :param body: Body of request 462 | :param time: 463 | :param app_uuid: 464 | :return: 465 | """ 466 | if mws_time is None: 467 | mws_time = self.mws_time 468 | if app_uuid is None: 469 | app_uuid = self.app_uuid 470 | key_suffix = "priv" 471 | if keytype == 'pkcs15': 472 | key_suffix = "priv_pkcs15" 473 | signer = requests_mauth.MAuth(app_uuid=app_uuid, private_key_data=load_key(key_suffix)) 474 | signature_string, seconds_since_epoch = signer.make_signature_string(verb=verb, url_path=path, body=body, 475 | seconds_since_epoch=mws_time) 476 | signed_string = signer.signer.sign(signature_string) 477 | auth_headers = signer.make_authentication_headers(signed_string, mws_time) 478 | return auth_headers 479 | 480 | def test_authenticates_a_genuine_message(self): 481 | """Given an authentic message, we authenticate""" 482 | mws_time = int(time.time()) 483 | headers = self.generate_headers("GET", 484 | "/mauth/v2/mauth.json", 485 | "", 486 | mws_time) 487 | request = mock.Mock(headers=headers, 488 | path="/mauth/v2/mauth.json?open=1", 489 | method="GET", 490 | data="") 491 | with mock.patch("flask_mauth.mauth.authenticators.SecurityTokenCacher") as tok: 492 | cacher = tok.return_value 493 | cacher.get.return_value = dict(app_name="Apple", 494 | app_uuid=self.app_uuid, 495 | security_token=dict(public_key_str=load_key('pub')), 496 | created_at="2016-11-20 12:08:46 UTC") 497 | authenticator = LocalAuthenticator(mauth_auth=mock.Mock(), 498 | logger=mock.Mock(), 499 | mauth_api_version='v2', 500 | mauth_base_url='https://mauth-sandbox.imedidata.net') 501 | 502 | result = authenticator.signature_valid(request) 503 | self.assertTrue(result) 504 | 505 | def test_authenticates_a_genuine_message_v15(self): 506 | """Given an authentic message using pkcs#1.5, we authenticate""" 507 | mws_time = int(time.time()) 508 | headers = self.generate_headers("GET", 509 | "/mauth/v2/mauth.json", 510 | "", 511 | mws_time, keytype='pkcs15') 512 | request = mock.Mock(headers=headers, 513 | path="/mauth/v2/mauth.json?open=1", 514 | method="GET", 515 | data="") 516 | with mock.patch("flask_mauth.mauth.authenticators.SecurityTokenCacher") as tok: 517 | cacher = tok.return_value 518 | cacher.get.return_value = dict(app_name="Apple", 519 | app_uuid=self.app_uuid, 520 | security_token=dict(public_key_str=load_key('pub_pkcs15')), 521 | created_at="2016-11-20 12:08:46 UTC") 522 | authenticator = LocalAuthenticator(mauth_auth=mock.Mock(), 523 | logger=mock.Mock(), 524 | mauth_api_version='v2', 525 | mauth_base_url='https://mauth-sandbox.imedidata.net') 526 | 527 | result = authenticator.signature_valid(request) 528 | self.assertTrue(result) 529 | 530 | def test_authentication_type(self): 531 | """We self-describe""" 532 | authenticator = LocalAuthenticator(mauth_auth=mock.Mock(), 533 | logger=mock.Mock(), 534 | mauth_api_version='v2', 535 | mauth_base_url='https://mauth-sandbox.imedidata.net') 536 | self.assertEqual('LOCAL', authenticator.authenticator_type) 537 | 538 | def test_does_not_authenticate_a_false_message(self): 539 | """Given an authentic message, we authenticate""" 540 | mws_time = int(time.time()) 541 | headers = self.generate_headers("GET", 542 | "/mauth/v1/mauth.json", 543 | "", 544 | mws_time) 545 | request = mock.Mock(headers=headers, 546 | path="/mauth/v2/mauth.json?open=1", 547 | method="GET", 548 | data="") 549 | with mock.patch("flask_mauth.mauth.authenticators.SecurityTokenCacher") as tok: 550 | cacher = tok.return_value 551 | cacher.get.return_value = dict(app_name="Apple", 552 | app_uuid=self.app_uuid, 553 | security_token=dict(public_key_str=load_key('pub')), 554 | created_at="2016-11-20 12:08:46 UTC") 555 | authenticator = LocalAuthenticator(mauth_auth=mock.Mock(), 556 | logger=mock.Mock(), 557 | mauth_api_version='v2', 558 | mauth_base_url='https://mauth-sandbox.imedidata.net') 559 | with self.assertRaises(InauthenticError) as exc: 560 | result = authenticator.signature_valid(request) 561 | self.assertEqual("Signature verification failed for Mock", str(exc.exception)) 562 | 563 | def test_flushes_an_invalid_token(self): 564 | """Given an authentic message, we authenticate""" 565 | mws_time = int(time.time()) 566 | headers = self.generate_headers("GET", 567 | "/mauth/v1/mauth.json", 568 | "", 569 | mws_time) 570 | request = mock.Mock(headers=headers, 571 | path="/mauth/v2/mauth.json?open=1", 572 | method="GET", 573 | data="") 574 | with mock.patch("flask_mauth.mauth.authenticators.SecurityTokenCacher") as tok: 575 | cacher = tok.return_value 576 | cacher.get.return_value = dict(app_name="Apple", 577 | app_uuid=self.app_uuid, 578 | security_token=dict(public_key_str="pineapple"), 579 | created_at="2016-11-20 12:08:46 UTC") 580 | flush = mock.Mock() 581 | cacher.flush = flush 582 | authenticator = LocalAuthenticator(mauth_auth=mock.Mock(), 583 | logger=mock.Mock(), 584 | mauth_api_version='v2', 585 | mauth_base_url='https://mauth-sandbox.imedidata.net') 586 | with self.assertRaises(UnableToAuthenticateError) as exc: 587 | result = authenticator.signature_valid(request) 588 | # bad key gets flushed from the cache 589 | flush.assert_called_once_with(self.app_uuid) 590 | # message is what we expect 591 | assertRegex(self, str(exc.exception), 592 | r'Unable to identify Public Key type from Signature') 593 | 594 | @patch.object(LocalAuthenticator, "authenticate") 595 | def test_is_authentic_all_ok(self, authenticate): 596 | """We get a True back if all tests pass""" 597 | authenticate.return_value = True 598 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 599 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 600 | path="/mauth/v2/mauth.json?open=1", 601 | method="GET", 602 | data="") 603 | authentic, status, message = self.authenticator.is_authentic(request) 604 | self.assertTrue(authentic) 605 | self.assertEqual(200, status) 606 | 607 | @patch.object(LocalAuthenticator, "authenticate") 608 | def test_is_authentic_fails(self, authenticate): 609 | """We get a False back if one or more tests fail""" 610 | authenticate.return_value = False 611 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 612 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 613 | path="/mauth/v2/mauth.json?open=1", 614 | method="GET", 615 | data="") 616 | authentic, status, message = self.authenticator.is_authentic(request) 617 | self.assertFalse(authentic) 618 | 619 | @patch.object(LocalAuthenticator, "signature_valid") 620 | @patch.object(LocalAuthenticator, "authentication_present") 621 | @patch.object(LocalAuthenticator, "time_valid") 622 | @patch.object(LocalAuthenticator, "token_valid") 623 | def test_is_authentic_some_token_invalid(self, token_valid, time_valid, authentication_present, signature_valid): 624 | """LocalAuthenticator: We get a False back if token invalid""" 625 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 626 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 627 | path="/mauth/v2/mauth.json?open=1", 628 | method="GET", 629 | data="") 630 | token_valid.side_effect = InauthenticError() 631 | time_valid.return_value = True 632 | authentication_present.return_value = True 633 | signature_valid.return_value = True 634 | authentic, status, message = self.authenticator.is_authentic(request) 635 | self.assertFalse(authentic) 636 | 637 | @patch.object(LocalAuthenticator, "signature_valid") 638 | @patch.object(LocalAuthenticator, "authentication_present") 639 | @patch.object(LocalAuthenticator, "time_valid") 640 | @patch.object(LocalAuthenticator, "token_valid") 641 | def test_is_authentic_some_time_invalid(self, token_valid, time_valid, authentication_present, signature_valid): 642 | """LocalAuthenticator: We get a False back if time invalid""" 643 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 644 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 645 | path="/mauth/v2/mauth.json?open=1", 646 | method="GET", 647 | data="") 648 | token_valid.return_value = True 649 | time_valid.side_effect = InauthenticError() 650 | authentication_present.return_value = True 651 | signature_valid.return_value = True 652 | authentic, status, message = self.authenticator.is_authentic(request) 653 | self.assertFalse(authentic) 654 | 655 | @patch.object(LocalAuthenticator, "signature_valid") 656 | @patch.object(LocalAuthenticator, "authentication_present") 657 | @patch.object(LocalAuthenticator, "time_valid") 658 | @patch.object(LocalAuthenticator, "token_valid") 659 | def test_is_authentic_some_authentication_missing(self, token_valid, time_valid, authentication_present, 660 | signature_valid): 661 | """LocalAuthenticator: We get a False back if mauth missing""" 662 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 663 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 664 | path="/mauth/v2/mauth.json?open=1", 665 | method="GET", 666 | data="") 667 | 668 | token_valid.return_value = True 669 | time_valid.return_value = True 670 | authentication_present.side_effect = InauthenticError() 671 | signature_valid.return_value = True 672 | authentic, status, message = self.authenticator.is_authentic(request) 673 | self.assertFalse(authentic) 674 | 675 | @patch.object(LocalAuthenticator, "signature_valid") 676 | @patch.object(LocalAuthenticator, "authentication_present") 677 | @patch.object(LocalAuthenticator, "time_valid") 678 | @patch.object(LocalAuthenticator, "token_valid") 679 | def test_is_authentic_some_signature_invalid(self, token_valid, time_valid, authentication_present, 680 | signature_valid): 681 | """LocalAuthenticator: We get a False back if token invalid""" 682 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 683 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 684 | path="/mauth/v2/mauth.json?open=1", 685 | method="GET", 686 | data="") 687 | token_valid.return_value = True 688 | time_valid.return_value = True 689 | authentication_present.return_value = True 690 | signature_valid.side_effect = InauthenticError() 691 | authentic, status, message = self.authenticator.is_authentic(request) 692 | self.assertFalse(authentic) 693 | 694 | @patch.object(LocalAuthenticator, "authenticate") 695 | def test_authenticate_error_conditions_inauthentic(self, authenticate): 696 | """ We get a False back if we raise a InauthenticError """ 697 | authenticate.side_effect = InauthenticError("") 698 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 699 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 700 | path="/mauth/v2/mauth.json?open=1", 701 | method="GET", 702 | data="") 703 | authentic, status, message = self.authenticator.is_authentic(request) 704 | self.assertFalse(authentic) 705 | self.assertEqual(401, status) 706 | self.assertEqual("", message) 707 | 708 | @patch.object(LocalAuthenticator, "authenticate") 709 | def test_authenticate_error_conditions_unable(self, authenticate): 710 | """LocalAuthenticator: We get a False back if we raise a UnableToAuthenticateError """ 711 | authenticate.side_effect = UnableToAuthenticateError("") 712 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 713 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 714 | path="/mauth/v2/mauth.json?open=1", 715 | method="GET", 716 | data="") 717 | authentic, status, message = self.authenticator.is_authentic(request) 718 | self.assertFalse(authentic) 719 | self.assertEqual(500, status) 720 | self.assertEqual("", message) 721 | 722 | @patch.object(LocalAuthenticator, "signature_valid") 723 | @patch.object(LocalAuthenticator, "authentication_present") 724 | @patch.object(LocalAuthenticator, "time_valid") 725 | @patch.object(LocalAuthenticator, "token_valid") 726 | def test_authenticate_is_ok(self, token_valid, time_valid, authentication_present, signature_valid): 727 | """LocalAuthenticator: We get a True back if all tests pass""" 728 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 729 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 730 | path="/mauth/v2/mauth.json?open=1", 731 | method="GET", 732 | data="") 733 | token_valid.return_value = True 734 | time_valid.return_value = True 735 | authentication_present.return_value = True 736 | signature_valid.return_value = True 737 | authentic = self.authenticator.authenticate(request) 738 | self.assertTrue(authentic) 739 | 740 | @patch.object(LocalAuthenticator, "signature_valid") 741 | @patch.object(LocalAuthenticator, "authentication_present") 742 | @patch.object(LocalAuthenticator, "time_valid") 743 | @patch.object(LocalAuthenticator, "token_valid") 744 | def test_authenticate_fails(self, token_valid, time_valid, authentication_present, signature_valid): 745 | """LocalAuthenticator: We get a False back if any tests fail""" 746 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 747 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 748 | path="/mauth/v2/mauth.json?open=1", 749 | method="GET", 750 | data="") 751 | token_valid.return_value = True 752 | time_valid.return_value = True 753 | authentication_present.return_value = True 754 | signature_valid.return_value = False 755 | authentic = self.authenticator.authenticate(request) 756 | self.assertFalse(authentic) 757 | 758 | 759 | class TestMWSAttr(TestCase): 760 | def setUp(self): 761 | self.mws_time = "1479392498" 762 | self.app_uuid = 'b0603e5c-c344-488e-83ba-9290ea8dc17d' 763 | 764 | def test_expected_outcome(self): 765 | """All present, attributes ok""" 766 | request = mock.Mock(headers={settings.x_mws_time: self.mws_time, 767 | settings.x_mws_authentication: "MWS %s:somethingelse" % self.app_uuid}, 768 | path="/mauth/v2/mauth.json?open=1", 769 | method="GET", 770 | data="") 771 | expected = ("MWS", self.app_uuid, "somethingelse", self.mws_time) 772 | self.assertEqual(expected, mws_attr(request)) 773 | 774 | def test_unexpected_outcome(self): 775 | """All present, attributes ok""" 776 | request = mock.Mock(headers={}, 777 | path="/mauth/v2/mauth.json?open=1", 778 | method="GET", 779 | data="") 780 | expected = ("", "", "", "") 781 | self.assertEqual(expected, mws_attr(request)) 782 | --------------------------------------------------------------------------------