├── 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 |
--------------------------------------------------------------------------------