├── warrant ├── tests │ ├── __init__.py │ └── tests.py ├── exceptions.py ├── aws_srp.py └── __init__.py ├── requirements_test.txt ├── requirements.txt ├── .bumpversion.cfg ├── .travis.yml ├── HISTORY.md ├── setup.py ├── .gitignore ├── LICENSE └── README.md /warrant/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | botocore~=1.13.49 2 | coverage~=5.0.2 3 | mock~=3.0.5 4 | nose~=1.3.7 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3>=1.10.49 2 | envs~=1.3 3 | python-jose[pycryptodome]~=3.1.0 4 | requests>=2.22.0 5 | six>=1.13.0 6 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.5.0 3 | commit = False 4 | tag = False 5 | 6 | [bumpversion:file:setup.py] 7 | 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | env: 3 | - USE_CLIENT_SECRET=True 4 | - USE_CLIENT_SECRET=False 5 | python: 6 | - "3.6" 7 | # command to install dependencies 8 | install: 9 | - "pip install -e '.[test]'" 10 | # command to run tests 11 | script: nosetests warrant.tests --nocapture --nologcapture 12 | -------------------------------------------------------------------------------- /warrant/exceptions.py: -------------------------------------------------------------------------------- 1 | class WarrantException(Exception): 2 | """Base class for all Warrant exceptions""" 3 | 4 | 5 | class ForceChangePasswordException(WarrantException): 6 | """Raised when the user is forced to change their password""" 7 | 8 | 9 | class TokenVerificationException(WarrantException): 10 | """Raised when token verification fails.""" -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | ## 0.6.0 4 | 5 | - Renamed boto3 argument, fixes aws_access_key error 6 | - Confirm user registration as admin without needing a confirmation code. 7 | - Added helper function _add_secret_hash, DRY 8 | - Added support for registering a user with a ClientSecret 9 | 10 | ## 0.5.0 11 | 12 | - Added support for app clients with a generated secret. 13 | - Add 'renew' flag to .check_token method 14 | - Use python-jose-cryptodome for Python 3.6 compatibility 15 | 16 | ## 0.2.0 17 | 18 | - Added support for Python 3.6 19 | - Verfication of jwts 20 | - Windows compatibility 21 | - Added admin_create_user method 22 | - Support new password challenge 23 | - Removed Django utilities (Moved to [Django Warrant](https://www.github.com/metametricsinc/django-warrant)) 24 | 25 | ## 0.1.0 26 | 27 | - Initial Release 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from setuptools import setup, find_packages 5 | 6 | 7 | def requirements_from_file(filename='requirements.txt'): 8 | with open(os.path.join(os.path.dirname(__file__), filename)) as r: 9 | reqs = r.read().strip().split('\n') 10 | # Return non emtpy lines and non comments 11 | return [r for r in reqs if re.match(r"^\w+", r)] 12 | 13 | 14 | version = '0.6.1' 15 | 16 | README = """Python class to integrate Boto3's Cognito client so it is easy to login users. With SRP support.""" 17 | 18 | setup( 19 | name='warrant', 20 | version=version, 21 | description=README, 22 | long_description=README, 23 | classifiers=[ 24 | "Programming Language :: Python :: 3.6", 25 | "Programming Language :: Python :: 3.7", 26 | "Programming Language :: Python :: 3.8", 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | "Environment :: Web Environment", 29 | ], 30 | keywords='aws,cognito,api,gateway,capless', 31 | author='Capless.io', 32 | author_email='opensource@capless.io', 33 | maintainer='Brian Jinwright', 34 | packages=find_packages(), 35 | url='https://github.com/capless/warrant', 36 | license='Apache License 2.0', 37 | install_requires=requirements_from_file(), 38 | extras_require={ 39 | 'dev': requirements_from_file('requirements_test.txt') 40 | }, 41 | include_package_data=True, 42 | zip_safe=True, 43 | ) 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.idea 6 | # C extensions 7 | *.so 8 | *.ipynb 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | # manage.py 27 | db.sqlite3 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | venv/ 84 | ENV/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | -------------------------------------------------------------------------------- /warrant/aws_srp.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | import datetime 4 | import hashlib 5 | import hmac 6 | import re 7 | 8 | import boto3 9 | import os 10 | import six 11 | 12 | from .exceptions import ForceChangePasswordException 13 | 14 | # https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L22 15 | n_hex = 'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1' + '29024E088A67CC74020BBEA63B139B22514A08798E3404DD' + \ 16 | 'EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245' + 'E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED' + \ 17 | 'EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D' + 'C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F' + \ 18 | '83655D23DCA3AD961C62F356208552BB9ED529077096966D' + '670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B' + \ 19 | 'E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9' + 'DE2BCBF6955817183995497CEA956AE515D2261898FA0510' + \ 20 | '15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64' + 'ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7' + \ 21 | 'ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B' + 'F12FFA06D98A0864D87602733EC86A64521F2B18177B200C' + \ 22 | 'BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31' + '43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF' 23 | # https://github.com/aws/amazon-cognito-identity-js/blob/master/src/AuthenticationHelper.js#L49 24 | g_hex = '2' 25 | info_bits = bytearray('Caldera Derived Key', 'utf-8') 26 | 27 | 28 | def hash_sha256(buf): 29 | """AuthenticationHelper.hash""" 30 | a = hashlib.sha256(buf).hexdigest() 31 | return (64 - len(a)) * '0' + a 32 | 33 | 34 | def hex_hash(hex_string): 35 | return hash_sha256(bytearray.fromhex(hex_string)) 36 | 37 | 38 | def hex_to_long(hex_string): 39 | return int(hex_string, 16) 40 | 41 | 42 | def long_to_hex(long_num): 43 | return '%x' % long_num 44 | 45 | 46 | def get_random(nbytes): 47 | random_hex = binascii.hexlify(os.urandom(nbytes)) 48 | return hex_to_long(random_hex) 49 | 50 | 51 | def pad_hex(long_int): 52 | """ 53 | Converts a Long integer (or hex string) to hex format padded with zeroes for hashing 54 | :param {Long integer|String} long_int Number or string to pad. 55 | :return {String} Padded hex string. 56 | """ 57 | if not isinstance(long_int, six.string_types): 58 | hash_str = long_to_hex(long_int) 59 | else: 60 | hash_str = long_int 61 | if len(hash_str) % 2 == 1: 62 | hash_str = '0%s' % hash_str 63 | elif hash_str[0] in '89ABCDEFabcdef': 64 | hash_str = '00%s' % hash_str 65 | return hash_str 66 | 67 | 68 | def compute_hkdf(ikm, salt): 69 | """ 70 | Standard hkdf algorithm 71 | :param {Buffer} ikm Input key material. 72 | :param {Buffer} salt Salt value. 73 | :return {Buffer} Strong key material. 74 | @private 75 | """ 76 | prk = hmac.new(salt, ikm, hashlib.sha256).digest() 77 | info_bits_update = info_bits + bytearray(chr(1), 'utf-8') 78 | hmac_hash = hmac.new(prk, info_bits_update, hashlib.sha256).digest() 79 | return hmac_hash[:16] 80 | 81 | 82 | def calculate_u(big_a, big_b): 83 | """ 84 | Calculate the client's value U which is the hash of A and B 85 | :param {Long integer} big_a Large A value. 86 | :param {Long integer} big_b Server B value. 87 | :return {Long integer} Computed U value. 88 | """ 89 | u_hex_hash = hex_hash(pad_hex(big_a) + pad_hex(big_b)) 90 | return hex_to_long(u_hex_hash) 91 | 92 | 93 | class AWSSRP(object): 94 | 95 | NEW_PASSWORD_REQUIRED_CHALLENGE = 'NEW_PASSWORD_REQUIRED' 96 | PASSWORD_VERIFIER_CHALLENGE = 'PASSWORD_VERIFIER' 97 | 98 | def __init__(self, username, password, pool_id, client_id, pool_region=None, 99 | client=None, client_secret=None): 100 | if pool_region is not None and client is not None: 101 | raise ValueError("pool_region and client should not both be specified " 102 | "(region should be passed to the boto3 client instead)") 103 | 104 | self.username = username 105 | self.password = password 106 | self.pool_id = pool_id 107 | self.client_id = client_id 108 | self.client_secret = client_secret 109 | self.client = client if client else boto3.client('cognito-idp', region_name=pool_region) 110 | self.big_n = hex_to_long(n_hex) 111 | self.g = hex_to_long(g_hex) 112 | self.k = hex_to_long(hex_hash('00' + n_hex + '0' + g_hex)) 113 | self.small_a_value = self.generate_random_small_a() 114 | self.large_a_value = self.calculate_a() 115 | 116 | def generate_random_small_a(self): 117 | """ 118 | helper function to generate a random big integer 119 | :return {Long integer} a random value. 120 | """ 121 | random_long_int = get_random(128) 122 | return random_long_int % self.big_n 123 | 124 | def calculate_a(self): 125 | """ 126 | Calculate the client's public value A = g^a%N 127 | with the generated random number a 128 | :param {Long integer} a Randomly generated small A. 129 | :return {Long integer} Computed large A. 130 | """ 131 | big_a = pow(self.g, self.small_a_value, self.big_n) 132 | # safety check 133 | if (big_a % self.big_n) == 0: 134 | raise ValueError('Safety check for A failed') 135 | return big_a 136 | 137 | def get_password_authentication_key(self, username, password, server_b_value, salt): 138 | """ 139 | Calculates the final hkdf based on computed S value, and computed U value and the key 140 | :param {String} username Username. 141 | :param {String} password Password. 142 | :param {Long integer} server_b_value Server B value. 143 | :param {Long integer} salt Generated salt. 144 | :return {Buffer} Computed HKDF value. 145 | """ 146 | u_value = calculate_u(self.large_a_value, server_b_value) 147 | if u_value == 0: 148 | raise ValueError('U cannot be zero.') 149 | username_password = '%s%s:%s' % (self.pool_id.split('_')[1], username, password) 150 | username_password_hash = hash_sha256(username_password.encode('utf-8')) 151 | 152 | x_value = hex_to_long(hex_hash(pad_hex(salt) + username_password_hash)) 153 | g_mod_pow_xn = pow(self.g, x_value, self.big_n) 154 | int_value2 = server_b_value - self.k * g_mod_pow_xn 155 | s_value = pow(int_value2, self.small_a_value + u_value * x_value, self.big_n) 156 | hkdf = compute_hkdf(bytearray.fromhex(pad_hex(s_value)), 157 | bytearray.fromhex(pad_hex(long_to_hex(u_value)))) 158 | return hkdf 159 | 160 | def get_auth_params(self): 161 | auth_params = {'USERNAME': self.username, 162 | 'SRP_A': long_to_hex(self.large_a_value)} 163 | if self.client_secret is not None: 164 | auth_params.update({ 165 | "SECRET_HASH": 166 | self.get_secret_hash(self.username, self.client_id, self.client_secret)}) 167 | return auth_params 168 | 169 | @staticmethod 170 | def get_secret_hash(username, client_id, client_secret): 171 | message = bytearray(username + client_id, 'utf-8') 172 | hmac_obj = hmac.new(bytearray(client_secret, 'utf-8'), message, hashlib.sha256) 173 | return base64.standard_b64encode(hmac_obj.digest()).decode('utf-8') 174 | 175 | def process_challenge(self, challenge_parameters): 176 | user_id_for_srp = challenge_parameters['USER_ID_FOR_SRP'] 177 | salt_hex = challenge_parameters['SALT'] 178 | srp_b_hex = challenge_parameters['SRP_B'] 179 | secret_block_b64 = challenge_parameters['SECRET_BLOCK'] 180 | # re strips leading zero from a day number (required by AWS Cognito) 181 | timestamp = re.sub(r" 0(\d) ", r" \1 ", 182 | datetime.datetime.utcnow().strftime("%a %b %d %H:%M:%S UTC %Y")) 183 | hkdf = self.get_password_authentication_key(user_id_for_srp, 184 | self.password, hex_to_long(srp_b_hex), salt_hex) 185 | secret_block_bytes = base64.standard_b64decode(secret_block_b64) 186 | msg = bytearray(self.pool_id.split('_')[1], 'utf-8') + bytearray(user_id_for_srp, 'utf-8') + \ 187 | bytearray(secret_block_bytes) + bytearray(timestamp, 'utf-8') 188 | hmac_obj = hmac.new(hkdf, msg, digestmod=hashlib.sha256) 189 | signature_string = base64.standard_b64encode(hmac_obj.digest()) 190 | response = {'TIMESTAMP': timestamp, 191 | 'USERNAME': user_id_for_srp, 192 | 'PASSWORD_CLAIM_SECRET_BLOCK': secret_block_b64, 193 | 'PASSWORD_CLAIM_SIGNATURE': signature_string.decode('utf-8')} 194 | if self.client_secret is not None: 195 | response.update({ 196 | "SECRET_HASH": 197 | self.get_secret_hash(self.username, self.client_id, self.client_secret)}) 198 | return response 199 | 200 | def authenticate_user(self, client=None): 201 | boto_client = self.client or client 202 | auth_params = self.get_auth_params() 203 | response = boto_client.initiate_auth( 204 | AuthFlow='USER_SRP_AUTH', 205 | AuthParameters=auth_params, 206 | ClientId=self.client_id 207 | ) 208 | if response['ChallengeName'] == self.PASSWORD_VERIFIER_CHALLENGE: 209 | challenge_response = self.process_challenge(response['ChallengeParameters']) 210 | tokens = boto_client.respond_to_auth_challenge( 211 | ClientId=self.client_id, 212 | ChallengeName=self.PASSWORD_VERIFIER_CHALLENGE, 213 | ChallengeResponses=challenge_response) 214 | 215 | if tokens.get('ChallengeName') == self.NEW_PASSWORD_REQUIRED_CHALLENGE: 216 | raise ForceChangePasswordException('Change password before authenticating') 217 | 218 | return tokens 219 | else: 220 | raise NotImplementedError('The %s challenge is not supported' % response['ChallengeName']) 221 | 222 | def set_new_password_challenge(self, new_password, client=None): 223 | boto_client = self.client or client 224 | auth_params = self.get_auth_params() 225 | response = boto_client.initiate_auth( 226 | AuthFlow='USER_SRP_AUTH', 227 | AuthParameters=auth_params, 228 | ClientId=self.client_id 229 | ) 230 | if response['ChallengeName'] == self.PASSWORD_VERIFIER_CHALLENGE: 231 | challenge_response = self.process_challenge(response['ChallengeParameters']) 232 | tokens = boto_client.respond_to_auth_challenge( 233 | ClientId=self.client_id, 234 | ChallengeName=self.PASSWORD_VERIFIER_CHALLENGE, 235 | ChallengeResponses=challenge_response) 236 | 237 | if tokens['ChallengeName'] == self.NEW_PASSWORD_REQUIRED_CHALLENGE: 238 | challenge_response = { 239 | 'USERNAME': auth_params['USERNAME'], 240 | 'NEW_PASSWORD': new_password 241 | } 242 | new_password_response = boto_client.respond_to_auth_challenge( 243 | ClientId=self.client_id, 244 | ChallengeName=self.NEW_PASSWORD_REQUIRED_CHALLENGE, 245 | Session=tokens['Session'], 246 | ChallengeResponses=challenge_response) 247 | return new_password_response 248 | return tokens 249 | else: 250 | raise NotImplementedError('The %s challenge is not supported' % response['ChallengeName']) 251 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![alt text](https://s3.amazonaws.com/capless/images/warrant-small.png "Warrant - Serverless Authentication") 2 | 3 | # Warrant 4 | 5 | Makes working with AWS Cognito easier for Python developers. 6 | 7 | [![Build Status](https://travis-ci.org/capless/warrant.svg?branch=master)](https://travis-ci.org/capless/warrant) 8 | 9 | ## Getting Started 10 | 11 | - [Python Versions Supported](#python-versions-supported) 12 | - [Install](#install) 13 | - [Environment Variables](#environment-variables) 14 | - [COGNITO_JWKS](#cognito-jwks) (optional) 15 | - [Cognito Utility Class](#cognito-utility-class) `warrant.Cognito` 16 | - [Cognito Methods](#cognito-methods) 17 | - [Register](#register) 18 | - [Authenticate](#authenticate) 19 | - [Admin Authenticate](#admin-authenticate) 20 | - [Initiate Forgot Password](#initiate-forgot-password) 21 | - [Confirm Forgot Password](#confirm-forgot-password) 22 | - [Change Password](#change-password) 23 | - [Confirm Sign Up](#confirm-sign-up) 24 | - [Update Profile](#update-profile) 25 | - [Send Verification](#send-verification) 26 | - [Get User Object](#get-user-object) 27 | - [Get User](#get-user) 28 | - [Get Users](#get-users) 29 | - [Get Group Object](#get-group-object) 30 | - [Get Group](#get-group) 31 | - [Get Groups](#get-groups) 32 | - [Check Token](#check-token) 33 | - [Logout](#logout) 34 | - [Cognito SRP Utility](#cognito-srp-utility) `warrant.aws_srp.AWSSRP` 35 | - [Using AWSSRP](#using-awssrp) 36 | - [Projects Using Warrant](#projects-using-warrant) 37 | - [Django Warrant](#django-warrant) 38 | - [Authors](#authors) 39 | - [Release Notes](#release-notes) 40 | 41 | ## Python Versions Supported 42 | 43 | - 2.7 44 | - 3.6 45 | 46 | ## Install 47 | 48 | `pip install warrant` 49 | 50 | 51 | ## Environment Variables 52 | 53 | #### COGNITO_JWKS 54 | 55 | **Optional:** This environment variable is a dictionary that represent the well known JWKs assigned to your user pool by AWS Cognito. You can find the keys for your user pool by substituting in your AWS region and pool id for the following example. 56 | `https://cognito-idp.{aws-region}.amazonaws.com/{user-pool-id}/.well-known/jwks.json` 57 | 58 | **Example Value (Not Real):** 59 | ```commandline 60 | COGNITO_JWKS={"keys": [{"alg": "RS256","e": "AQAB","kid": "123456789ABCDEFGHIJKLMNOP","kty": "RSA","n": "123456789ABCDEFGHIJKLMNOP","use": "sig"},{"alg": "RS256","e": "AQAB","kid": "123456789ABCDEFGHIJKLMNOP","kty": "RSA","n": "123456789ABCDEFGHIJKLMNOP","use": "sig"}]} 61 | ``` 62 | ## Cognito Utility Class 63 | 64 | ### Example with All Arguments ### 65 | 66 | ```python 67 | from warrant import Cognito 68 | 69 | u = Cognito('your-user-pool-id','your-client-id', 70 | client_secret='optional-client-secret' 71 | username='optional-username', 72 | id_token='optional-id-token', 73 | refresh_token='optional-refresh-token', 74 | access_token='optional-access-token', 75 | access_key='optional-access-key', 76 | secret_key='optional-secret-key') 77 | ``` 78 | 79 | #### Arguments 80 | 81 | - **user_pool_id:** Cognito User Pool ID 82 | - **client_id:** Cognito User Pool Application client ID 83 | - **client_secret:** App client secret (if app client is configured with client secret) 84 | - **username:** User Pool username 85 | - **id_token:** ID Token returned by authentication 86 | - **refresh_token:** Refresh Token returned by authentication 87 | - **access_token:** Access Token returned by authentication 88 | - **access_key:** AWS IAM access key 89 | - **secret_key:** AWS IAM secret key 90 | 91 | 92 | ### Examples with Realistic Arguments ### 93 | 94 | #### User Pool Id and Client ID Only #### 95 | 96 | Used when you only need information about the user pool (ex. list users in the user pool) 97 | ```python 98 | from warrant import Cognito 99 | 100 | u = Cognito('your-user-pool-id','your-client-id') 101 | ``` 102 | 103 | #### Username 104 | 105 | Used when the user has not logged in yet. Start with these arguments when you plan to authenticate with either SRP (authenticate) or admin_authenticate (admin_initiate_auth). 106 | ```python 107 | from warrant import Cognito 108 | 109 | u = Cognito('your-user-pool-id','your-client-id', 110 | username='bob') 111 | ``` 112 | 113 | #### Tokens #### 114 | 115 | Used after the user has already authenticated and you need to build a new Cognito instance (ex. for use in a view). 116 | 117 | ```python 118 | from warrant import Cognito 119 | 120 | u = Cognito('your-user-pool-id','your-client-id', 121 | id_token='your-id-token', 122 | refresh_token='your-refresh-token', 123 | access_token='your-access-token') 124 | ``` 125 | 126 | ## Cognito Methods ## 127 | 128 | #### Register #### 129 | 130 | Register a user to the user pool 131 | 132 | **Important:** The arguments for `add_base_attributes` and `add_custom_attributes` methods depend on your user pool's configuration, and make sure the client id (app id) used has write permissions for the attriubtes you are trying to create. Example, if you want to create a user with a given_name equal to Johnson make sure the client_id you're using has permissions to edit or create given_name for a user in the pool. 133 | 134 | 135 | ```python 136 | from warrant import Cognito 137 | 138 | u = Cognito('your-user-pool-id', 'your-client-id') 139 | 140 | u.add_base_attributes(email='you@you.com', some_random_attr='random value') 141 | 142 | u.register('username', 'password') 143 | ``` 144 | 145 | Register with custom attributes. 146 | 147 | Firstly, add custom attributes on 'General settings -> Attributes' page. 148 | Secondly, set permissions on 'Generals settings-> App clients-> Show details-> Set attribute read and write permissions' page. 149 | ```python 150 | from warrant import Cognito 151 | 152 | u = Cognito('your-user-pool-id', 'your-client-id') 153 | 154 | u.add_base_attributes(email='you@you.com', some_random_attr='random value') 155 | 156 | u.add_custom_attributes(state='virginia', city='Centreville') 157 | 158 | u.register('username', 'password') 159 | ``` 160 | ##### Arguments 161 | 162 | - **username:** User Pool username 163 | - **password:** User Pool password 164 | - **attr_map:** Attribute map to Cognito's attributes 165 | 166 | 167 | #### Authenticate #### 168 | 169 | Authenticates a user 170 | 171 | If this method call succeeds the instance will have the following attributes **id_token**, **refresh_token**, **access_token**, **expires_in**, **expires_datetime**, and **token_type**. 172 | 173 | ```python 174 | from warrant import Cognito 175 | 176 | u = Cognito('your-user-pool-id','your-client-id', 177 | username='bob') 178 | 179 | u.authenticate(password='bobs-password') 180 | ``` 181 | 182 | ##### Arguments 183 | 184 | - **password:** - User's password 185 | 186 | #### Admin Authenticate 187 | 188 | Authenticate the user using admin super privileges 189 | 190 | ```python 191 | from warrant import Cognito 192 | 193 | u = Cognito('your-user-pool-id','your-client-id', 194 | username='bob') 195 | 196 | u.admin_authenticate(password='bobs-password') 197 | ``` 198 | 199 | - **password:** User's password 200 | 201 | #### Initiate Forgot Password 202 | 203 | Sends a verification code to the user to use to change their password. 204 | 205 | ```python 206 | u = Cognito('your-user-pool-id','your-client-id', 207 | username='bob') 208 | 209 | u.initiate_forgot_password() 210 | ``` 211 | 212 | ##### Arguments 213 | 214 | No arguments 215 | 216 | #### Confirm Forgot Password 217 | 218 | Allows a user to enter a code provided when they reset their password 219 | to update their password. 220 | 221 | ```python 222 | u = Cognito('your-user-pool-id','your-client-id', 223 | username='bob') 224 | 225 | u.confirm_forgot_password('your-confirmation-code','your-new-password') 226 | ``` 227 | 228 | ##### Arguments 229 | 230 | - **confirmation_code:** The confirmation code sent by a user's request 231 | to retrieve a forgotten password 232 | - **password:** New password 233 | 234 | #### Change Password #### 235 | 236 | Changes the user's password 237 | 238 | ```python 239 | from warrant import Cognito 240 | 241 | #If you don't use your tokens then you will need to 242 | #use your username and password and call the authenticate method 243 | u = Cognito('your-user-pool-id','your-client-id', 244 | id_token='id-token',refresh_token='refresh-token', 245 | access_token='access-token') 246 | 247 | u.change_password('previous-password','proposed-password') 248 | ``` 249 | 250 | ##### Arguments 251 | 252 | - **previous_password:** - User's previous password 253 | - **proposed_password:** - The password that the user wants to change to. 254 | 255 | #### Confirm Sign Up #### 256 | 257 | Use the confirmation code that is sent via email or text to confirm the user's account 258 | 259 | ```python 260 | from warrant import Cognito 261 | 262 | u = Cognito('your-user-pool-id','your-client-id') 263 | 264 | u.confirm_sign_up('users-conf-code',username='bob') 265 | ``` 266 | 267 | ##### Arguments 268 | 269 | - **confirmation_code:** Confirmation code sent via text or email 270 | - **username:** User's username 271 | 272 | #### Update Profile #### 273 | 274 | Update the user's profile 275 | 276 | ```python 277 | from warrant import Cognito 278 | 279 | u = Cognito('your-user-pool-id','your-client-id', 280 | id_token='id-token',refresh_token='refresh-token', 281 | access_token='access-token') 282 | 283 | u.update_profile({'given_name':'Edward','family_name':'Smith',},attr_map=dict()) 284 | ``` 285 | 286 | ##### Arguments 287 | 288 | - **attrs:** Dictionary of attribute name, values 289 | - **attr_map:** Dictionary map from Cognito attributes to attribute names we would like to show to our users 290 | 291 | #### Send Verification #### 292 | 293 | Send verification email or text for either the email or phone attributes. 294 | 295 | ```python 296 | from warrant import Cognito 297 | 298 | u = Cognito('your-user-pool-id','your-client-id', 299 | id_token='id-token',refresh_token='refresh-token', 300 | access_token='access-token') 301 | 302 | u.send_verification(attribute='email') 303 | ``` 304 | 305 | ##### Arguments 306 | 307 | - **attribute:** - The attribute (email or phone) that needs to be verified 308 | 309 | #### Get User Object 310 | 311 | Returns an instance of the specified user_class. 312 | 313 | ```python 314 | u = Cognito('your-user-pool-id','your-client-id', 315 | id_token='id-token',refresh_token='refresh-token', 316 | access_token='access-token') 317 | 318 | u.get_user_obj(username='bjones', 319 | attribute_list=[{'Name': 'string','Value': 'string'},], 320 | metadata={}, 321 | attr_map={"given_name":"first_name","family_name":"last_name"} 322 | ) 323 | ``` 324 | ##### Arguments 325 | - **username:** Username of the user 326 | - **attribute_list:** List of tuples that represent the user's attributes as returned by the admin_get_user or get_user boto3 methods 327 | - **metadata: (optional)** Metadata about the user 328 | - **attr_map: (optional)** Dictionary that maps the Cognito attribute names to what we'd like to display to the users 329 | 330 | 331 | #### Get User 332 | 333 | Get all of the user's attributes. Gets the user's attributes using Boto3 and uses that info to create an instance of the user_class 334 | 335 | ```python 336 | from warrant import Cognito 337 | 338 | u = Cognito('your-user-pool-id','your-client-id', 339 | username='bob') 340 | 341 | user = u.get_user(attr_map={"given_name":"first_name","family_name":"last_name"}) 342 | ``` 343 | 344 | ##### Arguments 345 | - **attr_map:** Dictionary map from Cognito attributes to attribute names we would like to show to our users 346 | 347 | #### Get Users 348 | 349 | Get a list of the user in the user pool. 350 | 351 | 352 | ```python 353 | from warrant import Cognito 354 | 355 | u = Cognito('your-user-pool-id','your-client-id') 356 | 357 | user = u.get_users(attr_map={"given_name":"first_name","family_name":"last_name"}) 358 | ``` 359 | 360 | ##### Arguments 361 | - **attr_map:** Dictionary map from Cognito attributes to attribute names we would like to show to our users 362 | 363 | #### Get Group object 364 | 365 | Returns an instance of the specified group_class. 366 | 367 | ```python 368 | u = Cognito('your-user-pool-id', 'your-client-id') 369 | 370 | group_data = {'GroupName': 'user_group', 'Description': 'description', 371 | 'Precedence': 1} 372 | 373 | group_obj = u.get_group_obj(group_data) 374 | ``` 375 | 376 | ##### Arguments 377 | - **group_data:** Dictionary with group's attributes. 378 | 379 | #### Get Group 380 | 381 | Get all of the group's attributes. Returns an instance of the group_class. 382 | Requires developer credentials. 383 | 384 | ```python 385 | from warrant import Cognito 386 | 387 | u = Cognito('your-user-pool-id','your-client-id') 388 | 389 | group = u.get_group(group_name='some_group_name') 390 | ``` 391 | 392 | ##### Arguments 393 | - **group_name:** Name of a group 394 | 395 | #### Get Groups 396 | 397 | Get a list of groups in the user pool. Requires developer credentials. 398 | 399 | ```python 400 | from warrant import Cognito 401 | 402 | u = Cognito('your-user-pool-id','your-client-id') 403 | 404 | groups = u.get_groups() 405 | ``` 406 | 407 | #### Check Token 408 | 409 | Checks the exp attribute of the access_token and either refreshes the tokens by calling the renew_access_tokens method or does nothing. **IMPORTANT:** Access token is required 410 | 411 | ```python 412 | u = Cognito('your-user-pool-id','your-client-id', 413 | id_token='id-token',refresh_token='refresh-token', 414 | access_token='access-token') 415 | 416 | u.check_token() 417 | ``` 418 | ##### Arguments 419 | 420 | No arguments for check_token 421 | 422 | #### Logout #### 423 | 424 | Logs the user out of all clients and removes the expires_in, expires_datetime, id_token, refresh_token, access_token, and token_type attributes. 425 | 426 | ```python 427 | from warrant import Cognito 428 | 429 | #If you don't use your tokens then you will need to 430 | #use your username and password and call the authenticate method 431 | u = Cognito('your-user-pool-id','your-client-id', 432 | id_token='id-token',refresh_token='refresh-token', 433 | access_token='access-token') 434 | 435 | u.logout() 436 | ``` 437 | ##### Arguments 438 | 439 | No arguments for check_token 440 | 441 | ## Cognito SRP Utility 442 | The `AWSSRP` class is used to perform [SRP(Secure Remote Password protocol)](https://www.ietf.org/rfc/rfc2945.txt) authentication. 443 | This is the preferred method of user authentication with AWS Cognito. 444 | The process involves a series of authentication challenges and responses, which if successful, 445 | results in a final response that contains ID, access and refresh tokens. 446 | 447 | ### Using AWSSRP 448 | The `AWSSRP` class takes a username, password, cognito user pool id, cognito app id, an optional 449 | client secret (if app client is configured with client secret), an optional pool_region or `boto3` client. 450 | Afterwards, the `authenticate_user` class method is used for SRP authentication. 451 | 452 | 453 | ```python 454 | import boto3 455 | from warrant.aws_srp import AWSSRP 456 | 457 | client = boto3.client('cognito-idp') 458 | aws = AWSSRP(username='username', password='password', pool_id='user_pool_id', 459 | client_id='client_id', client=client) 460 | tokens = aws.authenticate_user() 461 | ``` 462 | 463 | ## Projects Using Warrant 464 | 465 | #### [Django Warrant](https://www.github.com/metametricsinc/django-warrant) 466 | 467 | ## Authors 468 | 469 | ### Brian Jinwright 470 | **Twitter:** [@brianjinwright](https://www.twitter.com/brianjinwright) 471 | **GitHub:** [@bjinwright](https://www.github.com/bjinwright/) 472 | 473 | ### Eric Petway 474 | **GitHub:** [@ebpetway](https://www.github.com/ebpetway) 475 | 476 | ### Sergey Vishnikin 477 | 478 | **GitHub:** [@armicron](https://www.github.com/armicron) 479 | 480 | ## [Release Notes](https://github.com/capless/warrant/blob/master/HISTORY.md) 481 | -------------------------------------------------------------------------------- /warrant/tests/tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from botocore.exceptions import ParamValidationError 4 | from botocore.stub import Stubber 5 | from envs import env 6 | from mock import patch 7 | 8 | from warrant import Cognito, UserObj, GroupObj, TokenVerificationException 9 | from warrant.aws_srp import AWSSRP 10 | 11 | 12 | def _mock_authenticate_user(_, client=None): 13 | return { 14 | 'AuthenticationResult': { 15 | 'TokenType': 'admin', 16 | 'IdToken': 'dummy_token', 17 | 'AccessToken': 'dummy_token', 18 | 'RefreshToken': 'dummy_token' 19 | } 20 | } 21 | 22 | 23 | def _mock_get_params(_): 24 | return {'USERNAME': 'bob', 'SRP_A': 'srp'} 25 | 26 | 27 | def _mock_verify_tokens(self, token, id_name, token_use): 28 | if 'wrong' in token: 29 | raise TokenVerificationException 30 | setattr(self, id_name, token) 31 | 32 | 33 | class UserObjTestCase(unittest.TestCase): 34 | 35 | def setUp(self): 36 | if env('USE_CLIENT_SECRET', 'False') == 'True': 37 | self.app_id = env('COGNITO_APP_WITH_SECRET_ID') 38 | else: 39 | self.app_id = env('COGNITO_APP_ID') 40 | self.cognito_user_pool_id = env('COGNITO_USER_POOL_ID', 'us-east-1_123456789') 41 | self.username = env('COGNITO_TEST_USERNAME') 42 | 43 | self.user = Cognito(user_pool_id=self.cognito_user_pool_id, 44 | client_id=self.app_id, 45 | username=self.username) 46 | 47 | self.user_metadata = { 48 | 'user_status': 'CONFIRMED', 49 | 'username': 'bjones', 50 | } 51 | self.user_info = [ 52 | {'Name': 'name', 'Value': 'Brian Jones'}, 53 | {'Name': 'given_name', 'Value': 'Brian'}, 54 | {'Name': 'birthdate', 'Value': '12/7/1980'} 55 | ] 56 | 57 | def test_init(self): 58 | u = UserObj('bjones', self.user_info, self.user, self.user_metadata) 59 | self.assertEqual(u.pk, self.user_metadata.get('username')) 60 | self.assertEqual(u.name, self.user_info[0].get('Value')) 61 | self.assertEqual(u.user_status, self.user_metadata.get('user_status')) 62 | 63 | 64 | class GroupObjTestCase(unittest.TestCase): 65 | 66 | def setUp(self): 67 | if env('USE_CLIENT_SECRET', 'False') == 'True': 68 | self.app_id = env('COGNITO_APP_WITH_SECRET_ID') 69 | else: 70 | self.app_id = env('COGNITO_APP_ID') 71 | self.cognito_user_pool_id = env('COGNITO_USER_POOL_ID', 'us-east-1_123456789') 72 | self.group_data = {'GroupName': 'test_group', 'Precedence': 1} 73 | self.cognito_obj = Cognito(user_pool_id=self.cognito_user_pool_id, 74 | client_id=self.app_id) 75 | 76 | def test_init(self): 77 | group = GroupObj(group_data=self.group_data, cognito_obj=self.cognito_obj) 78 | self.assertEqual(group.group_name, 'test_group') 79 | self.assertEqual(group.precedence, 1) 80 | 81 | 82 | class CognitoAuthTestCase(unittest.TestCase): 83 | 84 | def setUp(self): 85 | if env('USE_CLIENT_SECRET') == 'True': 86 | self.app_id = env('COGNITO_APP_WITH_SECRET_ID', 'app') 87 | self.client_secret = env('COGNITO_CLIENT_SECRET') 88 | else: 89 | self.app_id = env('COGNITO_APP_ID', 'app') 90 | self.client_secret = None 91 | self.cognito_user_pool_id = env('COGNITO_USER_POOL_ID', 'us-east-1_123456789') 92 | self.username = env('COGNITO_TEST_USERNAME', 'bob') 93 | self.password = env('COGNITO_TEST_PASSWORD', 'bobpassword') 94 | self.user = Cognito(self.cognito_user_pool_id, self.app_id, 95 | username=self.username, 96 | client_secret=self.client_secret) 97 | 98 | @patch('warrant.aws_srp.AWSSRP.authenticate_user', _mock_authenticate_user) 99 | @patch('warrant.Cognito.verify_token', _mock_verify_tokens) 100 | def test_authenticate(self): 101 | 102 | self.user.authenticate(self.password) 103 | self.assertNotEqual(self.user.access_token, None) 104 | self.assertNotEqual(self.user.id_token, None) 105 | self.assertNotEqual(self.user.refresh_token, None) 106 | 107 | @patch('warrant.aws_srp.AWSSRP.authenticate_user', _mock_authenticate_user) 108 | @patch('warrant.Cognito.verify_token', _mock_verify_tokens) 109 | def test_verify_token(self): 110 | self.user.authenticate(self.password) 111 | bad_access_token = '{}wrong'.format(self.user.access_token) 112 | 113 | with self.assertRaises(TokenVerificationException): 114 | self.user.verify_token(bad_access_token, 'access_token', 'access') 115 | 116 | # def test_logout(self): 117 | # self.user.authenticate(self.password) 118 | # self.user.logout() 119 | # self.assertEqual(self.user.id_token,None) 120 | # self.assertEqual(self.user.refresh_token,None) 121 | # self.assertEqual(self.user.access_token,None) 122 | 123 | @patch('warrant.Cognito', autospec=True) 124 | def test_register(self, cognito_user): 125 | u = cognito_user(self.cognito_user_pool_id, self.app_id, 126 | username=self.username) 127 | u.add_base_attributes( 128 | given_name='Brian', family_name='Jones', 129 | name='Brian Jones', email='bjones39@capless.io', 130 | phone_number='+19194894555', gender='Male', 131 | preferred_username='billyocean') 132 | u.register('sampleuser', 'sample4#Password') 133 | 134 | # TODO: Write assumptions 135 | 136 | @patch('warrant.aws_srp.AWSSRP.authenticate_user', _mock_authenticate_user) 137 | @patch('warrant.Cognito.verify_token', _mock_verify_tokens) 138 | @patch('warrant.Cognito._add_secret_hash', return_value=None) 139 | def test_renew_tokens(self, _): 140 | 141 | stub = Stubber(self.user.client) 142 | 143 | # By the stubber nature, we need to add the sequence 144 | # of calls for the AWS SRP auth to test the whole process 145 | stub.add_response(method='initiate_auth', 146 | service_response={ 147 | 'AuthenticationResult': { 148 | 'TokenType': 'admin', 149 | 'IdToken': 'dummy_token', 150 | 'AccessToken': 'dummy_token', 151 | 'RefreshToken': 'dummy_token' 152 | }, 153 | 'ResponseMetadata': {'HTTPStatusCode': 200} 154 | }, 155 | expected_params={ 156 | 'ClientId': self.app_id, 157 | 'AuthFlow': 'REFRESH_TOKEN', 158 | 'AuthParameters': {'REFRESH_TOKEN': 'dummy_token'} 159 | }) 160 | 161 | with stub: 162 | self.user.authenticate(self.password) 163 | self.user.renew_access_token() 164 | stub.assert_no_pending_responses() 165 | 166 | @patch('warrant.Cognito', autospec=True) 167 | def test_update_profile(self, cognito_user): 168 | u = cognito_user(self.cognito_user_pool_id, self.app_id, 169 | username=self.username) 170 | u.authenticate(self.password) 171 | u.update_profile({'given_name': 'Jenkins'}) 172 | 173 | def test_admin_get_user(self): 174 | 175 | stub = Stubber(self.user.client) 176 | 177 | stub.add_response(method='admin_get_user', 178 | service_response={ 179 | 'Enabled': True, 180 | 'UserStatus': 'CONFIRMED', 181 | 'Username': self.username, 182 | 'UserAttributes': [] 183 | }, 184 | expected_params={ 185 | 'UserPoolId': self.cognito_user_pool_id, 186 | 'Username': self.username 187 | }) 188 | 189 | with stub: 190 | u = self.user.admin_get_user() 191 | self.assertEqual(u.pk, self.username) 192 | stub.assert_no_pending_responses() 193 | 194 | def test_check_token(self): 195 | # This is a sample JWT with an expiration time set to January, 1st, 3000 196 | self.user.access_token = ('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG' 197 | '9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjMyNTAzNjgwMDAwfQ.C-1gPxrhUsiWeCvMvaZuuQYarkDNAc' 198 | 'pEGJPIqu_SrKQ') 199 | self.assertFalse(self.user.check_token()) 200 | 201 | @patch('warrant.Cognito', autospec=True) 202 | def test_validate_verification(self, cognito_user): 203 | u = cognito_user(self.cognito_user_pool_id, self.app_id, 204 | username=self.username) 205 | u.validate_verification('4321') 206 | 207 | @patch('warrant.Cognito', autospec=True) 208 | def test_confirm_forgot_password(self, cognito_user): 209 | u = cognito_user(self.cognito_user_pool_id, self.app_id, 210 | username=self.username) 211 | u.confirm_forgot_password('4553', 'samplepassword') 212 | with self.assertRaises(TypeError): 213 | u.confirm_forgot_password(self.password) 214 | 215 | @patch('warrant.aws_srp.AWSSRP.authenticate_user', _mock_authenticate_user) 216 | @patch('warrant.Cognito.verify_token', _mock_verify_tokens) 217 | @patch('warrant.Cognito.check_token', return_value=True) 218 | def test_change_password(self, _): 219 | # u = cognito_user(self.cognito_user_pool_id, self.app_id, 220 | # username=self.username) 221 | self.user.authenticate(self.password) 222 | 223 | stub = Stubber(self.user.client) 224 | 225 | stub.add_response(method='change_password', 226 | service_response={ 227 | 'ResponseMetadata': {'HTTPStatusCode': 200} 228 | }, 229 | expected_params={ 230 | 'PreviousPassword': self.password, 231 | 'ProposedPassword': 'crazypassword$45DOG', 232 | 'AccessToken': self.user.access_token 233 | }) 234 | 235 | with stub: 236 | self.user.change_password(self.password, 'crazypassword$45DOG') 237 | stub.assert_no_pending_responses() 238 | 239 | with self.assertRaises(ParamValidationError): 240 | self.user.change_password(self.password, None) 241 | 242 | def test_set_attributes(self): 243 | u = Cognito(self.cognito_user_pool_id, self.app_id) 244 | u._set_attributes({ 245 | 'ResponseMetadata': { 246 | 'HTTPStatusCode': 200 247 | } 248 | }, 249 | { 250 | 'somerandom': 'attribute' 251 | } 252 | ) 253 | self.assertEqual(u.somerandom, 'attribute') 254 | 255 | # 256 | 257 | @patch('warrant.Cognito.verify_token', _mock_verify_tokens) 258 | def test_admin_authenticate(self): 259 | 260 | stub = Stubber(self.user.client) 261 | 262 | # By the stubber nature, we need to add the sequence 263 | # of calls for the AWS SRP auth to test the whole process 264 | stub.add_response(method='admin_initiate_auth', 265 | service_response={ 266 | 'AuthenticationResult': { 267 | 'TokenType': 'admin', 268 | 'IdToken': 'dummy_token', 269 | 'AccessToken': 'dummy_token', 270 | 'RefreshToken': 'dummy_token' 271 | } 272 | }, 273 | expected_params={ 274 | 'UserPoolId': self.cognito_user_pool_id, 275 | 'ClientId': self.app_id, 276 | 'AuthFlow': 'ADMIN_NO_SRP_AUTH', 277 | 'AuthParameters': { 278 | 'USERNAME': self.username, 279 | 'PASSWORD': self.password} 280 | }) 281 | 282 | with stub: 283 | self.user.admin_authenticate(self.password) 284 | self.assertNotEqual(self.user.access_token, None) 285 | self.assertNotEqual(self.user.id_token, None) 286 | self.assertNotEqual(self.user.refresh_token, None) 287 | stub.assert_no_pending_responses() 288 | 289 | 290 | class AWSSRPTestCase(unittest.TestCase): 291 | 292 | def setUp(self): 293 | if env('USE_CLIENT_SECRET') == 'True': 294 | self.client_secret = env('COGNITO_CLIENT_SECRET') 295 | self.app_id = env('COGNITO_APP_WITH_SECRET_ID', 'app') 296 | else: 297 | self.app_id = env('COGNITO_APP_ID', 'app') 298 | self.client_secret = None 299 | self.cognito_user_pool_id = env('COGNITO_USER_POOL_ID', 'us-east-1_123456789') 300 | self.username = env('COGNITO_TEST_USERNAME') 301 | self.password = env('COGNITO_TEST_PASSWORD') 302 | self.aws = AWSSRP(username=self.username, 303 | password=self.password, 304 | pool_region='us-east-1', 305 | pool_id=self.cognito_user_pool_id, 306 | client_id=self.app_id, 307 | client_secret=self.client_secret) 308 | 309 | def tearDown(self): 310 | del self.aws 311 | 312 | @patch('warrant.aws_srp.AWSSRP.get_auth_params', _mock_get_params) 313 | @patch('warrant.aws_srp.AWSSRP.process_challenge', return_value={}) 314 | def test_authenticate_user(self, _): 315 | 316 | stub = Stubber(self.aws.client) 317 | 318 | # By the stubber nature, we need to add the sequence 319 | # of calls for the AWS SRP auth to test the whole process 320 | stub.add_response(method='initiate_auth', 321 | service_response={ 322 | 'ChallengeName': 'PASSWORD_VERIFIER', 323 | 'ChallengeParameters': {} 324 | }, 325 | expected_params={ 326 | 'AuthFlow': 'USER_SRP_AUTH', 327 | 'AuthParameters': _mock_get_params(None), 328 | 'ClientId': self.app_id 329 | }) 330 | 331 | stub.add_response(method='respond_to_auth_challenge', 332 | service_response={ 333 | 'AuthenticationResult': { 334 | 'IdToken': 'dummy_token', 335 | 'AccessToken': 'dummy_token', 336 | 'RefreshToken': 'dummy_token' 337 | } 338 | }, 339 | expected_params={ 340 | 'ClientId': self.app_id, 341 | 'ChallengeName': 'PASSWORD_VERIFIER', 342 | 'ChallengeResponses': {} 343 | }) 344 | 345 | with stub: 346 | tokens = self.aws.authenticate_user() 347 | self.assertTrue('IdToken' in tokens['AuthenticationResult']) 348 | self.assertTrue('AccessToken' in tokens['AuthenticationResult']) 349 | self.assertTrue('RefreshToken' in tokens['AuthenticationResult']) 350 | stub.assert_no_pending_responses() 351 | 352 | 353 | if __name__ == '__main__': 354 | unittest.main() 355 | -------------------------------------------------------------------------------- /warrant/__init__.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import boto3 3 | import datetime 4 | import re 5 | import requests 6 | 7 | from envs import env 8 | from jose import jwt, JWTError 9 | 10 | from .aws_srp import AWSSRP 11 | from .exceptions import TokenVerificationException 12 | 13 | 14 | def cognito_to_dict(attr_list, attr_map=None): 15 | if attr_map is None: 16 | attr_map = {} 17 | attr_dict = dict() 18 | for a in attr_list: 19 | name = a.get('Name') 20 | value = a.get('Value') 21 | if value in ['true', 'false']: 22 | value = ast.literal_eval(value.capitalize()) 23 | name = attr_map.get(name, name) 24 | attr_dict[name] = value 25 | return attr_dict 26 | 27 | 28 | def dict_to_cognito(attributes, attr_map=None): 29 | """ 30 | :param attributes: Dictionary of User Pool attribute names/values 31 | :param attr_map: Dictonnary with attributes mapping 32 | :return: list of User Pool attribute formatted dicts: {'Name': , 'Value': } 33 | """ 34 | if attr_map is None: 35 | attr_map = {} 36 | for k, v in attr_map.items(): 37 | if v in attributes.keys(): 38 | attributes[k] = attributes.pop(v) 39 | 40 | return [{'Name': key, 'Value': value} for key, value in attributes.items()] 41 | 42 | 43 | def camel_to_snake(camel_str): 44 | """ 45 | :param camel_str: string 46 | :return: string converted from a CamelCase to a snake_case 47 | """ 48 | s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel_str) 49 | return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() 50 | 51 | 52 | def snake_to_camel(snake_str): 53 | """ 54 | :param snake_str: string 55 | :return: string converted from a snake_case to a CamelCase 56 | """ 57 | components = snake_str.split('_') 58 | return ''.join(x.title() for x in components) 59 | 60 | 61 | class UserObj(object): 62 | 63 | def __init__(self, username, attribute_list, cognito_obj, metadata=None, attr_map=None): 64 | """ 65 | :param username: 66 | :param attribute_list: 67 | :param metadata: Dictionary of User metadata 68 | """ 69 | self.username = username 70 | self.pk = username 71 | self._cognito = cognito_obj 72 | self._attr_map = {} if attr_map is None else attr_map 73 | self._data = cognito_to_dict(attribute_list, self._attr_map) 74 | self.sub = self._data.pop('sub', None) 75 | self.email_verified = self._data.pop('email_verified', None) 76 | self.phone_number_verified = self._data.pop('phone_number_verified', None) 77 | self._metadata = {} if metadata is None else metadata 78 | 79 | def __repr__(self): 80 | return '<{class_name}: {uni}>'.format( 81 | class_name=self.__class__.__name__, uni=self.__unicode__()) 82 | 83 | def __unicode__(self): 84 | return self.username 85 | 86 | def __getattr__(self, name): 87 | if name in list(self.__dict__.get('_data', {}).keys()): 88 | return self._data.get(name) 89 | if name in list(self.__dict__.get('_metadata', {}).keys()): 90 | return self._metadata.get(name) 91 | 92 | def __setattr__(self, name, value): 93 | if name in list(self.__dict__.get('_data', {}).keys()): 94 | self._data[name] = value 95 | else: 96 | super(UserObj, self).__setattr__(name, value) 97 | 98 | def save(self, admin=False): 99 | if admin: 100 | self._cognito.admin_update_profile(self._data, self._attr_map) 101 | return 102 | self._cognito.update_profile(self._data, self._attr_map) 103 | 104 | def delete(self, admin=False): 105 | if admin: 106 | self._cognito.admin_delete_user() 107 | return 108 | self._cognito.delete_user() 109 | 110 | 111 | class GroupObj(object): 112 | 113 | def __init__(self, group_data, cognito_obj): 114 | """ 115 | :param group_data: a dictionary with information about a group 116 | :param cognito_obj: an instance of the Cognito class 117 | """ 118 | self._data = group_data 119 | self._cognito = cognito_obj 120 | self.group_name = self._data.pop('GroupName', None) 121 | self.description = self._data.pop('Description', None) 122 | self.creation_date = self._data.pop('CreationDate', None) 123 | self.last_modified_date = self._data.pop('LastModifiedDate', None) 124 | self.role_arn = self._data.pop('RoleArn', None) 125 | self.precedence = self._data.pop('Precedence', None) 126 | 127 | def __unicode__(self): 128 | return self.group_name 129 | 130 | def __repr__(self): 131 | return '<{class_name}: {uni}>'.format( 132 | class_name=self.__class__.__name__, uni=self.__unicode__()) 133 | 134 | 135 | class Cognito(object): 136 | user_class = UserObj 137 | group_class = GroupObj 138 | 139 | def __init__( 140 | self, user_pool_id, client_id, user_pool_region=None, 141 | username=None, id_token=None, refresh_token=None, 142 | access_token=None, client_secret=None, 143 | access_key=None, secret_key=None, 144 | ): 145 | """ 146 | :param user_pool_id: Cognito User Pool ID 147 | :param client_id: Cognito User Pool Application client ID 148 | :param username: User Pool username 149 | :param id_token: ID Token returned by authentication 150 | :param refresh_token: Refresh Token returned by authentication 151 | :param access_token: Access Token returned by authentication 152 | :param access_key: AWS IAM access key 153 | :param secret_key: AWS IAM secret key 154 | """ 155 | 156 | self.user_pool_id = user_pool_id 157 | self.client_id = client_id 158 | self.user_pool_region = user_pool_region if user_pool_region else self.user_pool_id.split('_')[0] 159 | self.username = username 160 | self.id_token = id_token 161 | self.access_token = access_token 162 | self.refresh_token = refresh_token 163 | self.client_secret = client_secret 164 | self.token_type = None 165 | self.custom_attributes = None 166 | self.base_attributes = None 167 | self.pool_jwk = None 168 | 169 | boto3_client_kwargs = {} 170 | if access_key and secret_key: 171 | boto3_client_kwargs['aws_access_key_id'] = access_key 172 | boto3_client_kwargs['aws_secret_access_key'] = secret_key 173 | if self.user_pool_region: 174 | boto3_client_kwargs['region_name'] = self.user_pool_region 175 | 176 | self.client = boto3.client('cognito-idp', **boto3_client_kwargs) 177 | 178 | def get_keys(self): 179 | 180 | if self.pool_jwk: 181 | return self.pool_jwk 182 | else: 183 | # Check for the dictionary in environment variables. 184 | pool_jwk_env = env('COGNITO_JWKS', {}, var_type='dict') 185 | if len(pool_jwk_env.keys()) > 0: 186 | self.pool_jwk = pool_jwk_env 187 | return self.pool_jwk 188 | # If it is not there use the requests library to get it 189 | self.pool_jwk = requests.get( 190 | 'https://cognito-idp.{}.amazonaws.com/{}/.well-known/jwks.json'.format( 191 | self.user_pool_region, self.user_pool_id 192 | )).json() 193 | return self.pool_jwk 194 | 195 | def get_key(self, kid): 196 | keys = self.get_keys().get('keys') 197 | key = list(filter(lambda x: x.get('kid') == kid, keys)) 198 | return key[0] 199 | 200 | def verify_token(self, token, id_name, token_use): 201 | kid = jwt.get_unverified_header(token).get('kid') 202 | unverified_claims = jwt.get_unverified_claims(token) 203 | token_use_verified = unverified_claims.get('token_use') == token_use 204 | if not token_use_verified: 205 | raise TokenVerificationException('Your {} token use could not be verified.') 206 | hmac_key = self.get_key(kid) 207 | try: 208 | verified = jwt.decode(token, hmac_key, algorithms=['RS256'], 209 | audience=unverified_claims.get('aud'), 210 | issuer=unverified_claims.get('iss')) 211 | except JWTError: 212 | raise TokenVerificationException('Your {} token could not be verified.') 213 | setattr(self, id_name, token) 214 | return verified 215 | 216 | def get_user_obj(self, username=None, attribute_list=None, metadata=None, 217 | attr_map=None): 218 | """ 219 | Returns the specified 220 | :param username: Username of the user 221 | :param attribute_list: List of tuples that represent the user's 222 | attributes as returned by the admin_get_user or get_user boto3 methods 223 | :param metadata: Metadata about the user 224 | :param attr_map: Dictionary that maps the Cognito attribute names to 225 | what we'd like to display to the users 226 | :return: 227 | """ 228 | return self.user_class(username=username, attribute_list=attribute_list, 229 | cognito_obj=self, 230 | metadata=metadata, attr_map=attr_map) 231 | 232 | def get_group_obj(self, group_data): 233 | """ 234 | Instantiates the self.group_class 235 | :param group_data: a dictionary with information about a group 236 | :return: an instance of the self.group_class 237 | """ 238 | return self.group_class(group_data=group_data, cognito_obj=self) 239 | 240 | def switch_session(self, session): 241 | """ 242 | Primarily used for unit testing so we can take advantage of the 243 | placebo library (https://githhub.com/garnaat/placebo) 244 | :param session: boto3 session 245 | :return: 246 | """ 247 | self.client = session.client('cognito-idp') 248 | 249 | def check_token(self, renew=True): 250 | """ 251 | Checks the exp attribute of the access_token and either refreshes 252 | the tokens by calling the renew_access_tokens method or does nothing 253 | :param renew: bool indicating whether to refresh on expiration 254 | :return: bool indicating whether access_token has expired 255 | """ 256 | if not self.access_token: 257 | raise AttributeError('Access Token Required to Check Token') 258 | now = datetime.datetime.now() 259 | dec_access_token = jwt.get_unverified_claims(self.access_token) 260 | 261 | if now > datetime.datetime.fromtimestamp(dec_access_token['exp']): 262 | expired = True 263 | if renew: 264 | self.renew_access_token() 265 | else: 266 | expired = False 267 | return expired 268 | 269 | def add_base_attributes(self, **kwargs): 270 | self.base_attributes = kwargs 271 | 272 | def add_custom_attributes(self, **kwargs): 273 | custom_key = 'custom' 274 | custom_attributes = {} 275 | 276 | for old_key, value in kwargs.items(): 277 | new_key = custom_key + ':' + old_key 278 | custom_attributes[new_key] = value 279 | 280 | self.custom_attributes = custom_attributes 281 | 282 | def register(self, username, password, attr_map=None): 283 | """ 284 | Register the user. Other base attributes from AWS Cognito User Pools 285 | are address, birthdate, email, family_name (last name), gender, 286 | given_name (first name), locale, middle_name, name, nickname, 287 | phone_number, picture, preferred_username, profile, zoneinfo, 288 | updated at, website 289 | :param username: User Pool username 290 | :param password: User Pool password 291 | :param attr_map: Attribute map to Cognito's attributes 292 | :return response: Response from Cognito 293 | 294 | Example response:: 295 | { 296 | 'UserConfirmed': True|False, 297 | 'CodeDeliveryDetails': { 298 | 'Destination': 'string', # This value will be obfuscated 299 | 'DeliveryMedium': 'SMS'|'EMAIL', 300 | 'AttributeName': 'string' 301 | } 302 | } 303 | """ 304 | attributes = self.base_attributes.copy() 305 | if self.custom_attributes: 306 | attributes.update(self.custom_attributes) 307 | cognito_attributes = dict_to_cognito(attributes, attr_map) 308 | params = { 309 | 'ClientId': self.client_id, 310 | 'Username': username, 311 | 'Password': password, 312 | 'UserAttributes': cognito_attributes 313 | } 314 | self._add_secret_hash(params, 'SecretHash') 315 | response = self.client.sign_up(**params) 316 | 317 | attributes.update(username=username, password=password) 318 | self._set_attributes(response, attributes) 319 | 320 | response.pop('ResponseMetadata') 321 | return response 322 | 323 | def admin_confirm_sign_up(self, username=None): 324 | """ 325 | Confirms user registration as an admin without using a confirmation 326 | code. Works on any user. 327 | :param username: User's username 328 | :return: 329 | """ 330 | if not username: 331 | username = self.username 332 | self.client.admin_confirm_sign_up( 333 | UserPoolId=self.user_pool_id, 334 | Username=username, 335 | ) 336 | 337 | def confirm_sign_up(self, confirmation_code, username=None): 338 | """ 339 | Using the confirmation code that is either sent via email or text 340 | message. 341 | :param confirmation_code: Confirmation code sent via text or email 342 | :param username: User's username 343 | :return: 344 | """ 345 | if not username: 346 | username = self.username 347 | params = {'ClientId': self.client_id, 348 | 'Username': username, 349 | 'ConfirmationCode': confirmation_code} 350 | self._add_secret_hash(params, 'SecretHash') 351 | self.client.confirm_sign_up(**params) 352 | 353 | def admin_authenticate(self, password): 354 | """ 355 | Authenticate the user using admin super privileges 356 | :param password: User's password 357 | :return: 358 | """ 359 | auth_params = { 360 | 'USERNAME': self.username, 361 | 'PASSWORD': password 362 | } 363 | self._add_secret_hash(auth_params, 'SECRET_HASH') 364 | tokens = self.client.admin_initiate_auth( 365 | UserPoolId=self.user_pool_id, 366 | ClientId=self.client_id, 367 | # AuthFlow='USER_SRP_AUTH'|'REFRESH_TOKEN_AUTH'|'REFRESH_TOKEN'|'CUSTOM_AUTH'|'ADMIN_NO_SRP_AUTH', 368 | AuthFlow='ADMIN_NO_SRP_AUTH', 369 | AuthParameters=auth_params, 370 | ) 371 | 372 | self.verify_token(tokens['AuthenticationResult']['IdToken'], 'id_token', 'id') 373 | self.refresh_token = tokens['AuthenticationResult']['RefreshToken'] 374 | self.verify_token(tokens['AuthenticationResult']['AccessToken'], 'access_token', 'access') 375 | self.token_type = tokens['AuthenticationResult']['TokenType'] 376 | 377 | def authenticate(self, password): 378 | """ 379 | Authenticate the user using the SRP protocol 380 | :param password: The user's passsword 381 | :return: 382 | """ 383 | aws = AWSSRP(username=self.username, password=password, pool_id=self.user_pool_id, 384 | client_id=self.client_id, client=self.client, 385 | client_secret=self.client_secret) 386 | tokens = aws.authenticate_user() 387 | self.verify_token(tokens['AuthenticationResult']['IdToken'], 'id_token', 'id') 388 | self.refresh_token = tokens['AuthenticationResult']['RefreshToken'] 389 | self.verify_token(tokens['AuthenticationResult']['AccessToken'], 'access_token', 'access') 390 | self.token_type = tokens['AuthenticationResult']['TokenType'] 391 | 392 | def new_password_challenge(self, password, new_password): 393 | """ 394 | Respond to the new password challenge using the SRP protocol 395 | :param password: The user's current passsword 396 | :param new_password: The user's new passsword 397 | """ 398 | aws = AWSSRP(username=self.username, password=password, pool_id=self.user_pool_id, 399 | client_id=self.client_id, client=self.client, 400 | client_secret=self.client_secret) 401 | tokens = aws.set_new_password_challenge(new_password) 402 | self.id_token = tokens['AuthenticationResult']['IdToken'] 403 | self.refresh_token = tokens['AuthenticationResult']['RefreshToken'] 404 | self.access_token = tokens['AuthenticationResult']['AccessToken'] 405 | self.token_type = tokens['AuthenticationResult']['TokenType'] 406 | 407 | def logout(self): 408 | """ 409 | Logs the user out of all clients and removes the expires_in, 410 | expires_datetime, id_token, refresh_token, access_token, and token_type 411 | attributes 412 | :return: 413 | """ 414 | self.client.global_sign_out( 415 | AccessToken=self.access_token 416 | ) 417 | 418 | self.id_token = None 419 | self.refresh_token = None 420 | self.access_token = None 421 | self.token_type = None 422 | 423 | def admin_update_profile(self, attrs, attr_map=None): 424 | user_attrs = dict_to_cognito(attrs, attr_map) 425 | self.client.admin_update_user_attributes( 426 | UserPoolId=self.user_pool_id, 427 | Username=self.username, 428 | UserAttributes=user_attrs 429 | ) 430 | 431 | def update_profile(self, attrs, attr_map=None): 432 | """ 433 | Updates User attributes 434 | :param attrs: Dictionary of attribute name, values 435 | :param attr_map: Dictionary map from Cognito attributes to attribute 436 | names we would like to show to our users 437 | """ 438 | user_attrs = dict_to_cognito(attrs, attr_map) 439 | self.client.update_user_attributes( 440 | UserAttributes=user_attrs, 441 | AccessToken=self.access_token 442 | ) 443 | 444 | def get_user(self, attr_map=None): 445 | """ 446 | Returns a UserObj (or whatever the self.user_class is) by using the 447 | user's access token. 448 | :param attr_map: Dictionary map from Cognito attributes to attribute 449 | names we would like to show to our users 450 | :return: 451 | """ 452 | user = self.client.get_user( 453 | AccessToken=self.access_token 454 | ) 455 | 456 | user_metadata = { 457 | 'username': user.get('Username'), 458 | 'id_token': self.id_token, 459 | 'access_token': self.access_token, 460 | 'refresh_token': self.refresh_token, 461 | } 462 | return self.get_user_obj(username=self.username, 463 | attribute_list=user.get('UserAttributes'), 464 | metadata=user_metadata, attr_map=attr_map) 465 | 466 | def get_users(self, attr_map=None): 467 | """ 468 | Returns all users for a user pool. Returns instances of the 469 | self.user_class. 470 | :param attr_map: 471 | :return: 472 | """ 473 | kwargs = {"UserPoolId": self.user_pool_id} 474 | 475 | response = self.client.list_users(**kwargs) 476 | return [self.get_user_obj(user.get('Username'), 477 | attribute_list=user.get('Attributes'), 478 | metadata={'username': user.get('Username')}, 479 | attr_map=attr_map) 480 | for user in response.get('Users')] 481 | 482 | def admin_get_user(self, attr_map=None): 483 | """ 484 | Get the user's details using admin super privileges. 485 | :param attr_map: Dictionary map from Cognito attributes to attribute 486 | names we would like to show to our users 487 | :return: UserObj object 488 | """ 489 | user = self.client.admin_get_user( 490 | UserPoolId=self.user_pool_id, 491 | Username=self.username) 492 | user_metadata = { 493 | 'enabled': user.get('Enabled'), 494 | 'user_status': user.get('UserStatus'), 495 | 'username': user.get('Username'), 496 | 'id_token': self.id_token, 497 | 'access_token': self.access_token, 498 | 'refresh_token': self.refresh_token 499 | } 500 | return self.get_user_obj(username=self.username, 501 | attribute_list=user.get('UserAttributes'), 502 | metadata=user_metadata, attr_map=attr_map) 503 | 504 | def admin_create_user(self, username, temporary_password='', attr_map=None, **kwargs): 505 | """ 506 | Create a user using admin super privileges. 507 | :param username: User Pool username 508 | :param temporary_password: The temporary password to give the user. 509 | Leave blank to make Cognito generate a temporary password for the user. 510 | :param attr_map: Attribute map to Cognito's attributes 511 | :param kwargs: Additional User Pool attributes 512 | :return response: Response from Cognito 513 | """ 514 | response = self.client.admin_create_user( 515 | UserPoolId=self.user_pool_id, 516 | Username=username, 517 | UserAttributes=dict_to_cognito(kwargs, attr_map), 518 | TemporaryPassword=temporary_password, 519 | ) 520 | kwargs.update(username=username) 521 | self._set_attributes(response, kwargs) 522 | 523 | response.pop('ResponseMetadata') 524 | return response 525 | 526 | def send_verification(self, attribute='email'): 527 | """ 528 | Sends the user an attribute verification code for the specified attribute name. 529 | :param attribute: Attribute to confirm 530 | """ 531 | self.check_token() 532 | self.client.get_user_attribute_verification_code( 533 | AccessToken=self.access_token, 534 | AttributeName=attribute 535 | ) 536 | 537 | def validate_verification(self, confirmation_code, attribute='email'): 538 | """ 539 | Verifies the specified user attributes in the user pool. 540 | :param confirmation_code: Code sent to user upon intiating verification 541 | :param attribute: Attribute to confirm 542 | """ 543 | self.check_token() 544 | return self.client.verify_user_attribute( 545 | AccessToken=self.access_token, 546 | AttributeName=attribute, 547 | Code=confirmation_code 548 | ) 549 | 550 | def renew_access_token(self): 551 | """ 552 | Sets a new access token on the User using the refresh token. 553 | """ 554 | auth_params = {'REFRESH_TOKEN': self.refresh_token} 555 | self._add_secret_hash(auth_params, 'SECRET_HASH') 556 | refresh_response = self.client.initiate_auth( 557 | ClientId=self.client_id, 558 | AuthFlow='REFRESH_TOKEN', 559 | AuthParameters=auth_params, 560 | ) 561 | 562 | self._set_attributes( 563 | refresh_response, 564 | { 565 | 'access_token': refresh_response['AuthenticationResult']['AccessToken'], 566 | 'id_token': refresh_response['AuthenticationResult']['IdToken'], 567 | 'token_type': refresh_response['AuthenticationResult']['TokenType'] 568 | } 569 | ) 570 | 571 | def initiate_forgot_password(self): 572 | """ 573 | Sends a verification code to the user to use to change their password. 574 | """ 575 | params = { 576 | 'ClientId': self.client_id, 577 | 'Username': self.username 578 | } 579 | self._add_secret_hash(params, 'SecretHash') 580 | self.client.forgot_password(**params) 581 | 582 | def delete_user(self): 583 | 584 | self.client.delete_user( 585 | AccessToken=self.access_token 586 | ) 587 | 588 | def admin_delete_user(self): 589 | self.client.admin_delete_user( 590 | UserPoolId=self.user_pool_id, 591 | Username=self.username 592 | ) 593 | 594 | def confirm_forgot_password(self, confirmation_code, password): 595 | """ 596 | Allows a user to enter a code provided when they reset their password 597 | to update their password. 598 | :param confirmation_code: The confirmation code sent by a user's request 599 | to retrieve a forgotten password 600 | :param password: New password 601 | """ 602 | params = {'ClientId': self.client_id, 603 | 'Username': self.username, 604 | 'ConfirmationCode': confirmation_code, 605 | 'Password': password 606 | } 607 | self._add_secret_hash(params, 'SecretHash') 608 | response = self.client.confirm_forgot_password(**params) 609 | self._set_attributes(response, {'password': password}) 610 | 611 | def change_password(self, previous_password, proposed_password): 612 | """ 613 | Change the User password 614 | """ 615 | self.check_token() 616 | response = self.client.change_password( 617 | PreviousPassword=previous_password, 618 | ProposedPassword=proposed_password, 619 | AccessToken=self.access_token 620 | ) 621 | self._set_attributes(response, {'password': proposed_password}) 622 | 623 | def _add_secret_hash(self, parameters, key): 624 | """ 625 | Helper function that computes SecretHash and adds it 626 | to a parameters dictionary at a specified key 627 | """ 628 | if self.client_secret is not None: 629 | secret_hash = AWSSRP.get_secret_hash(self.username, self.client_id, 630 | self.client_secret) 631 | parameters[key] = secret_hash 632 | 633 | def _set_attributes(self, response, attribute_dict): 634 | """ 635 | Set user attributes based on response code 636 | :param response: HTTP response from Cognito 637 | :attribute dict: Dictionary of attribute name and values 638 | """ 639 | status_code = response.get( 640 | 'HTTPStatusCode', 641 | response['ResponseMetadata']['HTTPStatusCode'] 642 | ) 643 | if status_code == 200: 644 | for k, v in attribute_dict.items(): 645 | setattr(self, k, v) 646 | 647 | def get_group(self, group_name): 648 | """ 649 | Get a group by a name 650 | :param group_name: name of a group 651 | :return: instance of the self.group_class 652 | """ 653 | response = self.client.get_group(GroupName=group_name, 654 | UserPoolId=self.user_pool_id) 655 | return self.get_group_obj(response.get('Group')) 656 | 657 | def get_groups(self): 658 | """ 659 | Returns all groups for a user pool. Returns instances of the 660 | self.group_class. 661 | :return: list of instances 662 | """ 663 | response = self.client.list_groups(UserPoolId=self.user_pool_id) 664 | return [self.get_group_obj(group_data) 665 | for group_data in response.get('Groups')] 666 | --------------------------------------------------------------------------------