├── tests ├── __init__.py ├── live │ ├── __init__.py │ ├── test_api_key.py │ ├── conftest.py │ ├── test_challenge.py │ ├── test_account_schema.py │ ├── test_error.py │ ├── test_saml.py │ ├── test_cache.py │ ├── test_id_site.py │ ├── test_phone.py │ ├── test_field.py │ ├── test_mirror_dir_agent.py │ ├── test_data_store.py │ ├── base.py │ ├── test_factor.py │ └── test_group.py └── mocks │ ├── __init__.py │ ├── test_phone.py │ ├── test_application.py │ ├── test_application_web_config.py │ ├── test_error.py │ ├── test_verification_email.py │ ├── test_account_linking_policy.py │ ├── test_account_creation_policy.py │ ├── test_challenge.py │ ├── test_factor.py │ ├── test_auth.py │ ├── test_saml.py │ ├── test_id_site.py │ └── test_provider.py ├── stormpath ├── cache │ ├── __init__.py │ ├── null_cache_store.py │ ├── stats.py │ ├── manager.py │ ├── memory_store.py │ ├── entry.py │ ├── cache.py │ ├── redis_store.py │ └── memcached_store.py ├── saml │ ├── __init__.py │ └── saml_idp_url_builder.py ├── nonce.py ├── __init__.py ├── resources │ ├── saml_signing_cert.py │ ├── sso_login_endpoint.py │ ├── sso_initiation_endpoint.py │ ├── saml_identity_provider_metadata.py │ ├── assertion_consumer_service_post_endpoint.py │ ├── saml_policy.py │ ├── registered_saml_service_providers.py │ ├── saml_service_provider_registrations.py │ ├── provider_data.py │ ├── saml_service_provider.py │ ├── saml_service_provider_metadata.py │ ├── account_schema.py │ ├── field.py │ ├── account_link.py │ ├── auth_token.py │ ├── account_linking_policy.py │ ├── oauth_policy.py │ ├── phone.py │ ├── default_relay_state.py │ ├── id_site.py │ ├── password_policy.py │ ├── account_store.py │ ├── password_reset_token.py │ ├── account_creation_policy.py │ ├── group_membership.py │ ├── tenant.py │ ├── saml_identity_provider.py │ ├── verification_email.py │ ├── attribute_statement_mapping_rule.py │ ├── challenge.py │ ├── web_config.py │ ├── account_store_mapping.py │ ├── provider.py │ ├── directory.py │ ├── organization_account_store_mapping.py │ ├── email_template.py │ ├── agent.py │ ├── __init__.py │ ├── api_key.py │ ├── organization.py │ ├── login_attempt.py │ ├── factor.py │ ├── password_strength.py │ └── custom_data.py ├── error.py ├── http.py └── data_store.py ├── keypair.enc ├── docs ├── _static │ ├── forgot.png │ ├── forgot-init.png │ ├── login-page.png │ ├── forgot-change.png │ ├── forgot-email.png │ ├── id-site-login.png │ ├── verification.png │ ├── forgot-complete.png │ ├── id-site-settings.png │ ├── login-page-basic.png │ ├── forgot-email-sent.png │ ├── google-enable-login.png │ ├── google-new-project.png │ ├── login-page-facebook.png │ ├── login-page-google.png │ ├── registration-page.png │ ├── verification-email.png │ ├── facebook-new-project.png │ ├── facebook-url-settings.png │ ├── google-oauth-settings.png │ ├── verification-complete.png │ ├── registration-page-basic.png │ ├── registration-page-error.png │ ├── login-page-google-account.png │ └── login-page-facebook-permissions.png ├── stormpath.client.rst ├── index.rst ├── _templates │ └── layout.html └── Makefile ├── .gitmodules ├── MANIFEST.in ├── .gitignore ├── tox.ini ├── .travis.yml ├── README.rst └── setup.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/live/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/mocks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stormpath/cache/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stormpath/saml/__init__.py: -------------------------------------------------------------------------------- 1 | from .saml_idp_url_builder import SamlIdpUrlBuilder 2 | -------------------------------------------------------------------------------- /keypair.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/keypair.enc -------------------------------------------------------------------------------- /docs/_static/forgot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/forgot.png -------------------------------------------------------------------------------- /docs/_static/forgot-init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/forgot-init.png -------------------------------------------------------------------------------- /docs/_static/login-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/login-page.png -------------------------------------------------------------------------------- /docs/_static/forgot-change.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/forgot-change.png -------------------------------------------------------------------------------- /docs/_static/forgot-email.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/forgot-email.png -------------------------------------------------------------------------------- /docs/_static/id-site-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/id-site-login.png -------------------------------------------------------------------------------- /docs/_static/verification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/verification.png -------------------------------------------------------------------------------- /docs/_static/forgot-complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/forgot-complete.png -------------------------------------------------------------------------------- /docs/_static/id-site-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/id-site-settings.png -------------------------------------------------------------------------------- /docs/_static/login-page-basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/login-page-basic.png -------------------------------------------------------------------------------- /docs/_static/forgot-email-sent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/forgot-email-sent.png -------------------------------------------------------------------------------- /docs/_static/google-enable-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/google-enable-login.png -------------------------------------------------------------------------------- /docs/_static/google-new-project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/google-new-project.png -------------------------------------------------------------------------------- /docs/_static/login-page-facebook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/login-page-facebook.png -------------------------------------------------------------------------------- /docs/_static/login-page-google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/login-page-google.png -------------------------------------------------------------------------------- /docs/_static/registration-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/registration-page.png -------------------------------------------------------------------------------- /docs/_static/verification-email.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/verification-email.png -------------------------------------------------------------------------------- /docs/_static/facebook-new-project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/facebook-new-project.png -------------------------------------------------------------------------------- /docs/_static/facebook-url-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/facebook-url-settings.png -------------------------------------------------------------------------------- /docs/_static/google-oauth-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/google-oauth-settings.png -------------------------------------------------------------------------------- /docs/_static/verification-complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/verification-complete.png -------------------------------------------------------------------------------- /docs/_static/registration-page-basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/registration-page-basic.png -------------------------------------------------------------------------------- /docs/_static/registration-page-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/registration-page-error.png -------------------------------------------------------------------------------- /docs/_static/login-page-google-account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/login-page-google-account.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs/_themes/stormpath"] 2 | path = docs/_themes/stormpath 3 | url = https://github.com/stormpath/stormpath-sphinx-theme.git 4 | -------------------------------------------------------------------------------- /docs/_static/login-page-facebook-permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stormpath/stormpath-sdk-python/HEAD/docs/_static/login-page-facebook-permissions.png -------------------------------------------------------------------------------- /docs/stormpath.client.rst: -------------------------------------------------------------------------------- 1 | .. _stormpath-client: 2 | 3 | .. module:: stormpath 4 | 5 | 6 | Client Object 7 | ------------- 8 | 9 | .. autoclass:: stormpath.client.Client 10 | :members: 11 | :inherited-members: 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include CHANGES.md 3 | graft docs 4 | prune docs/_build 5 | recursive-exclude tests *.py *.yml 6 | 7 | # Get rid of any Dropbox 'conflicted' file stuff -- just a precaution. 8 | global-exclude *conflict* 9 | -------------------------------------------------------------------------------- /stormpath/nonce.py: -------------------------------------------------------------------------------- 1 | class Nonce(object): 2 | def __init__(self, value): 3 | self.value = value 4 | 5 | # We fake the href to comply with what the data store expects. 6 | self.href = '//nonces/{}'.format(value) 7 | -------------------------------------------------------------------------------- /stormpath/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Stormpath, Inc.' 2 | __copyright__ = 'Copyright 2012-2015 Stormpath, Inc.' 3 | 4 | __version_info__ = ('2', '5', '5') 5 | __version__ = '.'.join(__version_info__) 6 | __short_version__ = '.'.join(__version_info__) 7 | -------------------------------------------------------------------------------- /stormpath/resources/saml_signing_cert.py: -------------------------------------------------------------------------------- 1 | """Stormpath x509 Signing Certificate""" 2 | 3 | 4 | from .base import ( 5 | DictMixin, 6 | Resource, 7 | ) 8 | 9 | 10 | class X509SigningCert(Resource, DictMixin): 11 | """x509SigningCert resource. 12 | """ 13 | pass 14 | -------------------------------------------------------------------------------- /stormpath/resources/sso_login_endpoint.py: -------------------------------------------------------------------------------- 1 | """Stormpath SSO login endpoint.""" 2 | 3 | 4 | from .base import ( 5 | DictMixin, 6 | Resource, 7 | ) 8 | 9 | 10 | class SsoLoginEndpoint(Resource, DictMixin): 11 | """SsoLoginEndpoint resource. 12 | """ 13 | pass 14 | -------------------------------------------------------------------------------- /stormpath/resources/sso_initiation_endpoint.py: -------------------------------------------------------------------------------- 1 | """Stormpath SSO initiation endpoint.""" 2 | 3 | 4 | from .base import ( 5 | DictMixin, 6 | Resource, 7 | ) 8 | 9 | 10 | class SsoInitiationEndpoint(Resource, DictMixin): 11 | """SsoInitiationEndpoint resource. 12 | """ 13 | pass 14 | -------------------------------------------------------------------------------- /stormpath/resources/saml_identity_provider_metadata.py: -------------------------------------------------------------------------------- 1 | """Stormpath SAML Identity Provider Metadata""" 2 | 3 | 4 | from .base import ( 5 | DictMixin, 6 | Resource, 7 | ) 8 | 9 | 10 | class SamlIdentityProviderMetadata(Resource, DictMixin): 11 | """SamlIdentityProviderMetadata resource. 12 | """ 13 | pass 14 | -------------------------------------------------------------------------------- /stormpath/resources/assertion_consumer_service_post_endpoint.py: -------------------------------------------------------------------------------- 1 | """Stormpath Assertion consumer service post endpoint.""" 2 | 3 | 4 | from .base import ( 5 | DictMixin, 6 | Resource, 7 | ) 8 | 9 | 10 | class AssertionConsumerServicePostEndpoint(Resource, DictMixin): 11 | """AssertionConsumerServicePostEndpoint resource. 12 | """ 13 | pass 14 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Stormpath API Reference 2 | ======================= 3 | 4 | If you are looking for information on a specific function, class or method, this 5 | part of the documentation is for you. 6 | 7 | .. toctree:: 8 | :maxdepth: 3 9 | 10 | stormpath.client 11 | stormpath.resources 12 | 13 | 14 | Indices and Tables 15 | ================== 16 | 17 | * :ref:`genindex` 18 | * :ref:`modindex` 19 | * :ref:`search` 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | .python-version 3 | 4 | # Packages 5 | *.egg 6 | *.egg-info 7 | dist 8 | build 9 | 10 | # Installer logs 11 | pip-log.txt 12 | 13 | # Unit test / coverage reports 14 | .cache 15 | .coverage 16 | .tox 17 | htmlcov/ 18 | pytest.ini 19 | 20 | # tools 21 | .idea 22 | *.iml 23 | *.ipr 24 | *.iws 25 | 26 | # Distutils 27 | MANIFEST 28 | 29 | # Other 30 | .DS_Store 31 | *.swp 32 | *.sh 33 | 34 | # Sphinx 35 | docs/_build/ 36 | -------------------------------------------------------------------------------- /stormpath/cache/null_cache_store.py: -------------------------------------------------------------------------------- 1 | """Null cache backend.""" 2 | 3 | 4 | class NullCacheStore(object): 5 | """Simple caching implementation that performs no caching.""" 6 | 7 | def __getitem__(self, key): 8 | return None 9 | 10 | def __setitem__(self, key, entry): 11 | pass 12 | 13 | def __delitem__(self, key): 14 | pass 15 | 16 | def clear(self): 17 | pass 18 | 19 | def __len__(self): 20 | return 0 21 | -------------------------------------------------------------------------------- /stormpath/resources/saml_policy.py: -------------------------------------------------------------------------------- 1 | """Stormpath SAML policy.""" 2 | 3 | 4 | from .base import DictMixin, Resource 5 | 6 | 7 | class SamlPolicy(Resource, DictMixin): 8 | """SamlPolicy resource.""" 9 | 10 | @staticmethod 11 | def get_resource_attributes(): 12 | from .saml_service_provider import SamlServiceProvider 13 | from .saml_identity_provider import SamlIdentityProvider 14 | 15 | return { 16 | 'service_provider': SamlServiceProvider, 17 | 'identity_provider': SamlIdentityProvider 18 | } 19 | -------------------------------------------------------------------------------- /tests/live/test_api_key.py: -------------------------------------------------------------------------------- 1 | """Live tests of ApiKeyList and ApiKey.""" 2 | 3 | from tests.live.base import ApiKeyBase 4 | 5 | 6 | class TestApiKey(ApiKeyBase): 7 | 8 | def test_create_api_key_with_name_and_description(self): 9 | _, account = self.create_account(self.app.accounts) 10 | 11 | name = 'key_name' 12 | description = 'some key description' 13 | 14 | api_key = account.api_keys.create({'name': name, 'description': description}) 15 | 16 | self.assertEqual(api_key.name, name) 17 | self.assertEqual(api_key.description, description) 18 | -------------------------------------------------------------------------------- /stormpath/resources/registered_saml_service_providers.py: -------------------------------------------------------------------------------- 1 | """Stormpath Registered SAML Service Providers endpoint 2 | on SAML Identity Provider.""" 3 | 4 | 5 | from .base import ( 6 | DictMixin, 7 | Resource, 8 | CollectionResource 9 | ) 10 | 11 | 12 | class RegisteredSamlServiceProvider(Resource, DictMixin): 13 | """RegisteredSamlServiceProvider resource. 14 | """ 15 | 16 | 17 | class RegisteredSamlServiceProviders(CollectionResource): 18 | """RegisteredSamlServiceProviders collection resource. 19 | """ 20 | resource_class = RegisteredSamlServiceProvider 21 | -------------------------------------------------------------------------------- /stormpath/resources/saml_service_provider_registrations.py: -------------------------------------------------------------------------------- 1 | """Stormpath SAML Service Provider Registrations endpoint 2 | on SAML Identity Provider.""" 3 | 4 | 5 | from .base import ( 6 | DictMixin, 7 | Resource, 8 | CollectionResource 9 | ) 10 | 11 | 12 | class SamlServiceProviderRegistration(Resource, DictMixin): 13 | """SamlServiceProviderRegistration resource. 14 | """ 15 | pass 16 | 17 | 18 | class SamlServiceProviderRegistrations(CollectionResource): 19 | """SamlServiceProviderRegistrations collection resource. 20 | """ 21 | resource_class = SamlServiceProviderRegistration 22 | -------------------------------------------------------------------------------- /stormpath/resources/provider_data.py: -------------------------------------------------------------------------------- 1 | """Stormpath Provider Data resource mappings.""" 2 | 3 | 4 | from .base import Resource, DictMixin 5 | 6 | 7 | class ProviderData(Resource, DictMixin): 8 | """Stormpath Provider Data resource. 9 | 10 | More info in documentation: 11 | http://docs.stormpath.com/python/product-guide/#integrating-with-google 12 | http://docs.stormpath.com/python/product-guide/#integrating-with-facebook 13 | """ 14 | 15 | writable_attrs = ( 16 | 'access_token', 17 | 'access_token_secret', 18 | 'code', 19 | 'provider_id', 20 | 'refresh_token', 21 | ) 22 | -------------------------------------------------------------------------------- /stormpath/resources/saml_service_provider.py: -------------------------------------------------------------------------------- 1 | """Stormpath SAML service provider.""" 2 | 3 | 4 | from .base import ( 5 | DictMixin, 6 | Resource, 7 | ) 8 | 9 | 10 | class SamlServiceProvider(Resource, DictMixin): 11 | """SamlServiceProvider resource. 12 | """ 13 | @staticmethod 14 | def get_resource_attributes(): 15 | from .sso_initiation_endpoint import SsoInitiationEndpoint 16 | from .default_relay_state import DefaultRelayStateList 17 | 18 | return { 19 | 'sso_initiation_endpoint': SsoInitiationEndpoint, 20 | 'default_relay_states': DefaultRelayStateList, 21 | } 22 | -------------------------------------------------------------------------------- /stormpath/resources/saml_service_provider_metadata.py: -------------------------------------------------------------------------------- 1 | """Stormpath SAML service provider metadata.""" 2 | 3 | 4 | from .base import ( 5 | DictMixin, 6 | Resource, 7 | ) 8 | 9 | 10 | class SamlServiceProviderMetadata(Resource, DictMixin): 11 | """SamlServiceProviderMetadata resource. 12 | """ 13 | @staticmethod 14 | def get_resource_attributes(): 15 | from .assertion_consumer_service_post_endpoint import ( 16 | AssertionConsumerServicePostEndpoint 17 | ) 18 | 19 | return { 20 | 'assertion_consumer_service_post_endpoint': 21 | AssertionConsumerServicePostEndpoint 22 | } 23 | -------------------------------------------------------------------------------- /stormpath/resources/account_schema.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Stormpath AccountSchema resource mappings.""" 4 | 5 | 6 | from .base import DictMixin, Resource, SaveMixin 7 | 8 | 9 | class AccountSchema(Resource, DictMixin, SaveMixin): 10 | """Stormpath AccountSchema resource. 11 | 12 | More info in documentation: 13 | https://docs.stormpath.com/rest/product-guide/latest/reference.html#account-schema 14 | """ 15 | 16 | @staticmethod 17 | def get_resource_attributes(): 18 | from .directory import Directory 19 | from .field import Field 20 | 21 | return { 22 | 'directory': Directory, 23 | 'field': Field, 24 | } 25 | -------------------------------------------------------------------------------- /stormpath/resources/field.py: -------------------------------------------------------------------------------- 1 | """Stormpath Field resource mappings.""" 2 | 3 | 4 | from .base import CollectionResource, DictMixin, Resource, SaveMixin 5 | 6 | 7 | class Field(Resource, DictMixin, SaveMixin): 8 | """Field resource. 9 | 10 | More info in documentation: 11 | https://docs.stormpath.com/rest/product-guide/latest/accnt_mgmt.html#account-schema 12 | """ 13 | writable_attrs = ('required',) 14 | 15 | @staticmethod 16 | def get_resource_attributes(): 17 | from .account_schema import AccountSchema 18 | 19 | return {'account_schema': AccountSchema} 20 | 21 | 22 | class FieldList(CollectionResource): 23 | """Field resource list.""" 24 | resource_class = Field 25 | -------------------------------------------------------------------------------- /stormpath/resources/account_link.py: -------------------------------------------------------------------------------- 1 | """Stormpath AccountLinks resource mappings.""" 2 | 3 | from .base import ( 4 | CollectionResource, 5 | DeleteMixin, 6 | DictMixin, 7 | Resource 8 | ) 9 | 10 | 11 | class AccountLink(Resource, DictMixin, DeleteMixin): 12 | writable_attrs = ( 13 | 'left_account', 14 | 'right_account' 15 | ) 16 | 17 | @staticmethod 18 | def get_resource_attributes(): 19 | from .account import Account 20 | 21 | return { 22 | 'left_account': Account, 23 | 'right_account': Account 24 | } 25 | 26 | 27 | class AccountLinkList(CollectionResource): 28 | """AccountLink resource list.""" 29 | resource_class = AccountLink 30 | -------------------------------------------------------------------------------- /tests/live/conftest.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | from stormpath.client import Client 3 | 4 | 5 | def pytest_keyboard_interrupt(excinfo): 6 | collection_resources = ['applications', 'organizations', 'directories'] 7 | test_prefix = 'stormpath-sdk-python-test' 8 | auth_scheme = 'basic' 9 | base_url = getenv('STORMPATH_BASE_URL') 10 | api_key_id = getenv('STORMPATH_API_KEY_ID') 11 | api_key_secret = getenv('STORMPATH_API_KEY_SECRET') 12 | 13 | client = Client(id=api_key_id, secret=api_key_secret, base_url=base_url, 14 | scheme=auth_scheme) 15 | for collection in collection_resources: 16 | for resource in list(getattr(client, collection).search(test_prefix)): 17 | resource.delete() 18 | -------------------------------------------------------------------------------- /tests/live/test_challenge.py: -------------------------------------------------------------------------------- 1 | """Live tests of Challenge and MFA functionality.""" 2 | 3 | 4 | from .base import MFABase 5 | 6 | 7 | class TestChallenge(MFABase): 8 | 9 | def setUp(self): 10 | super(TestChallenge, self).setUp() 11 | data = { 12 | 'phone': self.phone, 13 | 'type': 'SMS' 14 | } 15 | self.factor = self.account.factors.create(properties=data, challenge=False) 16 | 17 | def test_status_create(self): 18 | # Ensure that the status methods are properly working. 19 | # Ensure that a newly created challenge has a CREATED status. 20 | challenge = self.factor.challenge_factor() 21 | self.assertTrue(challenge.is_created()) 22 | self.assertEqual(challenge.status, 'CREATED') 23 | -------------------------------------------------------------------------------- /stormpath/resources/auth_token.py: -------------------------------------------------------------------------------- 1 | """Stormpath AccessToken resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | CollectionResource, 6 | DeleteMixin, 7 | DictMixin, 8 | Resource, 9 | ) 10 | 11 | 12 | class AuthToken(Resource, DictMixin, DeleteMixin): 13 | """Authentication token resource.""" 14 | 15 | @staticmethod 16 | def get_resource_attributes(): 17 | from .account import Account 18 | from .application import Application 19 | from .tenant import Tenant 20 | 21 | return { 22 | 'account': Account, 23 | 'application': Application, 24 | 'tenant': Tenant, 25 | } 26 | 27 | 28 | class AuthTokenList(CollectionResource): 29 | """AuthToken resource list.""" 30 | resource_class = AuthToken 31 | -------------------------------------------------------------------------------- /tests/mocks/test_phone.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from stormpath.resources.phone import Phone 4 | 5 | try: 6 | from mock import MagicMock 7 | except ImportError: 8 | from unittest.mock import MagicMock 9 | 10 | 11 | class TestPhone(TestCase): 12 | 13 | def setUp(self): 14 | self.client = MagicMock(BASE_URL='http://example.com') 15 | self.phone = Phone(self.client, properties={ 16 | 'number': '+123456789', 17 | 'verification_status': 'UNVERIFIED', 18 | }) 19 | 20 | def test_is_verified(self): 21 | # Ensure that verified status method is properly working. 22 | self.assertFalse(self.phone.is_verified()) 23 | self.phone.verification_status = 'VERIFIED' 24 | self.assertTrue(self.phone.is_verified()) 25 | -------------------------------------------------------------------------------- /stormpath/resources/account_linking_policy.py: -------------------------------------------------------------------------------- 1 | """Stormpath AccountLinkingPolicy resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | DictMixin, 6 | Resource, 7 | SaveMixin, 8 | StatusMixin 9 | ) 10 | 11 | 12 | class AccountLinkingPolicy(Resource, DictMixin, SaveMixin, StatusMixin): 13 | """Stormpath AccountLinkingPolicy resource. 14 | 15 | More info in documentation: 16 | https://docs.stormpath.com/rest/product-guide/latest/reference.html#account-linking-policy 17 | """ 18 | 19 | AUTOMATIC_PROVISIONING_ENABLED = 'ENABLED' 20 | AUTOMATIC_PROVISIONING_DISABLED = 'DISABLED' 21 | 22 | MATCHING_PROPERTY_EMAIL = 'email' 23 | MATCHING_PROPERTY_NULL = None 24 | 25 | writable_attrs = ( 26 | 'status', 27 | 'automatic_provisioning', 28 | 'matching_property' 29 | ) 30 | -------------------------------------------------------------------------------- /stormpath/resources/oauth_policy.py: -------------------------------------------------------------------------------- 1 | """Stormpath oAuthPolicy resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | DictMixin, 6 | Resource, 7 | SaveMixin, 8 | ) 9 | 10 | 11 | class OauthPolicy(Resource, DictMixin, SaveMixin): 12 | """Stormpath oAuthPolicy resource. 13 | 14 | More info in documentation: 15 | http://docs.stormpath.com/guides/token-management/ 16 | """ 17 | writable_attrs = ( 18 | 'access_token_ttl', 19 | 'refresh_token_ttl', 20 | ) 21 | 22 | timedelta_attrs = ( 23 | 'access_token_ttl', 24 | 'refresh_token_ttl', 25 | ) 26 | 27 | @staticmethod 28 | def get_resource_attributes(): 29 | from .application import Application 30 | from .tenant import Tenant 31 | 32 | return { 33 | 'application': Application, 34 | 'tenant': Tenant, 35 | } 36 | -------------------------------------------------------------------------------- /tests/live/test_account_schema.py: -------------------------------------------------------------------------------- 1 | """Live tests of basic AccountSchema functionality.""" 2 | 3 | from datetime import datetime 4 | 5 | from .base import AccountBase 6 | 7 | 8 | class TestAccountSchema(AccountBase): 9 | 10 | def test_account_schema_properties(self): 11 | schema = self.dir.account_schema 12 | 13 | self.assertTrue(schema.href) 14 | self.assertTrue(schema.created_at) 15 | self.assertTrue(schema.modified_at) 16 | self.assertTrue(schema.fields.href) 17 | self.assertIsInstance(schema.created_at, datetime) 18 | self.assertIsInstance(schema.modified_at, datetime) 19 | self.assertEqual(schema.directory.href, self.dir.href) 20 | 21 | def test_easy_import(self): 22 | try: 23 | from stormpath.resources import AccountSchema 24 | except Exception: 25 | self.fail('Could not import stormpath.resources.AccountSchema.') 26 | -------------------------------------------------------------------------------- /stormpath/resources/phone.py: -------------------------------------------------------------------------------- 1 | """Stormpath Factors resource mappings.""" 2 | 3 | from .base import CollectionResource, DeleteMixin, DictMixin, Resource, SaveMixin, StatusMixin 4 | 5 | 6 | class Phone(Resource, DeleteMixin, DictMixin, SaveMixin, StatusMixin): 7 | """ 8 | Stormpath Phone resource. 9 | 10 | More info in documentation: 11 | https://docs.stormpath.com/python/product-guide/latest/auth_n.html#using-multi-factor-authentication 12 | """ 13 | writable_attrs = ('number', 'status', 'verification_status') 14 | STATUS_VERIFIED = 'VERIFIED' 15 | STATUS_UNVERIFIED = 'UNVERIFIED' 16 | 17 | @staticmethod 18 | def get_resource_attributes(): 19 | from .account import Account 20 | return {'account': Account} 21 | 22 | def is_verified(self): 23 | return self.verification_status == self.STATUS_VERIFIED 24 | 25 | 26 | class PhoneList(CollectionResource): 27 | """Phone resource list.""" 28 | resource_class = Phone 29 | -------------------------------------------------------------------------------- /tests/mocks/test_application.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from stormpath.resources.password_reset_token import \ 3 | PasswordResetToken, PasswordResetTokenList 4 | 5 | try: 6 | from mock import MagicMock 7 | except ImportError: 8 | from unittest.mock import MagicMock 9 | 10 | 11 | class TestPasswordResetToken(TestCase): 12 | def setUp(self): 13 | super(TestPasswordResetToken, self).setUp() 14 | self.client = MagicMock(BASE_URL='http://example.com') 15 | 16 | def test_password_reset_tokens(self): 17 | tokens_href = 'http://example.com/tokens' 18 | token_string = 'I-AM-TOKEN' 19 | token_href = '%s/%s' % (tokens_href, token_string) 20 | 21 | token = PasswordResetToken( 22 | self.client, properties={'href': token_href}) 23 | self.assertEqual(token.token, token_string) 24 | 25 | tokens = PasswordResetTokenList(self.client, href=tokens_href) 26 | href = tokens.build_reset_href(token) 27 | self.assertEqual(href, token_href) 28 | -------------------------------------------------------------------------------- /stormpath/resources/default_relay_state.py: -------------------------------------------------------------------------------- 1 | """Stormpath Default relay state.""" 2 | 3 | 4 | from .base import ( 5 | CollectionResource, 6 | FixedAttrsDict, 7 | ) 8 | 9 | 10 | class DefaultRelayState(FixedAttrsDict): 11 | writable_attrs = ( 12 | 'callback_uri', 13 | 'organization', 14 | 'state', 15 | ) 16 | 17 | 18 | class DefaultRelayStateList(CollectionResource): 19 | """Stormpath DefaultRelayState list.""" 20 | resource_class = DefaultRelayState 21 | 22 | def create(self, properties=None, expand=None, **params): 23 | if properties is None: 24 | properties = {} 25 | 26 | return super(DefaultRelayStateList, self).create( 27 | properties, expand, **params) 28 | 29 | @staticmethod 30 | def _sanitize_property(value): 31 | from stormpath.resources import Organization 32 | 33 | if isinstance(value, Organization): 34 | return {'nameKey': value.name_key} 35 | 36 | return CollectionResource._sanitize_property(value) 37 | -------------------------------------------------------------------------------- /stormpath/cache/stats.py: -------------------------------------------------------------------------------- 1 | """Cache stats.""" 2 | 3 | 4 | from collections import namedtuple 5 | 6 | 7 | class CacheStats(object): 8 | """Represents cache statistics.""" 9 | Summary = namedtuple('CacheStats', 'puts hits misses expirations size') 10 | 11 | def __init__(self): 12 | self.puts = 0 13 | self.hits = 0 14 | self.misses = 0 15 | self.expirations = 0 16 | self.size = 0 17 | 18 | def put(self, new=True): 19 | self.puts += 1 20 | if new: 21 | self.size += 1 22 | 23 | def hit(self): 24 | self.hits += 1 25 | 26 | def miss(self, expired=False): 27 | self.misses += 1 28 | if expired: 29 | self.expirations += 1 30 | 31 | def delete(self): 32 | if self.size > 0: 33 | self.size -= 1 34 | 35 | def clear(self): 36 | self.size = 0 37 | 38 | @property 39 | def summary(self): 40 | return self.Summary(self.puts, self.hits, self.misses, 41 | self.expirations, self.size) 42 | -------------------------------------------------------------------------------- /stormpath/resources/id_site.py: -------------------------------------------------------------------------------- 1 | """Stormpath IDSite resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | CollectionResource, 6 | DictMixin, 7 | Resource, 8 | SaveMixin, 9 | ) 10 | 11 | 12 | class IDSite(Resource, DictMixin, SaveMixin): 13 | """Stormpath IDSite resource. 14 | 15 | More info in documentation: 16 | https://docs.stormpath.com/rest/product-guide/latest/reference.html#ref-id-site 17 | """ 18 | writable_attrs = ( 19 | 'domain_name', 20 | 'tls_public_cert', 21 | 'tls_private_key', 22 | 'git_repo_url', 23 | 'git_branch', 24 | 'authorized_origin_uris', 25 | 'authorized_redirect_uris', 26 | 'logo_url', 27 | 'session_tti', 28 | 'session_ttl', 29 | 'session_cookie_persistent', 30 | ) 31 | 32 | @staticmethod 33 | def get_resource_attributes(): 34 | from .tenant import Tenant 35 | return {'tenant': Tenant} 36 | 37 | 38 | class IDSiteList(CollectionResource): 39 | """IDSite resource list.""" 40 | resource_class = IDSite 41 | -------------------------------------------------------------------------------- /stormpath/cache/manager.py: -------------------------------------------------------------------------------- 1 | """Cache manager abstraction.""" 2 | 3 | 4 | from .cache import Cache 5 | 6 | 7 | class CacheManager(object): 8 | """Handles all the different caches used by the SDK 9 | 10 | It keeps track which resource belongs to which cache.\ 11 | E.g :class:`stormpath.resources.directory.Directory` resource data is stored 12 | in :class:`stormpath.cache.memory_store.MemoryStore` while \ 13 | :class:`stormpath.resources.application.Application` resource data is stored 14 | in :class:`stormpath.cache.redis_store.RedisStore`. 15 | The CacheManager, along with :class:`stormpath.http.HttpExecutor` is a part 16 | of the :class:`stormpath.data_store.DataStore`. 17 | """ 18 | 19 | def __init__(self): 20 | self.caches = {} 21 | 22 | def create_cache(self, region, **options): 23 | self.caches[region] = Cache(**options) 24 | 25 | def get_cache(self, region): 26 | return self.caches.get(region) 27 | 28 | @property 29 | def stats(self): 30 | return {region: cache.stats for region, cache in self.caches.items()} 31 | -------------------------------------------------------------------------------- /tests/live/test_error.py: -------------------------------------------------------------------------------- 1 | """Live tests of common error functionality. 2 | 3 | We can use (almost) any resource here - Account is a convenient choice. 4 | """ 5 | 6 | from .base import AuthenticatedLiveBase 7 | from stormpath.resources import Account 8 | from stormpath.error import Error 9 | 10 | 11 | class TestError(AuthenticatedLiveBase): 12 | 13 | def test_error_raised_with_error_json(self): 14 | error = None 15 | 16 | try: 17 | acc = Account( 18 | self.client, '%s/i.do.not.exist' % self.client.BASE_URL) 19 | acc.given_name 20 | except Error as e: 21 | error = e 22 | 23 | self.assertIsNotNone(error) 24 | self.assertIsInstance(error, Error) 25 | msg = str(error) 26 | self.assertEqual(error.status, 404) 27 | self.assertEqual(error.code, 404) 28 | self.assertEqual(error.developer_message, msg) 29 | self.assertEqual(error.user_message, msg) 30 | self.assertTrue(error.more_info) 31 | self.assertEqual(error.message, msg) 32 | self.assertTrue(error.request_id) 33 | -------------------------------------------------------------------------------- /stormpath/resources/password_policy.py: -------------------------------------------------------------------------------- 1 | """Stormpath PasswordPolicy resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | DictMixin, 6 | Resource, 7 | SaveMixin, 8 | ) 9 | 10 | 11 | class PasswordPolicy(Resource, DictMixin, SaveMixin): 12 | """Stormpath PasswordPolicy resource. 13 | 14 | More info in documentation: 15 | http://docs.stormpath.com/rest/product-guide/#directory-password-policy 16 | """ 17 | 18 | RESET_EMAIL_STATUS_ENABLED = 'ENABLED' 19 | RESET_EMAIL_STATUS_DISABLED = 'DISABLED' 20 | 21 | writable_attrs = ( 22 | 'reset_email_status', 23 | 'reset_success_email_status', 24 | 'reset_token_ttl', 25 | ) 26 | 27 | @staticmethod 28 | def get_resource_attributes(): 29 | from .email_template import EmailTemplateList, DefaultModelEmailTemplateList 30 | from .password_strength import PasswordStrength 31 | 32 | return { 33 | 'reset_email_templates': DefaultModelEmailTemplateList, 34 | 'reset_success_email_templates': EmailTemplateList, 35 | 'strength': PasswordStrength, 36 | } 37 | -------------------------------------------------------------------------------- /stormpath/resources/account_store.py: -------------------------------------------------------------------------------- 1 | """Stormpath AccountStore resource mappings.""" 2 | 3 | 4 | def AccountStore(client, properties=None): 5 | """AccountStore resource factory. 6 | 7 | Returns either a Group or a Directory or an Organization resource, 8 | based on the resource href. 9 | """ 10 | from .directory import Directory 11 | from .group import Group 12 | from .organization import Organization 13 | 14 | if not properties or 'href' not in properties: 15 | raise ValueError('AccountStore called without resource href') 16 | 17 | href = properties['href'] 18 | 19 | if href.startswith(client.BASE_URL): 20 | href = href[len(client.BASE_URL):] 21 | 22 | if href.startswith('/directories'): 23 | return Directory(client, properties=properties) 24 | elif href.startswith('/groups'): 25 | return Group(client, properties=properties) 26 | elif href.startswith('/organizations'): 27 | return Organization(client, properties=properties) 28 | else: 29 | raise ValueError('AccountStore called for non-account store href %s' % 30 | href) 31 | -------------------------------------------------------------------------------- /stormpath/resources/password_reset_token.py: -------------------------------------------------------------------------------- 1 | """Stormpath PasswordResetToken resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | CollectionResource, 6 | Resource, 7 | ) 8 | 9 | 10 | class PasswordResetToken(Resource): 11 | """Handles reset tokens used in password reset workflow. 12 | 13 | More info in documentation: 14 | http://docs.stormpath.com/rest/product-guide/#reset-an-accounts-password 15 | 16 | Attributes: 17 | 18 | :py:attr:`token` - Token with which to reset the password. 19 | """ 20 | writable_attrs = ('email', 'account_store') 21 | 22 | @staticmethod 23 | def get_resource_attributes(): 24 | from .account import Account 25 | from .account_store import AccountStore 26 | 27 | return { 28 | 'account': Account, 29 | 'account_store': AccountStore 30 | } 31 | 32 | @property 33 | def token(self): 34 | return self.href.split('/')[-1] 35 | 36 | 37 | class PasswordResetTokenList(CollectionResource): 38 | """List of reset tokens.""" 39 | resource_class = PasswordResetToken 40 | 41 | def build_reset_href(self, token): 42 | return self._get_create_path() + ('/%s' % token.token) 43 | -------------------------------------------------------------------------------- /stormpath/resources/account_creation_policy.py: -------------------------------------------------------------------------------- 1 | """Stormpath AccountCreationPolicy resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | DictMixin, 6 | Resource, 7 | SaveMixin 8 | ) 9 | 10 | 11 | class AccountCreationPolicy(Resource, DictMixin, SaveMixin): 12 | """Stormpath AccountCreationPolicy resource. 13 | 14 | More info in documentation: 15 | http://docs.stormpath.com/rest/product-guide/#directory-account-creation-policy 16 | """ 17 | 18 | EMAIL_STATUS_ENABLED = 'ENABLED' 19 | EMAIL_STATUS_DISABLED = 'DISABLED' 20 | 21 | writable_attrs = ( 22 | 'verification_email_status', 23 | 'verification_success_email_status', 24 | 'welcome_email_status', 25 | 'email_domain_whitelist', 26 | 'email_domain_blacklist' 27 | ) 28 | 29 | @staticmethod 30 | def get_resource_attributes(): 31 | from .email_template import ( 32 | EmailTemplateList, 33 | DefaultModelEmailTemplateList 34 | ) 35 | 36 | return { 37 | 'verification_email_templates': DefaultModelEmailTemplateList, 38 | 'verification_success_email_templates': EmailTemplateList, 39 | 'welcome_email_templates': EmailTemplateList 40 | } 41 | -------------------------------------------------------------------------------- /stormpath/resources/group_membership.py: -------------------------------------------------------------------------------- 1 | """Stormpath Directory resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | CollectionResource, 6 | DeleteMixin, 7 | Resource, 8 | ) 9 | 10 | 11 | class GroupMembership(Resource, DeleteMixin): 12 | """Stormpath GroupMembership resource. 13 | 14 | More info in documentation: 15 | http://docs.stormpath.com/python/product-guide/#create-a-group-membership 16 | """ 17 | writable_attrs = ( 18 | 'account', 19 | 'group', 20 | ) 21 | 22 | @staticmethod 23 | def get_resource_attributes(): 24 | from .account import Account 25 | from .group import Group 26 | 27 | return { 28 | 'account': Account, 29 | 'group': Group, 30 | } 31 | 32 | 33 | class GroupMembershipList(CollectionResource): 34 | """GroupMembership resource list.""" 35 | 36 | create_path = '/groupMemberships' 37 | resource_class = GroupMembership 38 | 39 | def _ensure_data(self): 40 | if self.href == '/groupMemberships': 41 | raise ValueError( 42 | "It is not possible to access group_memberships from the " 43 | "Client resource! Try using the Account resource instead.") 44 | 45 | super(GroupMembershipList, self)._ensure_data() 46 | -------------------------------------------------------------------------------- /tests/mocks/test_application_web_config.py: -------------------------------------------------------------------------------- 1 | """" 2 | Integration tests for various pieces involved in external provider support. 3 | """ 4 | 5 | from unittest import TestCase, main 6 | from stormpath.resources import WebConfig 7 | 8 | try: 9 | from mock import MagicMock 10 | except ImportError: 11 | from unittest.mock import MagicMock 12 | 13 | 14 | class TestApplicationWebConfig(TestCase): 15 | 16 | @staticmethod 17 | def test_modifying_application_web_config(): 18 | ds = MagicMock() 19 | ds.update_resource.return_value = {} 20 | 21 | web_config = WebConfig( 22 | client=MagicMock(data_store=ds, BASE_URL='http://example.com'), 23 | href='application-web-config') 24 | 25 | web_config._set_properties( 26 | { 27 | 'status': WebConfig.STATUS_ENABLED, 28 | 'dns_label': 'a-dns-label', 29 | 'register': {'enabled': True} 30 | }) 31 | web_config.save() 32 | 33 | ds.update_resource.assert_called_once_with( 34 | 'application-web-config', 35 | { 36 | 'status': WebConfig.STATUS_ENABLED, 37 | 'dnsLabel': 'a-dns-label', 38 | 'register': {'enabled': True} 39 | }) 40 | 41 | 42 | if __name__ == '__main__': 43 | main() 44 | -------------------------------------------------------------------------------- /stormpath/resources/tenant.py: -------------------------------------------------------------------------------- 1 | """Stormpath Tenant resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | DeleteMixin, 6 | DictMixin, 7 | Resource, 8 | SaveMixin, 9 | AutoSaveMixin, 10 | ) 11 | 12 | 13 | class Tenant(Resource, DeleteMixin, DictMixin, AutoSaveMixin, SaveMixin): 14 | """Stormpath Tenant resource. 15 | 16 | More info in documentation: 17 | http://docs.stormpath.com/python/product-guide/#tenants 18 | """ 19 | autosaves = ('custom_data',) 20 | writable_attrs = ( 21 | 'custom_data', 22 | 'key', 23 | 'name', 24 | ) 25 | 26 | @staticmethod 27 | def get_resource_attributes(): 28 | from .account import AccountList 29 | from .agent import AgentList 30 | from .application import ApplicationList 31 | from .custom_data import CustomData 32 | from .directory import DirectoryList 33 | from .group import GroupList 34 | from .id_site import IDSiteList 35 | from .organization import OrganizationList 36 | 37 | return { 38 | 'accounts': AccountList, 39 | 'agents': AgentList, 40 | 'applications': ApplicationList, 41 | 'custom_data': CustomData, 42 | 'directories': DirectoryList, 43 | 'groups': GroupList, 44 | 'id_sites': IDSiteList, 45 | 'organizations': OrganizationList, 46 | } 47 | -------------------------------------------------------------------------------- /stormpath/resources/saml_identity_provider.py: -------------------------------------------------------------------------------- 1 | """Stormpath SAML service provider.""" 2 | 3 | 4 | from .base import ( 5 | DictMixin, 6 | Resource, 7 | StatusMixin, 8 | SaveMixin 9 | ) 10 | 11 | 12 | class SamlIdentityProvider(Resource, DictMixin, StatusMixin, SaveMixin): 13 | """ 14 | SamlIdentityProvider resource. 15 | 16 | """ 17 | 18 | writable_attrs = ( 19 | 'status' 20 | ) 21 | 22 | @staticmethod 23 | def get_resource_attributes(): 24 | from .sso_login_endpoint import SsoLoginEndpoint 25 | from .saml_signing_cert import X509SigningCert 26 | from .saml_identity_provider_metadata import SamlIdentityProviderMetadata 27 | from .attribute_statement_mapping_rule import AttributeStatementMappingRules 28 | from .registered_saml_service_providers import RegisteredSamlServiceProviders 29 | from .saml_service_provider_registrations import SamlServiceProviderRegistrations 30 | 31 | return { 32 | 'sso_login_endpoint': SsoLoginEndpoint, 33 | 'x509_signing_cert': X509SigningCert, 34 | 'saml_identity_provider_metadata': SamlIdentityProviderMetadata, 35 | 'attribute_statement_mapping_rules': AttributeStatementMappingRules, 36 | 'registered_saml_service_providers': RegisteredSamlServiceProviders, 37 | 'saml_service_provider_registrations': SamlServiceProviderRegistrations 38 | } 39 | -------------------------------------------------------------------------------- /tests/mocks/test_error.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | from stormpath.error import Error 3 | 4 | 5 | class ErrorTest(TestCase): 6 | 7 | def test_error_parsing(self): 8 | err_dict = { 9 | "status": 404, 10 | "code": 404, 11 | "message": "Oops! The application you specified cannot be found.", 12 | "developerMessage": "The specified Application cannot be found...", 13 | "moreInfo": "http://www.stormpath.com/docs/errors/404" 14 | } 15 | e = Error(err_dict) 16 | 17 | self.assertEqual(e.status, 404) 18 | self.assertEqual(e.code, 404) 19 | self.assertEqual(e.message, 20 | "The specified Application cannot be found...") 21 | self.assertEqual(e.developer_message, 22 | "The specified Application cannot be found...") 23 | self.assertEqual(e.user_message, 24 | "Oops! The application you specified cannot be found.") 25 | self.assertEqual(e.more_info, 26 | "http://www.stormpath.com/docs/errors/404") 27 | 28 | def test_graceful_invalid_error_parsing(self): 29 | e = Error({}) 30 | 31 | self.assertEqual(e.status, -1) 32 | self.assertEqual(e.code, -1) 33 | 34 | def test_null_response_error_parsing(self): 35 | e = Error(None) 36 | 37 | self.assertEqual(e.status, -1) 38 | self.assertEqual(e.code, -1) 39 | 40 | if __name__ == '__main__': 41 | main() 42 | -------------------------------------------------------------------------------- /stormpath/resources/verification_email.py: -------------------------------------------------------------------------------- 1 | """Stormpath VerificationEmail resource mappings.""" 2 | 3 | from .account import Account 4 | from .base import ( 5 | CollectionResource, 6 | Resource, 7 | ) 8 | 9 | 10 | class VerificationEmail(Resource): 11 | pass 12 | 13 | 14 | class VerificationEmailList(CollectionResource): 15 | """List of email verfication requests.""" 16 | resource_class = VerificationEmail 17 | 18 | def _ensure_data(self): 19 | raise ValueError("It is not possible to access verification_emails!") 20 | 21 | def resend(self, account=None, account_store=None): 22 | """Resend the Email Verification Token. 23 | 24 | :param account: An :class:`stormpath.resources.account.Account` 25 | :param account_store: A :class:`stormpath.resources.directory.Directory` or :class:`stormpath.resources.group.Group`. 26 | """ 27 | if account is None or account_store is None: 28 | raise ValueError('You must specify the Account and Account Store') 29 | 30 | if isinstance(account_store, Resource): 31 | account_store = account_store.href 32 | 33 | if isinstance(account, Account): 34 | account = account.email 35 | 36 | data = self._store.create_resource(self._get_create_path(), { 37 | 'login': account, 38 | 'account_store': account_store 39 | }) 40 | 41 | return self.resource_class(client=self._client, properties=data) 42 | -------------------------------------------------------------------------------- /stormpath/resources/attribute_statement_mapping_rule.py: -------------------------------------------------------------------------------- 1 | """Stormpath Attribute statement mapping rule.""" 2 | 3 | 4 | from .base import ( 5 | SaveMixin, 6 | Resource, 7 | FixedAttrsDict, 8 | ListOnResource 9 | ) 10 | 11 | 12 | class AttributeStatementMappingRule(FixedAttrsDict): 13 | """Stormpath Attribute statement mapping rule. 14 | """ 15 | writable_attrs = ('name', 'name_format', 'account_attributes') 16 | 17 | def __init__(self, **kwargs): 18 | self._set_properties(kwargs) 19 | 20 | 21 | class AttributeStatementMappingRules(Resource, SaveMixin): 22 | """AttributeStatementMappingRules resource. 23 | """ 24 | writable_attrs = ('items', ) 25 | 26 | @staticmethod 27 | def get_resource_attributes(): 28 | return { 29 | 'items': ListOnResource, 30 | } 31 | 32 | def _wrap_resource_attr(self, cls, value): 33 | if isinstance(value, list) and cls == ListOnResource: 34 | return cls( 35 | self._client, properties=value, 36 | type=AttributeStatementMappingRule) 37 | 38 | return super(AttributeStatementMappingRules, self)._wrap_resource_attr( 39 | cls, value) 40 | 41 | @staticmethod 42 | def _sanitize_property(value): 43 | if isinstance(value, ListOnResource): 44 | return value._get_properties() 45 | 46 | return super(AttributeStatementMappingRules, self)._sanitize_property( 47 | value) 48 | -------------------------------------------------------------------------------- /tests/mocks/test_verification_email.py: -------------------------------------------------------------------------------- 1 | """" 2 | Integration tests for various pieces involved in external provider support. 3 | """ 4 | 5 | from unittest import TestCase, main 6 | 7 | try: 8 | from mock import MagicMock 9 | except ImportError: 10 | from unittest.mock import MagicMock 11 | 12 | from stormpath.resources.account import Account 13 | from stormpath.resources.application import Application 14 | from stormpath.resources.directory import Directory 15 | from stormpath.resources.verification_email import VerificationEmailList 16 | 17 | 18 | class TestVerificationEmail(TestCase): 19 | 20 | def test_resend(self): 21 | ds = MagicMock() 22 | ds.create_resource.return_value = {} 23 | client = MagicMock(data_store=ds, BASE_URL='http://example.com/') 24 | 25 | acc = Account( 26 | client=client, 27 | properties={'href': 'test/app','email': 'some@testmail.stormpath.com'}) 28 | vel = VerificationEmailList(client=client, href='test/emails') 29 | app = Application( 30 | client=client, 31 | properties={'href': 'test/app','verification_emails': vel}) 32 | dir = Directory(client=client, href='test/directory') 33 | 34 | app.verification_emails.resend(acc, dir) 35 | 36 | ds.create_resource.assert_called_once_with( 37 | 'http://example.com/test/emails', 38 | {'login': 'some@testmail.stormpath.com', 'account_store': 'test/directory'}) 39 | 40 | 41 | if __name__ == '__main__': 42 | main() 43 | -------------------------------------------------------------------------------- /stormpath/cache/memory_store.py: -------------------------------------------------------------------------------- 1 | """A memory store cache backend.""" 2 | 3 | from collections import OrderedDict 4 | 5 | 6 | class LimitedSizeDict(OrderedDict): 7 | 8 | def __init__(self, *args, **kwargs): 9 | self.size_limit = kwargs.pop("max_entries") 10 | if self.size_limit < 1: 11 | raise ValueError('Memory store: max entries needs to be a positive number.') 12 | OrderedDict.__init__(self, *args, **kwargs) 13 | self._check_size_limit() 14 | 15 | def __setitem__(self, key, value): 16 | OrderedDict.__setitem__(self, key, value) 17 | self._check_size_limit() 18 | 19 | def _check_size_limit(self): 20 | while len(self) > self.size_limit: 21 | self.popitem(last=False) 22 | 23 | 24 | class MemoryStore(object): 25 | """Simple caching implementation that uses memory as data storage.""" 26 | 27 | MAX_ENTRIES = 1000 # Maximum number of entries in cache 28 | 29 | def __init__(self, *args, **kwargs): 30 | max_entries = kwargs.pop('max_entries', self.MAX_ENTRIES) 31 | self.store = LimitedSizeDict(max_entries=max_entries) 32 | 33 | def __getitem__(self, key): 34 | return self.store.get(key) 35 | 36 | def __setitem__(self, key, entry): 37 | self.store[key] = entry 38 | 39 | def __delitem__(self, key): 40 | if key in self.store: 41 | del self.store[key] 42 | 43 | def clear(self): 44 | self.store.clear() 45 | 46 | def __len__(self): 47 | return len(self.store) 48 | -------------------------------------------------------------------------------- /stormpath/cache/entry.py: -------------------------------------------------------------------------------- 1 | """Cache entry abstractions.""" 2 | 3 | 4 | from datetime import datetime, timedelta 5 | 6 | 7 | class CacheEntry(object): 8 | """A single entry inside a cache. 9 | 10 | It contains the data as originally returned by Stormpath along with 11 | additional metadata like timestamps. 12 | """ 13 | 14 | def __init__(self, value, created_at=None, last_accessed_at=None): 15 | self.value = value 16 | self.created_at = created_at or datetime.utcnow() 17 | self.last_accessed_at = last_accessed_at or self.created_at 18 | 19 | def touch(self): 20 | self.last_accessed_at = datetime.utcnow() 21 | 22 | def is_expired(self, ttl, tti): 23 | now = datetime.utcnow() 24 | return (now >= self.created_at + timedelta(seconds=ttl) or now >= self.last_accessed_at + timedelta(seconds=tti)) 25 | 26 | @classmethod 27 | def parse(cls, data): 28 | def parse_date(val): 29 | try: 30 | return datetime.strptime(val, '%Y-%m-%d %H:%M:%S.%f') 31 | except Exception: 32 | return None 33 | 34 | return cls(data.get('value'), created_at=parse_date(data.get('created_at')), last_accessed_at=parse_date(data.get('last_accessed_at'))) 35 | 36 | def to_dict(self): 37 | format_date = lambda d: d.strftime('%Y-%m-%d %H:%M:%S.%f') 38 | 39 | return { 40 | 'created_at': format_date(self.created_at), 41 | 'last_accessed_at': format_date(self.last_accessed_at), 42 | 'value': self.value, 43 | } 44 | -------------------------------------------------------------------------------- /stormpath/resources/challenge.py: -------------------------------------------------------------------------------- 1 | """Stormpath Factors resource mappings.""" 2 | 3 | from .base import CollectionResource, DeleteMixin, DictMixin, Resource, SaveMixin 4 | 5 | 6 | class Challenge(Resource, DeleteMixin, DictMixin, SaveMixin): 7 | """ 8 | Stormpath Challenge resource. 9 | 10 | More info in documentation: 11 | https://docs.stormpath.com/python/product-guide/latest/auth_n.html#using-multi-factor-authentication 12 | """ 13 | writable_attrs = ('message', 'code') 14 | STATUS_SUCCESS = 'SUCCESS' 15 | STATUS_CREATED = 'CREATED' 16 | STATUS_WAITING = 'WAITING' 17 | 18 | @staticmethod 19 | def get_resource_attributes(): 20 | from .account import Account 21 | from .factor import Factor 22 | 23 | return { 24 | 'account': Account, 25 | 'factor': Factor, 26 | } 27 | 28 | def submit(self, code): 29 | """ 30 | This method will submit a challenge and attempt to activate the 31 | associated account (if the code is valid). 32 | 33 | :param str code: Account activation code. 34 | """ 35 | self.code = code 36 | self.save() 37 | self.refresh() 38 | 39 | return self 40 | 41 | def is_successful(self): 42 | return self.status == self.STATUS_SUCCESS 43 | 44 | def is_created(self): 45 | return self.status == self.STATUS_CREATED 46 | 47 | def is_waiting(self): 48 | return self.STATUS_WAITING in self.status 49 | 50 | 51 | class ChallengeList(CollectionResource): 52 | """Challenge resource list.""" 53 | resource_class = Challenge 54 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (https://tox.readthedocs.io/en/latest/) 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 | # If you want to skip live testing, run "tox -- --ignore tests/live". 7 | 8 | # If you get InterpeterNotFound error, make sure you include the path to your 9 | # Python interpreter as environment variable, e.g. 10 | # "$ export PYTHON_INTERPRETER_33=/opt/python3.3/bin/python3.3" 11 | 12 | 13 | [tox] 14 | envlist = py27, py33, py34, py35, pypy 15 | skipsdist = True 16 | 17 | [testenv] 18 | usedevelop = True 19 | commands = 20 | py.test --quiet {posargs} 21 | deps = 22 | requests 23 | pytest 24 | pytest-cov 25 | pytest-env 26 | oauthlib 27 | PyJWT 28 | python-dateutil 29 | pydispatcher 30 | isodate 31 | 32 | 33 | [testenv:py27] 34 | deps = 35 | mock 36 | requests 37 | pytest 38 | pytest-cov 39 | pytest-env 40 | oauthlib 41 | PyJWT 42 | python-dateutil 43 | pydispatcher 44 | isodate 45 | 46 | 47 | [testenv:py33] 48 | basepython = {env:PYTHON_INTERPRETER_33} 49 | 50 | 51 | [testenv:py34] 52 | basepython = {env:PYTHON_INTERPRETER_34} 53 | 54 | 55 | [testenv:py35] 56 | basepython = {env:PYTHON_INTERPRETER_35} 57 | 58 | 59 | [testenv:pypy] 60 | basepython = {env:PYTHON_INTERPRETER_PYPY} 61 | deps = 62 | mock 63 | requests 64 | pytest 65 | pytest-cov 66 | pytest-env 67 | oauthlib 68 | PyJWT 69 | python-dateutil 70 | pydispatcher 71 | isodate 72 | -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | 3 | {% block footer %} 4 | {{ super() }} 5 | 13 | 21 | 35 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /tests/mocks/test_account_linking_policy.py: -------------------------------------------------------------------------------- 1 | """" 2 | Integration tests for various pieces involved in external provider support. 3 | """ 4 | 5 | from unittest import TestCase, main 6 | from stormpath.resources import AccountLinkingPolicy 7 | 8 | try: 9 | from mock import MagicMock 10 | except ImportError: 11 | from unittest.mock import MagicMock 12 | 13 | 14 | class TestAccountLinkingPolicy(TestCase): 15 | 16 | @staticmethod 17 | def test_modifying_account_linking_policy(): 18 | ds = MagicMock() 19 | ds.update_resource.return_value = {} 20 | 21 | pp = AccountLinkingPolicy( 22 | client=MagicMock(data_store=ds, BASE_URL='http://example.com'), 23 | href='account-linking-policy') 24 | 25 | pp._set_properties( 26 | { 27 | 'status': 28 | AccountLinkingPolicy.STATUS_ENABLED, 29 | 'automatic_provisioning': 30 | AccountLinkingPolicy.AUTOMATIC_PROVISIONING_ENABLED, 31 | 'matching_property': 32 | AccountLinkingPolicy.MATCHING_PROPERTY_EMAIL 33 | }) 34 | pp.save() 35 | 36 | ds.update_resource.assert_called_once_with( 37 | 'account-linking-policy', 38 | { 39 | 'status': 40 | AccountLinkingPolicy.STATUS_ENABLED, 41 | 'automaticProvisioning': 42 | AccountLinkingPolicy.AUTOMATIC_PROVISIONING_ENABLED, 43 | 'matchingProperty': 44 | AccountLinkingPolicy.MATCHING_PROPERTY_EMAIL 45 | }) 46 | 47 | 48 | if __name__ == '__main__': 49 | main() 50 | -------------------------------------------------------------------------------- /stormpath/resources/web_config.py: -------------------------------------------------------------------------------- 1 | """Stormpath WebConfig resource mappings.""" 2 | 3 | from .base import ( 4 | StatusMixin, 5 | DictMixin, 6 | Resource, 7 | FixedAttrsDict, 8 | SaveMixin 9 | ) 10 | 11 | 12 | class EnableAttrDict(FixedAttrsDict): 13 | 14 | writable_attrs = ( 15 | 'enabled' 16 | ) 17 | 18 | 19 | class MeExpansionDict(FixedAttrsDict): 20 | 21 | writable_attrs = ( 22 | 'api_keys', 23 | 'applications', 24 | 'custom_data', 25 | 'directory', 26 | 'group_memberships', 27 | 'groups', 28 | 'provider_data', 29 | 'tenant' 30 | ) 31 | 32 | 33 | class MeDict(FixedAttrsDict): 34 | 35 | writable_attrs = ( 36 | 'enabled', 37 | 'expand' 38 | ) 39 | 40 | @staticmethod 41 | def get_dict_attributes(): 42 | 43 | return { 44 | 'expand': MeExpansionDict 45 | } 46 | 47 | 48 | class WebConfig(Resource, StatusMixin, SaveMixin, DictMixin): 49 | 50 | writable_attrs = ( 51 | 'status', 52 | 'dns_label', 53 | 'oauth2', 54 | 'register', 55 | 'verify_email', 56 | 'login', 57 | 'forgot_password', 58 | 'change_password', 59 | 'me' 60 | ) 61 | 62 | @staticmethod 63 | def get_resource_attributes(): 64 | 65 | return { 66 | 'oauth2': EnableAttrDict, 67 | 'register': EnableAttrDict, 68 | 'verify_email': EnableAttrDict, 69 | 'login': EnableAttrDict, 70 | 'forgot_password': EnableAttrDict, 71 | 'change_password': EnableAttrDict, 72 | 'me': MeDict 73 | } 74 | -------------------------------------------------------------------------------- /stormpath/resources/account_store_mapping.py: -------------------------------------------------------------------------------- 1 | """Stormpath AccountStoreMapping resource.""" 2 | 3 | 4 | from .base import ( 5 | CollectionResource, 6 | DeleteMixin, 7 | DictMixin, 8 | Resource, 9 | SaveMixin, 10 | ) 11 | 12 | 13 | class AccountStoreMapping(Resource, DeleteMixin, DictMixin, SaveMixin): 14 | """Mapping between an Application and an Account Store. 15 | 16 | Account Store is a generic term for a resource that stores Accounts. 17 | Currently, this includes Directories and Groups. 18 | 19 | More info in documentation: 20 | http://docs.stormpath.com/python/product-guide/#account-store-mappings 21 | """ 22 | writable_attrs = ( 23 | 'account_store', 24 | 'application', 25 | 'is_default_account_store', 26 | 'is_default_group_store', 27 | 'list_index', 28 | ) 29 | 30 | @staticmethod 31 | def get_resource_attributes(): 32 | from .account_store import AccountStore 33 | from .application import Application 34 | 35 | return { 36 | 'account_store': AccountStore, 37 | 'application': Application, 38 | } 39 | 40 | 41 | class AccountStoreMappingList(CollectionResource): 42 | """Account Store Mapping list.""" 43 | 44 | create_path = '/accountStoreMappings' 45 | resource_class = AccountStoreMapping 46 | 47 | def _ensure_data(self): 48 | if self.href == '/accountStoreMappings': 49 | raise ValueError( 50 | "It is not possible to access account_store_mappings from the " 51 | "Client resource! Try using the Application resource instead.") 52 | 53 | super(AccountStoreMappingList, self)._ensure_data() 54 | -------------------------------------------------------------------------------- /stormpath/resources/provider.py: -------------------------------------------------------------------------------- 1 | """Stormpath Provider resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | DeleteMixin, 6 | DictMixin, 7 | Resource, 8 | SaveMixin, 9 | ) 10 | 11 | 12 | class Provider(Resource, DeleteMixin, DictMixin, SaveMixin): 13 | """Stormpath Provider resource. 14 | 15 | More info in documentation: 16 | http://docs.stormpath.com/python/product-guide/#integrating-with-google 17 | """ 18 | 19 | GOOGLE = 'google' 20 | FACEBOOK = 'facebook' 21 | GITHUB = 'github' 22 | LINKEDIN = 'linkedin' 23 | TWITTER = 'twitter' 24 | STORMPATH = 'stormpath' 25 | SAML = 'saml' 26 | 27 | SIGNING_ALGORITHM_RSA_SHA_1 = 'RSA-SHA1' 28 | SIGNING_ALGORITHM_RSA_SHA_256 = 'RSA-SHA256' 29 | 30 | writable_attrs = ( 31 | 'agent', 32 | 'attribute_statement_mapping_rules', 33 | 'client_id', 34 | 'client_secret', 35 | 'encoded_x509_signing_cert', 36 | 'redirect_uri', 37 | 'request_signature_algorithm', 38 | 'provider_id', 39 | 'sso_login_url', 40 | 'sso_logout_url', 41 | ) 42 | 43 | @staticmethod 44 | def get_resource_attributes(): 45 | from .agent import Agent 46 | from .attribute_statement_mapping_rule import AttributeStatementMappingRules 47 | from .saml_service_provider_metadata import SamlServiceProviderMetadata 48 | 49 | return { 50 | 'agent': Agent, 51 | 'attribute_statement_mapping_rules': AttributeStatementMappingRules, 52 | 'service_provider_metadata': SamlServiceProviderMetadata 53 | } 54 | 55 | def save(self): 56 | if self.provider_id == self.STORMPATH: 57 | return 58 | 59 | super(Provider, self).save() 60 | -------------------------------------------------------------------------------- /stormpath/resources/directory.py: -------------------------------------------------------------------------------- 1 | """Stormpath Directory resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | CollectionResource, 6 | DeleteMixin, 7 | DictMixin, 8 | Resource, 9 | SaveMixin, 10 | StatusMixin, 11 | AutoSaveMixin, 12 | ) 13 | 14 | 15 | class Directory(Resource, DeleteMixin, DictMixin, AutoSaveMixin, SaveMixin, StatusMixin): 16 | """Stormpath Directory resource. 17 | 18 | More info in documentation: 19 | http://docs.stormpath.com/python/product-guide/#directories 20 | """ 21 | autosaves = ('provider', 'custom_data',) 22 | writable_attrs = ( 23 | 'custom_data', 24 | 'description', 25 | 'name', 26 | 'password_policy', 27 | 'provider', 28 | 'status', 29 | ) 30 | resolvable_attrs = ( 31 | 'name', 32 | ) 33 | 34 | @staticmethod 35 | def get_resource_attributes(): 36 | from .account import AccountList 37 | from .account_creation_policy import AccountCreationPolicy 38 | from .account_schema import AccountSchema 39 | from .group import GroupList 40 | from .provider import Provider 41 | from .tenant import Tenant 42 | from .custom_data import CustomData 43 | from .password_policy import PasswordPolicy 44 | 45 | return { 46 | 'account_creation_policy': AccountCreationPolicy, 47 | 'account_schema': AccountSchema, 48 | 'custom_data': CustomData, 49 | 'accounts': AccountList, 50 | 'groups': GroupList, 51 | 'password_policy': PasswordPolicy, 52 | 'provider': Provider, 53 | 'tenant': Tenant, 54 | } 55 | 56 | 57 | class DirectoryList(CollectionResource): 58 | """Directory resource list.""" 59 | create_path = '/directories' 60 | resource_class = Directory 61 | -------------------------------------------------------------------------------- /stormpath/saml/saml_idp_url_builder.py: -------------------------------------------------------------------------------- 1 | try: 2 | from urllib import urlencode 3 | except ImportError: 4 | from urllib.parse import urlencode 5 | from uuid import uuid4 6 | from datetime import datetime 7 | import jwt 8 | from oauthlib.common import to_unicode 9 | 10 | 11 | class SamlIdpUrlBuilder(object): 12 | 13 | def __init__(self, application): 14 | self.application = application 15 | 16 | def _get_service_provider(self): 17 | return self.application.saml_policy.service_provider 18 | 19 | def build(self, options=None): 20 | service_provider = self._get_service_provider() 21 | api_key_secret = self.application._client.auth.secret 22 | api_key_id = self.application._client.auth.id 23 | 24 | try: 25 | jti = uuid4().get_hex() 26 | except AttributeError: 27 | jti = uuid4().hex 28 | 29 | claims = { 30 | 'iat': datetime.utcnow(), 31 | 'jti': jti, 32 | 'iss': api_key_id 33 | } 34 | 35 | if options: 36 | if 'cb_uri' in options: 37 | claims['cb_uri'] = options['cb_uri'] 38 | 39 | if 'ash' in options: 40 | claims['ash'] = options['ash'] 41 | 42 | if 'onk' in options: 43 | claims['onk'] = options['onk'] 44 | 45 | if 'state' in options: 46 | claims['state'] = options['state'] 47 | 48 | jwt_signature = to_unicode( 49 | jwt.encode( 50 | claims, api_key_secret, 'HS256', headers={'kid': api_key_id}), 51 | 'UTF-8') 52 | url_params = {'accessToken': jwt_signature} 53 | sso_initiation_endpoint = service_provider.sso_initiation_endpoint.href 54 | 55 | encoded_params = urlencode(url_params) 56 | init_url = "%s?%s" % (sso_initiation_endpoint, encoded_params) 57 | 58 | return init_url 59 | -------------------------------------------------------------------------------- /stormpath/resources/organization_account_store_mapping.py: -------------------------------------------------------------------------------- 1 | """Stormpath OrganizationAccountStoreMapping resource.""" 2 | 3 | 4 | from .base import ( 5 | CollectionResource, 6 | DeleteMixin, 7 | DictMixin, 8 | Resource, 9 | SaveMixin, 10 | ) 11 | 12 | 13 | class OrganizationAccountStoreMapping(Resource, DeleteMixin, DictMixin, 14 | SaveMixin): 15 | """Mapping between an Organization and an Account Store. 16 | 17 | Account Store is a generic term for a resource that stores Accounts. 18 | Currently, this includes Directories and Groups. 19 | 20 | More info in documentation: 21 | http://docs.stormpath.com/python/product-guide/#adding-an-account-store-to-an-organization 22 | """ 23 | writable_attrs = ( 24 | 'account_store', 25 | 'organization', 26 | 'is_default_account_store', 27 | 'is_default_group_store', 28 | 'list_index', 29 | ) 30 | 31 | @staticmethod 32 | def get_resource_attributes(): 33 | from .account_store import AccountStore 34 | from .organization import Organization 35 | 36 | return { 37 | 'account_store': AccountStore, 38 | 'organization': Organization, 39 | } 40 | 41 | 42 | class OrganizationAccountStoreMappingList(CollectionResource): 43 | """Organization Account Store Mapping list.""" 44 | 45 | create_path = '/organizationAccountStoreMappings' 46 | resource_class = OrganizationAccountStoreMapping 47 | 48 | def _ensure_data(self): 49 | if self.href == '/organizationAccountStoreMappings': 50 | raise ValueError( 51 | "It is not possible to access " 52 | "organization_account_store_mappings from the Client " 53 | "resource! Try using the Organization resource instead.") 54 | 55 | super(OrganizationAccountStoreMappingList, self)._ensure_data() 56 | -------------------------------------------------------------------------------- /stormpath/cache/cache.py: -------------------------------------------------------------------------------- 1 | """Cache abstractions.""" 2 | 3 | 4 | from .entry import CacheEntry 5 | from .memory_store import MemoryStore 6 | from .stats import CacheStats 7 | 8 | 9 | class Cache(object): 10 | """A unified interface to different implementations of data caching. 11 | 12 | Example of an implementetion is 13 | :class:`stormpath.cache.memory_store.MemoryStore`. 14 | It also provides usage statistics with 15 | :class:`stormpath.cache.stats.CacheStats`. 16 | """ 17 | DEFAULT_STORE = MemoryStore 18 | DEFAULT_TTL = 5 * 60 # seconds 19 | DEFAULT_TTI = 5 * 60 # seconds 20 | 21 | def __init__(self, store=DEFAULT_STORE, ttl=DEFAULT_TTL, tti=DEFAULT_TTI, 22 | **kwargs): 23 | self.ttl = ttl 24 | self.tti = tti 25 | store_opts = kwargs.get('store_opts', {}) 26 | 27 | # Pass along max entries only to memory store instances. 28 | if store != MemoryStore: 29 | store_opts.pop('max_entries', None) 30 | 31 | self.store = store(**store_opts) 32 | self.stats = CacheStats() 33 | 34 | def get(self, key): 35 | entry = self.store[key] 36 | 37 | if entry: 38 | if entry.is_expired(self.ttl, self.tti): 39 | self.stats.miss(expired=True) 40 | del self.store[key] 41 | 42 | return None 43 | 44 | self.stats.hit() 45 | entry.touch() 46 | 47 | return entry.value 48 | 49 | self.stats.miss() 50 | return None 51 | 52 | def put(self, key, value, new=True): 53 | self.store[key] = CacheEntry(value) 54 | self.stats.put(new=new) 55 | 56 | def delete(self, key): 57 | del self.store[key] 58 | self.stats.delete() 59 | 60 | def clear(self): 61 | self.store.clear() 62 | self.stats.clear() 63 | 64 | @property 65 | def size(self): 66 | return len(self.store) 67 | -------------------------------------------------------------------------------- /tests/live/test_saml.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | from stormpath.saml import SamlIdpUrlBuilder 3 | from tests.live.base import AuthenticatedLiveBase 4 | 5 | 6 | class TestSamlIdpUrlBuilder(AuthenticatedLiveBase): 7 | 8 | def setUp(self): 9 | super(TestSamlIdpUrlBuilder, self).setUp() 10 | 11 | self.app_name = self.get_random_name() 12 | self.app = self.client.applications.create({ 13 | 'name': self.app_name, 14 | 'description': 'test app' 15 | }) 16 | 17 | def test_build_default_url(self): 18 | saml_idp_url_builder = SamlIdpUrlBuilder(self.app) 19 | url = saml_idp_url_builder.build() 20 | 21 | self.assertTrue('accessToken' in url) 22 | 23 | token = url.split('accessToken=')[1] 24 | result = jwt.decode(token, self.app._client.auth.secret) 25 | 26 | self.assertTrue('iss' in result.keys()) 27 | self.assertTrue('iat' in result.keys()) 28 | self.assertTrue('jti' in result.keys()) 29 | 30 | def test_build_uri_with_options(self): 31 | options = { 32 | 'cb_uri': 'http://some_cb_uri.com/', 33 | 'ash': 'ash', 34 | 'onk': 'onk', 35 | 'state': 'state' 36 | } 37 | 38 | saml_idp_url_builder = SamlIdpUrlBuilder(self.app) 39 | url = saml_idp_url_builder.build(options) 40 | 41 | self.assertTrue('accessToken' in url) 42 | 43 | token = url.split('accessToken=')[1] 44 | result = jwt.decode(token, self.app._client.auth.secret) 45 | 46 | self.assertTrue('cb_uri' in result.keys()) 47 | self.assertTrue('ash' in result.keys()) 48 | self.assertTrue('onk' in result.keys()) 49 | self.assertTrue('state' in result.keys()) 50 | 51 | self.assertEqual(result['cb_uri'], options['cb_uri']) 52 | self.assertEqual(result['ash'], options['ash']) 53 | self.assertEqual(result['onk'], options['onk']) 54 | self.assertEqual(result['state'], options['state']) 55 | -------------------------------------------------------------------------------- /stormpath/resources/email_template.py: -------------------------------------------------------------------------------- 1 | """Stormpath EmailTemplate resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | CollectionResource, 6 | DictMixin, 7 | Resource, 8 | SaveMixin, 9 | ) 10 | 11 | 12 | class EmailTemplate(Resource, DictMixin, SaveMixin): 13 | """Stormpath EmailTemplate resource. 14 | 15 | More info in documentation: 16 | http://docs.stormpath.com/rest/product-guide/#directory-password-policy 17 | """ 18 | 19 | MIME_TYPE_PLAIN_TEXT = 'text/plain' 20 | MIME_TYPE_HTML = 'text/html' 21 | 22 | writable_attrs = ( 23 | 'description', 24 | 'from_email_address', 25 | 'from_name', 26 | 'html_body', 27 | 'mime_type', 28 | 'name', 29 | 'subject', 30 | 'text_body', 31 | ) 32 | 33 | 34 | class EmailTemplateList(CollectionResource): 35 | """EmailTemplate resource list.""" 36 | resource_class = EmailTemplate 37 | 38 | 39 | class DefaultModelEmailTemplate(EmailTemplate): 40 | """Stormpath DefaultModelEmailTemplate resource. 41 | 42 | More info in documentation: 43 | http://docs.stormpath.com/rest/product-guide/#directory-password-policy 44 | (Password Reset Workflow for Directory's Accounts section) 45 | """ 46 | 47 | writable_attrs = EmailTemplate.writable_attrs + ('default_model', ) 48 | 49 | def get_link_base_url(self): 50 | """ 51 | Gets link_base_url from default_model. 52 | :return: Value of "linkBaseUrl" key in defaultModel dict. 53 | """ 54 | return self.default_model.get('linkBaseUrl') 55 | 56 | def set_link_base_url(self, value): 57 | """ 58 | Sets link_base_url from default_model dict.. 59 | :param value: Value to which "linkBaseUrl" key will be set. 60 | """ 61 | self.default_model['linkBaseUrl'] = value 62 | 63 | 64 | class DefaultModelEmailTemplateList(CollectionResource): 65 | """DefaultModelEmailTemplate resource list.""" 66 | resource_class = DefaultModelEmailTemplate 67 | -------------------------------------------------------------------------------- /stormpath/resources/agent.py: -------------------------------------------------------------------------------- 1 | """Stormpath Provider resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | DeleteMixin, 6 | DictMixin, 7 | FixedAttrsDict, 8 | Resource, 9 | SaveMixin, 10 | CollectionResource, ) 11 | 12 | 13 | class AgentAccountConfig(FixedAttrsDict): 14 | """Stormpath Agent account config. 15 | """ 16 | writable_attrs = ( 17 | 'dn_suffix', 'object_class', 'object_filter', 'email_rdn', 18 | 'given_name_rdn', 'middle_name_rdn', 'surname_rdn', 'username_rdn', 19 | 'password_rdn') 20 | 21 | 22 | class AgentGroupConfig(FixedAttrsDict): 23 | """Stormpath Agent group config. 24 | """ 25 | writable_attrs = ( 26 | 'dn_suffix', 'object_class', 'object_filter', 'name_rdn', 27 | 'description_rdn', 'members_rdn') 28 | 29 | 30 | class AgentConfig(FixedAttrsDict): 31 | """Stormpath Agent config. 32 | """ 33 | writable_attrs = ( 34 | 'directory_host', 'directory_port', 'ssl_required', 'agent_user_dn', 35 | 'agent_user_dn_password', 'base_dn', 'poll_interval', 'referral_mode', 36 | 'ignore_referral_issues', 'account_config', 'group_config') 37 | 38 | @staticmethod 39 | def get_dict_attributes(): 40 | return { 41 | 'account_config': AgentAccountConfig, 42 | 'group_config': AgentGroupConfig 43 | } 44 | 45 | 46 | class AgentDownload(Resource): 47 | """Stormpath Agent download. 48 | """ 49 | pass 50 | 51 | 52 | class Agent(Resource, DeleteMixin, DictMixin, SaveMixin): 53 | """Stormpath Agent resource. 54 | """ 55 | writable_attrs = ('config', ) 56 | 57 | @staticmethod 58 | def get_resource_attributes(): 59 | return { 60 | 'config': AgentConfig, 61 | 'download': AgentDownload 62 | } 63 | 64 | 65 | class AgentList(CollectionResource): 66 | """Agent resource list.""" 67 | resource_class = Agent 68 | 69 | def create(self, properties, expand=None, **params): 70 | raise ValueError( 71 | "Can't create new Agents, create mirror directory instead") 72 | -------------------------------------------------------------------------------- /tests/mocks/test_account_creation_policy.py: -------------------------------------------------------------------------------- 1 | """" 2 | Integration tests for various pieces involved in external provider support. 3 | """ 4 | 5 | from unittest import TestCase, main 6 | from stormpath.resources import AccountCreationPolicy 7 | 8 | try: 9 | from mock import MagicMock 10 | except ImportError: 11 | from unittest.mock import MagicMock 12 | 13 | 14 | class TestAccountCreationPolicy(TestCase): 15 | 16 | def setUp(self): 17 | self.ds = MagicMock() 18 | self.ds.update_resource.return_value = {} 19 | 20 | self.pp = AccountCreationPolicy( 21 | client=MagicMock(data_store=self.ds, 22 | BASE_URL='http://example.com'), 23 | href='account-creation-policy' 24 | ) 25 | 26 | def test_modifying_account_creation_policy(self): 27 | self.pp._set_properties( 28 | { 29 | 'verification_email_status': 30 | AccountCreationPolicy.EMAIL_STATUS_ENABLED 31 | }) 32 | self.pp.save() 33 | 34 | self.ds.update_resource.assert_called_once_with( 35 | 'account-creation-policy', 36 | { 37 | 'verificationEmailStatus': 38 | AccountCreationPolicy.EMAIL_STATUS_ENABLED 39 | }) 40 | 41 | def test_modifying_email_domain_whitelist_and_blacklist(self): 42 | self.pp._set_properties( 43 | { 44 | 'email_domain_whitelist': 45 | ['gmail.com', 'yahoo.com', 'stormpath.com'], 46 | 'email_domain_blacklist': 47 | ['mail.ru', 'somedomain.com'] 48 | } 49 | ) 50 | self.pp.save() 51 | 52 | self.ds.update_resource.assert_called_once_with( 53 | 'account-creation-policy', 54 | { 55 | 'emailDomainWhitelist': 56 | ['gmail.com', 'yahoo.com', 'stormpath.com'], 57 | 'emailDomainBlacklist': 58 | ['mail.ru', 'somedomain.com'] 59 | } 60 | ) 61 | 62 | if __name__ == '__main__': 63 | main() 64 | -------------------------------------------------------------------------------- /stormpath/resources/__init__.py: -------------------------------------------------------------------------------- 1 | """All Stormpath API resources.""" 2 | 3 | 4 | from .account import Account, AccountList 5 | from .account_creation_policy import AccountCreationPolicy 6 | from .account_linking_policy import AccountLinkingPolicy 7 | from .account_link import AccountLink, AccountLinkList 8 | from .account_schema import AccountSchema 9 | from .account_store import AccountStore 10 | from .assertion_consumer_service_post_endpoint import AssertionConsumerServicePostEndpoint 11 | from .attribute_statement_mapping_rule import AttributeStatementMappingRule, AttributeStatementMappingRules 12 | from .auth_token import AuthToken, AuthTokenList 13 | from .base import Expansion, Resource, CollectionResource, SaveMixin, DeleteMixin, AutoSaveMixin 14 | from .challenge import Challenge, ChallengeList 15 | from .custom_data import CustomData 16 | from .default_relay_state import DefaultRelayState, DefaultRelayStateList 17 | from .directory import Directory 18 | from .factor import Factor, FactorList 19 | from .field import Field 20 | from .group import Group, GroupList 21 | from .group_membership import GroupMembership, GroupMembershipList 22 | from .organization import Organization, OrganizationList 23 | from .organization_account_store_mapping import OrganizationAccountStoreMapping, OrganizationAccountStoreMappingList 24 | from .password_reset_token import PasswordResetTokenList 25 | from .phone import Phone, PhoneList 26 | from .provider import Provider 27 | from .saml_policy import SamlPolicy 28 | from .saml_service_provider import SamlServiceProvider 29 | from .saml_service_provider_metadata import SamlServiceProviderMetadata 30 | from .saml_identity_provider import SamlIdentityProvider 31 | from .saml_signing_cert import X509SigningCert 32 | from .sso_login_endpoint import SsoLoginEndpoint 33 | from .saml_identity_provider_metadata import SamlIdentityProviderMetadata 34 | from .registered_saml_service_providers import RegisteredSamlServiceProviders 35 | from .saml_service_provider_registrations import SamlServiceProviderRegistrations 36 | from .sso_initiation_endpoint import SsoInitiationEndpoint 37 | from .tenant import Tenant 38 | from .web_config import WebConfig 39 | -------------------------------------------------------------------------------- /stormpath/resources/api_key.py: -------------------------------------------------------------------------------- 1 | """Stormpath ApiKey resource mappings.""" 2 | 3 | from .base import ( 4 | CollectionResource, 5 | DeleteMixin, 6 | DictMixin, 7 | Resource, 8 | SaveMixin, 9 | StatusMixin, 10 | ) 11 | from stormpath.error import Error 12 | 13 | 14 | class ApiKey(Resource, DictMixin, DeleteMixin, SaveMixin, StatusMixin): 15 | writable_attrs = ( 16 | 'status', 17 | 'name', 18 | 'description' 19 | ) 20 | 21 | @staticmethod 22 | def get_resource_attributes(): 23 | from .account import Account 24 | from .tenant import Tenant 25 | 26 | return { 27 | 'account': Account, 28 | 'tenant': Tenant, 29 | } 30 | 31 | 32 | class ApiKeyList(CollectionResource): 33 | """Application resource list.""" 34 | resource_class = ApiKey 35 | 36 | def _ensure_data(self): 37 | if self.href == '/apiKeys': 38 | raise ValueError( 39 | "It is not possible to access api_keys from the " 40 | "Client resource! Try using the Application resource instead.") 41 | 42 | super(ApiKeyList, self)._ensure_data() 43 | 44 | def get_key(self, client_id, client_secret=None): 45 | search = {'id': client_id} 46 | try: 47 | key = None 48 | 49 | # First, try to get the key from the cache using its ID. 50 | if '/applications' in self.href: 51 | href = '%s/apiKeys/%s' % ( 52 | self.href.split('/applications')[0], client_id) 53 | try: 54 | key = self.resource_class(self._client, href) 55 | key.secret 56 | except Error: 57 | key = None 58 | 59 | # If there was no key with client_id in cache, make HTTP 60 | # request to the Stormpath service 61 | if not key: 62 | key = self.search(search)[0] 63 | 64 | if client_secret and not client_secret == key.secret: 65 | return False 66 | return key 67 | except IndexError: 68 | return False 69 | -------------------------------------------------------------------------------- /stormpath/error.py: -------------------------------------------------------------------------------- 1 | """Custom error classes.""" 2 | 3 | 4 | from six import string_types 5 | 6 | 7 | class Error(RuntimeError): 8 | """Error returned from the StormPath API service. 9 | 10 | The string content of the error is the low-level message, intended for 11 | the developer. The error also contains the following attributes: 12 | 13 | :py:attr:`status` - The corresponding HTTP status code. 14 | 15 | :py:attr:`code` - A Stormpath-specific error code that can be used to 16 | obtain more information. 17 | 18 | :py:attr:`developer_message` - A clear, plain text explanation with 19 | technical details that might assist a developer calling the 20 | Stormpath API. 21 | 22 | :py:attr:`message` - A simple, easy to understand message that you can 23 | show directly to your application end-user. 24 | 25 | :py:attr:`more_info` - A fully qualified URL that may be accessed to 26 | obtain more information about the error. 27 | 28 | :py:attr:`request_id` - The unique request ID of this error. This will be 29 | None if no ID was supplied. 30 | """ 31 | def __init__(self, error, http_status=None): 32 | if error is None: 33 | error = {} 34 | 35 | def try_int(val): 36 | try: 37 | return int(val) 38 | except: 39 | return -1 40 | 41 | if isinstance(error, string_types): 42 | error = { 43 | 'developerMessage': error, 44 | 'userMessage': 'Unknown error.', 45 | 'moreInfo': '', 46 | 'requestId': None, 47 | } 48 | 49 | msg = error.get('developerMessage', 'Unknown error' + (' ({})'.format(http_status) if http_status else '')) 50 | super(Error, self).__init__(msg) 51 | self.status = try_int(error.get('status', http_status)) 52 | self.code = try_int(error.get('code', -1)) 53 | self.developer_message = msg 54 | self.user_message = error.get('message') 55 | self.more_info = error.get('moreInfo') 56 | self.message = msg 57 | self.request_id = error.get('requestId') 58 | -------------------------------------------------------------------------------- /tests/live/test_cache.py: -------------------------------------------------------------------------------- 1 | """Live tests of common cache functionality. 2 | """ 3 | 4 | from .base import AuthenticatedLiveBase 5 | 6 | from stormpath.client import Client 7 | from stormpath.resources.application import Application 8 | from stormpath.cache.null_cache_store import NullCacheStore 9 | 10 | 11 | class TestCache(AuthenticatedLiveBase): 12 | def test_cache_opts_with_different_cache_stores(self): 13 | cache_opts = { 14 | 'regions': { 15 | 'customData': { 16 | 'store': NullCacheStore, 17 | } 18 | } 19 | } 20 | 21 | client = Client( 22 | id=self.api_key_id, secret=self.api_key_secret, 23 | scheme=self.AUTH_SCHEME, cache_options=cache_opts) 24 | 25 | app_name = self.get_random_name() 26 | app = client.applications.create( 27 | { 28 | 'name': app_name, 29 | 'description': 'test app', 30 | 'custom_data': {'a': 1} 31 | } 32 | ) 33 | href = app.href 34 | 35 | # this will cache application 36 | self.assertEqual(Application(client, href=href).name, app_name) 37 | 38 | # pretend that app name is changed elsewhere 39 | properties = app._get_properties() 40 | properties['name'] = 'changed %s' % app_name 41 | client.data_store.executor.post(app.href, properties) 42 | 43 | # we get stale, cached app name 44 | self.assertEqual(Application(client, href=href).name, app_name) 45 | 46 | # unless we refresh 47 | app.refresh() 48 | self.assertEqual( 49 | Application(client, href=href).name, properties['name']) 50 | 51 | # this will not cache custom data 52 | self.assertEqual(Application(client, href=href).custom_data['a'], 1) 53 | 54 | # pretend that app's custom data is changed elsewhere 55 | properties = app.custom_data._get_properties() 56 | properties['a'] = 2 57 | client.data_store.executor.post(app.custom_data.href, properties) 58 | 59 | # we get fresh custom data 60 | self.assertEqual(Application(client, href=href).custom_data['a'], 2) 61 | 62 | app.delete() 63 | -------------------------------------------------------------------------------- /stormpath/resources/organization.py: -------------------------------------------------------------------------------- 1 | """Stormpath Organization resource mappings.""" 2 | 3 | 4 | from .base import ( 5 | AutoSaveMixin, 6 | CollectionResource, 7 | DeleteMixin, 8 | DictMixin, 9 | Resource, 10 | StatusMixin, 11 | ) 12 | 13 | from .account_linking_policy import AccountLinkingPolicy 14 | 15 | 16 | class Organization(Resource, AutoSaveMixin, DeleteMixin, DictMixin, StatusMixin): 17 | """Organization resource. 18 | More info in documentation: 19 | http://docs.stormpath.com/python/product-guide/#organizations 20 | """ 21 | autosaves = ('custom_data',) 22 | writable_attrs = ( 23 | 'custom_data', 24 | 'description', 25 | 'name', 26 | 'name_key', 27 | 'status', 28 | ) 29 | 30 | @staticmethod 31 | def get_resource_attributes(): 32 | from .account import AccountList 33 | from .account_store_mapping import ( 34 | AccountStoreMapping, 35 | AccountStoreMappingList, 36 | ) 37 | from .custom_data import CustomData 38 | from .group import GroupList 39 | from .tenant import Tenant 40 | 41 | return { 42 | 'custom_data': CustomData, 43 | 'accounts': AccountList, 44 | 'account_store_mappings': AccountStoreMappingList, 45 | 'default_account_store_mapping': AccountStoreMapping, 46 | 'default_group_store_mapping': AccountStoreMapping, 47 | 'groups': GroupList, 48 | 'tenant': Tenant, 49 | 'account_linking_policy': AccountLinkingPolicy 50 | } 51 | 52 | @property 53 | def organization_account_store_mappings(self): 54 | return self._client.organization_account_store_mappings 55 | 56 | 57 | class OrganizationList(CollectionResource): 58 | """Organization resource list.""" 59 | create_path = '/organizations' 60 | resource_class = Organization 61 | 62 | def _ensure_data(self): 63 | if self.href == '/organizations': 64 | raise ValueError( 65 | "It is not possible to access organizations from " 66 | "Client resource! Try using Tenant resource instead.") 67 | 68 | super(OrganizationList, self)._ensure_data() 69 | -------------------------------------------------------------------------------- /tests/live/test_id_site.py: -------------------------------------------------------------------------------- 1 | """Live tests for IDSite functionality.""" 2 | 3 | 4 | from uuid import uuid4 5 | 6 | from .base import AuthenticatedLiveBase 7 | from stormpath.resources.id_site import IDSite, IDSiteList 8 | from stormpath.resources.tenant import Tenant 9 | 10 | 11 | class TestIDSite(AuthenticatedLiveBase): 12 | 13 | def test_id_site_collection_contains_single_idsite(self): 14 | self.assertEqual(len(self.client.tenant.id_sites), 1) 15 | 16 | def test_id_site_is_id_site_resource(self): 17 | self.assertIsInstance(self.client.tenant.id_sites, IDSiteList) 18 | self.assertIsInstance(self.client.tenant.id_sites[0], IDSite) 19 | 20 | def test_id_site_attributes_can_be_written(self): 21 | id_site = self.client.tenant.id_sites[0] 22 | domain_name = uuid4().hex + '.example.com' 23 | 24 | id_site.domain_name = domain_name 25 | id_site.tls_public_cert = 'hi' 26 | id_site.tls_private_key = 'hi' 27 | id_site.git_repo_url = 'https://github.com/stormpath/stormpath-sdk-python.git' 28 | id_site.git_branch = 'master' 29 | id_site.authorized_origin_uris = ['https://hi.com'] 30 | id_site.authorized_redirect_uris = ['https://hi.com'] 31 | id_site.logo_url = 'https://hi.com/woot.jpg' 32 | id_site.session_tti = 'P1D' 33 | id_site.session_ttl = 'P1D' 34 | id_site.session_cookie_persistent = True 35 | 36 | id_site.save() 37 | id_site = self.client.tenant.id_sites[0] 38 | id_site.refresh() 39 | 40 | self.assertEqual(id_site.domain_name, domain_name) 41 | self.assertEqual(id_site.tls_public_cert, 'hi') 42 | self.assertEqual(id_site.tls_private_key, 'hi') 43 | self.assertEqual(id_site.git_repo_url, 'https://github.com/stormpath/stormpath-sdk-python.git') 44 | self.assertEqual(id_site.git_branch, 'master') 45 | self.assertEqual(id_site.authorized_origin_uris, ['https://hi.com']) 46 | self.assertEqual(id_site.authorized_redirect_uris, ['https://hi.com']) 47 | self.assertEqual(id_site.logo_url, 'https://hi.com/woot.jpg') 48 | self.assertEqual(id_site.session_tti, 'P1D') 49 | self.assertEqual(id_site.session_ttl, 'P1D') 50 | self.assertEqual(id_site.session_cookie_persistent, True) 51 | 52 | def test_id_site_tenant(self): 53 | id_site = self.client.tenant.id_sites[0] 54 | self.assertIsInstance(id_site.tenant, Tenant) 55 | -------------------------------------------------------------------------------- /tests/live/test_phone.py: -------------------------------------------------------------------------------- 1 | """Live tests of Factors and MFA functionality.""" 2 | 3 | 4 | from stormpath.error import Error as StormpathError 5 | 6 | from .base import MFABase 7 | 8 | 9 | class TestPhone(MFABase): 10 | 11 | def test_verified_phone_number_immutable(self): 12 | # Ensure that a number from a verified Phone instance cannot be 13 | # changed. 14 | 15 | # Ensure that an unverified phone can change its number. 16 | self.phone.number = '+18883915282' 17 | self.assertEqual(self.phone.verification_status, 'UNVERIFIED') 18 | self.phone.save() 19 | 20 | # Ensure that a verified phone number is immutable. 21 | self.phone.verification_status = 'VERIFIED' 22 | self.phone.number = '+18883915283' 23 | 24 | with self.assertRaises(StormpathError) as error: 25 | self.phone.save() 26 | self.assertEqual(error.exception.message, 'Verified phone numbers cannot be modified.') 27 | 28 | def test_phone_number_unique_on_account(self): 29 | # Ensure that phone numbers in an Account instance are unique. 30 | 31 | with self.assertRaises(StormpathError) as error: 32 | self.account.phones.create({'number': '+18883915282'}) 33 | 34 | self.assertEqual(error.exception.message, 'An existing phone with that number already exists for this Account.') 35 | 36 | def test_phone_numbers_not_unique_between_accounts(self): 37 | # Ensure that same phone numbers can exist between multiple accounts 38 | # in the same directory. 39 | new_username, new_account = self.create_account(self.app.accounts) 40 | 41 | # The number used here is the same as in self.phone. 42 | phone = new_account.phones.create({'number': '+18883915282'}) 43 | self.assertEqual(self.phone.number, phone.number) 44 | self.assertEqual(self.phone.account.directory.href, phone.account.directory.href) 45 | 46 | def test_phone_factor_deleted(self): 47 | # Ensure that all factors that reference a phone instance are deleted 48 | # when that phone instance is deleted. 49 | factor = self.account.factors.create({ 50 | "phone": self.phone, 51 | "type": "SMS", 52 | }, challenge=False) 53 | self.phone.delete() 54 | 55 | with self.assertRaises(StormpathError) as error: 56 | factor.refresh() 57 | 58 | self.assertEqual(error.exception.message, 'The requested resource does not exist.') 59 | -------------------------------------------------------------------------------- /tests/live/test_field.py: -------------------------------------------------------------------------------- 1 | """Live tests of basic Field functionality.""" 2 | 3 | from datetime import datetime 4 | 5 | from stormpath.error import Error 6 | 7 | from .base import AccountBase 8 | 9 | 10 | def TestFields(AccountBase): 11 | 12 | def test_fields_properties(self): 13 | fields = self.dir.account_schema.fields 14 | 15 | self.assertEqual(len(fields), 2) 16 | 17 | for field in fields: 18 | self.assertTrue(field.href) 19 | self.assertTrue(field.created_at) 20 | self.assertTrue(field.modified_at) 21 | self.assertIsInstance(field.created_at, datetime) 22 | self.assertIsInstance(field.modified_at, datetime) 23 | self.assertTrue(field.name) 24 | self.assertFalse(field.required) 25 | self.assertTrue(field.schema.href) 26 | 27 | for field in fields: 28 | field.required = True 29 | field.save() 30 | 31 | for field in fields: 32 | self.assertTrue(field.required) 33 | 34 | def test_fields_required(self): 35 | fields = self.dir.account_schema.fields 36 | 37 | acc = self.dir.accounts.create({ 38 | 'email': 'test@testmail.stormpath.com', 39 | 'password': 'hIthereIL0V3C00kies!!', 40 | }) 41 | 42 | self.assertEqual(acc.given_name, None) 43 | self.assertEqual(acc.surname, None) 44 | 45 | for field in fields: 46 | field.required = True 47 | field.svae() 48 | 49 | with self.assertRaises(Error): 50 | acc = self.dir.accounts.create({ 51 | 'email': 'test@testmail.stormpath.com', 52 | 'password': 'hIthereIL0V3C00kies!!', 53 | }) 54 | 55 | acc = self.dir.accounts.create({ 56 | 'given_name': 'Randall', 57 | 'surname': 'Degges', 58 | 'email': 'test@testmail.stormpath.com', 59 | 'password': 'hIthereIL0V3C00kies!!', 60 | }) 61 | 62 | def test_fields_search(self): 63 | fields = self.dir.account_schema.fields 64 | 65 | field = fields.search('givenName')[0] 66 | self.assertEqual(field.name, 'givenName') 67 | 68 | field = fields.search('surname')[0] 69 | self.assertEqual(field.name, 'surname') 70 | 71 | def test_easy_import(self): 72 | try: 73 | from stormpath.resources import Field 74 | except Exception: 75 | self.fail('Could not import stormpath.resources.Field.') 76 | -------------------------------------------------------------------------------- /stormpath/cache/redis_store.py: -------------------------------------------------------------------------------- 1 | """A redis cache backend.""" 2 | 3 | 4 | from json import ( 5 | dumps, 6 | loads, 7 | ) 8 | 9 | from .entry import CacheEntry 10 | 11 | 12 | class RedisStore(object): 13 | """Caching implementation that uses Redis as data storage. 14 | 15 | :param host: String representing the hostname or IP of the Redis server 16 | 17 | :param port: Port number (int) on which the Redis server is listening 18 | 19 | :param db: DB querystring option (Default: 0, e.g. redis://localhost?db=0) 20 | 21 | :param password: Redis server password (Default: No Password) 22 | 23 | :param socket_timeout: Connection timeout to Redis server 24 | 25 | :param connection_pool: ConnectionPool for Redis instance (See redis-py docs) 26 | 27 | :param charset: Default character set 28 | 29 | :param errors: Default error settings 30 | 31 | :param decode_responses: Default: False (values in message dictionaries will be 32 | byte strings (str on Python 2, bytes on Python 3) 33 | 34 | :param unix_socket_path: Socker path. For using UnixDomainSocketConnection 35 | (see redis-py docs for more details) 36 | 37 | :param ttl: Default TTL 38 | """ 39 | 40 | DEFAULT_TTL = 5 * 60 # seconds 41 | 42 | def __init__(self, host='localhost', port=6379, db=0, password=None, 43 | socket_timeout=None, connection_pool=None, charset='utf-8', 44 | errors='strict', decode_responses=False, unix_socket_path=None, 45 | ttl=DEFAULT_TTL): 46 | self.ttl = ttl 47 | try: 48 | from redis import Redis 49 | except ImportError: 50 | raise RuntimeError('Redis support is not available. Run "pip install redis".') 51 | 52 | self.redis = Redis(host=host, port=port, db=db, 53 | password=password, socket_timeout=socket_timeout, 54 | connection_pool=connection_pool, charset=charset, 55 | errors=errors, decode_responses=decode_responses, 56 | unix_socket_path=unix_socket_path) 57 | 58 | def __getitem__(self, key): 59 | entry = self.redis.get(key) 60 | if entry is None: 61 | return None 62 | 63 | entry = loads(entry.decode('utf-8')) 64 | return CacheEntry.parse(entry) 65 | 66 | def __setitem__(self, key, entry): 67 | data = dumps(entry.to_dict()).encode('utf-8') 68 | self.redis.setex(key, data, self.ttl) 69 | 70 | def __delitem__(self, key): 71 | self.redis.delete(key) 72 | 73 | def clear(self): 74 | self.redis.flushdb() 75 | 76 | def __len__(self): 77 | return self.redis.dbsize() 78 | -------------------------------------------------------------------------------- /stormpath/resources/login_attempt.py: -------------------------------------------------------------------------------- 1 | """Stormpath LoginAttempt resource mappings.""" 2 | 3 | 4 | import datetime 5 | import jwt 6 | 7 | from base64 import b64encode 8 | from uuid import uuid4 9 | 10 | from oauthlib.common import to_unicode 11 | from stormpath.api_auth import AccessToken 12 | 13 | from .base import CollectionResource, Resource 14 | 15 | 16 | class AuthenticationResult(Resource): 17 | """Handles Base64-encoded login data. 18 | 19 | More info in documentation: 20 | http://docs.stormpath.com/rest/product-guide/#authenticate-an-account 21 | """ 22 | 23 | writable_attrs = ('type', 'value', 'account_store', 'application') 24 | 25 | @staticmethod 26 | def get_resource_attributes(): 27 | from .account import Account 28 | 29 | return { 30 | 'account': Account, 31 | } 32 | 33 | def __repr__(self): 34 | return '<%s attributes=%s>' % (self.__class__.__name__, str(self._get_property_names())) 35 | 36 | def get_jwt(self): 37 | if not hasattr(self, 'application'): 38 | raise ValueError('JWT cannot be generated without application') 39 | 40 | secret = self.application._client.auth.secret 41 | now = datetime.datetime.utcnow() 42 | 43 | try: 44 | jti = uuid4().get_hex() 45 | except AttributeError: 46 | jti = uuid4().hex 47 | 48 | data = { 49 | 'iss': self.application.href, 50 | 'sub': self.account.href, 51 | 'jti': jti, 52 | 'exp': now + datetime.timedelta(seconds=3600) 53 | } 54 | 55 | token = jwt.encode(data, secret, 'HS256') 56 | token = to_unicode(token, 'UTF-8') 57 | 58 | return token 59 | 60 | def get_access_token(self, jwt=None): 61 | if not hasattr(self, 'application'): 62 | raise ValueError('Access token cannot be generated without application') 63 | 64 | if jwt is None: 65 | jwt = self.get_jwt() 66 | 67 | return AccessToken(self.application, jwt) 68 | 69 | def get_access_token_response(self, jwt=None): 70 | return self.get_access_token(jwt).to_json() 71 | 72 | 73 | class LoginAttemptList(CollectionResource): 74 | """List of login data.""" 75 | 76 | resource_class = AuthenticationResult 77 | 78 | def basic_auth(self, login, password, expand, account_store=None, app=None, organization_name_key=None): 79 | value = login + ':' + password 80 | value = b64encode(value.encode('utf-8')).decode('ascii') 81 | properties = { 82 | 'type': 'basic', 83 | 'value': value, 84 | } 85 | 86 | if account_store: 87 | properties['account_store'] = account_store 88 | if organization_name_key: 89 | properties['account_store'] = {'name_key': organization_name_key} 90 | 91 | result = self.create(properties, expand=expand) 92 | 93 | if app: 94 | result.application = app 95 | 96 | return result 97 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '2.7' 4 | - '3.3' 5 | - '3.4' 6 | - '3.5' 7 | - '3.6' 8 | - pypy 9 | install: 10 | - pip install -e .[test] 11 | script: 12 | - travis_retry python setup.py test 13 | - test -z "$STORMPATH_API_KEY_SECRET" || travis_retry python setup.py livetest 14 | - test -z "$BUILD_DOCS" || python setup.py docs 15 | after_success: 16 | - coverage xml 17 | - coveralls 18 | - python-codacy-coverage -r coverage.xml 19 | - test -z "$BUILD_DOCS" || CURRENT_HASH=`git rev-parse HEAD` 20 | - test -z "$BUILD_DOCS" || RELEASE_VERSION=`git tag | xargs -I@ git log --format=format:"%ai @%n" -1 @ | sort | awk '{print $4}' | tail -n 1` 21 | - test -z "$BUILD_DOCS" || RELEASE_HASH=`git rev-list $RELEASE_VERSION -n 1` 22 | - test -z "$BUILD_DOCS" || if [ "$CURRENT_HASH" = "$RELEASE_HASH" ]; then DEPLOY_DOCS=true; fi 23 | - test -z "$DEPLOY_DOCS" || git config --global user.email "evangelists@stormpath.com" 24 | - test -z "$DEPLOY_DOCS" || git config --global user.name "stormpath-sdk-python Auto Doc Build" 25 | - test -z "$DEPLOY_DOCS" || git clone git@github.com:stormpath/stormpath.github.io.git 26 | - test -z "$DEPLOY_DOCS" || cd stormpath.github.io 27 | - test -z "$DEPLOY_DOCS" || git fetch origin source:source 28 | - test -z "$DEPLOY_DOCS" || git checkout source 29 | - test -z "$DEPLOY_DOCS" || rm -rf source/python/apidocs/latest 30 | - test -z "$DEPLOY_DOCS" || cp -r ../docs/_build/html source/python/apidocs/latest 31 | - test -z "$DEPLOY_DOCS" || cp -r ../docs/_build/html source/python/apidocs/$RELEASE_VERSION 32 | - test -z "$DEPLOY_DOCS" || git add --all 33 | - test -z "$DEPLOY_DOCS" || git commit -m "stormpath-sdk-python release $RELEASE_VERSION" 34 | - test -z "$DEPLOY_DOCS" || git push origin source 35 | env: 36 | global: 37 | - STORMPATH_BASE_URL=https://api.stormpath.com/v1 38 | - secure: A9wEUphFS7vwLzQ2aseYhobr0gR+YtmFWSj4Dths0Fp9ip/dhCtx5maABBMwJuNYO+GZolP9TjpUqppy0wq14SiTBOphpTmnZxmgNpHxVX9vXWGIO6Be/Re7Z2iamA3whiA/Ogx6PVkFbjCctlxl9Sy2dfvkkj3QAeJfkGLgXR4= 39 | - secure: hoXPNFIcNy4iqR/bTBgUZhCBT66pb+QXI39ArMXAUvx5lAYhdpnpnN8j4xmiY8d6KOgf9lM3eBBJe/F17lE+xBUrZ/ympgGu6ctQjt0RtmwtyQg+OH40+2K9Gz15dxNxv67WySx5rGfGuX+tEw3pxcsXi2anAqQAOdqq1w2Sc1A= 40 | - secure: P3kOlZN04EVT6WoQ/P+Ri1CRUz+4LuFEUAElvOBdbAoU7BPyLRSReUG8xsq4MfyjPyGFxD2Tv/OthlRcNgNcdGdaLnMJa7JRnVkeLmDG/HbFNkw/auwID4ut3k3tKyV9jlETN8T814BDKZnfwIeXiUJElsslXl8Q+IQKmz0JqAY= 41 | - secure: Rg3r6TRbBbiszXNkASfhAW5/as56VuT+oFR50bYohHn1+OqyRcinIjwV2lFZb/C/FYhb6qqPGRitDGx92sOhI8Sgq3JSVPDfgcIale0bC9YRQxZKOo+TSzNrIMQEChi5hKTmLGSMopJRuie+MJSFdYTpzLa4+HvojSuh+EsGGIU= 42 | - secure: cBBBFFzNmelXp0ZLGy5XL9N/7Aho55qXs5SxHDsGNAzxBtGx71vNw3/FYlODZg4wvXKGp2AMm8sS+/bIfXVya5m7JLwIZwYF781xg33CrIaYKI/C5eyZeoA9CmQzDNp9cxpAtz7sz5h/W1YRImV3mHMPD5QMZD2hHftDYqFFhQw= 43 | matrix: 44 | include: 45 | - env: BUILD_DOCS=true 46 | python: '2.7' 47 | before_install: 48 | - if [ $TRAVIS_PULL_REQUEST == 'false' ]; then 49 | test -z "$BUILD_DOCS" || openssl aes-256-cbc -K $encrypted_09e6ef1dc349_key -iv $encrypted_09e6ef1dc349_iv -in keypair.enc -out ~/.ssh/id_rsa -d; 50 | test -z "$BUILD_DOCS" || chmod 600 ~/.ssh/id_rsa; 51 | fi 52 | -------------------------------------------------------------------------------- /stormpath/resources/factor.py: -------------------------------------------------------------------------------- 1 | """Stormpath Factors resource mappings.""" 2 | 3 | from .base import CollectionResource, DeleteMixin, DictMixin, Resource, SaveMixin, StatusMixin 4 | from .phone import Phone 5 | 6 | 7 | class Factor(Resource, DeleteMixin, DictMixin, SaveMixin, StatusMixin): 8 | """ 9 | Stormpath Factor resource. 10 | 11 | More info in documentation: 12 | https://docs.stormpath.com/python/product-guide/latest/auth_n.html#using-multi-factor-authentication 13 | """ 14 | writable_attrs = ('type', 'phone', 'challenge', 'status', 'issuer', 'account_name') 15 | STATUS_VERIFIED = 'VERIFIED' 16 | STATUS_UNVERIFIED = 'UNVERIFIED' 17 | TYPE_SMS = 'SMS' 18 | TYPE_GOOGLE = 'google-authenticator' 19 | 20 | @staticmethod 21 | def get_resource_attributes(): 22 | from .account import Account 23 | from .challenge import Challenge, ChallengeList 24 | 25 | return { 26 | 'account': Account, 27 | 'challenges': ChallengeList, 28 | 'most_recent_challenge': Challenge, 29 | 'phone': Phone 30 | } 31 | 32 | def is_verified(self): 33 | return self.verification_status == self.STATUS_VERIFIED 34 | 35 | def is_sms(self): 36 | return self.type == 'SMS' 37 | 38 | def challenge_factor(self, message=None, code=None): 39 | """ 40 | This method will challenge a factor and by sending the activation 41 | code. 42 | 43 | :param str message: SMS message template. Message must contain '%s' 44 | format specifier that serves as the activation code placeholder. 45 | :param str code: Activation code that should be passed on challenge 46 | creation if factor type is 'google-authenticator'. 47 | """ 48 | if self.is_sms(): 49 | properties = {'message': message} 50 | else: 51 | if code is None: 52 | raise ValueError('When challenging a google-authenticator factor, activation code must be provided.') 53 | 54 | properties = {'code': code} 55 | 56 | challenge = self.challenges.create(properties=properties) 57 | self.refresh() 58 | 59 | return challenge 60 | 61 | 62 | class FactorList(CollectionResource): 63 | """Factor resource list.""" 64 | resource_class = Factor 65 | 66 | def create(self, properties, expand=None, **params): 67 | """ 68 | This method will check for the challenge argument, set the proper 69 | message (custom or default), and call the CollectionResource create 70 | method. 71 | 72 | :param bool challenge: Determines if a challenge is created on factor 73 | creation. 74 | """ 75 | if ( 76 | properties.get('type') == 'SMS' and 77 | params.get('challenge') is False and 78 | properties.get('challenge') 79 | ): 80 | # If the url query string challenge is false, make sure that 81 | # challenge is also absent from the body, otherwise a 82 | # challenge will be created. 83 | raise ValueError('If challenge is set to False, it must also be absent from properties.') 84 | 85 | return super(FactorList, self).create(properties, expand=expand, **params) 86 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | Stormpath is Joining Okta 3 | ========================== 4 | 5 | We are incredibly excited to announce that `Stormpath is joining forces with Okta `_. Please visit `the Migration FAQs `_ for a detailed look at what this means for Stormpath users. 6 | 7 | We're available to answer all questions at `support@stormpath.com `_. 8 | 9 | Stormpath Python SDK 10 | ==================== 11 | 12 | .. image:: https://img.shields.io/pypi/v/stormpath.svg 13 | :alt: stormpath Release 14 | :target: https://pypi.python.org/pypi/stormpath 15 | 16 | .. image:: https://img.shields.io/pypi/dm/stormpath.svg 17 | :alt: stormpath Downloads 18 | :target: https://pypi.python.org/pypi/stormpath 19 | 20 | .. image:: https://api.codacy.com/project/badge/grade/2d697e13e6e3436f84bc6e7611ef9939 21 | :alt: stormpath-sdk-python Code Quality 22 | :target: https://www.codacy.com/app/r/stormpath-sdk-python 23 | 24 | .. image:: https://img.shields.io/travis/stormpath/stormpath-sdk-python.svg 25 | :alt: stormpath-sdk-python Build 26 | :target: https://travis-ci.org/stormpath/stormpath-sdk-python 27 | 28 | .. image:: https://coveralls.io/repos/github/stormpath/stormpath-sdk-python/badge.svg?branch=master 29 | :alt: stormpath-sdk-python Coverage 30 | :target: https://coveralls.io/github/stormpath/stormpath-sdk-python?branch=master 31 | 32 | *A simple user management library for Python.* 33 | 34 | `Stormpath`_ is a hosted user management API service. This library gives your 35 | Python app access to all of Stormpath's features: 36 | 37 | - Robust authentication and authorization. 38 | - Schema-less user data. 39 | - Hosted login screens. 40 | - Social login with Facebook and Google OAuth. 41 | - Generate and manage API keys and OAuth2 tokens for your API service. 42 | 43 | If you have feedback about this library, please `email us`_ and share your 44 | thoughts! 45 | 46 | 47 | Documentation 48 | ------------- 49 | 50 | All of this library's documentation can be found here: 51 | http://docs.stormpath.com/python/product-guide/ (*It's ridiculously easy to get 52 | started with.*) 53 | 54 | 55 | Links 56 | ----- 57 | 58 | Below are some resources you might find useful: 59 | 60 | - `Quickstart`_ 61 | - `Stormpath Python Documentation`_ 62 | 63 | **Flask-Stormpath** 64 | 65 | - `Flask-Stormpath on Github`_ 66 | - `Flask-Stormpath Documentation`_ 67 | 68 | **django-stormpath** 69 | 70 | - `django-stormpath on Github`_ 71 | - `django-stormpath Documentation`_ 72 | 73 | 74 | .. _Stormpath: https://stormpath.com/ 75 | .. _email us: mailto:support@stormpath.com 76 | .. _Quickstart: https://docs.stormpath.com/python/quickstart/ 77 | .. _Stormpath Python Documentation: http://docs.stormpath.com/python/product-guide/ 78 | .. _Flask-Stormpath on Github: https://github.com/stormpath/stormpath-flask 79 | .. _Flask-Stormpath Documentation: http://flask-stormpath.readthedocs.org/en/latest/ 80 | .. _django-stormpath on Github: https://github.com/stormpath/stormpath-django 81 | .. _django-stormpath Documentation: https://github.com/stormpath/stormpath-django#django-stormpath 82 | -------------------------------------------------------------------------------- /tests/live/test_mirror_dir_agent.py: -------------------------------------------------------------------------------- 1 | """Live tests of Mirror Directories and Agent functionality.""" 2 | 3 | from .base import AuthenticatedLiveBase 4 | from stormpath.resources.agent import AgentConfig, AgentDownload 5 | 6 | 7 | class TestMirrorDirectoryAgent(AuthenticatedLiveBase): 8 | 9 | def setUp(self): 10 | super(TestMirrorDirectoryAgent, self).setUp() 11 | 12 | self.name = self.get_random_name() 13 | 14 | self.ad_directory = self.client.directories.create({ 15 | 'name': self.name, 16 | 'description': 'test dir', 17 | 'provider': { 18 | 'provider_id': 'ad', 19 | 'agent': { 20 | 'config': { 21 | 'directory_host': 'ldap.local', 22 | 'directory_port': '666', 23 | 'ssl_required': True, 24 | 'agent_user_dn': 'user@testmail.stormpath.com', 25 | 'agent_user_dn_password': 'Password', 26 | 'base_dn': 'dc=example,dc=com', 27 | 'poll_interval': 60, 28 | 'referral_mode': 'ignore', 29 | 'ignore_referral_issues': False, 30 | 'account_config': { 31 | 'dn_suffix': 'ou=employees', 32 | 'object_class': 'person', 33 | 'object_filter': '(cn=finance)', 34 | 'email_rdn': 'email', 35 | 'given_name_rdn': 'givenName', 36 | 'middle_name_rdn': 'middleName', 37 | 'surname_rdn': 'sn', 38 | 'username_rdn': 'uid', 39 | }, 40 | 'group_config': { 41 | 'dn_suffix': 'ou=groups', 42 | 'object_class': 'groupOfUniqueNames', 43 | 'object_filter': '(ou=*-group)', 44 | 'name_rdn': 'cn', 45 | 'description_rdn': 'description', 46 | 'members_rdn': 'uniqueMember' 47 | } 48 | } 49 | } 50 | } 51 | }) 52 | 53 | self.ad_agent = self.ad_directory.provider.agent 54 | 55 | def test_get_agent_from_agents(self): 56 | agent = self.client.agents.get(self.ad_agent.href) 57 | 58 | self.assertEqual(agent.status, 'OFFLINE') 59 | self.assertIsInstance(agent.config, AgentConfig) 60 | self.assertIsInstance(agent.download, AgentDownload) 61 | 62 | def test_update_agent(self): 63 | self.ad_agent.config.ssl_required = False 64 | self.ad_agent.config.account_config.email_rdn = 'electronic-mail' 65 | self.ad_agent.config.group_config.name_rdn = 'name' 66 | self.ad_agent.save() 67 | 68 | agent = self.client.agents.get(self.ad_agent.href) 69 | self.assertFalse(agent.config.ssl_required) 70 | self.assertEqual( 71 | agent.config.account_config.email_rdn, 'electronic-mail') 72 | self.assertEqual(agent.config.group_config.name_rdn, 'name') 73 | 74 | def test_get_agents_from_tenant(self): 75 | self.assertTrue( 76 | self.ad_agent.href in [a.href for a in self.client.tenant.agents]) 77 | 78 | def test_create_agent(self): 79 | with self.assertRaises(ValueError): 80 | self.client.agents.create('whatever') 81 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Python packaging stuff.""" 2 | 3 | 4 | from os import chdir, system 5 | from os.path import abspath, dirname, join, normpath 6 | from subprocess import call 7 | from sys import exit, version_info 8 | 9 | from setuptools import setup, find_packages, Command 10 | 11 | from stormpath import __version__ 12 | 13 | 14 | PY_VERSION = version_info[:2] 15 | 16 | 17 | class BaseCommand(Command): 18 | user_options = [] 19 | 20 | def pytest(self, *args): 21 | ret = call(['py.test', '--quiet', '--cov-report=term-missing', '--cov', 'stormpath'] + list(args)) 22 | exit(ret) 23 | 24 | def initialize_options(self): 25 | pass 26 | 27 | def finalize_options(self): 28 | pass 29 | 30 | 31 | class TestCommand(BaseCommand): 32 | 33 | description = 'run self-tests' 34 | 35 | def run(self): 36 | self.pytest('--ignore', 'tests/live', 'tests') 37 | 38 | 39 | class ReleaseCommand(BaseCommand): 40 | 41 | description = 'cut a new PyPI release' 42 | 43 | def run(self): 44 | call(['rm', '-rf', 'build', 'dist']) 45 | ret = call(['python', 'setup.py', 'sdist', 'bdist_wheel', '--universal', 'upload']) 46 | exit(ret) 47 | 48 | 49 | class LiveTestCommand(BaseCommand): 50 | 51 | description = 'run live-tests' 52 | 53 | def run(self): 54 | self.pytest('tests/live') 55 | 56 | 57 | class DocCommand(BaseCommand): 58 | 59 | description = 'generate documentation' 60 | 61 | def run(self): 62 | try: 63 | chdir('docs') 64 | ret = system('make html') 65 | exit(ret) 66 | except OSError as e: 67 | print(e) 68 | exit(-1) 69 | 70 | 71 | setup( 72 | name = 'stormpath', 73 | version = __version__, 74 | description = 'Official Stormpath SDK, used to interact with the Stormpath REST API.', 75 | author = 'Stormpath, Inc.', 76 | author_email = 'python@stormpath.com', 77 | url = 'https://github.com/stormpath/stormpath-sdk-python', 78 | zip_safe = False, 79 | keywords = ['stormpath', 'authentication', 'users', 'security'], 80 | install_requires = [ 81 | 'PyJWT>=1.0.0', 82 | 'oauthlib<=1.0.3', 83 | 'requests>=2.4.3', 84 | 'six>=1.6.1', 85 | 'python-dateutil>=2.4.0', 86 | 'pydispatcher>=2.0.5', 87 | 'isodate>=0.5.4', 88 | ], 89 | extras_require = { 90 | 'test': ['codacy-coverage', 'mock', 'python-coveralls', 'pytest', 'pytest-cov', 'sphinx'], 91 | }, 92 | packages = find_packages(exclude=['*.tests', '*.tests.*', 'tests.*', 'tests']), 93 | classifiers = [ 94 | 'Development Status :: 5 - Production/Stable', 95 | 'Intended Audience :: Developers', 96 | 'License :: OSI Approved :: Apache Software License', 97 | 'Operating System :: OS Independent', 98 | 'Programming Language :: Python', 99 | 'Programming Language :: Python :: 2', 100 | 'Programming Language :: Python :: 2.7', 101 | 'Programming Language :: Python :: 3', 102 | 'Programming Language :: Python :: 2.7', 103 | 'Programming Language :: Python :: 3.3', 104 | 'Programming Language :: Python :: 3.4', 105 | 'Programming Language :: Python :: 3.5', 106 | 'Programming Language :: Python :: 3.6', 107 | 'Programming Language :: Python :: Implementation :: CPython', 108 | 'Programming Language :: Python :: Implementation :: PyPy', 109 | 'Topic :: Internet :: WWW/HTTP :: Site Management', 110 | 'Topic :: Security', 111 | 'Topic :: Security :: Cryptography', 112 | 'Topic :: Software Development :: Libraries :: Python Modules', 113 | 'Topic :: Software Development :: Libraries', 114 | ], 115 | cmdclass = { 116 | 'test': TestCommand, 117 | 'livetest': LiveTestCommand, 118 | 'docs': DocCommand, 119 | 'release': ReleaseCommand, 120 | }, 121 | long_description = open(normpath(join(dirname(abspath(__file__)), 'README.rst'))).read(), 122 | ) 123 | -------------------------------------------------------------------------------- /stormpath/cache/memcached_store.py: -------------------------------------------------------------------------------- 1 | """A memcached cache backend.""" 2 | 3 | import socket 4 | from functools import wraps 5 | from json import dumps, loads 6 | 7 | from .entry import CacheEntry 8 | 9 | 10 | STR_VALUE = 1 11 | JSON_VALUE = 2 12 | 13 | 14 | def json_serializer(key, value): 15 | if isinstance(value, str): 16 | return value, STR_VALUE 17 | 18 | return dumps(value.to_dict()).encode('utf-8'), JSON_VALUE 19 | 20 | 21 | def json_deserializer(key, value, flags): 22 | if flags == STR_VALUE: 23 | return value 24 | if flags == JSON_VALUE: 25 | return loads(value.decode('utf-8')) 26 | raise Exception("Unknown serialization format") 27 | 28 | 29 | def memcache_error_handling(f): 30 | @wraps(f) 31 | def wrapper(self, *args, **kwargs): 32 | try: 33 | ret = f(self, *args, **kwargs) 34 | except Exception: 35 | return None 36 | return ret 37 | return wrapper 38 | 39 | 40 | class MemcachedStore(object): 41 | """Caching implementation that uses Memcached as data storage. 42 | 43 | :param host: String representation of hostname or IP of the memcached server 44 | 45 | :param port: Port number (int) on which the memcached server is listening 46 | 47 | :param connect_timeout: optional float, seconds to wait for a connection to 48 | the memcached server. Defaults to "forever" (uses the underlying 49 | default socket timeout, which can be very long) 50 | 51 | :param timeout: optional float, seconds to wait for send or recv calls on 52 | the socket connected to memcached. Defaults to "forever" (uses the 53 | underlying default socket timeout, which can be very long). 54 | 55 | :param no_delay: optional bool, set the TCP_NODELAY flag, which may help 56 | with performance in some cases. Defaults to False. 57 | 58 | :param ignore_exc: optional bool, True to cause the "get", "gets", 59 | "get_many" and "gets_many" calls to treat any errors as cache 60 | misses. Defaults to True. Ie. if the cache is failing use the 61 | Stormpath API. 62 | 63 | :param socket_module: socket module to use, e.g. gevent.socket. Defaults to 64 | the standard library's socket module. 65 | 66 | :param key_prefix: Prefix of key. You can use this as namespace. Defaults 67 | to b''. 68 | 69 | """ 70 | 71 | DEFAULT_TTL = 5 * 60 # seconds 72 | 73 | def __init__(self, host='localhost', port=11211, 74 | connect_timeout=None, timeout=None, 75 | no_delay=False, ignore_exc=True, 76 | key_prefix=b'', socket_module=socket, ttl=DEFAULT_TTL): 77 | self.ttl = ttl 78 | 79 | try: 80 | from pymemcache.client import Client as Memcache 81 | except ImportError: 82 | raise RuntimeError('Memcached support is not available. Run "pip install pymemcache".') 83 | 84 | self.memcache = Memcache( 85 | (host, port), 86 | serializer=json_serializer, 87 | deserializer=json_deserializer, 88 | connect_timeout=connect_timeout, 89 | timeout=timeout, 90 | socket_module=socket_module, 91 | no_delay=no_delay, 92 | ignore_exc=ignore_exc, 93 | key_prefix=key_prefix) 94 | 95 | @memcache_error_handling 96 | def __getitem__(self, key): 97 | entry = self.memcache.get(key) 98 | 99 | if entry is None: 100 | return None 101 | 102 | return CacheEntry.parse(entry) 103 | 104 | @memcache_error_handling 105 | def __setitem__(self, key, entry): 106 | self.memcache.set(key, entry, expire=self.ttl) 107 | 108 | @memcache_error_handling 109 | def __delitem__(self, key): 110 | self.memcache.delete(key) 111 | 112 | @memcache_error_handling 113 | def clear(self): 114 | self.memcache.flush_all() 115 | 116 | @memcache_error_handling 117 | def __len__(self): 118 | return self.memcache.stats()['curr_items'] 119 | 120 | -------------------------------------------------------------------------------- /tests/live/test_data_store.py: -------------------------------------------------------------------------------- 1 | """Live tests of DataStore functionality.""" 2 | 3 | 4 | from unittest import TestCase, main 5 | try: 6 | from mock import MagicMock 7 | except ImportError: 8 | from unittest.mock import MagicMock 9 | 10 | from stormpath.cache.null_cache_store import NullCacheStore 11 | from stormpath.data_store import DataStore 12 | from stormpath.http import HttpExecutor 13 | 14 | from .base import SingleApplicationBase 15 | 16 | 17 | class TestLiveDataStore(SingleApplicationBase): 18 | """Assert the DataStore works as expected.""" 19 | 20 | def setUp(self): 21 | super(TestLiveDataStore, self).setUp() 22 | self.executor = HttpExecutor( 23 | base_url = 'https://api.stormpath.com/v1', 24 | auth = (self.api_key_id, self.api_key_secret), 25 | ) 26 | 27 | def test_get_resource(self): 28 | data_store = DataStore(executor=self.executor) 29 | self.assertIsInstance(data_store.get_resource(self.app.href), dict) 30 | 31 | acc = self.app.accounts.create({ 32 | 'given_name': 'Randall', 33 | 'surname': 'Degges', 34 | 'email': '{}@testmail.stormpath.com'.format(self.get_random_name()), 35 | 'password': 'wootILOVEc00kies!!<33', 36 | }) 37 | key = acc.api_keys.create() 38 | 39 | data_store = DataStore(executor=self.executor) 40 | data = data_store.get_resource(self.app.href + '/apiKeys', {'id': key.id}) 41 | self.assertIsInstance(data, dict) 42 | self.assertEqual(data['items'][0]['id'], key.id) 43 | 44 | data_store = DataStore(executor=self.executor) 45 | data = data_store.get_resource(self.app.tenant.href + '/applications') 46 | self.assertIsInstance(data, dict) 47 | 48 | hrefs = [data['items'][i]['href'] for i in range(len(data['items']))] 49 | self.assertTrue(self.app.href in hrefs) 50 | 51 | 52 | class TestDataStoreWithMemoryCache(TestCase): 53 | 54 | def test_get_resource_is_cached(self): 55 | ex = MagicMock() 56 | ds = DataStore(ex) 57 | 58 | ex.get.return_value = { 59 | 'href': 'http://example.com/accounts/FOO', 60 | 'name': 'Foo', 61 | } 62 | 63 | # make the request twice 64 | ds.get_resource('http://example.com/accounts/FOO') 65 | ds.get_resource('http://example.com/accounts/FOO') 66 | 67 | ex.get.assert_called_once_with('http://example.com/accounts/FOO', 68 | params=None) 69 | 70 | def test_get_resource_api_keys_is_cached(self): 71 | 72 | ex = MagicMock() 73 | ds = DataStore(ex) 74 | 75 | ex.get.return_value = { 76 | 'href': 77 | 'https://www.example.com/applications/APPLICATION_ID/apiKeys', 78 | 'items': [ 79 | { 80 | 'href': 'http://example.com/apiKeys/KEY_ID', 81 | 'id': 'KEY_ID', 82 | 'secret': 'KEY_SECRET' 83 | } 84 | ] 85 | } 86 | 87 | ds.get_resource( 88 | 'https://www.example.com/applications/APPLICATION_ID/apiKeys', 89 | {'id': 'KEY_ID'}) 90 | 91 | ex.get.assert_called_once_with( 92 | 'https://www.example.com/applications/APPLICATION_ID/apiKeys', 93 | params={'id': 'KEY_ID'}) 94 | 95 | self.assertEqual( 96 | ds._cache_get('http://example.com/apiKeys/KEY_ID'), 97 | { 98 | 'secret': 'KEY_SECRET', 99 | 'href': 'http://example.com/apiKeys/KEY_ID', 100 | 'id': 'KEY_ID' 101 | }) 102 | 103 | 104 | class TestDataStoreWithNullCache(TestCase): 105 | def test_get_resource_is_not_cached(self): 106 | ex = MagicMock() 107 | ds = DataStore( 108 | ex, {'regions': {'accounts': {'store': NullCacheStore}}}) 109 | 110 | ex.get.return_value = { 111 | 'href': 'http://example.com/accounts/FOO', 112 | 'name': 'Foo', 113 | } 114 | 115 | # make the request twice 116 | ds.get_resource('http://example.com/accounts/FOO') 117 | ds.get_resource('http://example.com/accounts/FOO') 118 | 119 | self.assertEqual(ex.get.call_count, 2) 120 | 121 | 122 | if __name__ == '__main__': 123 | main() 124 | -------------------------------------------------------------------------------- /tests/mocks/test_challenge.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest import TestCase 3 | from stormpath.resources.account import Account 4 | from stormpath.resources.factor import Factor 5 | from stormpath.resources.challenge import Challenge 6 | from stormpath.data_store import DataStore 7 | from stormpath.http import HttpExecutor 8 | 9 | try: 10 | from mock import MagicMock, patch 11 | except ImportError: 12 | from unittest.mock import MagicMock, patch 13 | 14 | 15 | class TestChallenge(TestCase): 16 | 17 | @patch('stormpath.http.Session') 18 | def setUp(self, Session): 19 | 20 | # Set mock. 21 | self.request_mock = Session.return_value.request 22 | self.request_mock.return_value = MagicMock(status_code=200) 23 | 24 | ex = HttpExecutor('https://api.stormpath.com/v1', ('user', 'pass')) 25 | self.client = MagicMock(BASE_URL='http://example.com') 26 | self.data_store = DataStore(ex) 27 | self.client.data_store = self.data_store 28 | self.account = Account( 29 | self.client, 30 | properties={ 31 | 'href': 'http://example.com/account', 32 | 'username': 'username', 33 | 'given_name': 'given_name', 34 | 'surname': 'surname', 35 | 'email': 'test@example.com', 36 | 'password': 'Password123!'}) 37 | self.factor = Factor( 38 | self.client, 39 | properties={ 40 | 'href': '/factors/factor_id', 41 | 'name': 'factor' 42 | }) 43 | self.challenge = Challenge( 44 | self.client, 45 | properties={ 46 | 'href': '/challenges/challenge_id', 47 | 'factor': self.factor, 48 | 'account': self.account 49 | }) 50 | 51 | def test_submit(self): 52 | # Ensure that submitting a challenge will produce a proper request. 53 | 54 | # Set activation code and most recent challenge 55 | data = {'code': '000000'} 56 | self.factor._set_properties({'most_recent_challenge': self.challenge}) 57 | self.factor.most_recent_challenge.submit(data['code']) 58 | 59 | # Ensure that a POST request was made to submit the challenge, 60 | # and a GET request to refresh the instance. 61 | self.assertEqual(self.request_mock.call_count, 3) 62 | call1 = self.request_mock._mock_call_args_list[0] 63 | call2 = self.request_mock._mock_call_args_list[1] 64 | call3 = self.request_mock._mock_call_args_list[2] 65 | 66 | call_params = ( 67 | ('POST', 'https://api.stormpath.com/v1/challenges/challenge_id'), 68 | { 69 | 'headers': None, 70 | 'allow_redirects': False, 71 | 'params': None, 72 | 'data': json.dumps(data) 73 | } 74 | ) 75 | self.assertEqual(tuple(call1), call_params) 76 | 77 | call_params = ( 78 | ('GET', 'https://api.stormpath.com/v1/challenges/challenge_id'), 79 | { 80 | 'headers': None, 81 | 'allow_redirects': False, 82 | 'params': None, 83 | 'data': None 84 | } 85 | ) 86 | self.assertEqual(tuple(call2), call_params) 87 | 88 | call_params = ( 89 | ('GET', 'https://api.stormpath.com/v1/challenges/challenge_id'), 90 | { 91 | 'headers': None, 92 | 'allow_redirects': False, 93 | 'params': None, 94 | 'data': None 95 | } 96 | ) 97 | self.assertEqual(tuple(call3), call_params) 98 | 99 | def test_status_successful(self): 100 | # Ensure that successful status method is properly working. 101 | 102 | self.challenge._set_properties({'status': 'SUCCESS'}) 103 | self.assertTrue(self.challenge.is_successful()) 104 | 105 | def test_status_waiting(self): 106 | # Ensure that waiting status method is properly working. 107 | 108 | # Ensure that WAITING_FOR_PROVIDER (waiting for Twilio to send it out) 109 | # will return True on is_waiting(). 110 | self.challenge._set_properties({'status': 'WAITING_FOR_PROVIDER'}) 111 | self.assertTrue(self.challenge.is_waiting()) 112 | 113 | # Ensure that WAITING_FOR_VALIDATION (waiting for user to submit code) 114 | # will return True on is_waiting(). 115 | self.challenge._set_properties({'status': 'WAITING_FOR_VALIDATION'}) 116 | self.assertTrue(self.challenge.is_waiting()) 117 | -------------------------------------------------------------------------------- /tests/live/base.py: -------------------------------------------------------------------------------- 1 | """Base classes for the live tests against the Stormpath API service.""" 2 | 3 | from os import getenv 4 | from unittest import TestCase 5 | from uuid import uuid4 6 | 7 | from stormpath.client import Client 8 | 9 | 10 | class LiveBase(TestCase): 11 | """Picks up Stormpath API key/secret from environment. 12 | 13 | Environment variables used are the same as if using environment variables 14 | for API authentication in actual use: 15 | 16 | * STORMPATH_API_KEY_ID 17 | * STORMPATH_API_KEY_SECRET 18 | 19 | If these variables are not present in the environment, the tests will 20 | complain immediately instead of throwing cryptic "Invalid API key" 21 | error message from the service. 22 | """ 23 | 24 | @classmethod 25 | def setUpClass(cls): 26 | cls.api_key_id = getenv('STORMPATH_API_KEY_ID') 27 | cls.api_key_secret = getenv('STORMPATH_API_KEY_SECRET') 28 | if not cls.api_key_id or not cls.api_key_secret: 29 | raise ValueError('STORMPATH_API_KEY_ID or ' 30 | 'STORMPATH_API_KEY_SECRET not provided') 31 | 32 | 33 | class AuthenticatedLiveBase(LiveBase): 34 | AUTH_SCHEME = 'basic' 35 | TEST_PREFIX = 'stormpath-sdk-python-test' 36 | COLLECTION_RESOURCES = ['applications', 'organizations', 'directories'] 37 | 38 | def setUp(self): 39 | self.client = Client(id=self.api_key_id, secret=self.api_key_secret, base_url=getenv('STORMPATH_BASE_URL'), scheme=self.AUTH_SCHEME) 40 | self.prefix = '{}-{}'.format(self.TEST_PREFIX, uuid4().hex) 41 | 42 | def get_random_name(self): 43 | return '{}-{}'.format(self.prefix, uuid4().hex) 44 | 45 | def clear_cache(self): 46 | for cache in self.client.data_store.cache_manager.caches.values(): 47 | cache.clear() 48 | 49 | def tearDown(self): 50 | """ 51 | On tear-down, we'll pro-actively clean up after ourselves by deleting 52 | any resources our test has created on Stormpath with our given test 53 | prefix. 54 | 55 | This means that as long as each test uses the `self.get_random_name()` 56 | method when naming resources, these resources will be magically cleaned 57 | up =) 58 | """ 59 | for collection in self.COLLECTION_RESOURCES: 60 | for resource in list(getattr(self.client, collection).search(self.prefix)): 61 | resource.delete() 62 | 63 | 64 | class SingleApplicationBase(AuthenticatedLiveBase): 65 | 66 | def setUp(self): 67 | super(SingleApplicationBase, self).setUp() 68 | self.app_name = self.get_random_name() 69 | self.app = self.client.applications.create({ 70 | 'name': self.app_name, 71 | 'description': 'test app' 72 | }, create_directory=self.app_name) 73 | self.dir = self.app.default_account_store_mapping.account_store 74 | 75 | 76 | class AccountBase(SingleApplicationBase): 77 | 78 | def create_account(self, coll, username=None, email=None, password=None, 79 | custom_data=None, given_name=None, surname=None): 80 | if username is None: 81 | username = self.get_random_name() 82 | if email is None: 83 | email = username + '@testmail.stormpath.com' 84 | if given_name is None: 85 | given_name = 'Given ' + username 86 | if surname is None: 87 | surname = 'Sur ' + username 88 | if password is None: 89 | password = 'W00t123!' + username 90 | 91 | props = { 92 | 'username': username, 93 | 'email': email, 94 | 'given_name': given_name, 95 | 'surname': surname, 96 | 'password': password, 97 | } 98 | 99 | if custom_data: 100 | props['custom_data'] = custom_data 101 | 102 | account = coll.create(props) 103 | return username, account 104 | 105 | 106 | class ApiKeyBase(AccountBase): 107 | 108 | def create_api_key(self, acc): 109 | return acc.api_keys.create() 110 | 111 | 112 | class MFABase(AccountBase): 113 | 114 | def setUp(self): 115 | super(MFABase, self).setUp() 116 | self.username, self.account = self.create_account(self.app.accounts) 117 | 118 | # This is Twilio's official testing phone number: 119 | # https://www.twilio.com/docs/api/rest/test-credentials#test-sms-messages 120 | self.phone = self.account.phones.create({'number': '+18883915282'}) 121 | 122 | 123 | class SignalReceiver(object): 124 | received_signals = None 125 | 126 | def signal_created_receiver_function(self, signal, sender, data, params): 127 | if self.received_signals is None: 128 | self.received_signals = [] 129 | self.received_signals.append((sender, data, params)) 130 | 131 | def signal_updated_receiver_function(self, signal, sender, href, properties): 132 | if self.received_signals is None: 133 | self.received_signals = [] 134 | self.received_signals.append((sender, href, properties)) 135 | 136 | def signal_deleted_receiver_function(self, signal, sender, href): 137 | if self.received_signals is None: 138 | self.received_signals = [] 139 | self.received_signals.append((sender, href)) 140 | -------------------------------------------------------------------------------- /tests/mocks/test_factor.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from stormpath.resources.factor import Factor, FactorList 4 | from stormpath.resources.challenge import Challenge, ChallengeList 5 | from stormpath.data_store import DataStore 6 | 7 | try: 8 | from mock import MagicMock, patch 9 | except ImportError: 10 | from unittest.mock import MagicMock, patch 11 | 12 | 13 | class TestFactor(TestCase): 14 | 15 | def setUp(self): 16 | self.client = MagicMock(BASE_URL='http://example.com') 17 | self.client.data_store = DataStore(MagicMock()) 18 | self.factor = Factor(self.client, properties={ 19 | 'href': '/factors/factor_id', 20 | 'name': 'factor', 21 | 'type': 'SMS', 22 | 'challenges': ChallengeList(self.client, properties={'href': '/challenges'}), 23 | 'verification_status': 'UNVERIFIED', 24 | }) 25 | self.factors = FactorList(client=self.client, href='test/factors') 26 | self.challenge = Challenge(self.client, properties={ 27 | 'href': '/challenges/challenge_id', 28 | 'factor': self.factor 29 | }) 30 | 31 | def test_create_challenge_invalid(self): 32 | # Ensure that a ValueError is raised if challenge is set in properties 33 | # but challenge param is set to False. 34 | properties = { 35 | 'type': 'SMS', 36 | 'phone': {'number': '+666'}, 37 | 'challenge': {'message': '${code}'} 38 | } 39 | 40 | with self.assertRaises(ValueError) as error: 41 | self.factors.create(properties=properties, challenge=False) 42 | 43 | error_msg = 'If challenge is set to False, it must also be absent from properties.' 44 | self.assertEqual(str(error.exception), error_msg) 45 | 46 | # Ensure that a properly set create parameters won't raise the 47 | # ValueError. 48 | properties.pop('challenge') 49 | self.factors.create(properties=properties, challenge=False) 50 | 51 | @patch('stormpath.resources.challenge.ChallengeList.create') 52 | def test_challenge_factor_correct_params(self, create): 53 | # Ensure that message is properly passed on challenge create. 54 | create.return_value = self.challenge 55 | 56 | self.factor._set_properties({'most_recent_challenge': self.challenge}) 57 | self.factor.challenge_factor(message='This is your message ${code}.') 58 | 59 | create.assert_called_once_with(properties={'message': 'This is your message ${code}.'}) 60 | 61 | @patch('stormpath.resources.challenge.ChallengeList.create') 62 | def test_challenge_factor_google_authenticator_code(self, create): 63 | # Ensure that the code parameter is present when challenging 64 | # a google-authenticator factor. 65 | create.return_value = self.challenge 66 | 67 | self.factor._set_properties({'most_recent_challenge': self.challenge}) 68 | self.factor.type = 'google-authenticator' 69 | 70 | # Ensure that the missing code will raise an error. 71 | with self.assertRaises(ValueError) as error: 72 | self.factor.challenge_factor() 73 | 74 | self.assertEqual(str(error.exception), 'When challenging a google-authenticator factor, activation code must be provided.') 75 | 76 | # Ensure that the code will challenge the factor. 77 | challenge = self.factor.challenge_factor(code='123456') 78 | self.assertEqual(challenge, self.challenge) 79 | 80 | # Ensure that challenge_factor did not create a request when the code 81 | # was missing. 82 | create.assert_called_once_with(properties={'code': '123456'}) 83 | 84 | @patch('stormpath.resources.challenge.ChallengeList.create') 85 | def test_challenge_factor_message_default(self, create): 86 | # Ensure that the default message is properly set when challenging 87 | # factor. 88 | create.return_value = self.challenge 89 | 90 | self.factor._set_properties({'most_recent_challenge': self.challenge}) 91 | self.factor.challenge_factor() 92 | 93 | create.assert_called_once_with(properties={'message': None}) 94 | 95 | @patch('stormpath.resources.challenge.ChallengeList.create') 96 | def test_challenge_factor_message_custom(self, create): 97 | # Ensure that the default message is properly overridden when 98 | # challenging factor. 99 | create.return_value = self.challenge 100 | 101 | self.factor._set_properties({'most_recent_challenge': self.challenge}) 102 | self.factor.challenge_factor(message='This is your message ${code}.') 103 | 104 | create.assert_called_once_with(properties={'message': 'This is your message ${code}.'}) 105 | 106 | @patch('stormpath.resources.base.CollectionResource.create') 107 | def test_create_factor_message_custom(self, create): 108 | # Ensure that the custom message is properly set when creating a 109 | # factor with challenge=True. 110 | create.return_value = self.challenge 111 | 112 | properties = { 113 | 'type': 'SMS', 114 | 'phone': {'number': '+666'}, 115 | 'challenge': {'message': 'This is my custom message ${code}.'} 116 | } 117 | 118 | self.factors.create(properties=properties, challenge=True) 119 | create.assert_called_once_with(properties, challenge=True, expand=None) 120 | 121 | def test_is_verified(self): 122 | # Ensure that the is_verified returns true if status is 'VERIFIED'. 123 | self.assertFalse(self.factor.is_verified()) 124 | self.factor._set_properties({'verification_status': 'VERIFIED'}) 125 | self.assertTrue(self.factor.is_verified()) 126 | 127 | def test_is_sms(self): 128 | # Ensure that the is_sms returns true if type is 'SMS'. 129 | self.assertTrue(self.factor.is_sms()) 130 | self.factor.type = 'google-authenticator' 131 | self.assertFalse(self.factor.is_sms()) 132 | -------------------------------------------------------------------------------- /tests/mocks/test_auth.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, main 2 | from stormpath.auth import Auth, Sauthc1Signer 3 | from stormpath.client import Client 4 | try: 5 | from mock import patch, MagicMock, PropertyMock 6 | except ImportError: 7 | from unittest.mock import patch, MagicMock, PropertyMock 8 | import datetime 9 | 10 | 11 | class AuthTest(TestCase): 12 | 13 | @patch.object(Auth, '_load_properties') 14 | def test_auth_key_file_parsing(self, _load_properties): 15 | _load_properties.return_value = { 16 | 'apiKey.id': 'MyId', 17 | 'apiKey.secret': 'Shush!' 18 | } 19 | mock_isfile = MagicMock(return_value=True) 20 | 21 | with patch('stormpath.auth.isfile', mock_isfile): 22 | a = Auth(api_key_file='apiKey.properties') 23 | self.assertEqual(a.id, 'MyId') 24 | self.assertEqual(a.secret, 'Shush!') 25 | 26 | def test_auth_key_dict(self): 27 | a = Auth(api_key={'id': 'MyId', 'secret': 'Shush!'}) 28 | 29 | self.assertEqual(a.id, 'MyId') 30 | self.assertEqual(a.secret, 'Shush!') 31 | 32 | def test_set_id_secret_directly(self): 33 | a = Auth(id='MyId', secret='Shush!') 34 | 35 | self.assertEqual(a.id, 'MyId') 36 | self.assertEqual(a.secret, 'Shush!') 37 | 38 | def test_sauthc1signer(self): 39 | r = MagicMock() 40 | r.headers = {} 41 | r.url = 'https://api.stormpath.com/v1/' 42 | r.method = 'GET' 43 | r.body = None 44 | 45 | mock_dt = MagicMock() 46 | mock_dt.utcnow.return_value = datetime.datetime(2013, 7, 1, 47 | 0, 0, 0, 0) 48 | mock_uuid4 = MagicMock( 49 | return_value='a43a9d25-ab06-421e-8605-33fd1e760825') 50 | s = Sauthc1Signer(id='MyId', secret='Shush!') 51 | with patch('stormpath.auth.datetime', mock_dt): 52 | with patch('stormpath.auth.uuid4', mock_uuid4): 53 | r2 = s(r) 54 | 55 | self.assertEqual(r, r2) 56 | self.assertEqual(r.headers['Authorization'], 57 | 'SAuthc1 sauthc1Id=MyId/20130701/a43a9d25-ab06-421e-8605-33fd1e760825/sauthc1_request, ' + # noqa 58 | 'sauthc1SignedHeaders=host;x-stormpath-date, ' + 59 | 'sauthc1Signature=990a95aabbcbeb53e48fb721f73b75bd3ae025a2e86ad359d08558e1bbb9411c') # noqa 60 | 61 | def test_sauthc1signer_query(self): 62 | # The plus sign in a url query must be replaced with %20 63 | r = MagicMock() 64 | r.headers = {} 65 | r.url = 'https://api.stormpath.com/v1/directories?orderBy=name+asc' 66 | r.method = 'GET' 67 | r.body = None 68 | 69 | mock_dt = MagicMock() 70 | mock_dt.utcnow.return_value = datetime.datetime(2013, 7, 1, 71 | 0, 0, 0, 0) 72 | mock_uuid4 = MagicMock( 73 | return_value='a43a9d25-ab06-421e-8605-33fd1e760825') 74 | s = Sauthc1Signer(id='MyId', secret='Shush!') 75 | with patch('stormpath.auth.datetime', mock_dt): 76 | with patch('stormpath.auth.uuid4', mock_uuid4): 77 | r2 = s(r) 78 | 79 | self.assertEqual(r, r2) 80 | self.assertEqual(r.headers['Authorization'], 81 | 'SAuthc1 sauthc1Id=MyId/20130701/a43a9d25-ab06-421e-8605-33fd1e760825/sauthc1_request, ' + # noqa 82 | 'sauthc1SignedHeaders=host;x-stormpath-date, ' + 83 | 'sauthc1Signature=fc04c5187cc017bbdf9c0bb743a52a9487ccb91c0996267988ceae3f10314176') # noqa 84 | 85 | @patch('stormpath.http.Session') 86 | def test_auth_method(self, session): 87 | tenant_return = MagicMock(status_code=200, 88 | json=MagicMock(return_value={'applications': 89 | {'href': 'applications'}})) 90 | 91 | app_return = MagicMock(status_code=200, 92 | json=MagicMock(return_value={'name': 'LCARS'})) 93 | 94 | with patch('stormpath.client.Auth.digest', new_callable=PropertyMock) \ 95 | as digest: 96 | with patch('stormpath.client.Auth.basic', 97 | new_callable=PropertyMock) as basic: 98 | 99 | client = Client(api_key={'id': 'MyId', 'secret': 'Shush!'}) 100 | session.return_value.request.return_value = tenant_return 101 | application = client.applications.get('application_url') 102 | session.return_value.request.return_value = app_return 103 | application.name 104 | self.assertTrue(digest.called) 105 | self.assertFalse(basic.called) 106 | 107 | digest.reset_mock() 108 | client = Client(api_key={'id': 'MyId', 'secret': 'Shush!'}, 109 | method='digest') 110 | session.return_value.request.return_value = tenant_return 111 | application = client.applications.get('application_url') 112 | session.return_value.request.return_value = app_return 113 | application.name 114 | self.assertTrue(digest.called) 115 | self.assertFalse(basic.called) 116 | 117 | digest.reset_mock() 118 | client = Client(api_key={'id': 'MyId', 'secret': 'Shush!'}) 119 | session.return_value.request.return_value = tenant_return 120 | application = client.applications.get('application_url') 121 | session.return_value.request.return_value = app_return 122 | application.name 123 | self.assertTrue(digest.called) 124 | self.assertFalse(basic.called) 125 | 126 | digest.reset_mock() 127 | client = Client(api_key={'id': 'MyId', 'secret': 'Shush!'}, 128 | method='basic') 129 | session.return_value.request.return_value = tenant_return 130 | application = client.applications.get('application_url') 131 | session.return_value.request.return_value = app_return 132 | application.name 133 | self.assertFalse(digest.called) 134 | self.assertTrue(basic.called) 135 | 136 | 137 | if __name__ == '__main__': 138 | main() 139 | -------------------------------------------------------------------------------- /stormpath/resources/password_strength.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Stormpath PasswordStrength resource mappings.""" 4 | 5 | 6 | from .base import ( 7 | DictMixin, 8 | Resource, 9 | SaveMixin, 10 | ) 11 | 12 | 13 | class PasswordStrength(Resource, DictMixin, SaveMixin): 14 | """Stormpath PasswordStrength resource. 15 | 16 | More info in documentation: 17 | http://docs.stormpath.com/rest/product-guide/#directory-password-policy 18 | (Password Strength Policy for Directory's Accounts section) 19 | """ 20 | 21 | writable_attrs = ( 22 | 'max_length', 23 | 'min_diacritic', 24 | 'min_length', 25 | 'min_lower_case', 26 | 'min_numeric', 27 | 'min_symbol', 28 | 'min_upper_case', 29 | 'prevent_reuse', 30 | ) 31 | 32 | NUMERIC_DIGIT_MIN = u'\u0030' 33 | NUMERIC_DIGIT_MAX = u'\u0039' 34 | 35 | UPPER_CASE_LETTER_MIN = u'\u0041' 36 | UPPER_CASE_LETTER_MAX = u'\u005A' 37 | 38 | LOWER_CASE_LETTER_MIN = u'\u0061' 39 | LOWER_CASE_LETTER_MAX = u'\u007A' 40 | 41 | SYMBOL_CHARS = ( 42 | u'\u0020', # 43 | u'\u0021', # ! 44 | u'\u0022', # " 45 | u'\u0023', # # 46 | u'\u0024', # $ 47 | u'\u0025', # % 48 | u'\u0026', # & 49 | u'\u0027', # ' 50 | u'\u0028', # ( 51 | u'\u0029', # ) 52 | u'\u002A', # * 53 | u'\u002B', # + 54 | u'\u002C', # , 55 | u'\u002D', # - 56 | u'\u002E', # . 57 | u'\u002F', # / 58 | u'\u003A', # : 59 | u'\u003B', # ; 60 | u'\u003C', # < 61 | u'\u003D', # = 62 | u'\u003E', # > 63 | u'\u003F', # ? 64 | u'\u0040', # @ 65 | u'\u005B', # [ 66 | u'\u005C', # \ 67 | u'\u005D', # ] 68 | u'\u005E', # ^ 69 | u'\u005F', # _ 70 | u'\u0060', # ` 71 | u'\u007B', # { 72 | u'\u007D', # } 73 | u'\u007E', # ~ 74 | u'\u007C', # | 75 | u'\u00A1', # ¡ 76 | u'\u00A6', # ¦ 77 | u'\u00A7', # § 78 | u'\u00A9', # © 79 | u'\u00AB', # « 80 | u'\u00AC', # ¬ 81 | u'\u00AE', # ® 82 | u'\u00B1', # ± 83 | u'\u00B5', # µ 84 | u'\u00B6', # ¶ 85 | u'\u00B7', # · 86 | u'\u00BB', # » 87 | u'\u00BD', # ½ 88 | u'\u00BF', # ¿ 89 | u'\u00D7', # × 90 | u'\u00F7') # ÷ 91 | 92 | DIACRITIC_CHARS = ( 93 | u'\u00C0', # À 94 | u'\u00C1', # Á 95 | u'\u00C2', # Â 96 | u'\u00C3', # Ã 97 | u'\u00C4', # Ä 98 | u'\u00C5', # Å 99 | u'\u00C6', # Æ 100 | u'\u00C7', # Ç 101 | u'\u00C8', # È 102 | u'\u00C9', # É 103 | u'\u00CA', # Ê 104 | u'\u00CB', # Ë 105 | u'\u00CC', # Ì 106 | u'\u00CD', # Í 107 | u'\u00CE', # Î 108 | u'\u00CF', # Ï 109 | u'\u00D0', # Ð 110 | u'\u00D1', # Ñ 111 | u'\u00D2', # Ò 112 | u'\u00D3', # Ó 113 | u'\u00D4', # Ô 114 | u'\u00D5', # Õ 115 | u'\u00D6', # Ö 116 | u'\u00D8', # Ø 117 | u'\u00D9', # Ù 118 | u'\u00DA', # Ú 119 | u'\u00DB', # Û 120 | u'\u00DC', # Ü 121 | u'\u00DD', # Ý 122 | u'\u00DE', # Þ 123 | u'\u00DF', # ß 124 | u'\u00E0', # à 125 | u'\u00E1', # á 126 | u'\u00E2', # â 127 | u'\u00E3', # ã 128 | u'\u00E4', # ä 129 | u'\u00E5', # å 130 | u'\u00E6', # æ 131 | u'\u00E7', # ç 132 | u'\u00E8', # è 133 | u'\u00E9', # é 134 | u'\u00EA', # ê 135 | u'\u00EB', # ë 136 | u'\u00EC', # ì 137 | u'\u00ED', # í 138 | u'\u00EE', # î 139 | u'\u00EF', # ï 140 | u'\u00F0', # ð 141 | u'\u00F1', # ñ 142 | u'\u00F2', # ò 143 | u'\u00F3', # ó 144 | u'\u00F4', # ô 145 | u'\u00F5', # õ 146 | u'\u00F6', # ö 147 | u'\u00F8', # ø 148 | u'\u00F9', # ù 149 | u'\u00FA', # ú 150 | u'\u00FB', # û 151 | u'\u00FC', # ü 152 | u'\u00FD', # ý 153 | u'\u00FE', # þ 154 | u'\u00FF') # ÿ 155 | 156 | def validate_password(self, password): 157 | if len(password) > self.max_length: 158 | raise ValueError('Password exceeded the maximum length.') 159 | 160 | if len(password) < self.min_length: 161 | raise ValueError('Password minimum length not satisfied.') 162 | 163 | num_of_lower_case = sum(1 for c in password if ( 164 | self.LOWER_CASE_LETTER_MIN <= c <= self.LOWER_CASE_LETTER_MAX)) 165 | 166 | if num_of_lower_case < self.min_lower_case: 167 | raise ValueError( 168 | 'Password requires at least %d lowercase characters.' % 169 | self.min_lower_case) 170 | 171 | num_of_numeric = sum( 172 | 1 for c in password if ( 173 | self.NUMERIC_DIGIT_MIN <= c <= self.NUMERIC_DIGIT_MAX)) 174 | 175 | if num_of_numeric < self.min_numeric: 176 | raise ValueError( 177 | 'Password requires at least %d numeric characters.' % 178 | self.min_numeric) 179 | 180 | num_of_symbol = sum( 181 | 1 for c in password if c in self.SYMBOL_CHARS) 182 | 183 | if num_of_symbol < self.min_symbol: 184 | raise ValueError( 185 | 'Password requires at least %d symbol characters.' % 186 | self.min_symbol) 187 | 188 | num_of_upper_case = sum(1 for c in password if ( 189 | self.UPPER_CASE_LETTER_MIN <= c <= self.UPPER_CASE_LETTER_MAX)) 190 | 191 | if num_of_upper_case < self.min_upper_case: 192 | raise ValueError( 193 | 'Password requires at least %d uppercase characters.' % 194 | self.min_upper_case) 195 | 196 | num_of_diacritic = sum( 197 | 1 for c in password if c in self.DIACRITIC_CHARS) 198 | 199 | if num_of_diacritic < self.min_diacritic: 200 | raise ValueError( 201 | 'Password requires at least %d diacritic characters.' % 202 | self.min_diacritic) 203 | -------------------------------------------------------------------------------- /stormpath/resources/custom_data.py: -------------------------------------------------------------------------------- 1 | """Stormpath CustomData resource mappings.""" 2 | 3 | from dateutil.parser import parse 4 | 5 | from .base import ( 6 | DeleteMixin, 7 | Resource, 8 | SaveMixin, 9 | ) 10 | 11 | 12 | class CustomData(Resource, DeleteMixin, SaveMixin): 13 | """CustomData Resource for custom user data. 14 | 15 | Resources have predefined fields that are useful to many applications, 16 | but you are likely to have your own custom data that you need to associate 17 | with a resource as well. It behaves like a Python dictionary. 18 | 19 | More info in documentation: 20 | http://docs.stormpath.com/rest/product-guide/#custom-data 21 | """ 22 | data_field = '_data' 23 | readonly_attrs = ( 24 | 'created_at', 25 | 'href', 26 | 'ionmeta', 27 | 'ion_meta', 28 | 'meta', 29 | 'modified_at', 30 | 'spmeta', 31 | 'sp_meta', 32 | 'sp_http_status', 33 | ) 34 | 35 | exposed_readonly_timestamp_attrs = ( 36 | 'created_at', 37 | 'modified_at', 38 | ) 39 | 40 | def __init__(self, *args, **kwargs): 41 | super(CustomData, self).__init__(*args, **kwargs) 42 | self._deletes = set([]) 43 | 44 | def __getitem__(self, key): 45 | if key == self.data_field: 46 | return self.__dict__[self.data_field] 47 | 48 | if (key not in self.__dict__.get(self.data_field, {})) and \ 49 | (self._get_key_href(key) not in self._deletes): 50 | self._ensure_data() 51 | 52 | return getattr(self, self.data_field)[key] 53 | 54 | def __setitem__(self, key, value): 55 | if key == self.data_field: 56 | self.__dict__[self.data_field] = value 57 | 58 | if key in self.readonly_attrs or \ 59 | self.from_camel_case(key) in self.readonly_attrs: 60 | raise KeyError( 61 | "Custom data property '%s' is not writable" % (key)) 62 | else: 63 | if key.startswith('-'): 64 | raise KeyError( 65 | "Usage of '-' at the beginning of key is not allowed") 66 | 67 | key_href = self._get_key_href(key) 68 | if key_href in self._deletes: 69 | self._deletes.remove(key_href) 70 | 71 | getattr(self, self.data_field)[key] = value 72 | 73 | def __delitem__(self, key): 74 | if key in self.exposed_readonly_timestamp_attrs: 75 | raise KeyError( 76 | "Custom data property '%s' is not deletable" % (key)) 77 | 78 | self._ensure_data() 79 | 80 | for href in self._deletes: 81 | try: 82 | del self.__dict__.get(self.data_field, {})[href.split('/')[-1]] 83 | except KeyError: 84 | pass 85 | 86 | del self.__dict__.get(self.data_field, {})[key] 87 | 88 | if not self.is_new(): 89 | self._deletes.add(self._get_key_href(key)) 90 | 91 | def __contains__(self, key): 92 | self._ensure_data() 93 | return key in self.__dict__.get(self.data_field, {}) 94 | 95 | def __setattr__(self, name, value): 96 | ctype = self.get_resource_attributes().get(name) 97 | 98 | if ctype and not isinstance(value, ctype) \ 99 | and name not in self.readonly_attrs: 100 | getattr(self, name)._set_properties(value) 101 | elif name.startswith('_') or name in self.writable_attrs: 102 | super(CustomData, self).__setattr__(name, value) 103 | else: 104 | self._set_properties({name: value}) 105 | 106 | def __getattr__(self, name): 107 | if name == 'href': 108 | return self.__dict__.get('href') 109 | 110 | self._ensure_data() 111 | if name in self.__dict__: 112 | return self.__dict__[name] 113 | elif name in self.__dict__[self.data_field]: 114 | return self.__dict__[self.data_field][name] 115 | else: 116 | raise AttributeError( 117 | "%s has no attribute '%s'" % 118 | (self.__class__.__name__, name)) 119 | 120 | def _get_key_href(self, key): 121 | return '%s/%s' % (self.href, key) 122 | 123 | def keys(self): 124 | self._ensure_data() 125 | return self.__dict__.get(self.data_field, {}).keys() 126 | 127 | def values(self): 128 | self._ensure_data() 129 | return self.__dict__.get(self.data_field, {}).values() 130 | 131 | def items(self): 132 | self._ensure_data() 133 | return self.__dict__.get(self.data_field, {}).items() 134 | 135 | def get(self, key, default=None): 136 | try: 137 | return self.__getitem__(key) 138 | except KeyError: 139 | return default 140 | 141 | def __iter__(self): 142 | self._ensure_data() 143 | return iter(self.__dict__.get(self.data_field, {})) 144 | 145 | def _get_properties(self): 146 | data = self.__dict__.get(self.data_field, {}) 147 | writable_attrs = set(data) - set( 148 | self.exposed_readonly_timestamp_attrs) 149 | if data: 150 | return {k: self.__dict__[self.data_field][k] for k in writable_attrs} 151 | return data 152 | 153 | def _set_properties(self, properties, overwrite=False): 154 | data = self.__dict__.get(self.data_field, {}) 155 | for k, v in properties.items(): 156 | kcc = self.from_camel_case(k) 157 | if kcc in self.readonly_attrs: 158 | if kcc in self.exposed_readonly_timestamp_attrs: 159 | v = parse(v) 160 | data[kcc] = v 161 | self.__dict__[kcc] = v 162 | else: 163 | if k not in data: 164 | data[k] = v 165 | if data: 166 | self.__dict__[self.data_field] = data 167 | 168 | def save(self): 169 | for href in self._deletes: 170 | self._store.delete_resource(href) 171 | 172 | self._deletes = set() 173 | 174 | if self.data_field in self.__dict__ and \ 175 | len(self._get_properties()): 176 | super(CustomData, self).save() 177 | 178 | def delete(self): 179 | super(CustomData, self).delete() 180 | self.__dict__[self.data_field] = {} 181 | -------------------------------------------------------------------------------- /tests/mocks/test_saml.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | import datetime 3 | import jwt 4 | from oauthlib.common import to_unicode 5 | 6 | from unittest import TestCase 7 | try: 8 | from mock import MagicMock, patch 9 | except ImportError: 10 | from unittest.mock import MagicMock, patch 11 | 12 | from stormpath.resources.application import ( 13 | Application, ApplicationList, StormpathCallbackResult 14 | ) 15 | from stormpath.resources.default_relay_state import DefaultRelayStateList 16 | from stormpath.resources.organization import Organization 17 | 18 | 19 | class SamlBuildURITest(TestCase): 20 | 21 | def setUp(self): 22 | self.client = MagicMock(BASE_URL='') 23 | self.client.auth = MagicMock() 24 | self.client.auth.id = 'ID' 25 | self.client.auth.secret = 'SECRET' 26 | 27 | 28 | def test_building_saml_redirect_uri(self): 29 | try: 30 | from urlparse import urlparse 31 | except ImportError: 32 | from urllib.parse import urlparse 33 | 34 | app = Application(client=self.client, properties={'href': 'apphref'}) 35 | 36 | ret = app.build_saml_idp_redirect_url( 37 | 'http://localhost/', 'apphref/saml/sso/idpRedirect') 38 | try: 39 | jwt_response = urlparse(ret).query.split('=')[1] 40 | except: 41 | self.fail("Failed to parse ID site redirect uri") 42 | 43 | try: 44 | decoded_data = jwt.decode( 45 | jwt_response, verify=False, algorithms=['HS256']) 46 | except jwt.DecodeError: 47 | self.fail("Invaid JWT generated.") 48 | 49 | self.assertIsNotNone(decoded_data.get('iat')) 50 | self.assertIsNotNone(decoded_data.get('jti')) 51 | self.assertIsNotNone(decoded_data.get('iss')) 52 | self.assertIsNotNone(decoded_data.get('sub')) 53 | self.assertIsNotNone(decoded_data.get('cb_uri')) 54 | self.assertEqual(decoded_data.get('cb_uri'), 'http://localhost/') 55 | self.assertIsNone(decoded_data.get('path')) 56 | self.assertIsNone(decoded_data.get('state')) 57 | 58 | ret = app.build_saml_idp_redirect_url( 59 | 'http://testserver/', 60 | 'apphref/saml/sso/idpRedirect', 61 | path='/#/register', 62 | state='test') 63 | try: 64 | jwt_response = urlparse(ret).query.split('=')[1] 65 | except: 66 | self.fail("Failed to parse SAML redirect uri") 67 | 68 | try: 69 | decoded_data = jwt.decode( 70 | jwt_response, verify=False, algorithms=['HS256']) 71 | except jwt.DecodeError: 72 | self.fail("Invaid JWT generated.") 73 | 74 | self.assertEqual(decoded_data.get('path'), '/#/register') 75 | self.assertEqual(decoded_data.get('state'), 'test') 76 | 77 | 78 | class SamlCallbackTest(SamlBuildURITest): 79 | 80 | def setUp(self): 81 | super(SamlCallbackTest, self).setUp() 82 | self.store = MagicMock() 83 | self.store.get_resource.return_value = { 84 | 'href': 'acchref', 85 | 'sp_http_status': 200, 86 | 'applications': ApplicationList( 87 | client=self.client, 88 | properties={ 89 | 'href': 'apps', 90 | 'items': [{'href': 'apphref'}], 91 | 'offset': 0, 92 | 'limit': 25 93 | }) 94 | } 95 | self.store._cache_get.return_value = False # ignore nonce 96 | 97 | self.client.data_store = self.store 98 | 99 | self.app = Application( 100 | client=self.client, 101 | properties={'href': 'apphref', 'accounts': {'href': 'acchref'}}) 102 | 103 | self.acc = MagicMock(href='acchref') 104 | now = datetime.datetime.utcnow() 105 | 106 | try: 107 | irt = uuid4().get_hex() 108 | except AttributeError: 109 | irt = uuid4().hex 110 | 111 | fake_jwt_data = { 112 | 'exp': now + datetime.timedelta(seconds=3600), 113 | 'aud': self.app._client.auth.id, 114 | 'irt': irt, 115 | 'iss': 'Stormpath', 116 | 'sub': self.acc.href, 117 | 'isNewSub': False, 118 | 'state': None, 119 | } 120 | 121 | self.fake_jwt = to_unicode(jwt.encode( 122 | fake_jwt_data, 123 | self.app._client.auth.secret, 124 | 'HS256'), 'UTF-8') 125 | 126 | def test_saml_callback_handler(self): 127 | fake_jwt_response = 'http://localhost/?jwtResponse=%s' % self.fake_jwt 128 | 129 | with patch.object(Application, 'has_account') as mock_has_account: 130 | mock_has_account.return_value = True 131 | ret = self.app.handle_stormpath_callback(fake_jwt_response) 132 | 133 | self.assertIsNotNone(ret) 134 | self.assertIsInstance(ret, StormpathCallbackResult) 135 | self.assertEqual(ret.account.href, self.acc.href) 136 | self.assertIsNone(ret.state) 137 | 138 | 139 | class DefaultRelayStateTest(SamlBuildURITest): 140 | 141 | def setUp(self): 142 | super(DefaultRelayStateTest, self).setUp() 143 | self.store = MagicMock() 144 | self.client.data_store = self.store 145 | 146 | self.drss = DefaultRelayStateList( 147 | client=self.client, properties={'href': 'drss'}) 148 | self.organization = Organization( 149 | client=self.client, properties={'name_key': 'NAME KEY'}) 150 | 151 | def test_default_relay_state_create_empty(self): 152 | self.drss.create() 153 | 154 | self.store.create_resource.assert_called_once_with( 155 | 'drss', {}, params={}) 156 | 157 | def test_default_relay_state_create_organization(self): 158 | self.drss.create({'organization': self.organization}) 159 | 160 | self.store.create_resource.assert_called_once_with( 161 | 'drss', {'organization': {'nameKey': 'NAME KEY'}}, params={}) 162 | 163 | def test_default_relay_state_create_organization_name_key(self): 164 | self.drss.create({'organization': {'name_key': 'ANOTHER NAME KEY'}}) 165 | 166 | self.store.create_resource.assert_called_once_with( 167 | 'drss', 168 | {'organization': {'nameKey': 'ANOTHER NAME KEY'}}, 169 | params={}) 170 | -------------------------------------------------------------------------------- /tests/mocks/test_id_site.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | import datetime 3 | 4 | try: 5 | from urlparse import urlparse 6 | except ImportError: 7 | from urllib.parse import urlparse 8 | 9 | from unittest import TestCase 10 | try: 11 | from mock import MagicMock, patch 12 | except ImportError: 13 | from unittest.mock import MagicMock, patch 14 | 15 | import jwt 16 | from oauthlib.common import to_unicode 17 | 18 | from stormpath.resources.application import ( 19 | Application, ApplicationList, StormpathCallbackResult 20 | ) 21 | 22 | class IDSiteBuildURITest(TestCase): 23 | 24 | def setUp(self): 25 | self.client = MagicMock(BASE_URL='') 26 | self.client.auth = MagicMock() 27 | self.client.auth.id = 'ID' 28 | self.client.auth.secret = 'SECRET' 29 | 30 | 31 | def test_building_id_site_redirect_uri(self): 32 | app = Application(client=self.client, properties={'href': 'apphref'}) 33 | ret = app.build_id_site_redirect_url('http://localhost/') 34 | decoded_data = self.decode_jwt(ret) 35 | self.assertIsNotNone(decoded_data.get('iat')) 36 | self.assertIsNotNone(decoded_data.get('jti')) 37 | self.assertIsNotNone(decoded_data.get('iss')) 38 | self.assertIsNotNone(decoded_data.get('sub')) 39 | self.assertIsNotNone(decoded_data.get('cb_uri')) 40 | self.assertEqual(decoded_data.get('cb_uri'), 'http://localhost/') 41 | self.assertIsNone(decoded_data.get('path')) 42 | self.assertIsNone(decoded_data.get('state')) 43 | self.assertNotEqual(decoded_data.get('sof'), True) 44 | self.assertIsNone(decoded_data.get('onk')) 45 | self.assertIsNone(decoded_data.get('sp_token')) 46 | 47 | ret = app.build_id_site_redirect_url( 48 | 'http://testserver/', 49 | path='/#/register', 50 | state='test') 51 | decoded_data = self.decode_jwt(ret) 52 | self.assertEqual(decoded_data.get('path'), '/#/register') 53 | self.assertEqual(decoded_data.get('state'), 'test') 54 | 55 | sp_token = '{"test":"test"}' 56 | ret = app.build_id_site_redirect_url( 57 | 'http://localhost/', show_organization_field=True, sp_token=sp_token) 58 | decoded_data = self.decode_jwt(ret) 59 | self.assertEqual(decoded_data["sof"], True) 60 | self.assertEqual(decoded_data["sp_token"], sp_token) 61 | self.assertIsNone(decoded_data.get('onk')) 62 | 63 | ret = app.build_id_site_redirect_url( 64 | 'http://localhost/', organization_name_key="testorg") 65 | decoded_data = self.decode_jwt(ret) 66 | self.assertEqual(decoded_data["onk"], "testorg") 67 | self.assertNotEqual(decoded_data.get('sof'), True) 68 | 69 | def test_building_id_site_redirect_uri_with_usd(self): 70 | app = Application(client=self.client, properties={'href': 'apphref'}) 71 | ret = app.build_id_site_redirect_url('http://localhost/', use_subdomain=True) 72 | decoded_data = self.decode_jwt(ret) 73 | self.assertEqual(decoded_data.get('usd'), True) 74 | 75 | def decode_jwt(self, ret): 76 | try: 77 | jwt_response = urlparse(ret).query.split('=')[1] 78 | except: 79 | self.fail("Failed to parse ID site redirect uri") 80 | 81 | try: 82 | decoded_data = jwt.decode( 83 | jwt_response, verify=False, algorithms=['HS256']) 84 | except jwt.DecodeError: 85 | self.fail("Invaid JWT generated.") 86 | return decoded_data 87 | 88 | 89 | class IDSiteCallbackTest(IDSiteBuildURITest): 90 | 91 | def setUp(self): 92 | super(IDSiteCallbackTest, self).setUp() 93 | self.store = MagicMock() 94 | self.store.get_resource.return_value = { 95 | 'href': 'acchref', 96 | 'sp_http_status': 200, 97 | 'applications': ApplicationList( 98 | client=self.client, 99 | properties={ 100 | 'href': 'apps', 101 | 'items': [{'href': 'apphref'}], 102 | 'offset': 0, 103 | 'limit': 25 104 | }) 105 | } 106 | self.store._cache_get.return_value = False # ignore nonce 107 | 108 | self.client.data_store = self.store 109 | 110 | self.app = Application( 111 | client=self.client, 112 | properties={'href': 'apphref', 'accounts': {'href': 'acchref'}}) 113 | 114 | self.acc = MagicMock(href='acchref') 115 | now = datetime.datetime.utcnow() 116 | 117 | try: 118 | irt = uuid4().get_hex() 119 | except AttributeError: 120 | irt = uuid4().hex 121 | 122 | fake_jwt_data = { 123 | 'exp': now + datetime.timedelta(seconds=3600), 124 | 'aud': self.app._client.auth.id, 125 | 'irt': irt, 126 | 'iss': 'Stormpath', 127 | 'sub': self.acc.href, 128 | 'isNewSub': False, 129 | 'state': None, 130 | } 131 | 132 | self.fake_jwt = to_unicode(jwt.encode( 133 | fake_jwt_data, 134 | self.app._client.auth.secret, 135 | 'HS256'), 'UTF-8') 136 | 137 | def test_id_site_callback_handler(self): 138 | fake_jwt_response = 'http://localhost/?jwtResponse=%s' % self.fake_jwt 139 | 140 | with patch.object(Application, 'has_account') as mock_has_account: 141 | mock_has_account.return_value = True 142 | ret = self.app.handle_stormpath_callback(fake_jwt_response) 143 | 144 | self.assertIsNotNone(ret) 145 | self.assertIsInstance(ret, StormpathCallbackResult) 146 | self.assertEqual(ret.account.href, self.acc.href) 147 | self.assertIsNone(ret.state) 148 | 149 | def test_id_site_callback_handler_jwt_already_used(self): 150 | self.store._cache_get.return_value = True # Fake Nonce already used 151 | 152 | fake_jwt_response = 'http://localhost/?jwtResponse=%s' % self.fake_jwt 153 | self.assertRaises( 154 | ValueError, self.app.handle_stormpath_callback, fake_jwt_response) 155 | 156 | def test_id_site_callback_handler_invalid_jwt(self): 157 | fake_jwt_response = 'http://localhost/?jwtResponse=%s' % 'INVALID_JWT' 158 | ret = self.app.handle_stormpath_callback(fake_jwt_response) 159 | self.assertIsNone(ret) 160 | 161 | def test_id_site_callback_handler_invalid_url_response(self): 162 | fake_jwt_response = 'invalid_url_response' 163 | ret = self.app.handle_stormpath_callback(fake_jwt_response) 164 | self.assertIsNone(ret) 165 | -------------------------------------------------------------------------------- /tests/mocks/test_provider.py: -------------------------------------------------------------------------------- 1 | """" 2 | Integration tests for various pieces involved in external provider support. 3 | """ 4 | 5 | from datetime import datetime 6 | from dateutil.tz import tzutc 7 | from unittest import TestCase, main 8 | from stormpath.resources import Provider 9 | 10 | try: 11 | from mock import MagicMock 12 | except ImportError: 13 | from unittest.mock import MagicMock 14 | 15 | from stormpath.resources.account import Account 16 | from stormpath.resources.application import Application 17 | from stormpath.resources.directory import DirectoryList 18 | from stormpath.resources.provider_data import ProviderData 19 | 20 | 21 | class TestProvider(TestCase): 22 | 23 | def test_cannot_save_new_provider(self): 24 | provider = Provider( 25 | client=MagicMock(), 26 | properties= { 27 | 'client_id': 'ID', 28 | 'client_secret': 'SECRET', 29 | 'redirect_uri': 'SOME_URL', 30 | 'provider_id': 'myprovider' 31 | }) 32 | 33 | with self.assertRaises(ValueError): 34 | provider.save() 35 | 36 | 37 | class TestProviderAccounts(TestCase): 38 | 39 | def test_is_new_account_if_sp_http_status_is_201(self): 40 | ds = MagicMock() 41 | ds.get_resource.return_value = { 42 | 'sp_http_status': 201 43 | } 44 | 45 | acc = Account(client=MagicMock(data_store=ds), href='test/account') 46 | is_new = acc.is_new_account 47 | 48 | ds.get_resource.assert_called_once_with('test/account', params=None) 49 | self.assertTrue(is_new) 50 | 51 | def test_is_not_new_account_if_sp_http_status_is_200(self): 52 | ds = MagicMock() 53 | ds.get_resource.return_value = { 54 | 'sp_http_status': 200 55 | } 56 | 57 | acc = Account(client=MagicMock(data_store=ds), href='test/account') 58 | is_new = acc.is_new_account 59 | 60 | ds.get_resource.assert_called_once_with('test/account', params=None) 61 | self.assertFalse(is_new) 62 | 63 | def test_app_get_provider_acc_does_create_w_provider_data(self): 64 | ds = MagicMock() 65 | ds.get_resource.return_value = {} 66 | client = MagicMock(data_store=ds, BASE_URL='http://example.com') 67 | 68 | app = Application(client=client, properties={ 69 | 'href': 'test/app', 70 | 'accounts': {'href': '/test/app/accounts'} 71 | }) 72 | 73 | app.get_provider_account('myprovider', access_token='foo') 74 | 75 | ds.create_resource.assert_called_once_with( 76 | 'http://example.com/test/app/accounts', { 77 | 'providerData': { 78 | 'providerId': 'myprovider', 79 | 'accessToken': 'foo' 80 | } 81 | }, params={}) 82 | 83 | 84 | class TestProviderDirectories(TestCase): 85 | 86 | def test_creating_provider_directory_passes_provider_info(self): 87 | ds = MagicMock() 88 | ds.create_resource.return_value = {} 89 | 90 | dl = DirectoryList( 91 | client=MagicMock(data_store=ds, BASE_URL='http://example.com'), 92 | href='directories') 93 | 94 | dl.create({ 95 | 'name': 'Foo', 96 | 'description': 'Desc', 97 | 'provider': { 98 | 'client_id': 'ID', 99 | 'client_secret': 'SECRET', 100 | 'redirect_uri': 'SOME_URL', 101 | 'provider_id': 'myprovider' 102 | } 103 | }) 104 | 105 | ds.create_resource.assert_called_once_with( 106 | 'http://example.com/directories', { 107 | 'description': 'Desc', 108 | 'name': 'Foo', 109 | 'provider': { 110 | 'clientSecret': 'SECRET', 111 | 'providerId': 'myprovider', 112 | 'redirectUri': 'SOME_URL', 113 | 'clientId': 'ID' 114 | } 115 | }, params={}) 116 | 117 | def test_modify_directory_provider(self): 118 | ds = MagicMock() 119 | ds.create_resource.return_value = { 120 | 'name': 'Foo', 121 | 'description': 'Desc', 122 | 'provider': { 123 | 'href': 'provider', 124 | 'client_id': 'ID', 125 | 'client_secret': 'SECRET', 126 | 'redirect_uri': 'SOME_URL', 127 | 'provider_id': 'myprovider' 128 | } 129 | } 130 | ds.update_resource.return_value = {} 131 | 132 | dl = DirectoryList( 133 | client=MagicMock(data_store=ds, BASE_URL='http://example.com'), 134 | href='directories') 135 | 136 | d = dl.create({ 137 | 'name': 'Foo', 138 | 'description': 'Desc', 139 | 'provider': { 140 | 'client_id': 'ID', 141 | 'client_secret': 'SECRET', 142 | 'redirect_uri': 'SOME_URL', 143 | 'provider_id': 'myprovider' 144 | } 145 | }) 146 | 147 | d.provider.redirect_uri = 'SOME_OTHER_URL' 148 | d.provider.save() 149 | 150 | ds.update_resource.assert_called_once_with( 151 | 'provider', 152 | { 153 | 'clientSecret': 'SECRET', 154 | 'providerId': 'myprovider', 155 | 'redirectUri': 'SOME_OTHER_URL', 156 | 'clientId': 'ID' 157 | }) 158 | 159 | 160 | class TestProviderData(TestCase): 161 | 162 | def test_provider_data_get_exposed_readonly_timestamp_attrs(self): 163 | ds = MagicMock() 164 | created_and_modified_at = datetime( 165 | 2015, 2, 26, 12, 0, 0 ,0, tzinfo=tzutc()) 166 | ds.get_resource.return_value = { 167 | 'created_at': '2015-02-26 12:00:00+00:00', 168 | 'modified_at': '2015-02-26 12:00:00+00:00' 169 | } 170 | 171 | pd = ProviderData( 172 | client=MagicMock(data_store=ds, BASE_URL='http://example.com'), 173 | href='provider-data') 174 | 175 | self.assertEqual(pd.created_at, created_and_modified_at) 176 | self.assertEqual(pd['created_at'], created_and_modified_at) 177 | self.assertEqual(pd.modified_at, created_and_modified_at) 178 | self.assertEqual(pd['modified_at'], created_and_modified_at) 179 | 180 | def test_provider_data_modify_exposed_readonly_timestamp_attrs(self): 181 | ds = MagicMock() 182 | ds.get_resource.return_value = { 183 | 'created_at': '2015-02-26 12:00:00+00:00', 184 | 'modified_at': '2015-02-26 12:00:00+00:00' 185 | } 186 | 187 | pd = ProviderData( 188 | client=MagicMock(data_store=ds, BASE_URL='http://example.com'), 189 | href='provider-data') 190 | 191 | with self.assertRaises(AttributeError): 192 | pd.created_at = 'whatever' 193 | with self.assertRaises(AttributeError): 194 | pd['created_at'] = 'whatever' 195 | with self.assertRaises(AttributeError): 196 | pd.modified_at = 'whatever' 197 | with self.assertRaises(AttributeError): 198 | pd['modified_at'] = 'whatever' 199 | 200 | with self.assertRaises(Exception): 201 | del pd['created_at'] 202 | with self.assertRaises(Exception): 203 | del pd['modified_at'] 204 | 205 | 206 | if __name__ == '__main__': 207 | main() 208 | -------------------------------------------------------------------------------- /stormpath/http.py: -------------------------------------------------------------------------------- 1 | """HTTP request handling utilities.""" 2 | 3 | import cgi 4 | import time 5 | import random 6 | 7 | from collections import OrderedDict 8 | from json import dumps 9 | from requests import Session 10 | from requests.exceptions import RequestException 11 | from sys import version_info as vi 12 | 13 | # Hack for Google App Engine 14 | # GAE doesn't allow users to import `win32_ver` as it's sandbox mode rips 15 | # `_winreg` out of the standard library :( This patch works by creating a stub 16 | # replacement for it that won't error. 17 | try: 18 | from platform import platform, mac_ver, win32_ver, linux_distribution, system 19 | except ImportError: 20 | win32_ver = lambda: ('', '', '', '') 21 | 22 | 23 | from stormpath import __version__ as STORMPATH_VERSION 24 | from .error import Error 25 | 26 | 27 | class HttpExecutor(object): 28 | """Handles the actual HTTP requests to the Stormpath service. 29 | 30 | It uses the Requests library: http://docs.python-requests.org/en/latest/. 31 | The HttpExecutor, along with :class:`stormpath.cache.manager.CacheManager` 32 | is a part of the :class:`stormpath.data_store.DataStore`. 33 | 34 | :param base_url: The root of the Stormpath service. 35 | Paths to specific resources will be prepended by this url. 36 | 37 | :param auth: Authentication manager, like 38 | :class:`stormpath.auth.Sauthc1Signer`. 39 | :param get_delay: A Function that will return the number of milliseconds 40 | to wait before retrying the request. The function must take one parameter 41 | which is the number of retries already done. If no function is supplied 42 | the default backoff strategy is used (see the pause_exponentially method). 43 | """ 44 | DEFAULT_MAX_RETRIES = 4 45 | MAX_BACKOFF_IN_MILLISECONDS = 20 * 1000 46 | 47 | os_info = platform() 48 | os_versions = { 49 | 'Linux': "%s (%s)" % (linux_distribution()[0], os_info), 50 | 'Windows': "%s (%s)" % (win32_ver()[0], os_info), 51 | 'Darwin': "%s (%s)" % (mac_ver()[0], os_info), 52 | } 53 | 54 | USER_AGENT = 'stormpath-sdk-python/%s python/%s %s/%s' % ( 55 | STORMPATH_VERSION, 56 | '%s.%s.%s' % (vi.major, vi.minor, vi.micro), 57 | system(), 58 | os_versions.get(system(), ''), 59 | ) 60 | 61 | def __init__(self, base_url, auth, proxies=None, user_agent=None, get_delay=None): 62 | # If a custom user agent is specified, we'll append it to the end of 63 | # our built-in user agent. This way we'll get very detailed user agent 64 | # strings. 65 | if user_agent is not None: 66 | self.USER_AGENT = user_agent + ' ' + self.USER_AGENT 67 | 68 | self.get_delay = get_delay 69 | self.base_url = base_url 70 | self.session = Session() 71 | self.session.proxies = proxies or {} 72 | self.session.auth = auth 73 | self.session.headers.update({ 74 | 'Accept': 'application/json', 75 | 'Content-Type': 'application/json', 76 | 'User-Agent': self.USER_AGENT, 77 | }) 78 | 79 | def is_throttling_or_unexpected_error(self, status): 80 | """Helper method for determining if the request was told to back off, 81 | or if an unexpected error in the 5xx range occured.""" 82 | 83 | if isinstance(status, RequestException): 84 | return True 85 | elif isinstance(status, int) and (status == 429 or status >= 500): 86 | return True 87 | else: 88 | return False 89 | 90 | def pause_exponentially(self, retries): 91 | """Helper method for calculating the number of milliseconds to sleep 92 | before re-trying a request.""" 93 | 94 | if self.get_delay is not None: 95 | delay = self.get_delay(retries) 96 | else: 97 | scale_factor = 500 + random.randint(1, 100) 98 | delay = 2 ** retries * scale_factor 99 | 100 | delay = min(delay, self.MAX_BACKOFF_IN_MILLISECONDS) 101 | 102 | # sleep in seconds 103 | time.sleep(delay / float(1000)) 104 | 105 | def should_retry(self, retries, status): 106 | """Helper method for deciding if a request should be retried.""" 107 | if self.is_throttling_or_unexpected_error(status): 108 | if retries < self.DEFAULT_MAX_RETRIES: 109 | return True 110 | return False 111 | 112 | def raise_error(self, r): 113 | try: 114 | ret = r.json() 115 | except ValueError as e: 116 | ret = "An unexpected error occurred. HTTP Status code: %s. " % r.status_code 117 | ret += "Error message: %s. " % e 118 | ret += "Consider setting the logging level to debug for more detail." 119 | 120 | if not isinstance(ret, dict): 121 | ret = {'developerMessage': ret} 122 | elif 'developerMessage' not in ret: 123 | ret['developerMessage'] = ret 124 | 125 | raise Error(ret, http_status=r.status_code) 126 | 127 | def return_response(self, r): 128 | if not r.text: 129 | return {} 130 | try: 131 | d = r.json() 132 | d['sp_http_status'] = r.status_code 133 | except ValueError: 134 | d = {} 135 | d['content'] = r.content 136 | _, params = cgi.parse_header( 137 | r.headers.get('Content-Disposition', '')) 138 | d['filename'] = params.get('filename') 139 | return d 140 | 141 | def request(self, method, url, data=None, params=None, headers=None, retry_count=0): 142 | if params: 143 | params = OrderedDict(sorted(params.items())) 144 | 145 | if not url.startswith(self.base_url): 146 | url = self.base_url + url 147 | 148 | try: 149 | r = self.session.request(method, url, data=data, params=params, headers=headers, allow_redirects=False) 150 | except Exception as e: 151 | if self.should_retry(retry_count, e): 152 | self.pause_exponentially(retry_count) 153 | return self.request(method, url, data=data, params=params, headers=headers, retry_count=retry_count + 1) 154 | else: 155 | raise Error({'developerMessage': str(e)}) 156 | 157 | if r.status_code in [301, 302] and 'location' in r.headers: 158 | if not r.headers['location'].startswith(self.base_url): 159 | message = 'Trying to redirect outside of API base url: {}'.format(r.headers['location']) 160 | raise Error({'developerMessage': message}) 161 | 162 | return self.request('GET', r.headers['location'], params=params) 163 | 164 | if r.status_code >= 400 and r.status_code <= 600: 165 | if self.should_retry(retry_count, r.status_code): 166 | self.pause_exponentially(retry_count) 167 | return self.request(method, url, data=data, params=params, headers=headers, retry_count=retry_count + 1) 168 | else: 169 | self.raise_error(r) 170 | 171 | return self.return_response(r) 172 | 173 | def get(self, url, params=None): 174 | return self.request('GET', url, params=params) 175 | 176 | def post(self, url, data, params=None, headers=None): 177 | return self.request('POST', url, data=dumps(data), params=params, headers=headers) 178 | 179 | def delete(self, url): 180 | return self.request('DELETE', url) 181 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/stormpath-sdk-python.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/stormpath-sdk-python.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/stormpath-sdk-python" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/stormpath-sdk-python" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /tests/live/test_factor.py: -------------------------------------------------------------------------------- 1 | """Live tests of Factors and MFA functionality.""" 2 | 3 | 4 | from .base import MFABase 5 | from stormpath.resources.factor import FactorList 6 | from stormpath.resources.challenge import Challenge, ChallengeList 7 | from stormpath.resources.phone import PhoneList 8 | from stormpath.error import Error 9 | 10 | 11 | class TestFactor(MFABase): 12 | 13 | def test_create(self): 14 | data = { 15 | 'phone': self.phone, 16 | 'challenge': {'message': '${code}'}, 17 | 'type': 'SMS' 18 | } 19 | factor = self.account.factors.create(properties=data, challenge=True) 20 | self.account.refresh() 21 | 22 | # Ensure that the factor and phone resources have been successfully 23 | # added to the account instance. 24 | self.assertTrue(isinstance(self.account.factors, FactorList)) 25 | self.assertEqual(len(self.account.factors.items), 1) 26 | self.assertEqual(self.account.factors.items[0].href, factor.href) 27 | self.assertTrue(isinstance(self.account.phones, PhoneList)) 28 | self.assertTrue(len(self.account.phones.items), 1) 29 | self.assertEqual(self.account.phones.items[0].number, data['phone']['number']) 30 | 31 | def test_create_invalid_number(self): 32 | # Try creating a factor using an invalid phone number. 33 | 34 | data = { 35 | 'phone': {'number': '+666'}, 36 | 'challenge': {'message': '${code}'}, 37 | 'type': 'SMS' 38 | } 39 | 40 | with self.assertRaises(Error) as error: 41 | self.account.factors.create(properties=data, challenge=True) 42 | self.assertEqual(error.exception.message, 'The provided phone number is invalid.') 43 | 44 | def test_create_with_challenge(self): 45 | # Create factor with challenge. 46 | 47 | data = { 48 | 'phone': self.phone, 49 | 'challenge': {'message': '${code}'}, 50 | 'type': 'SMS' 51 | } 52 | factor = self.account.factors.create(properties=data, challenge=True) 53 | self.account.refresh() 54 | 55 | # Ensure that the challenge list resource has been successfully 56 | # created. 57 | self.assertTrue(isinstance(factor.challenges, ChallengeList)) 58 | 59 | # Ensure that the newly created factor created a challenge. 60 | self.assertEqual(len(factor.challenges.items), 1) 61 | self.assertTrue(isinstance(factor.most_recent_challenge, Challenge)) 62 | 63 | def test_create_without_challenge(self): 64 | # Create factor without challenge. 65 | 66 | data = { 67 | 'phone': self.phone, 68 | 'type': 'SMS' 69 | } 70 | factor = self.account.factors.create(properties=data, challenge=False) 71 | self.account.refresh() 72 | 73 | # Ensure that the challenge list resource has been successfully 74 | # created. 75 | self.assertTrue(isinstance(factor.challenges, ChallengeList)) 76 | 77 | # Ensure that the newly created factor did not create a challenge. 78 | self.assertEqual(len(factor.challenges.items), 0) 79 | self.assertEqual(factor.most_recent_challenge, None) 80 | 81 | # Ensure that passing the challenge params while challenge=False 82 | # won't create a challenge. 83 | factor.delete() 84 | 85 | data = { 86 | 'phone': self.phone, 87 | 'challenge': {'message': '${code}'}, 88 | 'type': 'SMS' 89 | } 90 | with self.assertRaises(ValueError) as error: 91 | factor = self.account.factors.create(properties=data, challenge=False) 92 | error_msg = 'If challenge is set to False, it must also be absent from properties.' 93 | self.assertEqual(str(error.exception), error_msg) 94 | 95 | # Ensure that the newly created factor did not create a challenge. 96 | self.assertEqual(len(factor.challenges.items), 0) 97 | self.assertIsNone(factor.most_recent_challenge) 98 | 99 | def test_challenge_list(self): 100 | # Ensure that challenges are present in our ChallengeList after we've 101 | # created them. 102 | 103 | data = { 104 | 'phone': self.phone, 105 | 'type': 'SMS' 106 | } 107 | 108 | # Ensure that challenge=False will not create a challenge 109 | factor = self.account.factors.create(properties=data, challenge=False) 110 | factor.refresh() 111 | self.assertEqual(len(factor.challenges.items), 0) 112 | 113 | # Ensure that the factor will create a challenge. 114 | challenge = factor.challenge_factor(message='${code}') 115 | factor.refresh() 116 | self.assertEqual(len(factor.challenges.items), 1) 117 | 118 | # Ensure that you can create a new challenge on top of the old one. 119 | challenge2 = factor.challenge_factor(message='New ${code}') 120 | factor.refresh() 121 | self.assertEqual(len(factor.challenges.items), 2) 122 | self.assertNotEqual(challenge, challenge2) 123 | 124 | def test_challenge_factor_message(self): 125 | # Specifying a custom message on factor challenge. 126 | 127 | data = { 128 | 'phone': self.phone, 129 | 'type': 'SMS' 130 | } 131 | factor = self.account.factors.create(properties=data, challenge=False) 132 | 133 | # Ensure that you cannot specify a message without a '${code}' 134 | # placeholder. 135 | message = 'This message is missing a placeholder.' 136 | with self.assertRaises(Error) as error: 137 | factor.challenge_factor(message) 138 | 139 | self.assertEqual(error.exception.message, "The challenge message must include '${code}'.") 140 | factor.refresh() 141 | self.assertIsNone(factor.most_recent_challenge) 142 | 143 | # Ensure that you can customize your message. 144 | message = 'This is my custom message: ${code}.' 145 | factor.challenge_factor(message=message) 146 | factor.refresh() 147 | self.assertEqual(factor.most_recent_challenge.message, message) 148 | 149 | # Ensure that the default message will be set. 150 | default_message = 'Your verification code is ${code}' 151 | factor.challenge_factor() 152 | factor.refresh() 153 | self.assertEqual(factor.most_recent_challenge.message, default_message) 154 | 155 | def test_create_factor_message(self): 156 | # Specifying a custom message on factor create. 157 | 158 | data = { 159 | 'phone': self.phone, 160 | 'challenge': {'message': 'This message is missing a placeholder.'}, 161 | 'type': 'SMS' 162 | } 163 | 164 | # Ensure that you cannot specify a message without a '${code}' 165 | # placeholder. 166 | with self.assertRaises(Error) as error: 167 | self.account.factors.create(properties=data, challenge=True) 168 | 169 | self.assertEqual(error.exception.message, "The challenge message must include '${code}'.") 170 | 171 | # Ensure that you can customize your message. 172 | data['challenge']['message'] = 'This is my custom message: ${code}.' 173 | factor = self.account.factors.create(properties=data, challenge=True) 174 | self.assertEqual(factor.most_recent_challenge.message, data['challenge']['message']) 175 | 176 | factor.delete() 177 | data.pop('challenge') 178 | 179 | # Ensure that the default message will be set. 180 | default_message = 'Your verification code is ${code}' 181 | factor = self.account.factors.create(properties=data, challenge=True) 182 | self.assertEqual(factor.most_recent_challenge.message, default_message) 183 | -------------------------------------------------------------------------------- /stormpath/data_store.py: -------------------------------------------------------------------------------- 1 | """Data store abstractions.""" 2 | 3 | 4 | from .cache.manager import CacheManager 5 | 6 | 7 | class DataStore(object): 8 | """ 9 | The DataStore object is an intermediary between Stormpath resources and the 10 | Stormpath API. It handles fetching Stormpath data from either the Stormpath 11 | API service, or a cache. 12 | 13 | More info can be found in our documentation: 14 | http://docs.stormpath.com/python/product-guide/#sdk-concepts 15 | 16 | Examples:: 17 | 18 | from stormpath.cache.memory_store import MemoryStore 19 | from stormpath.cache.redis_store import RedisStore 20 | 21 | data_store = DataStore(executor, { 22 | 'store': MemoryStore, 23 | 'regions': { 24 | 'applications': { 25 | 'store': RedisStore, 26 | 'ttl': 300, 27 | 'tti': 300, 28 | 'store_opts': { 29 | 'host': 'localhost', 30 | 'port': 6739, 31 | } 32 | }, 33 | 'directories': { 34 | 'ttl': 60, 35 | 'tti': 60, 36 | } 37 | } 38 | }) 39 | """ 40 | CACHE_REGIONS = ( 41 | 'accounts', 42 | 'apiKeys', 43 | 'accountStoreMappings', 44 | 'applications', 45 | 'customData', 46 | 'directories', 47 | 'organizations', 48 | 'groups', 49 | 'groupMemberships', 50 | 'tenants', 51 | 'nonces', 52 | ) 53 | 54 | def __init__(self, executor, cache_options=None): 55 | """ 56 | Initialize the DataStore. 57 | 58 | :param obj executor: An HTTP request executor. 59 | :type executor: :class:`stormpath.http.HttpExecutor` 60 | :param cache_options: A dictionary with cache settings. 61 | :type cache_options: dict or None, optional 62 | :returns: The initialized DataStore object. 63 | :rtype: :class:`stormpath.data_store.DataStore` 64 | """ 65 | self.cache_manager = CacheManager() 66 | self.executor = executor 67 | 68 | if cache_options is None: 69 | cache_options = {} 70 | 71 | for region in self.CACHE_REGIONS: 72 | opts = cache_options.get('regions', {}).get(region, {}) 73 | for k, v in cache_options.items(): 74 | if k not in opts and k != 'regions': 75 | opts[k] = v 76 | 77 | self.cache_manager.create_cache(region, **opts) 78 | 79 | def _get_cache(self, href): 80 | class NoCache(object): 81 | def get(self, *args, **kwargs): 82 | return None 83 | 84 | def put(self, *args, **kwargs): 85 | pass 86 | 87 | def delete(self, *args, **kwargs): 88 | pass 89 | 90 | if '/' not in href: 91 | return NoCache() 92 | 93 | parts = href.split('/') 94 | 95 | # resource hrefs are in format: 96 | # ".../resource/resource_uid" 97 | if parts[-2] in self.CACHE_REGIONS: # We only care about instances. 98 | return self.cache_manager.get_cache(parts[-2]) or NoCache() 99 | 100 | # custom data hrefs are in format: 101 | # ".../resource/resource_uid/customData" 102 | elif parts[-1] in self.CACHE_REGIONS and parts[-1] == 'customData': 103 | return self.cache_manager.get_cache(parts[-1]) or NoCache() 104 | 105 | else: 106 | return NoCache() 107 | 108 | def _cache_get(self, href): 109 | return self._get_cache(href).get(href) 110 | 111 | def _cache_put(self, href, data, new=True): 112 | resource_data = {} 113 | for name, value in data.items(): 114 | if isinstance(value, dict) and 'href' in value: 115 | v2 = {'href': value['href']} 116 | if 'items' in value: 117 | v2['items'] = [] 118 | 119 | for item in value['items']: 120 | self._cache_put(item['href'], item) 121 | v2['items'].append({'href': item['href']}) 122 | else: 123 | if len(value) > 1: 124 | self._cache_put(value['href'], value) 125 | else: 126 | v2 = value 127 | 128 | resource_data[name] = v2 129 | 130 | self._get_cache(href).put(href, resource_data, new=new) 131 | 132 | def uncache_resource(self, href): 133 | """ 134 | This method will purge a resource from the cache. 135 | 136 | :param str href: The resource href to uncache. 137 | 138 | .. note:: 139 | CustomData is a special case here. If a developer is deleting only 140 | a specific CustomData key, we need to delete the entire CustomData 141 | cache we have, since we only cache CustomData as a whole, and not on 142 | a per-key basis. 143 | 144 | Examples:: 145 | 146 | data_store.uncache_resource('https://api.stormpath.com/v1/accounts/xxx') 147 | data_store.uncache_resource('https://api.stormpath.com/v1/accounts/xxx/customData/blah') 148 | """ 149 | # This modifies any URLs that look like: 150 | # https://api.stormpath.com/v1/accounts/xxx/customData/key and makes 151 | # them look like this: 152 | # https://api.stormpath.com/v1/accounts/xxx/customData 153 | if 'customData' in href and not href.endswith('customData'): 154 | parts = href.split('/') 155 | href = '/'.join(parts[:-1]) 156 | 157 | self._get_cache(href).delete(href) 158 | 159 | def get_resource(self, href, params=None): 160 | """ 161 | This method will retrieve a resource from either the cache, or the 162 | Stormpath API service. 163 | 164 | If the resource needs to be retrieved from the Stormpath API service, it 165 | will also be inserted into the cache. 166 | 167 | :param str href: The href of the resource to retrieve. 168 | :param params: Any additional params to use when fetching the resource. 169 | :type params: dict or None, optional 170 | :returns: The retrieved resource. 171 | :rtype: dict 172 | 173 | Examples:: 174 | 175 | account = data_store.get_resource('https://api.stormpath.com/v1/accounts/xxx') 176 | api_key = data_store.get_resource('https://api.stormpath.com/v1/applications/xxx/apiKeys', params={'id': 'yyy'}) 177 | """ 178 | 179 | # TODO: 180 | # - encrypt cached api keys 181 | # - decrypt cached api keys when retrieving 182 | # - check to see if object is cacheable at all (is it a collection? 183 | # no) 184 | # - recursively cache resources via expansions 185 | # - remove expanded resources and 'clean' objects before caching 186 | data = self._cache_get(href) 187 | if data is None: 188 | data = self.executor.get(href, params=params) 189 | 190 | if data.get('items') and len(data['items']) > 0: 191 | for item in data.get('items'): 192 | self._cache_put(item['href'], item) 193 | 194 | self._cache_put(href, data) 195 | 196 | return data 197 | 198 | def create_resource(self, href, data, params=None): 199 | data = self.executor.post(href, data, params=params) 200 | self._cache_put(href, data) 201 | 202 | return data 203 | 204 | def update_resource(self, href, data): 205 | data = self.executor.post(href, data) 206 | self._cache_put(href, data, new=False) 207 | 208 | return data 209 | 210 | def delete_resource(self, href): 211 | self.executor.delete(href) 212 | self.uncache_resource(href) 213 | -------------------------------------------------------------------------------- /tests/live/test_group.py: -------------------------------------------------------------------------------- 1 | """Live tests of Groups functionality.""" 2 | 3 | from stormpath.error import Error 4 | 5 | from .base import SingleApplicationBase, AccountBase 6 | 7 | 8 | class TestGroups(SingleApplicationBase): 9 | 10 | def test_groups_client_iteration(self): 11 | for _ in self.client.groups: 12 | pass 13 | 14 | def test_groups_client_create(self): 15 | with self.assertRaises(ValueError): 16 | self.client.groups.create({ 17 | 'name': self.get_random_name(), 18 | 'description': 'test group', 19 | }) 20 | 21 | def test_groups_client_get(self): 22 | group = self.app.groups.create({ 23 | 'name': self.get_random_name(), 24 | 'description': 'test group', 25 | }) 26 | 27 | client_group = self.client.groups.get(group.href) 28 | 29 | self.assertEqual(client_group.name, group.name) 30 | 31 | def test_application_group_creation_and_removal(self): 32 | name = self.get_random_name() 33 | 34 | group = self.app.groups.create({ 35 | 'name': name, 36 | 'description': 'test group', 37 | }) 38 | 39 | self.assertTrue(group.is_enabled()) 40 | self.assertEqual(group.directory.href, self.dir.href) 41 | 42 | group2 = self.app.groups.get(group.href) 43 | self.assertEqual(group.href, group2.href) 44 | 45 | group.delete() 46 | 47 | self.assertEqual(len(self.app.groups.query(name=group.name)), 0) 48 | 49 | def test_directory_group_creation_and_removal(self): 50 | name = self.get_random_name() 51 | 52 | group = self.dir.groups.create({ 53 | 'name': name, 54 | 'description': 'test group', 55 | }) 56 | 57 | self.assertTrue(group.is_enabled()) 58 | self.assertEqual(group.directory.href, self.dir.href) 59 | 60 | group2 = self.dir.groups.get(group.href) 61 | self.assertEqual(group.href, group2.href) 62 | 63 | group.delete() 64 | 65 | self.assertEqual(len(self.dir.groups.query(name=group.name)), 0) 66 | 67 | def test_group_creation_failure(self): 68 | name = self.get_random_name() 69 | 70 | self.app.groups.create({ 71 | 'name': name, 72 | 'description': 'test group', 73 | }) 74 | 75 | with self.assertRaises(Error): 76 | self.app.groups.create({ 77 | 'name': name, 78 | 'description': 'test group', 79 | }) 80 | 81 | def test_group_modification(self): 82 | name = self.get_random_name() 83 | 84 | group = self.app.groups.create({ 85 | 'name': name, 86 | 'description': 'test group', 87 | }) 88 | 89 | group.description = 'updated desc' 90 | group.save() 91 | 92 | group2 = self.app.groups.get(group.href) 93 | self.assertEqual(group2.description, group.description) 94 | 95 | def test_setting_group_as_account_store(self): 96 | name = self.get_random_name() 97 | 98 | group = self.app.groups.create({ 99 | 'name': name, 100 | 'description': 'test group', 101 | }) 102 | 103 | self.app.account_store_mappings.create({ 104 | 'application': self.app, 105 | 'account_store': group, 106 | 'is_default_account_store': True 107 | }) 108 | 109 | account_stores = [mapping.account_store.href for mapping in 110 | self.app.account_store_mappings] 111 | self.assertTrue(group.href in account_stores) 112 | 113 | 114 | class TestAccountGroups(AccountBase): 115 | 116 | def create_group(self, name=None, description=None): 117 | if name is None: 118 | name = self.get_random_name() 119 | if description is None: 120 | description = name 121 | 122 | group = self.app.groups.create({ 123 | 'name': name, 124 | 'description': description 125 | }) 126 | 127 | return name, group 128 | 129 | def test_account_group_assignment_and_removal_works(self): 130 | _, account = self.create_account(self.app.accounts) 131 | _, group = self.create_group() 132 | 133 | group.add_account(account) 134 | self.assertTrue(group.has_account(account)) 135 | self.assertTrue(account.has_group(group)) 136 | 137 | group.remove_account(account) 138 | self.assertFalse(group.has_account(account)) 139 | self.assertFalse(account.has_group(group)) 140 | 141 | account.add_group(group) 142 | self.assertTrue(group.has_account(account)) 143 | self.assertTrue(account.has_group(group)) 144 | 145 | account.remove_group(group) 146 | self.assertFalse(group.has_account(account)) 147 | self.assertFalse(account.has_group(group)) 148 | 149 | def test_account_group_helpers(self): 150 | username, account = self.create_account(self.app.accounts) 151 | _, group = self.create_group() 152 | 153 | group.add_account(username) 154 | 155 | self.assertTrue(group.has_account(account.href)) 156 | self.assertTrue(group.has_accounts([account])) 157 | 158 | self.assertTrue(account.has_group(group.name)) 159 | self.assertTrue(account.has_group(group.href)) 160 | self.assertTrue(account.has_groups([group])) 161 | 162 | account.remove_group(group.href) 163 | 164 | def test_has_multiple_groups_accounts(self): 165 | _, account1 = self.create_account(self.app.accounts) 166 | _, group1 = self.create_group() 167 | _, account2 = self.create_account(self.app.accounts) 168 | _, group2 = self.create_group() 169 | 170 | group1.add_account(account1) 171 | self.assertFalse(group1.has_accounts([account1, account2])) 172 | self.assertTrue(group1.has_accounts([account1, account2], all=False)) 173 | 174 | group1.add_account(account2) 175 | self.assertTrue(group1.has_accounts([account1, account2])) 176 | 177 | self.assertFalse(account1.has_groups([group1, group2])) 178 | self.assertTrue(account1.has_groups([group1, group2], all=False)) 179 | 180 | account1.add_group(group2) 181 | self.assertTrue(account1.has_groups([group1, group2])) 182 | 183 | 184 | class TestGroupAccounts(AccountBase): 185 | 186 | def test_resolve_account(self): 187 | _, account = self.create_account(self.app.accounts) 188 | group = account.directory.groups.create({'name': self.get_random_name()}) 189 | 190 | self.assertEqual(group._resolve_account(account).href, account.href) 191 | self.assertEqual(group._resolve_account(account.href).href, account.href) 192 | self.assertEqual(group._resolve_account(account.username).href, account.href) 193 | self.assertEqual(group._resolve_account(account.email).href, account.href) 194 | self.assertEqual(group._resolve_account({'username': account.username}).href, account.href) 195 | self.assertEqual(group._resolve_account({'username': '*' + account.username + '*'}).href, account.href) 196 | 197 | def test_add_accounts(self): 198 | _, account1 = self.create_account(self.app.accounts) 199 | _, account2 = self.create_account(self.app.accounts) 200 | 201 | group = account1.directory.groups.create({'name': self.get_random_name()}) 202 | group.add_accounts([account1, account2.href]) 203 | 204 | self.assertTrue(group.has_account(account1)) 205 | self.assertTrue(group.has_account(account2)) 206 | 207 | def test_remove_accounts(self): 208 | _, account1 = self.create_account(self.app.accounts) 209 | _, account2 = self.create_account(self.app.accounts) 210 | _, account3 = self.create_account(self.app.accounts) 211 | 212 | group = account1.directory.groups.create({'name': self.get_random_name()}) 213 | group.add_accounts([account1, account2.href]) 214 | self.assertTrue(group.has_accounts([account1, account2])) 215 | 216 | group.remove_accounts([account1, account2]) 217 | self.assertFalse(group.has_account(account1)) 218 | self.assertFalse(group.has_account(account2)) 219 | 220 | self.assertRaises(Error, group.remove_accounts, [account3]) 221 | --------------------------------------------------------------------------------