diff --git a/.gitignore b/.gitignore index 0048795..16cdbb7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ _build dist *.pyc __pycache__ +.cache/ +.coverage +htmlcov/ +pytest.ini +*.ini diff --git a/docs/changelog.rst b/docs/changelog.rst index 2cd31d2..d7f57ba 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,22 @@ Change Log All library changes, in descending order. +Version 0.5.0 +------------- + +**Released March 1, 2017.** + +- Refactored views from function based to class based. +- Adding VerifyEmail and Me view. +- Adding request parsing. +- Upgrading social login. +- Upgraded CSRF protection. +- Adding new configuration loading. +- Replaced settings with 'STORMPATH' prefix to one from our Stormpath Config + object. +- Adding dynamic forms. + + Version 0.4.8 ------------- diff --git a/docs/product.rst b/docs/product.rst index 94e407c..cd79810 100644 --- a/docs/product.rst +++ b/docs/product.rst @@ -480,12 +480,12 @@ Next, copy the following code into ``templates/login.html``:: {# If social login is enabled, display social login buttons. #} - {% if config['STORMPATH_ENABLE_FACEBOOK'] or config['STORMPATH_ENABLE_GOOGLE'] %} + {% if config['stormpath']['web']['social']['facebook']['enabled'] or config['stormpath']['web']['social']['google']['enabled'] %}

Or, log in using a social provider.

- {% if config['STORMPATH_ENABLE_FACEBOOK'] %} + {% if config['stormpath']['web']['social']['facebook']['enabled'] %} {% include "flask_stormpath/facebook_login_form.html" %} {% endif %} - {% if config['STORMPATH_ENABLE_GOOGLE'] %} + {% if config['stormpath']['web']['social']['google']['enabled'] %} {% include "flask_stormpath/google_login_form.html" %} {% endif %} {% endif %} diff --git a/docs/upgrading.rst b/docs/upgrading.rst index d1650f9..0049022 100644 --- a/docs/upgrading.rst +++ b/docs/upgrading.rst @@ -8,6 +8,12 @@ This page contains specific upgrading instructions to help you migrate between Flask-Stormpath releases. +Version 0.4.8 -> Version 0.5.0 +------------------------------ + +**No changes needed!** + + Version 0.4.7 -> Version 0.4.8 ------------------------------ diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index bd7798f..470dd78 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -1,4 +1,52 @@ # -*- coding: utf-8 -*- + + +import os +from datetime import timedelta +from flask import Blueprint, __version__ as flask_version, current_app + +from flask_login import ( + LoginManager, + current_user, + login_required, + login_user, + logout_user +) + +from flask_login.utils import _get_user +from stormpath.client import Client +from stormpath.error import Error as StormpathError +from stormpath_config.loader import ConfigLoader +from stormpath_config.strategies import ( + LoadEnvConfigStrategy, + LoadFileConfigStrategy, + LoadAPIKeyConfigStrategy, + LoadAPIKeyFromConfigStrategy, + ValidateClientConfigStrategy, + EnrichClientFromRemoteConfigStrategy, + EnrichIntegrationFromRemoteConfigStrategy, + MoveAPIKeyToClientAPIKeyStrategy, + ExtendConfigStrategy, + MoveSettingsToConfigStrategy) + +from werkzeug.local import LocalProxy +from .context_processors import user_context_processor +from .request_processors import request_wants_json +from .models import User +from .settings import StormpathSettings +from .views import ( + RegisterView, + LoginView, + ForgotPasswordView, + ChangePasswordView, + VerifyEmailView, + LogoutView, + MeView, + GoogleLoginView, + FacebookLoginView +) + + """ flask-stormpath --------------- @@ -15,52 +63,13 @@ """ -__version__ = '0.4.8' +__version__ = '0.5.0' __version_info__ = __version__.split('.') __author__ = 'Stormpath, Inc.' __license__ = 'Apache' __copyright__ = '(c) 2012 - 2015 Stormpath, Inc.' -from flask import ( - Blueprint, - __version__ as flask_version, - _app_ctx_stack as stack, - current_app, -) - -from flask_login import ( - LoginManager, - current_user, - login_required, - login_user, - logout_user, -) -try: - from flask_login.utils import _get_user -except ImportError: - from flask_login import _get_user - -from stormpath.client import Client -from stormpath.error import Error as StormpathError - -from werkzeug.local import LocalProxy - -from .context_processors import user_context_processor -from .decorators import groups_required -from .models import User -from .settings import check_settings, init_settings -from .views import ( - google_login, - facebook_login, - forgot, - forgot_change, - login, - logout, - register, -) - - # A proxy for the current user. user = LocalProxy(lambda: _get_user()) @@ -72,19 +81,26 @@ class StormpathManager(object): specific apps, so you can create one in the main body of your code and then bind it to your app in a factory function. """ - def __init__(self, app=None): + def __init__(self, app=None, csrf=None): """ Initialize this extension. :param obj app: (optional) The Flask app. + :param obj csrf: (optional) CSRFProtect object. """ self.app = app + self.csrf = csrf # If the user specifies an app, let's configure go ahead and handle all # configuration stuff for the user's app. if app is not None: self.init_app(app) + @app.before_request + def check_csrf(): + if self.csrf and not request_wants_json(): + csrf.protect() + def init_app(self, app): """ Initialize this application. @@ -100,11 +116,7 @@ def init_app(self, app): """ # Initialize all of the Flask-Stormpath configuration variables and # settings. - init_settings(app.config) - - # Check our user defined settings to ensure Flask-Stormpath is properly - # configured. - check_settings(app.config) + self.init_settings(app.config) # Initialize the Flask-Login extension. self.init_login(app) @@ -113,7 +125,8 @@ def init_app(self, app): self.init_routes(app) # Initialize our blueprint. This lets us do cool template stuff. - blueprint = Blueprint('flask_stormpath', 'flask_stormpath', template_folder='templates') + blueprint = Blueprint( + 'flask_stormpath', 'flask_stormpath', template_folder='templates') app.register_blueprint(blueprint) # Ensure the `user` context is available in templates. This makes it @@ -125,6 +138,64 @@ def init_app(self, app): # necessary! self.app = app + def init_settings(self, config): + """ + Initialize the Flask-Stormpath settings. + + This function sets all default configuration values. + + :param dict config: The Flask app config. + """ + # Basic Stormpath credentials and configuration. + web_config_file = config.get('STORMPATH_CONFIG_PATH') + + # Set default settings needed to init stormpath-flask. + self.set_default_settings(config) + + config_loader = ConfigLoader( + load_strategies=[ + LoadFileConfigStrategy(web_config_file), + LoadAPIKeyConfigStrategy("~/.stormpath/apiKey.properties"), + LoadFileConfigStrategy("~/.stormpath/stormpath.json"), + LoadFileConfigStrategy("~/.stormpath/stormpath.yaml"), + LoadAPIKeyConfigStrategy("./apiKey.properties"), + LoadFileConfigStrategy("./stormpath.yaml"), + LoadFileConfigStrategy("./stormpath.json"), + LoadEnvConfigStrategy(prefix='STORMPATH'), + ExtendConfigStrategy(extend_with={}), # FIXME: This is still unimplemented. + MoveSettingsToConfigStrategy(config=config) + ], + post_processing_strategies=[ + LoadAPIKeyFromConfigStrategy(), + MoveAPIKeyToClientAPIKeyStrategy() + ], + validation_strategies=[ValidateClientConfigStrategy()]) + config['stormpath'] = StormpathSettings(config_loader.load()) + + # Create our custom user agent. This allows us to see which + # version of this SDK are out in the wild! + user_agent = 'stormpath-flask/%s flask/%s' % ( + __version__, flask_version) + + # Instantiate client with apiKey id and secret from config. + self.client = Client( + id=self.app.config['stormpath']['client']['apiKey']['id'], + secret=self.app.config['stormpath']['client']['apiKey']['secret'], + user_agent=user_agent, + cache_options=self.app.config['stormpath']['cache'], + ) + + # Enrich config from API service, and validate crucial settings. + ecfrcs = EnrichClientFromRemoteConfigStrategy( + client_factory=lambda client: self.client) + ecfrcs.process(self.app.config['stormpath'].store) + eifrcs = EnrichIntegrationFromRemoteConfigStrategy( + client_factory=lambda client: self.client) + eifrcs.process(self.app.config['stormpath'].store) + + self.application = self.client.applications.get( + self.app.config['stormpath']['application']['href']) + def init_login(self, app): """ Initialize the Flask-Login extension. @@ -134,18 +205,21 @@ def init_login(self, app): :param obj app: The Flask app. """ - app.config['REMEMBER_COOKIE_DURATION'] = app.config['STORMPATH_COOKIE_DURATION'] - app.config['REMEMBER_COOKIE_DOMAIN'] = app.config['STORMPATH_COOKIE_DOMAIN'] + app.config['REMEMBER_COOKIE_DURATION'] = app.config[ + 'stormpath']['cookie']['duration'] + app.config['REMEMBER_COOKIE_DOMAIN'] = app.config[ + 'stormpath']['cookie']['domain'] app.login_manager = LoginManager(app) app.login_manager.user_callback = self.load_user app.stormpath_manager = self - if app.config['STORMPATH_ENABLE_LOGIN']: + if app.config['stormpath']['web']['login']['enabled']: app.login_manager.login_view = 'stormpath.login' # Make this Flask session expire automatically. - app.config['PERMANENT_SESSION_LIFETIME'] = app.config['STORMPATH_COOKIE_DURATION'] + app.config['PERMANENT_SESSION_LIFETIME'] = app.config[ + 'stormpath']['cookie']['duration'] def init_routes(self, app): """ @@ -158,93 +232,99 @@ def init_routes(self, app): :param obj app: The Flask app. """ - if app.config['STORMPATH_ENABLE_REGISTRATION']: + if app.config['stormpath']['web']['basePath']: + base_path = app.config['stormpath']['web']['basePath'] + else: + base_path = '/' + + if app.config['stormpath']['web']['register']['enabled']: app.add_url_rule( - app.config['STORMPATH_REGISTRATION_URL'], + os.path.join( + base_path, + app.config['stormpath']['web']['register'][ + 'uri'].strip('/')), 'stormpath.register', - register, - methods = ['GET', 'POST'], + RegisterView.as_view('register'), + methods=['GET', 'POST'], ) - if app.config['STORMPATH_ENABLE_LOGIN']: + if app.config['stormpath']['web']['login']['enabled']: app.add_url_rule( - app.config['STORMPATH_LOGIN_URL'], + os.path.join( + base_path, app.config['stormpath']['web']['login'][ + 'uri'].strip('/')), 'stormpath.login', - login, - methods = ['GET', 'POST'], + LoginView.as_view('login'), + methods=['GET', 'POST'], ) - if app.config['STORMPATH_ENABLE_FORGOT_PASSWORD']: + if app.config['stormpath']['web']['forgotPassword']['enabled']: app.add_url_rule( - app.config['STORMPATH_FORGOT_PASSWORD_URL'], + os.path.join( + base_path, + app.config['stormpath']['web']['forgotPassword'][ + 'uri'].strip('/')), 'stormpath.forgot', - forgot, - methods = ['GET', 'POST'], + ForgotPasswordView.as_view('forgot'), + methods=['GET', 'POST'], ) app.add_url_rule( - app.config['STORMPATH_FORGOT_PASSWORD_CHANGE_URL'], + os.path.join( + base_path, + app.config['stormpath']['web']['changePassword'][ + 'uri'].strip('/')), 'stormpath.forgot_change', - forgot_change, - methods = ['GET', 'POST'], + ChangePasswordView.as_view('change'), + methods=['GET', 'POST'], ) - if app.config['STORMPATH_ENABLE_LOGOUT']: + if app.config['stormpath']['web']['verifyEmail']['enabled']: app.add_url_rule( - app.config['STORMPATH_LOGOUT_URL'], + app.config['stormpath']['web']['verifyEmail']['uri'], + 'stormpath.verify', + VerifyEmailView.as_view('verify'), + methods=['GET', 'POST'], + ) + + if app.config['stormpath']['web']['logout']['enabled']: + app.add_url_rule( + os.path.join( + base_path, + app.config['stormpath']['web']['logout'][ + 'uri'].strip('/')), 'stormpath.logout', - logout, + LogoutView.as_view('logout'), ) - if app.config['STORMPATH_ENABLE_GOOGLE']: + if app.config['stormpath']['web']['me']['enabled']: app.add_url_rule( - app.config['STORMPATH_GOOGLE_LOGIN_URL'], + os.path.join( + base_path, + app.config['stormpath']['web']['me']['uri'].strip('/')), + 'stormpath.me', + MeView.as_view('me'), + ) + + if app.config['stormpath']['web']['social']['google']['enabled']: + app.add_url_rule( + os.path.join( + base_path, + app.config[ + 'stormpath']['web']['social']['google']['login_url']), 'stormpath.google_login', - google_login, + GoogleLoginView.as_view('google'), ) - if app.config['STORMPATH_ENABLE_FACEBOOK']: + if app.config['stormpath']['web']['social']['facebook']['enabled']: app.add_url_rule( - app.config['STORMPATH_FACEBOOK_LOGIN_URL'], + os.path.join( + base_path, + app.config[ + 'stormpath']['web']['social']['facebook']['login_url']), 'stormpath.facebook_login', - facebook_login, + FacebookLoginView.as_view('facebook'), ) - @property - def client(self): - """ - Lazily load the Stormpath Client object we need to access the raw - Stormpath SDK. - """ - ctx = stack.top.app - if ctx is not None: - if not hasattr(ctx, 'stormpath_client'): - - # Create our custom user agent. This allows us to see which - # version of this SDK are out in the wild! - user_agent = 'stormpath-flask/%s flask/%s' % (__version__, flask_version) - - # If the user is specifying their credentials via a file path, - # we'll use this. - if self.app.config['STORMPATH_API_KEY_FILE']: - ctx.stormpath_client = Client( - api_key_file_location = self.app.config['STORMPATH_API_KEY_FILE'], - user_agent = user_agent, - cache_options = self.app.config['STORMPATH_CACHE'], - ) - - # If the user isn't specifying their credentials via a file - # path, it means they're using environment variables, so we'll - # try to grab those values. - else: - ctx.stormpath_client = Client( - id = self.app.config['STORMPATH_API_KEY_ID'], - secret = self.app.config['STORMPATH_API_KEY_SECRET'], - user_agent = user_agent, - cache_options = self.app.config['STORMPATH_CACHE'], - ) - - return ctx.stormpath_client - @property def login_view(self): """ @@ -260,25 +340,6 @@ def login_view(self, value): """ self.app.login_manager.login_view = value - @property - def application(self): - """ - Lazily load the Stormpath Application object we need to handle user - authentication, etc. - """ - ctx = stack.top.app - if ctx is not None: - if not hasattr(ctx, 'stormpath_application'): - applications = self.client.applications.search( - self.app.config['STORMPATH_APPLICATION'] - ) - if applications is None: - raise Exception('Failed to find ' + self.app.config['STORMPATH_APPLICATION'] + ' application. Please add it in the Stormpath console.') - - ctx.stormpath_application = applications[0] - - return ctx.stormpath_application - @staticmethod def load_user(account_href): """ @@ -296,3 +357,41 @@ def load_user(account_href): return user except StormpathError: return None + + def set_default_settings(self, config): + """ + Sets default settings for crucial settings needed to run + Flask-Stormpath. + """ + + # Set csrf default check to False, since our StormpathManager applies + # custom logic for applying csrf tokens. + config.setdefault('WTF_CSRF_CHECK_DEFAULT', False) + + # Which fields should be displayed when registering new users? + config.setdefault('STORMPATH_ENABLE_FACEBOOK', False) + config.setdefault('STORMPATH_ENABLE_GOOGLE', False) + + # Configure URL mappings. These URL mappings control which URLs will + # be used by Flask-Stormpath views. + config.setdefault('STORMPATH_GOOGLE_LOGIN_URL', '/google') + config.setdefault('STORMPATH_FACEBOOK_LOGIN_URL', '/facebook') + + # Cache configuration. + config.setdefault('STORMPATH_CACHE', None) + + # Configure templates. These template settings control which + # templates are used to render the Flask-Stormpath views. + config.setdefault( + 'STORMPATH_BASE_TEMPLATE', 'flask_stormpath/base.html') + + # Social login configuration. + config.setdefault('STORMPATH_SOCIAL', {}) + + # Cookie configuration. + config.setdefault('STORMPATH_COOKIE_DOMAIN', None) + config.setdefault('STORMPATH_COOKIE_DURATION', timedelta(days=365)) + + # Cookie name (this is not overridable by users, at least + # not explicitly). + config.setdefault('REMEMBER_COOKIE_NAME', 'stormpath_token') diff --git a/flask_stormpath/config/default-config.yml b/flask_stormpath/config/default-config.yml new file mode 100644 index 0000000..30e9661 --- /dev/null +++ b/flask_stormpath/config/default-config.yml @@ -0,0 +1,310 @@ +web: + + # The basePath is used as the default path for the cookies that are set by + # this library. If not defined, we will default to / + basePath: null + + domainName: null # Required if using subdomain-based multi-tenancy + + multiTenancy: + + # When enabled, the framework will require the user to authenticate against + # a specific Organization. The authenticated organization is persisted in + # the access token that is issued. + # + # At the moment we only support a sub-domain based strategy, wherby the + # user must arrive on the subdomain that correlates with their tenant in + # order to authenticate. If they visit the parent domain, they will be + # required to identify the organization they wish to use. + + enabled: false + strategy: "subdomain" + + oauth2: + enabled: true + uri: "/oauth/token" + client_credentials: + enabled: true + accessToken: + ttl: 3600 + password: + enabled: true + validationStrategy: "local" + + accessTokenCookie: + + # Controls the name of the cookie that we send to the browser. + name: "access_token" + + # httpOnly is true, to prevent XSS attacks from hijacking your cookies. We + # highly recommend that you do not change this. + httpOnly: true + + # The secure property controls the Secure flag on the cookie. The + # framework will auto-detect if the incoming request is over HTTPS, by + # looking at req.protocol === 'https' and turn on Secure if so. You can + # override auto-detection and force the Secure flag on by setting this + # property to true, or force off by setting this property to false. + secure: null + + # Controls the path flag of the cookie. Inherits from basePath, but can be + # overridden here. + path: null + + # Controls the domain flag on the cookie, will not be set unless specified. + domain: null + + # Refresh Token Cookie has same options as the Access Token Cookie (above). + refreshTokenCookie: + name: "refresh_token" + httpOnly: true + secure: null + path: null + domain: null + + # If the request does not specify an Accept header, or the preferred content + # type is */*, the Stormpath integration will respond with the first type in + # this list. + produces: + - application/json + - text/html + + # By default the Stormpath integration will respond to JSON and HTML + # requests. If a requested type is not in this list, the Stormpath + # integration should pass on the request, and allow the developer or base + # framework to handle the response. + invalidRequest: + uri: "/invalid_request" + + register: + enabled: true + uri: "/register" + nextUri: "/" + + # autoLogin is possible only if the email verification feature is disabled + # on the default account store of the defined Stormpath application. + autoLogin: false + view: "register" + form: + fields: + givenName: + enabled: true + label: "First Name" + placeholder: "First Name" + required: true + type: "text" + middleName: + enabled: false + label: "Middle Name" + placeholder: "Middle Name" + required: true + type: "text" + surname: + enabled: true + label: "Last Name" + placeholder: "Last Name" + required: true + type: "text" + username: + enabled: true + label: "Username" + placeholder: "Username" + required: true + type: "text" + email: + enabled: true + label: "Email" + placeholder: "Email" + required: true + type: "email" + password: + enabled: true + label: "Password" + placeholder: "Password" + required: true + type: "password" + confirmPassword: + enabled: false + label: "Confirm Password" + placeholder: "Confirm Password" + required: true + type: "password" + fieldOrder: + - "username" + - "givenName" + - "middleName" + - "surname" + - "email" + - "password" + - "confirmPassword" + + # Unless verifyEmail.enabled is specifically set to false, the email + # verification feature must be automatically enabled if the default account + # store for the defined Stormpath application has the email verification + # workflow enabled. + verifyEmail: + enabled: true + uri: "/verify" + nextUri: "/login?status=verified" + unverifiedUri: "/login?status=unverified" + view: "verify" + form: + fields: + email: + enabled: true + visible: true + label: "Email" + placeholder: "Email" + required: true + type: "email" + fieldOrder: + - "email" + + login: + enabled: true + uri: "/login" + nextUri: "/" + view: "login" + form: + fields: + login: + enabled: true + label: "Username or Email" + placeholder: "Username or Email" + required: true + type: "text" + password: + enabled: true + label: "Password" + placeholder: "Password" + required: true + type: "password" + fieldOrder: + - "login" + - "password" + + logout: + enabled: true + uri: "/logout" + nextUri: "/" + + organizationSelect: + view: "organization-select" + form: + fields: + organizationNameKey: + label: "Enter your organization name to continue" + placeholder: "e.g. my-company" + + # Unless forgotPassword.enabled is explicitly set to false, this feature + # will be automatically enabled if the default account store for the defined + # Stormpath application has the password reset workflow enabled. + forgotPassword: + enabled: true + uri: "/forgot" + nextUri: "/login?status=forgot" + view: "forgot-password" + form: + fields: + email: + enabled: true + visible: true + label: "Email" + placeholder: "Email" + required: true + type: "email" + fieldOrder: + - "email" + + # Unless changePassword.enabled is explicitly set to false, this feature + # will be automatically enabled if the default account store for the defined + # Stormpath application has the password reset workflow enabled. + changePassword: + enabled: true + uri: "/change" + nextUri: "/login?status=reset" + errorUri: "/forgot?status=invalid_sptoken" + autoLogin: false + view: "change-password" + form: + fields: + password: + enabled: true + label: "Password" + placeholder: "Password" + required: true + type: "password" + confirmPassword: + enabled: true + label: "Confirm Password" + placeholder: "Confirm Password" + required: true + type: "password" + fieldOrder: + - "password" + - "confirmPassword" + + # If idSite.enabled is true, the user should be redirected to ID site for + # login, registration, and password reset. They should also be redirected + # through ID Site on logout. + idSite: + enabled: false + uri: "/idSiteResult" + nextUri: "/" + loginUri: "" + forgotUri: "/#/forgot" + registerUri: "/#/register" + + # Social login configuration. This defines the callback URIs for OAuth + # flows, and the scope that is requested of each provider. Some providers + # want space-separated scopes, some want comma-separated. As such, these + # string values should be passed directly, as defined. + # + # These settings have no affect if the application does not have an account + # store for the given provider. + social: + facebook: + uri: "/callbacks/facebook" + scope: "email" + github: + uri: "/callbacks/github" + scope: "user:email" + google: + uri: "/callbacks/google" + scope: "email profile" + linkedin: + uri: "/callbacks/linkedin" + scope: "r_basicprofile, r_emailaddress" + + # The /me route is for front-end applications, it returns a JSON object with + # the current user object. The developer can opt-in to expanding account + # resources on this enpdoint. + me: + enabled: true + uri: "/me" + expand: + apiKeys: false + applications: false + customData: false + directory: false + groupMemberships: false + groups: false + providerData: false + tenant: false + + # If the developer wants our integration to serve their Single Page + # Application (SPA) in response to HTML requests for our default routes, + # such as /login, then they will need to enable this feature and tell us + # where the root of their SPA is. This is likely a file path on the + # filesystem. + # + # If the developer does not want our integration to handle their SPA, they + # will need to configure the framework themselves and remove 'text/html' + # from `stormpath.web.produces`, so that we don't serve our default + # HTML views. + spa: + enabled: false + view: null + + unauthorized: + view: "unauthorized" diff --git a/flask_stormpath/context_processors.py b/flask_stormpath/context_processors.py index 5ac3c4a..cae4796 100644 --- a/flask_stormpath/context_processors.py +++ b/flask_stormpath/context_processors.py @@ -1,11 +1,7 @@ """Custom context processors to make template development simpler.""" -from flask import current_app -try: - from flask_login.utils import _get_user -except ImportError: - from flask_login import _get_user +from flask_login.utils import _get_user def user_context_processor(): diff --git a/flask_stormpath/errors.py b/flask_stormpath/errors.py deleted file mode 100644 index 106f7ad..0000000 --- a/flask_stormpath/errors.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Custom errors.""" - - -class ConfigurationError(Exception): - """ - This exception is raised if a user has misconfigured Flask-Stormpath. - """ - pass diff --git a/flask_stormpath/forms.py b/flask_stormpath/forms.py index d99728c..737ba5f 100644 --- a/flask_stormpath/forms.py +++ b/flask_stormpath/forms.py @@ -2,98 +2,97 @@ from flask_wtf import FlaskForm -from flask_wtf.form import _Auto +from wtforms.widgets import HiddenInput from wtforms.fields import PasswordField, StringField -from wtforms.validators import Email, EqualTo, InputRequired, ValidationError - - -class RegistrationForm(FlaskForm): - """ - Register a new user. - - This class is used to provide safe user registration. The only required - fields are `email` and `password` -- everything else is optional (and can - be configured by the developer to be used or not). - - .. note:: - This form only includes the fields that are available to register - users with Stormpath directly -- this doesn't include support for - Stormpath's social login stuff. - - Since social login stuff is handled separately (registration happens - through Javascript) we don't need to have a form for registering users - that way. - """ - username = StringField('Username') - given_name = StringField('First Name') - middle_name = StringField('Middle Name') - surname = StringField('Last Name') - email = StringField('Email', validators=[ - InputRequired('You must provide an email address.'), - Email('You must provide a valid email address.') - ]) - password = PasswordField('Password', validators=[InputRequired('You must supply a password.')]) - - def __init__(self, formdata=_Auto, config=None, **kwargs): - super(RegistrationForm, self).__init__(formdata=formdata, **kwargs) - - if config: - if config['STORMPATH_ENABLE_USERNAME'] and config['STORMPATH_REQUIRE_USERNAME']: - self.username.validators.append(InputRequired('Username is required.')) - - if config['STORMPATH_ENABLE_GIVEN_NAME'] and config['STORMPATH_REQUIRE_GIVEN_NAME']: - self.given_name.validators.append(InputRequired('First name is required.')) - - if config['STORMPATH_ENABLE_MIDDLE_NAME'] and config['STORMPATH_REQUIRE_MIDDLE_NAME']: - self.middle_name.validators.append(InputRequired('Middle name is required.')) - - if config['STORMPATH_ENABLE_SURNAME'] and config['STORMPATH_REQUIRE_SURNAME']: - self.surname.validators.append(InputRequired('Surname is required.')) - - -class LoginForm(FlaskForm): - """ - Log in an existing user. - - This class is used to provide safe user login. A user can log in using - a login identifier (either email or username) and password. Stormpath - handles the username / email abstractions itself, so we don't need any - special logic to handle those cases. - - .. note:: - This form only includes the fields that are available to log users in - with Stormpath directly -- this doesn't include support for Stormpath's - social login stuff. - - Since social login stuff is handled separately (login happens through - Javascript) we don't need to have a form for logging in users that way. - """ - login = StringField('Login', validators=[InputRequired('Login identifier required.')]) - password = PasswordField('Password', validators=[InputRequired('Password required.')]) - - -class ForgotPasswordForm(FlaskForm): - """ - Retrieve a user's email address for initializing the password reset - workflow. - - This class is used to retrieve a user's email address. - """ - email = StringField('Email', validators=[ - InputRequired('Email address required.'), - Email('You must provide a valid email address.') - ]) - - -class ChangePasswordForm(FlaskForm): - """ - Change a user's password. - - This class is used to retrieve a user's password twice to ensure it's valid - before making a change. - """ - password = PasswordField('Password', validators=[InputRequired('Password required.')]) - password_again = PasswordField('Password (again)', validators=[ - InputRequired('Please verify the password.'), - EqualTo('password', 'Passwords do not match.') - ]) +from wtforms.validators import InputRequired, EqualTo, Email +from stormpath.resources import Resource +import json + + +class StormpathForm(FlaskForm): + @classmethod + def specialize_form(basecls, config): + """ + Dynamic form. + + This class is used to set fields dynamically based on the form fields + settings from the config. + + .. note:: + This doesn't include support for Stormpath's social login stuff. + Since social login stuff is handled separately (through + Javascript), we don't need to have a form for registering/logging + in users that way. + """ + + class cls(basecls): + # Make sure that the original class is left unaltered. + pass + + field_list = config.get('fields', {}) + field_order = config.get('fieldOrder', []) + + setattr(cls, '_json', []) + + for field in field_order: + if field_list[field]['enabled']: + validators = [] + placeholder = field_list[field]['placeholder'] + + # Construct json fields + json_field = {'name': Resource.from_camel_case(field)} + json_field['placeholder'] = placeholder + + # Apply validators. + if field_list[field]['required']: + validators.append(InputRequired( + message='%s is required.' % placeholder)) + + if field_list[field]['type'] == 'email': + validators.append(Email( + message='Email must be in valid format.')) + + if field == 'confirmPassword': + validators.append(EqualTo( + 'password', message='Passwords do not match.')) + json_field['required'] = field_list[field]['required'] + + # Apply widgets. + if field_list[field].get('visible', True): + widget = None + json_field['visible'] = True + else: + widget = HiddenInput() + json_field['visible'] = False + + # Apply field classes. + if field_list[field]['type'] == 'password': + field_class = PasswordField + else: + field_class = StringField + json_field['type'] = field_list[field]['type'] + + # Apply labels. + if 'label' in field_list[field] and isinstance( + field_list[field]['label'], str): + label = field_list[field]['label'] + else: + label = '' + json_field['label'] = field_list[field]['label'] + + # Set json fields. + cls._json.append(json_field) + + # Finally, create our fields dynamically. + setattr( + cls, Resource.from_camel_case(field), + field_class( + label, validators=validators, + render_kw={"placeholder": placeholder}, + widget=widget)) + + return cls + + @property + def json(self): + return json.dumps(self._json) diff --git a/flask_stormpath/models.py b/flask_stormpath/models.py index 53a48e2..18fb893 100644 --- a/flask_stormpath/models.py +++ b/flask_stormpath/models.py @@ -1,13 +1,16 @@ """Custom data models.""" -from flask import current_app +from flask import current_app, request from six import text_type from blinker import Namespace from stormpath.resources.account import Account from stormpath.resources.provider import Provider +from . import StormpathError +from datetime import datetime +import json stormpath_signals = Namespace() @@ -60,9 +63,9 @@ def save(self): """ Send signal after user is updated. """ - return_value = super(User, self).save() + super(User, self).save() user_updated.send(self, user=dict(self)) - return return_value + return self def delete(self): """ @@ -73,8 +76,39 @@ def delete(self): user_deleted.send(None, user=user_dict) return return_value + def to_json(self): + def datetime_handler(obj): + if hasattr(obj, 'isoformat'): + return obj.isoformat() + else: + raise TypeError + + attrs = ( + 'href', + 'modified_at', + 'created_at', + 'status', + 'username', + 'email', + 'given_name', + 'middle_name', + 'surname', + 'full_name' + ) + json_data = { + 'account': {attr: getattr(self, attr, None) for attr in attrs}} + + # In case me view was called with expanded options enabled. + if hasattr(self._expand, 'items'): + json_data['account'].update(self._expand.items) + + return json.dumps(json_data, default=datetime_handler) + @classmethod - def create(cls, email, password, given_name, surname, username=None, middle_name=None, custom_data=None, status='ENABLED'): + def create( + self, email=None, password=None, given_name=None, surname=None, + username=None, middle_name=None, custom_data=None, + status='ENABLED'): """ Create a new User. @@ -113,12 +147,12 @@ def create(cls, email, password, given_name, surname, username=None, middle_name 'status': status, }) _user.__class__ = User - user_created.send(cls, user=dict(_user)) + user_created.send(self, user=dict(_user)) return _user @classmethod - def from_login(cls, login, password): + def from_login(self, login, password): """ Create a new User class given a login (`email` or `username`), and password. @@ -126,13 +160,86 @@ def from_login(cls, login, password): If something goes wrong, this will raise an exception -- most likely -- a `StormpathError` (flask_stormpath.StormpathError). """ - _user = current_app.stormpath_manager.application.authenticate_account(login, password).account + _user = current_app.stormpath_manager.application.authenticate_account( + login, password).account + _user.refresh() _user.__class__ = User return _user + @staticmethod + def from_social(social_name, access_token, provider): + """ + Helper method for our social methods. + """ + + kwargs = {'provider': provider.get('provider_id')} + if social_name == 'facebook': + kwargs['access_token'] = access_token + elif social_name == 'google': + kwargs['code'] = access_token + else: + raise ValueError('Social service is not supported.') + + try: + _user = ( + current_app.stormpath_manager. + application.get_provider_account(**kwargs)) + except StormpathError as err: + social_directory_exists = False + + # If we failed here, it usually means that this application doesn't + # have a social directory -- so we'll create one! + for asm in ( + current_app.stormpath_manager.application. + account_store_mappings): + + # If there is a social directory, we know this isn't the + # problem. + if ( + getattr(asm.account_store, 'provider') and + asm.account_store.provider.provider_id == provider.get( + 'provider_id') + ): + social_directory_exists = True + break + + # If there is a social directory already, we'll just pass on the + # exception we got. + if social_directory_exists: + raise err + + # Otherwise, we'll try to create a social directory on the user's + # behalf (magic!). + dir = current_app.stormpath_manager.client.directories.create({ + 'name': ( + current_app.stormpath_manager.application.name + + '-' + social_name), + 'provider': provider + }) + + # Now that we have a social directory, we'll map it to our + # application so it is active. + asm = ( + current_app.stormpath_manager.application. + account_store_mappings.create({ + 'application': current_app.stormpath_manager.application, + 'account_store': dir, + 'list_index': 99, + 'is_default_account_store': False, + 'is_default_group_store': False, + })) + + # Lastly, let's retry the social login one more time. + _user = ( + current_app.stormpath_manager. + application.get_provider_account(**kwargs)) + + _user.__class__ = User + return _user + @classmethod - def from_google(cls, code): + def from_google(self, code): """ Create a new User class given a Google access code. @@ -142,29 +249,33 @@ def from_google(cls, code): If something goes wrong, this will raise an exception -- most likely -- a `StormpathError` (flask_stormpath.StormpathError). """ - _user = current_app.stormpath_manager.application.get_provider_account( - code = code, - provider = Provider.GOOGLE, - ) - _user.__class__ = User - - return _user + provider = { + 'client_id': current_app.config[ + 'stormpath']['web']['social']['google']['clientId'], + 'client_secret': current_app.config[ + 'stormpath']['web']['social']['google']['clientSecret'], + 'redirect_uri': request.url_root[:-1] + current_app.config[ + 'stormpath']['web']['social']['google']['login_url'], + 'provider_id': Provider.GOOGLE, + } + return self.from_social('google', code, provider) @classmethod - def from_facebook(cls, access_token): + def from_facebook(self, access_token): """ Create a new User class given a Facebook user's access token. - Access tokens must be retrieved from Facebooks's OAuth service (Facebook - Login). + Access tokens must be retrieved from Facebooks's OAuth service + (Facebook Login). If something goes wrong, this will raise an exception -- most likely -- a `StormpathError` (flask_stormpath.StormpathError). """ - _user = current_app.stormpath_manager.application.get_provider_account( - access_token = access_token, - provider = Provider.FACEBOOK, - ) - _user.__class__ = User - - return _user + provider = { + 'client_id': current_app.config[ + 'stormpath']['web']['social']['facebook']['clientId'], + 'client_secret': current_app.config[ + 'stormpath']['web']['social']['facebook']['clientId'], + 'provider_id': Provider.FACEBOOK, + } + return self.from_social('facebook', access_token, provider) diff --git a/flask_stormpath/request_processors.py b/flask_stormpath/request_processors.py new file mode 100644 index 0000000..3def03f --- /dev/null +++ b/flask_stormpath/request_processors.py @@ -0,0 +1,30 @@ +"""Description here.""" + +from flask import request, current_app +from stormpath_config.errors import ConfigurationError + + +def get_accept_header(): + """ + Fetch the request content type and match it against our allowed types. + """ + if current_app and 'stormpath' in current_app.config: + allowed_types = current_app.config['stormpath']['web']['produces'] + request_accept_types = request.accept_mimetypes + + # If no accept types are specified, or the preferred accept type is + # */*, response type will be the first element of self.allowed_types. + if not request_accept_types or request_accept_types[0][0] == '*/*': + return allowed_types[0] + else: + return request_accept_types.best_match(allowed_types) + else: + raise ConfigurationError( + 'You must initialize flask app before calling this function.') + + +def request_wants_json(): + """ + Check if request wants json. + """ + return get_accept_header() == 'application/json' diff --git a/flask_stormpath/settings.py b/flask_stormpath/settings.py index 29c06c1..08528d6 100644 --- a/flask_stormpath/settings.py +++ b/flask_stormpath/settings.py @@ -1,142 +1,116 @@ """Helper functions for dealing with Flask-Stormpath settings.""" -from datetime import timedelta - -from .errors import ConfigurationError - - -def init_settings(config): - """ - Initialize the Flask-Stormpath settings. - - This function sets all default configuration values. - - :param dict config: The Flask app config. - """ - # Basic Stormpath credentials and configuration. - config.setdefault('STORMPATH_API_KEY_ID', None) - config.setdefault('STORMPATH_API_KEY_SECRET', None) - config.setdefault('STORMPATH_API_KEY_FILE', None) - config.setdefault('STORMPATH_APPLICATION', None) - - # Which fields should be displayed when registering new users? - config.setdefault('STORMPATH_ENABLE_FACEBOOK', False) - config.setdefault('STORMPATH_ENABLE_GOOGLE', False) - config.setdefault('STORMPATH_ENABLE_EMAIL', True) # If this is diabled, - # only social login can - # be used. - config.setdefault('STORMPATH_ENABLE_USERNAME', False) - config.setdefault('STORMPATH_ENABLE_EMAIL', True) # This MUST be True! - config.setdefault('STORMPATH_ENABLE_PASSWORD', True) # This MUST be True! - config.setdefault('STORMPATH_ENABLE_GIVEN_NAME', True) - config.setdefault('STORMPATH_ENABLE_MIDDLE_NAME', True) - config.setdefault('STORMPATH_ENABLE_SURNAME', True) - - # If the user attempts to create a non-social account, which fields should - # we require? (Email and password are always required, so those are not - # mentioned below.) - config.setdefault('STORMPATH_REQUIRE_USERNAME', True) - config.setdefault('STORMPATH_REQUIRE_EMAIL', True) # This MUST be True! - config.setdefault('STORMPATH_REQUIRE_PASSWORD', True) # This MUST be True! - config.setdefault('STORMPATH_REQUIRE_GIVEN_NAME', True) - config.setdefault('STORMPATH_REQUIRE_MIDDLE_NAME', False) - config.setdefault('STORMPATH_REQUIRE_SURNAME', True) - - # Will new users be required to verify new accounts via email before - # they're made active? - config.setdefault('STORMPATH_VERIFY_EMAIL', False) - - # Configure views. These views can be enabled or disabled. If they're - # enabled (default), then you automatically get URL routes, working views, - # and working templates for common operations: registration, login, logout, - # forgot password, and changing user settings. - config.setdefault('STORMPATH_ENABLE_REGISTRATION', True) - config.setdefault('STORMPATH_ENABLE_LOGIN', True) - config.setdefault('STORMPATH_ENABLE_LOGOUT', True) - config.setdefault('STORMPATH_ENABLE_FORGOT_PASSWORD', False) - config.setdefault('STORMPATH_ENABLE_SETTINGS', True) - - # Configure URL mappings. These URL mappings control which URLs will be - # used by Flask-Stormpath views. - config.setdefault('STORMPATH_REGISTRATION_URL', '/register') - config.setdefault('STORMPATH_LOGIN_URL', '/login') - config.setdefault('STORMPATH_LOGOUT_URL', '/logout') - config.setdefault('STORMPATH_FORGOT_PASSWORD_URL', '/forgot') - config.setdefault('STORMPATH_FORGOT_PASSWORD_CHANGE_URL', '/forgot/change') - config.setdefault('STORMPATH_SETTINGS_URL', '/settings') - config.setdefault('STORMPATH_GOOGLE_LOGIN_URL', '/google') - config.setdefault('STORMPATH_FACEBOOK_LOGIN_URL', '/facebook') - - # After a successful login, where should users be redirected? - config.setdefault('STORMPATH_REDIRECT_URL', '/') - - # Cache configuration. - config.setdefault('STORMPATH_CACHE', None) - - # Configure templates. These template settings control which templates are - # used to render the Flask-Stormpath views. - config.setdefault('STORMPATH_BASE_TEMPLATE', 'flask_stormpath/base.html') - config.setdefault('STORMPATH_REGISTRATION_TEMPLATE', 'flask_stormpath/register.html') - config.setdefault('STORMPATH_LOGIN_TEMPLATE', 'flask_stormpath/login.html') - config.setdefault('STORMPATH_FORGOT_PASSWORD_TEMPLATE', 'flask_stormpath/forgot.html') - config.setdefault('STORMPATH_FORGOT_PASSWORD_EMAIL_SENT_TEMPLATE', 'flask_stormpath/forgot_email_sent.html') - config.setdefault('STORMPATH_FORGOT_PASSWORD_CHANGE_TEMPLATE', 'flask_stormpath/forgot_change.html') - config.setdefault('STORMPATH_FORGOT_PASSWORD_COMPLETE_TEMPLATE', 'flask_stormpath/forgot_complete.html') - config.setdefault('STORMPATH_SETTINGS_TEMPLATE', 'flask_stormpath/settings.html') - - # Social login configuration. - config.setdefault('STORMPATH_SOCIAL', {}) - - # Cookie configuration. - config.setdefault('STORMPATH_COOKIE_DOMAIN', None) - config.setdefault('STORMPATH_COOKIE_DURATION', timedelta(days=365)) - - # Cookie name (this is not overridable by users, at least not explicitly). - config.setdefault('REMEMBER_COOKIE_NAME', 'stormpath_token') - - -def check_settings(config): - """ - Ensure the user-specified settings are valid. - - This will raise a ConfigurationError if anything mandatory is not - specified. - - :param dict config: The Flask app config. - """ - if not ( - all([ - config['STORMPATH_API_KEY_ID'], - config['STORMPATH_API_KEY_SECRET'], - ]) or config['STORMPATH_API_KEY_FILE'] - ): - raise ConfigurationError('You must define your Stormpath credentials.') - - if not config['STORMPATH_APPLICATION']: - raise ConfigurationError('You must define your Stormpath application.') - - if config['STORMPATH_ENABLE_GOOGLE']: - google_config = config['STORMPATH_SOCIAL'].get('GOOGLE') - - if not google_config or not all([ - google_config.get('client_id'), - google_config.get('client_secret'), - ]): - raise ConfigurationError('You must define your Google app settings.') - - if config['STORMPATH_ENABLE_FACEBOOK']: - facebook_config = config['STORMPATH_SOCIAL'].get('FACEBOOK') - - if not facebook_config or not all([ - facebook_config, - facebook_config.get('app_id'), - facebook_config.get('app_secret'), - ]): - raise ConfigurationError('You must define your Facebook app settings.') - - if config['STORMPATH_COOKIE_DOMAIN'] and not isinstance(config['STORMPATH_COOKIE_DOMAIN'], str): - raise ConfigurationError('STORMPATH_COOKIE_DOMAIN must be a string.') - - if config['STORMPATH_COOKIE_DURATION'] and not isinstance(config['STORMPATH_COOKIE_DURATION'], timedelta): - raise ConfigurationError('STORMPATH_COOKIE_DURATION must be a timedelta object.') +import collections + + +class StormpathSettings(collections.MutableMapping): + STORMPATH_PREFIX = 'STORMPATH' + DELIMITER = '_' + REGEX_SIGN = '*' + MAPPINGS = { # used for backwards compatibility + 'API_KEY_ID': 'client_apiKey_id', + 'API_KEY_SECRET': 'client_apiKey_secret', + 'APPLICATION': 'application_name', + + 'ENABLE_LOGIN': 'web_login_enabled', + 'ENABLE_REGISTRATION': 'web_register_enabled', + 'ENABLE_FORGOT_PASSWORD': 'web_forgotPassword_enabled', + + 'LOGIN_URL': 'web_login_uri', + 'REGISTRATION_URL': 'web_register_uri', + 'LOGOUT_URL': 'web_logout_uri', + + 'REDIRECT_URL': 'web_login_nextUri', + + 'REGISTRATION_TEMPLATE': 'web_register_template', + 'LOGIN_TEMPLATE': 'web_login_template', + + 'REGISTRATION_REDIRECT_URL': 'web_register_nextUri', + 'REQUIRE_*': 'web_register_form_fields_*_required', + 'ENABLE_*': 'web_register_form_fields_*_enabled', + + 'FORGOT_PASSWORD_TEMPLATE': 'web_forgotPassword_template', + 'FORGOT_PASSWORD_CHANGE_TEMPLATE': 'web_changePassword_template' + # 'FORGOT_PASSWORD_EMAIL_SENT_TEMPLATE' + # 'FORGOT_PASSWORD_COMPLETE_TEMPLATE' + # 'ENABLE_FACEBOOK' + # 'ENABLE_GOOGLE' + # 'SOCIAL' + # 'CACHE' + } + + def __init__(self, *args, **kwargs): + self.store = dict(*args, **kwargs) + + @staticmethod + def _from_camel(key): + cs = [] + for c in key: + cl = c.lower() + if c == cl: + cs.append(c) + else: + cs.append('_') + cs.append(c.lower()) + return ''.join(cs).upper() + + def __search__(self, root, key, root_string): + for node in root.keys(): + search_string = '%s%s%s' % ( + root_string, self.DELIMITER, + self._from_camel(node) + ) + if key == search_string: + return root, node + if key.startswith(search_string): + return self.__search__(root[node], key, search_string) + raise KeyError + + def __traverse__(self, parent, descendants): + child = descendants.pop(0) + if descendants: + if child not in parent: + parent[child] = {} + return self.__traverse__(parent[child], descendants) + return parent, child + + def __nodematch__(self, key): + if key.startswith(self.STORMPATH_PREFIX): + store_key = key.lstrip(self.STORMPATH_PREFIX).strip(self.DELIMITER) + if store_key in self.MAPPINGS: + members = self.MAPPINGS[store_key].split(self.DELIMITER) + store = self.__traverse__(self.store, members) + else: + store = self.__search__(self.store, key, self.STORMPATH_PREFIX) + else: + store = self.store, key + return store + + def __getitem__(self, key): + node, child = self.__nodematch__(key) + return node[child] + + def __setitem__(self, key, value): + node, child = self.__nodematch__(key) + node[child] = value + + def __delitem__(self, key): + node, child = self.__keytransform__(key) + del node[child] + + def __contains__(self, key): + try: + # FIXME: passwordPolicy breaks the code, in + # stormpath-python-config.stormpath_config:strategies._enrich_with_directory_policies + # self.__nodematch__(key) + self[key] + return True + except KeyError: + return False + + def __iter__(self): + return iter(self.store) + + def __len__(self): + return len(self.store) diff --git a/flask_stormpath/templates/flask_stormpath/base.html b/flask_stormpath/templates/flask_stormpath/base.html index 64ccdf6..767cd25 100644 --- a/flask_stormpath/templates/flask_stormpath/base.html +++ b/flask_stormpath/templates/flask_stormpath/base.html @@ -15,6 +15,7 @@ html, body { height: 100%; + margin: auto; } @media (max-width: 767px) { @@ -23,12 +24,12 @@ padding: 0 4px; } } - + body { margin-left: auto; margin-right: auto; } - + body, div, p, diff --git a/flask_stormpath/templates/flask_stormpath/forgot_change.html b/flask_stormpath/templates/flask_stormpath/change_password.html similarity index 91% rename from flask_stormpath/templates/flask_stormpath/forgot_change.html rename to flask_stormpath/templates/flask_stormpath/change_password.html index 4f437a9..adbddac 100644 --- a/flask_stormpath/templates/flask_stormpath/forgot_change.html +++ b/flask_stormpath/templates/flask_stormpath/change_password.html @@ -1,4 +1,4 @@ -{% extends config['STORMPATH_BASE_TEMPLATE'] %} +{% extends config['stormpath']['base_template'] %} {% block title %}Change Your Password{% endblock %} {% block description %}Change your password here.{% endblock %} @@ -38,7 +38,7 @@
- {{ form.password_again(class='form-control', placeholder='Password (again)', required='true') }} + {{ form.confirm_password(class='form-control', placeholder='Confirm Password', required='true') }}
diff --git a/flask_stormpath/templates/flask_stormpath/forgot_complete.html b/flask_stormpath/templates/flask_stormpath/change_password_success.html similarity index 84% rename from flask_stormpath/templates/flask_stormpath/forgot_complete.html rename to flask_stormpath/templates/flask_stormpath/change_password_success.html index 0d83d1d..18ee9f5 100644 --- a/flask_stormpath/templates/flask_stormpath/forgot_complete.html +++ b/flask_stormpath/templates/flask_stormpath/change_password_success.html @@ -1,10 +1,10 @@ -{% extends config['STORMPATH_BASE_TEMPLATE'] %} +{% extends config['stormpath']['base_template'] %} {% block title %}Password Change Complete{% endblock %} {% block description %}You have successfully changed your password!{% endblock %} {% block bodytag %}login{% endblock %} {% block head %} - + {% endblock %} {% block body %} diff --git a/flask_stormpath/templates/flask_stormpath/facebook_login_form.html b/flask_stormpath/templates/flask_stormpath/facebook_login_form.html index 3609ffb..31c2b4a 100644 --- a/flask_stormpath/templates/flask_stormpath/facebook_login_form.html +++ b/flask_stormpath/templates/flask_stormpath/facebook_login_form.html @@ -7,17 +7,17 @@ if (response.status === 'connected') { var queryStr = window.location.search.replace('?', ''); if (queryStr) { - window.location.replace('{{ config['STORMPATH_FACEBOOK_LOGIN_URL'] }}?' + queryStr); + window.location.replace('{{ config['stormpath']['web']['social']['facebook']['login_url'] }}?' + queryStr); } else { - window.location.replace('{{ config['STORMPATH_FACEBOOK_LOGIN_URL'] }}'); + window.location.replace('{{ config['stormpath']['web']['social']['facebook']['login_url'] }}'); } } - }, {scope: 'email{% if config["STORMPATH_SOCIAL"]["FACEBOOK"].get("scopes") %},{{ ",".join(config["STORMPATH_SOCIAL"]["FACEBOOK"]["scopes"]) }}{% endif %}'}); + }, {scope: 'email{% if config["stormpath"]["web"]["social"]["facebook"].get("scopes") %},{{ ",".join(config["stormpath"]["web"]["social"]["facebook"]["scopes"]) }}{% endif %}'}); } window.fbAsyncInit = function() { FB.init({ - appId : '{{ config['STORMPATH_SOCIAL']['FACEBOOK']['app_id'] }}', + appId : '{{ config['stormpath']['web']['social']['facebook']['clientId'] }}', cookie : true, xfbml : true, version : 'v2.0' diff --git a/flask_stormpath/templates/flask_stormpath/forgot.html b/flask_stormpath/templates/flask_stormpath/forgot_password.html similarity index 94% rename from flask_stormpath/templates/flask_stormpath/forgot.html rename to flask_stormpath/templates/flask_stormpath/forgot_password.html index b8352b7..6af2d4a 100644 --- a/flask_stormpath/templates/flask_stormpath/forgot.html +++ b/flask_stormpath/templates/flask_stormpath/forgot_password.html @@ -1,4 +1,4 @@ -{% extends config['STORMPATH_BASE_TEMPLATE'] %} +{% extends config['stormpath']['base_template'] %} {% block title %}Forgot Your Password?{% endblock %} {% block description %}Forgot your password? No worries!{% endblock %} @@ -41,7 +41,7 @@
- {% if config['STORMPATH_ENABLE_LOGIN'] %} + {% if config['stormpath']['web']['login']['enabled'] %} Back to Log In {% endif %} diff --git a/flask_stormpath/templates/flask_stormpath/forgot_email_sent.html b/flask_stormpath/templates/flask_stormpath/forgot_password_success.html similarity index 94% rename from flask_stormpath/templates/flask_stormpath/forgot_email_sent.html rename to flask_stormpath/templates/flask_stormpath/forgot_password_success.html index a2072c0..f5defe9 100644 --- a/flask_stormpath/templates/flask_stormpath/forgot_email_sent.html +++ b/flask_stormpath/templates/flask_stormpath/forgot_password_success.html @@ -1,4 +1,4 @@ -{% extends config['STORMPATH_BASE_TEMPLATE'] %} +{% extends config['stormpath']['base_template'] %} {% block title %}Password Reset Email Sent{% endblock %} {% block description %}Your password reset email has been sent!{% endblock %} diff --git a/flask_stormpath/templates/flask_stormpath/google_login_form.html b/flask_stormpath/templates/flask_stormpath/google_login_form.html index a605b7c..71fe72e 100644 --- a/flask_stormpath/templates/flask_stormpath/google_login_form.html +++ b/flask_stormpath/templates/flask_stormpath/google_login_form.html @@ -1,6 +1,6 @@ diff --git a/flask_stormpath/templates/flask_stormpath/login.html b/flask_stormpath/templates/flask_stormpath/login.html index b4f924e..dd5ff2b 100644 --- a/flask_stormpath/templates/flask_stormpath/login.html +++ b/flask_stormpath/templates/flask_stormpath/login.html @@ -1,4 +1,4 @@ -{% extends config['STORMPATH_BASE_TEMPLATE'] %} +{% extends config['stormpath']['base_template'] %} {% block title %}Log In{% endblock %} {% block description %}Log into your account!{% endblock %} @@ -9,10 +9,10 @@
- diff --git a/flask_stormpath/templates/flask_stormpath/register.html b/flask_stormpath/templates/flask_stormpath/register.html index f8b1184..db56576 100644 --- a/flask_stormpath/templates/flask_stormpath/register.html +++ b/flask_stormpath/templates/flask_stormpath/register.html @@ -1,4 +1,4 @@ -{% extends config['STORMPATH_BASE_TEMPLATE'] %} +{% extends config['stormpath']['base_template'] %} {% block title %}Create an Account{% endblock %} {% block description %}Create a new account.{% endblock %} @@ -24,71 +24,23 @@
{% endif %} {% endwith %} - {% if config['STORMPATH_ENABLE_USERNAME'] %} + {% for field in form if field.widget.input_type != 'hidden' %}
- +
- {% if config['STORMPATH_REQUIRE_USERNAME'] %} - {{ form.username(class='form-control', placeholder='Username', required='true') }} - {% else %} - {{ form.username(class='form-control', placeholder='Username') }} - {% endif %} + {% if field.flags.required %} + {{ field(class='form-control', required='true') }} + {% else %} + {{ field(class='form-control') }} + {% endif %}
- {% endif %} - {% if config['STORMPATH_ENABLE_GIVEN_NAME'] %} -
- -
- {% if config['STORMPATH_REQUIRE_GIVEN_NAME'] %} - {{ form.given_name(class='form-control', placeholder='First Name', required='true') }} - {% else %} - {{ form.given_name(class='form-control', placeholder='First Name') }} - {% endif %} -
-
- {% endif %} - {% if config['STORMPATH_ENABLE_MIDDLE_NAME'] %} -
- -
- {% if config['STORMPATH_REQUIRE_MIDDLE_NAME'] %} - {{ form.middle_name(class='form-control', placeholder='Middle Name', required='true') }} - {% else %} - {{ form.middle_name(class='form-control', placeholder='Middle Name') }} - {% endif %} -
-
- {% endif %} - {% if config['STORMPATH_ENABLE_SURNAME'] %} -
- -
- {% if config['STORMPATH_REQUIRE_SURNAME'] %} - {{ form.surname(class='form-control', placeholder='Last Name', required='true') }} - {% else %} - {{ form.surname(class='form-control', placeholder='Last Name') }} - {% endif %} -
-
- {% endif %} -
- -
- {{ form.email(class='form-control', placeholder='Email', required='true', type='email') }} -
-
-
- -
- {{ form.password(class='form-control', placeholder='Password', required='true', type='password') }} -
-
+ {% endfor %}
- {% if config['STORMPATH_ENABLE_LOGIN'] %} + {% if config['stormpath']['web']['login']['enabled'] %} Back to Log In {% endif %} diff --git a/flask_stormpath/templates/flask_stormpath/verify_email.html b/flask_stormpath/templates/flask_stormpath/verify_email.html new file mode 100644 index 0000000..92e6a7d --- /dev/null +++ b/flask_stormpath/templates/flask_stormpath/verify_email.html @@ -0,0 +1,46 @@ +{% extends config['stormpath']['base_template'] %} + +{% block title %}Resend Verification Email{% endblock %} +{% block description %}Set your verification email here.{% endblock %} +{% block bodytag %}login{% endblock %} + +{% block body %} +
+
+ +
+
+{% endblock %} diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index f00f4a5..315ef22 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -1,8 +1,7 @@ """Our pluggable views.""" -import sys -from facebook import get_user_from_cookie +import json from flask import ( abort, current_app, @@ -10,22 +9,151 @@ redirect, render_template, request, + make_response ) -from flask_login import login_user +from flask.views import View +from flask_login import ( + login_user, logout_user, login_required, current_user) from six import string_types from stormpath.resources.provider import Provider - -from . import StormpathError, logout_user -from .forms import ( - ChangePasswordForm, - ForgotPasswordForm, - LoginForm, - RegistrationForm, -) +from stormpath.resources import Expansion +from . import StormpathError +from .forms import StormpathForm from .models import User +from .request_processors import get_accept_header, request_wants_json +from facebook import get_user_from_cookie -def register(): +""" Views parent class. """ + + +class StormpathView(View): + """ + Class for Stormpath views. + + This class initializes form building through config specs and handles + both html and json responses. + Specialized logic for each view is handled in the process_request method + and should be specified on each child class. + """ + + def __init__(self, config, *args, **kwargs): + self.config = config + self.accept_header = get_accept_header() + self.allowed_types = current_app.config['stormpath']['web']['produces'] + + # Set a default value for the error redirect uri. + self.error_redirect_url = None + + # If the request type is specified, but not html or json, mark the + # invalid_request flag. + if self.accept_header not in self.allowed_types: + self.invalid_request = True + else: + self.invalid_request = False + + # Build the form + if request_wants_json(): + form_kwargs = {'meta': {'csrf': False}} + else: + form_kwargs = { + 'meta': {'csrf': current_app.config['WTF_CSRF_ENABLED']}} + self.form = StormpathForm.specialize_form( + config.get('form'))(**form_kwargs) + + def make_stormpath_response( + self, data, template=None, return_json=True, status_code=200): + """ Create a response based on request type (html or json). """ + if return_json: + stormpath_response = make_response(data, status_code) + stormpath_response.mimetype = 'application/json' + else: + stormpath_response = render_template(template, **data) + return stormpath_response + + def process_request(self): + """ Custom logic specialized for each view. Must be implemented in + the subclass. """ + raise NotImplementedError('Subclasses must implement this method.') + + def process_stormpath_error(self, error): + """ Check for StormpathErrors. """ + + # If an error.user_message is inadequate, then catch regular message. + # Otherwise try to catch user_message. + if error.code in [7102, ]: + error_message = error.message + else: + error_message = ( + error.user_message if error.user_message else error.message) + + if request_wants_json(): + status_code = error.status if error.status else 400 + return self.make_stormpath_response( + data=json.dumps({ + 'status': status_code, + 'message': error_message}), + status_code=status_code) + flash(error_message) + return None + + def dispatch_request(self): + """ Basic view skeleton. """ + + # If the request is not valid, pass the response to the + # 'invalid_request' view. + if self.invalid_request: + invalid_request_uri = current_app.config[ + 'stormpath']['web']['invalidRequest']['uri'] + endpoints = [ + rule.rule for rule in current_app.url_map.iter_rules()] + + # Redirect to a flask view for invalid requests (if implemented). + # If not, return a 501. + if invalid_request_uri in endpoints: + return redirect(invalid_request_uri) + else: + abort(501) + + # Redirect to an error uri if one is set. + if self.error_redirect_url: + return redirect(self.error_redirect_url) + + if request.method == 'POST': + # If we received a POST request with valid information, we'll + # continue processing. + + if not self.form.validate_on_submit(): + # If form.data is not valid, return error messages. + if request_wants_json(): + return self.make_stormpath_response( + data=json.dumps({ + 'status': 400, + 'message': self.form.errors}), + status_code=400) + for field_error in self.form.errors.keys(): + flash(self.form.errors[field_error][0]) + + else: + try: + return self.process_request() + except StormpathError as error: + stormpath_error = self.process_stormpath_error(error) + if stormpath_error: + return stormpath_error + + if request_wants_json(): + return self.make_stormpath_response(data=self.form.json) + + return self.make_stormpath_response( + template=self.template, data={'form': self.form}, + return_json=False) + + +""" Child views. """ + + +class RegisterView(StormpathView): """ Register a new user with Stormpath. @@ -36,55 +164,55 @@ def register(): template that is used to render this page can all be controlled via Flask-Stormpath settings. """ - form = RegistrationForm(config=current_app.config) - - # If we received a POST request with valid information, we'll continue - # processing. - if form.validate_on_submit(): - data = form.data - # Attempt to create the user's account on Stormpath. - try: - # Create the user account on Stormpath. If this fails, an - # exception will be raised. - optional_params = {k: data[k] for k in ('username','middle_name','custom_data', 'status') if k in data} - account = User.create( - data.get('email'), - data.get('password'), - # Since Stormpath requires both the given_name and surname - # fields be set, we'll just set the both to 'Anonymous' if - # the user has # explicitly said they don't want to collect - # those fields. - data.get('given_name', 'Anonymous') or 'Anonymous', - data.get('surname', 'Anonymous') or 'Anonymous', - **optional_params - ) - - # If we're able to successfully create the user's account, - # we'll log the user in (creating a secure session using - # Flask-Login), then redirect the user to the - # STORMPATH_REDIRECT_URL setting. + template = 'flask_stormpath/register.html' + + def __init__(self, *args, **kwargs): + config = current_app.config['stormpath']['web']['register'] + super(RegisterView, self).__init__(config, *args, **kwargs) + self.data = self.form.data + + def process_request(self): + # We'll just set the field values to 'Anonymous' if the user + # has explicitly said they don't want to collect those fields. + for field in ['given_name', 'surname']: + if not self.data.get(field): + self.data[field] = 'Anonymous' + # Remove the confirmation password so it won't cause an error + if 'confirm_password' in self.data: + self.data.pop('confirm_password') + + # Create the user account on Stormpath. If this fails, an + # exception will be raised. + self.data.pop('csrf_token', None) + account = User.create(**self.data) + + # If verifyEmail is enabled, send a verification email. + # FIXME: Edit templates to show a verification email has been sent. + # FIXME: error message is None + pass + + # If we're able to successfully create the user's account, + # we'll log the user in (creating a secure session using + # Flask-Login), then redirect the user to the + # Stormpath login nextUri setting but only if autoLogin is enabled. + if (self.config['autoLogin'] and not current_app.config[ + 'stormpath']['web']['verifyEmail']['enabled']): login_user(account, remember=True) - # The email address must be verified, so pop an alert about it. - if current_app.config['STORMPATH_VERIFY_EMAIL'] is True: - flash('You must validate your email address before logging in. Please check your email for instructions.') - - if 'STORMPATH_REGISTRATION_REDIRECT_URL' in current_app.config: - redirect_url = current_app.config['STORMPATH_REGISTRATION_REDIRECT_URL'] - else: - redirect_url = current_app.config['STORMPATH_REDIRECT_URL'] - return redirect(redirect_url) - - except StormpathError as err: - flash(err.message) + if request_wants_json(): + return self.make_stormpath_response(data=account.to_json()) - return render_template( - current_app.config['STORMPATH_REGISTRATION_TEMPLATE'], - form = form, - ) + # Set redirect priority + redirect_url = self.config['nextUri'] + if not redirect_url: + redirect_url = current_app.config['stormpath'][ + 'web']['login']['nextUri'] + if not redirect_url: + redirect_url = '/' + return redirect(redirect_url) -def login(): +class LoginView(StormpathView): """ Log in an existing Stormpath user. @@ -95,33 +223,37 @@ def login(): template that is used to render this page can all be controlled via Flask-Stormpath settings. """ - form = LoginForm() + template = 'flask_stormpath/login.html' - # If we received a POST request with valid information, we'll continue - # processing. - if form.validate_on_submit(): - try: - # Try to fetch the user's account from Stormpath. If this - # fails, an exception will be raised. - account = User.from_login(form.login.data, form.password.data) - - # If we're able to successfully retrieve the user's account, - # we'll log the user in (creating a secure session using - # Flask-Login), then redirect the user to the ?next= - # query parameter, or the STORMPATH_REDIRECT_URL setting. - login_user(account, remember=True) + def __init__(self, *args, **kwargs): + config = current_app.config['stormpath']['web']['login'] + super(LoginView, self).__init__(config, *args, **kwargs) + + def process_request(self): + # Try to fetch the user's account from Stormpath. If this + # fails, an exception will be raised. + account = User.from_login( + self.form.login.data, self.form.password.data) + + # If we're able to successfully retrieve the user's account, + # we'll log the user in (creating a secure session using + # Flask-Login), then redirect the user to the ?next= + # query parameter, or the Stormpath login nextUri setting. + login_user(account, remember=True) - return redirect(request.args.get('next') or current_app.config['STORMPATH_REDIRECT_URL']) - except StormpathError as err: - flash(err.message) + if request_wants_json(): + return self.make_stormpath_response(data=current_user.to_json()) - return render_template( - current_app.config['STORMPATH_LOGIN_TEMPLATE'], - form = form, - ) + # Set redirect priority + redirect_url = request.args.get('next') + if not redirect_url: + redirect_url = self.config['nextUri'] + if not redirect_url: + redirect_url = '/' + return redirect(redirect_url) -def forgot(): +class ForgotPasswordView(StormpathView): """ Initialize 'password reset' functionality for a user who has forgotten his password. @@ -132,42 +264,51 @@ def forgot(): The URL this view is bound to, and the template that is used to render this page can all be controlled via Flask-Stormpath settings. """ - form = ForgotPasswordForm() - - # If we received a POST request with valid information, we'll continue - # processing. - if form.validate_on_submit(): - try: - # Try to fetch the user's account from Stormpath. If this - # fails, an exception will be raised. - account = current_app.stormpath_manager.application.send_password_reset_email(form.email.data) - account.__class__ = User - - # If we're able to successfully send a password reset email to this - # user, we'll display a success page prompting the user to check - # their inbox to complete the password reset process. - return render_template( - current_app.config['STORMPATH_FORGOT_PASSWORD_EMAIL_SENT_TEMPLATE'], - user = account, - ) - except StormpathError as err: - # If the error message contains 'https', it means something failed - # on the network (network connectivity, most likely). - if isinstance(err.message, string_types) and 'https' in err.message.lower(): - flash('Something went wrong! Please try again.') - - # Otherwise, it means the user is trying to reset an invalid email - # address. - else: - flash('Invalid email address.') - - return render_template( - current_app.config['STORMPATH_FORGOT_PASSWORD_TEMPLATE'], - form = form, - ) - - -def forgot_change(): + template = 'flask_stormpath/forgot_password.html' + template_success = 'flask_stormpath/forgot_password_success.html' + + def __init__(self, *args, **kwargs): + config = current_app.config['stormpath']['web']['forgotPassword'] + super(ForgotPasswordView, self).__init__(config, *args, **kwargs) + + def process_stormpath_error(self, error): + # If the error message contains 'https', it means something + # failed on the network (network connectivity, most likely). + if (isinstance(error.message, string_types) and + 'https' in error.message.lower()): + error.user_message = 'Something went wrong! Please try again.' + + # Otherwise, it means the user is trying to reset an invalid + # email address. + else: + error.user_message = 'Invalid email address.' + return super(ForgotPasswordView, self).process_stormpath_error(error) + + def process_request(self): + # Try to fetch the user's account from Stormpath. If this + # fails, an exception will be raised. + account = ( + current_app.stormpath_manager.application. + send_password_reset_email(self.form.email.data)) + account.__class__ = User + + # If we're able to successfully send a password reset email to + # this user, we'll display a success page prompting the user + # to check their inbox to complete the password reset process. + + if request_wants_json(): + return self.make_stormpath_response( + data=json.dumps({ + 'status': 200, + 'message': {'email': self.form.data.get('email')}}), + status_code=200) + + return self.make_stormpath_response( + template=self.template_success, data={'user': account}, + return_json=False) + + +class ChangePasswordView(StormpathView): """ Allow a user to change his password. @@ -178,44 +319,268 @@ def forgot_change(): The URL this view is bound to, and the template that is used to render this page can all be controlled via Flask-Stormpath settings. """ - try: - account = current_app.stormpath_manager.application.verify_password_reset_token(request.args.get('sptoken')) - except StormpathError as err: - abort(400) - - form = ChangePasswordForm() + template = "flask_stormpath/change_password.html" + template_success = "flask_stormpath/change_password_success.html" - # If we received a POST request with valid information, we'll continue - # processing. - if form.validate_on_submit(): + def __init__(self, *args, **kwargs): + config = current_app.config['stormpath']['web']['changePassword'] + super(ChangePasswordView, self).__init__(config, *args, **kwargs) try: - # Update this user's passsword. - account.password = form.password.data - account.save() + self.account = ( + current_app.stormpath_manager.application. + verify_password_reset_token(request.args.get('sptoken'))) + except StormpathError: + self.error_redirect_url = config['errorUri'] + if not self.error_redirect_url: + self.error_redirect_url = '/' + + def process_stormpath_error(self, error): + # If the error message contains 'https', it means something + # failed on the network (network connectivity, most likely). + if (isinstance(error.message, string_types) and + 'https' in error.message.lower()): + error.user_message = 'Something went wrong! Please try again.' + return super(ChangePasswordView, self).process_stormpath_error(error) + + def process_request(self): + # Update this user's passsword. + self.account.password = self.form.password.data + self.account.save() + + # Log this user into their account. + account = User.from_login(self.account.email, self.form.password.data) + login_user(account, remember=True) + + if request_wants_json(): + return self.make_stormpath_response(data=current_user.to_json()) + + return self.make_stormpath_response( + template=self.template_success, data={'form': self.form}, + return_json=False) + + +class VerifyEmailView(StormpathView): + """ + Verify a newly created Stormpath user. - # Log this user into their account. - account = User.from_login(account.email, form.password.data) - login_user(account, remember=True) + This view will activate a user's account with the token specified in the + activation link the user received via email. If the token is invalid or + missing, the user can request a new activation link. - return render_template(current_app.config['STORMPATH_FORGOT_PASSWORD_COMPLETE_TEMPLATE']) - except StormpathError as err: - if isinstance(err.message, string_types) and 'https' in err.message.lower(): - flash('Something went wrong! Please try again.') + The URL this view is bound to, and the template that is used to render + this page can all be controlled via Flask-Stormpath settings. + """ + template = "flask_stormpath/verify_email.html" + + def __init__(self, *args, **kwargs): + config = current_app.config['stormpath']['web']['verifyEmail'] + super(VerifyEmailView, self).__init__(config, *args, **kwargs) + + def process_stormpath_error(self, error): + # If the error message contains 'https', it means something + # failed on the network (network connectivity, most likely). + if (isinstance(error.message, string_types) and + 'https' in error.message.lower()): + error.user_message = 'Something went wrong! Please try again.' + return super(VerifyEmailView, self).process_stormpath_error(error) + + def dispatch_request(self): + # If the request is POST, resend the confirmation email. + if request.method == 'POST': + # If form.data is not valid, return error messages. + if not self.form.validate_on_submit(): + if request_wants_json(): + return self.make_stormpath_response( + data=json.dumps({ + 'status': 400, + 'message': self.form.errors}), + status_code=400) + for field_error in self.form.errors.keys(): + flash(self.form.errors[field_error][0]) + redirect_url = request.url else: - flash(err.message) + # Try to retrieve an account associated with the email. + search_query = ( + current_app.stormpath_manager.client.tenant.accounts. + search(self.form.data.get('email'))) + + if search_query.items: + account = search_query.items[0] + + # Resend the activation token + (current_app.stormpath_manager.application. + verification_emails.resend(account, account.directory)) + + if request_wants_json(): + return self.make_stormpath_response( + data=json.dumps({})) + redirect_url = self.config['unverifiedUri'] + + # If the request is GET, try to parse and verify the authorization + # token. + else: + verification_token = request.args.get('sptoken', '') + try: + # Try to verify the sptoken. + account = ( + current_app.stormpath_manager.client.accounts. + verify_email_token(verification_token) + ) + account.__class__ = User + + # If autologin is enabled, log the user in and redirect him + # to login nextUri. If not, redirect to verifyEmail nextUri. + if current_app.config[ + 'stormpath']['web']['register']['autoLogin']: + login_user(account, remember=True) + account.refresh() + if request_wants_json(): + return self.make_stormpath_response( + data=account.to_json()) + redirect_url = current_app.config[ + 'stormpath']['web']['login']['nextUri'] + else: + if request_wants_json(): + return self.make_stormpath_response( + data=json.dumps({})) + redirect_url = self.config['nextUri'] + + except StormpathError as error: + # If the sptoken is invalid or missing, render an email + # form that will resend an sptoken to the new email provided. + + # Sets an error message. + if request_wants_json(): + if error.status == 400: + error_message = 'sptoken parameter not provided.' + else: + error_message = ( + error.user_message if error.user_message + else error.message) + + return self.make_stormpath_response( + data=json.dumps({ + 'status': error.status, + 'message': error_message}), + status_code=error.status) + + return self.make_stormpath_response( + template=self.template, data={'form': self.form}, + return_json=False) + + # Set redirect priority + if not redirect_url: + redirect_url = '/' + return redirect(redirect_url) + + +class LogoutView(StormpathView): + """ + Log a user out of their account. + + This view will log a user out of their account (destroying their session), + then redirect the user to the home page of the site. + + .. note:: + We'll override the default StormpathView logic, since we don't need + form and api request validation. + """ - # If this is a POST request, and the form isn't valid, this means the - # user's password was no good, so we'll display a message. - elif request.method == 'POST': - flash("Passwords don't match.") + def __init__(self, *args, **kwargs): + config = current_app.config['stormpath']['web']['logout'].copy() - return render_template( - current_app.config['STORMPATH_FORGOT_PASSWORD_CHANGE_TEMPLATE'], - form = form, - ) + # We'll pass login form here since logout needs the form for the json + # response. (Successful logout redirects to login view.) + config['form'] = current_app.config['stormpath']['web']['login'][ + 'form'] + super(LogoutView, self).__init__(config, *args, **kwargs) + def dispatch_request(self): + logout_user() -def facebook_login(): + # Set redirect priority + redirect_url = self.config['nextUri'] + if not redirect_url: + redirect_url = '/' + return redirect(redirect_url) + + +class MeView(View): + """ + Get a JSON object with the current user information. + + .. note:: + We'll override the default StormpathView logic, since we don't need + json support or form and api request validation. + """ + decorators = [login_required] + + def dispatch_request(self): + expansion = Expansion() + for attr, flag in current_app.config['stormpath']['web']['me'][ + 'expand'].items(): + if flag: + expansion.add_property(attr) + if expansion.items: + current_user._expand = expansion + current_user.refresh() + + response = make_response(current_user.to_json(), 200) + response.mimetype = 'application/json' + return response + + +""" Social views. """ + + +class SocialView(View): + """ Parent class for social login views. """ + def __init__(self, *args, **kwargs): + # First validate social view call + social_name = kwargs.pop('social_name') + if social_name not in ['facebook', 'google']: + raise ValueError('Social service is not supported.') + self.social_name = social_name + + # Then set the access token and the provider + self.access_token = kwargs.pop('access_token') + self.provider_social = getattr(Provider, self.social_name.upper()) + + # Set a user error message in case the login fails. + self.error_message = ( + 'Oops! We encountered an unexpected error. Please contact ' + + 'support and explain what you were doing at the time this ' + + 'error occurred.' + ) + + def get_account(self): + return getattr( + User, 'from_%s' % self.social_name)(self.access_token) + + def dispatch_request(self): + """ Basic social view skeleton. """ + # We'll try to have Stormpath either create or update this user's + # Stormpath account, by automatically handling the social API stuff + # for us. + try: + account = self.get_account() + except StormpathError: + flash(self.error_message) + redirect_url = current_app.config[ + 'stormpath']['web']['login']['uri'] + return redirect(redirect_url if redirect_url else '/') + + # Now we'll log the new user into their account. From this point on, + # this social user will be treated exactly like a normal Stormpath + # user! + login_user(account, remember=True) + + return redirect( + request.args.get('next') or + current_app.config['stormpath']['web']['login']['nextUri']) + + +class FacebookLoginView(SocialView): """ Handle Facebook login. @@ -228,8 +593,8 @@ def facebook_login(): - Read the user's session using the Facebook SDK, extracting the user's Facebook access token. - - Once we have the user's access token, we send it to Stormpath, so that - we can either create (or update) the user on Stormpath's side. + - Once we have the user's access token, we send it to Stormpath, so + that we can either create (or update) the user on Stormpath's side. - Then we retrieve the Stormpath account object for the user, and log them in using our normal session support (powered by Flask-Login). @@ -240,71 +605,27 @@ def facebook_login(): The location this view redirects users to can be configured via Flask-Stormpath settings. """ - # First, we'll try to grab the Facebook user's data by accessing their - # session data. - facebook_user = get_user_from_cookie( - request.cookies, - current_app.config['STORMPATH_SOCIAL']['FACEBOOK']['app_id'], - current_app.config['STORMPATH_SOCIAL']['FACEBOOK']['app_secret'], - ) - - # Now, we'll try to have Stormpath either create or update this user's - # Stormpath account, by automatically handling the Facebook Graph API stuff - # for us. - try: - account = User.from_facebook(facebook_user['access_token']) - except StormpathError as err: - social_directory_exists = False - - # If we failed here, it usually means that this application doesn't have - # a Facebook directory -- so we'll create one! - for asm in current_app.stormpath_manager.application.account_store_mappings: - - # If there is a Facebook directory, we know this isn't the problem. - if ( - getattr(asm.account_store, 'provider') and - asm.account_store.provider.provider_id == Provider.FACEBOOK - ): - social_directory_exists = True - break - - # If there is a Facebook directory already, we'll just pass on the - # exception we got. - if social_directory_exists: - raise err - - # Otherwise, we'll try to create a Facebook directory on the user's - # behalf (magic!). - dir = current_app.stormpath_manager.client.directories.create({ - 'name': current_app.stormpath_manager.application.name + '-facebook', - 'provider': { - 'client_id': current_app.config['STORMPATH_SOCIAL']['FACEBOOK']['app_id'], - 'client_secret': current_app.config['STORMPATH_SOCIAL']['FACEBOOK']['app_secret'], - 'provider_id': Provider.FACEBOOK, - }, - }) - - # Now that we have a Facebook directory, we'll map it to our application - # so it is active. - asm = current_app.stormpath_manager.application.account_store_mappings.create({ - 'application': current_app.stormpath_manager.application, - 'account_store': dir, - 'list_index': 99, - 'is_default_account_store': False, - 'is_default_group_store': False, - }) - - # Lastly, let's retry the Facebook login one more time. - account = User.from_facebook(facebook_user['access_token']) - - # Now we'll log the new user into their account. From this point on, this - # Facebook user will be treated exactly like a normal Stormpath user! - login_user(account, remember=True) - - return redirect(request.args.get('next') or current_app.config['STORMPATH_REDIRECT_URL']) - - -def google_login(): + def __init__(self, *args, **kwargs): + # We'll try to grab the Facebook user's data by accessing their + # session data. If this doesn't exist, we'll abort with a + # 400 BAD REQUEST (since something horrible must have happened). + facebook_user = get_user_from_cookie( + request.cookies, + current_app.config[ + 'stormpath']['web']['social']['facebook']['clientId'], + current_app.config[ + 'stormpath']['web']['social']['facebook']['clientSecret'], + ) + if facebook_user: + access_token = facebook_user.get('access_token') + else: + abort(400) + + super(FacebookLoginView, self).__init__( + social_name='facebook', access_token=access_token) + + +class GoogleLoginView(SocialView): """ Handle Google login. @@ -319,75 +640,13 @@ def google_login(): The location this view redirects users to can be configured via Flask-Stormpath settings. """ - # First, we'll try to grab the 'code' query string that Google should be - # passing to us. If this doesn't exist, we'll abort with a 400 BAD REQUEST - # (since something horrible must have happened). - code = request.args.get('code') - if not code: - abort(400) - - # Next, we'll try to have Stormpath either create or update this user's - # Stormpath account, by automatically handling the Google API stuff for us. - try: - account = User.from_google(code) - except StormpathError as err: - social_directory_exists = False - - # If we failed here, it usually means that this application doesn't - # have a Google directory -- so we'll create one! - for asm in current_app.stormpath_manager.application.account_store_mappings: - - # If there is a Google directory, we know this isn't the problem. - if ( - getattr(asm.account_store, 'provider') and - asm.account_store.provider.provider_id == Provider.GOOGLE - ): - social_directory_exists = True - break - - # If there is a Google directory already, we'll just pass on the - # exception we got. - if social_directory_exists: - raise err - - # Otherwise, we'll try to create a Google directory on the user's - # behalf (magic!). - dir = current_app.stormpath_manager.client.directories.create({ - 'name': current_app.stormpath_manager.application.name + '-google', - 'provider': { - 'client_id': current_app.config['STORMPATH_SOCIAL']['GOOGLE']['client_id'], - 'client_secret': current_app.config['STORMPATH_SOCIAL']['GOOGLE']['client_secret'], - 'redirect_uri': request.url_root[:-1] + current_app.config['STORMPATH_GOOGLE_LOGIN_URL'], - 'provider_id': Provider.GOOGLE, - }, - }) - - # Now that we have a Google directory, we'll map it to our application - # so it is active. - asm = current_app.stormpath_manager.application.account_store_mappings.create({ - 'application': current_app.stormpath_manager.application, - 'account_store': dir, - 'list_index': 99, - 'is_default_account_store': False, - 'is_default_group_store': False, - }) - - # Lastly, let's retry the Google login one more time. - account = User.from_google(code) - - # Now we'll log the new user into their account. From this point on, this - # Google user will be treated exactly like a normal Stormpath user! - login_user(account, remember=True) - - return redirect(request.args.get('next') or current_app.config['STORMPATH_REDIRECT_URL']) - - -def logout(): - """ - Log a user out of their account. - - This view will log a user out of their account (destroying their session), - then redirect the user to the home page of the site. - """ - logout_user() - return redirect('/') + def __init__(self, *args, **kwargs): + # We'll try to grab the 'code' query string that Google should + # be passing to us. If this doesn't exist, we'll abort with a + # 400 BAD REQUEST (since something horrible must have happened). + code = request.args.get('code') + if not code: + abort(400) + + super(GoogleLoginView, self).__init__( + social_name='google', access_token=code) diff --git a/setup.py b/setup.py index 3bb73f2..dd39a8d 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ def run(self): setup( name = 'Flask-Stormpath', - version = '0.4.8', + version = '0.5.0', url = 'https://github.com/stormpath/stormpath-flask', license = 'Apache', author = 'Stormpath, Inc.', @@ -48,21 +48,22 @@ def run(self): include_package_data = True, platforms = 'any', install_requires = [ - 'Flask>=0.9.0', - 'Flask-Login==0.3.2', + 'Flask>=0.11.1', + 'Flask-Login==0.4.0', 'Flask-WTF>=0.13.1', 'facebook-sdk==2.0.0', - 'oauth2client==1.5.2', - 'stormpath==2.4.4', + 'oauth2client==4.0.0', + 'stormpath==2.4.5', 'blinker==1.4' ], extras_require = { - 'test': ['coverage', 'pytest', 'pytest-cov', 'python-coveralls', 'Sphinx==1.3.6', 'pytest-xdist'], + 'test': ['coverage', 'pytest', 'pytest-cov', 'pytest-env', 'python-coveralls', 'Sphinx', 'pytest-xdist', 'ruamel.yaml'], }, dependency_links=[ - 'git+git://github.com/pythonforfacebook/facebook-sdk.git@e65d06158e48388b3932563f1483ca77065951b3#egg=facebook-sdk-1.0.0-alpha', + 'git+git://github.com/pythonforfacebook/facebook-sdk.git@e65d06158' + + 'e48388b3932563f1483ca77065951b3#egg=facebook-sdk-1.0.0-alpha', ], - classifiers = [ + classifiers=[ 'Environment :: Web Environment', 'Framework :: Flask', 'Intended Audience :: Developers', @@ -80,6 +81,5 @@ def run(self): 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', ], ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7493654 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +from .helpers import bootstrap_client + + +def pytest_keyboard_interrupt(excinfo): + collection_resources = ['applications', 'directories'] + test_prefix = 'flask-stormpath-tests' + client = bootstrap_client() + + for collection in collection_resources: + for resource in list(getattr(client, collection).search(test_prefix)): + resource.delete() diff --git a/tests/helpers.py b/tests/helpers.py index 0b2327b..1b18808 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -6,20 +6,24 @@ """ -from os import environ +import os from unittest import TestCase from uuid import uuid4 from flask import Flask -from flask_stormpath import StormpathManager +from flask_stormpath import StormpathManager, StormpathError, User +from facebook import GraphAPI, GraphAPIError from stormpath.client import Client -from stormpath.error import Error +from stormpath.resources.provider import Provider +from oauth2client.client import OAuth2WebServerFlow +import requests +import json class StormpathTestCase(TestCase): """ - Custom test case which bootstraps a Stormpath client, application, and Flask - app. + Custom test case which bootstraps a Stormpath client, application, and + Flask app. This makes writing tests significantly easier as there's no work to do for setUp / tearDown. @@ -30,31 +34,99 @@ class StormpathTestCase(TestCase): def setUp(self): """Provision a new Client, Application, and Flask app.""" self.client = bootstrap_client() - self.application = bootstrap_app(self.client) + self.name = 'flask-stormpath-tests-%s' % uuid4().hex + self.application = bootstrap_app(self.client, self.name) self.app = bootstrap_flask_app(self.application) + # Create social directories if necessary. + if self.app.config['STORMPATH_ENABLE_FACEBOOK']: + facebook_provider = { + 'client_id': os.environ.get('FACEBOOK_API_ID'), + 'client_secret': os.environ.get('FACEBOOK_API_SECRET'), + 'provider_id': Provider.FACEBOOK, + } + self.create_social_directory( + social_name='facebook', provider=facebook_provider) + + if self.app.config['STORMPATH_ENABLE_GOOGLE']: + google_provider = { + 'client_id': os.environ.get('GOOGLE_CLIENT_ID'), + 'client_secret': os.environ.get('GOOGLE_CLIENT_SECRET'), + 'redirect_uri': ( + ''.join( + (os.environ.get('ROOT_URL'), ':', + os.environ.get('PORT'))) + '/google'), + 'provider_id': Provider.GOOGLE, + } + + self.create_social_directory( + social_name='google', provider=google_provider) + + self.manager = StormpathManager(self.app) + + # html and json header settings + self.html_header = 'text/html' + self.json_header = 'application/json' + + # Remember default wsgi_app instance for dynamically changing request + # type later in tests. + self.default_wsgi_app = self.app.wsgi_app + + # Make sure our requests don't trigger a json response. + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, self.html_header) + + # Create a user. + with self.app.app_context(): + self.user = User.create( + username='rdegges', + given_name='Randall', + surname='Degges', + email='r@rdegges.com', + password='woot1LoveCookies!', + ) + def tearDown(self): - """Destroy all provisioned Stormpath resources.""" - # Clean up the application. - app_name = self.application.name - self.application.delete() + """ Destroy all provisioned Stormpath resources. """ + destroy_resources(self.application, self.client) - # Clean up the directories. - for directory in self.client.directories.search(app_name): - directory.delete() + def assertDictList(self, list1, list2, key_name): + # Sorts list of dictionaries by key name and compares them. - # Clean up API keys - self.delete_api_key(app_name) + sorted_list1 = sorted(list1, key=lambda k: k[key_name]) + sorted_list2 = sorted(list2, key=lambda k: k[key_name]) + self.assertEqual(sorted_list1, sorted_list2) - def delete_api_key(self, app_name): - try: - for account in self.client.accounts.items: - for key in account.api_keys.items: - if key.name and app_name in key.name: - self.client.data_store.delete_resource(key.href) - except Error: - # Resource not found - ignore. - pass + def reinit_app(self): + # Reinitializes our testing application. + + self.app = bootstrap_flask_app(self.application) + self.manager = StormpathManager(self.app) + + # Make sure our requests don't trigger a json response. + self.default_wsgi_app = self.app.wsgi_app + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, self.html_header) + + def create_social_directory(self, social_name=None, provider=None): + # Creates a stormpath social directory. + social_dir = ( + self.application._client.directories.create({ + 'name': self.application.name + '-' + social_name, + 'provider': provider + }) + ) + + # Now we'll map the new directory to our application. + self.application.account_store_mappings.create( + { + 'application': self.application, + 'account_store': social_dir, + 'list_index': 99, + 'is_default_account_store': False, + 'is_default_group_store': False, + } + ) class SignalReceiver(object): @@ -66,6 +138,92 @@ def signal_user_receiver_function(self, sender, user): self.received_signals.append((sender, user)) +class HttpAcceptWrapper(object): + """ + Helper class for injecting HTTP headers. + """ + def __init__(self, app, accept_header): + self.app = app + self.accept_header = accept_header + + def __call__(self, environ, start_response): + environ['HTTP_ACCEPT'] = (self.accept_header) + return self.app(environ, start_response) + + +class CredentialsValidator(object): + """ + Helper class for validating all the environment variables. + """ + + def validate_stormpath_credentials(self, client): + """ + Ensure that we have proper credentials needed to properly test our + Flask-Stormpath integration. + """ + try: + # Trying to access a resource that requires an api call + # (like a tenant key) without the proper id and secret should + # raise an error. + client.tenant.key + except StormpathError: + raise ValueError( + 'Stormpath api id and secret invalid or missing. Set your ' + + 'credentials as environment variables before testing.') + + def validate_facebook_credentials(self, app): + # Ensure that Facebook api id and secret are valid: + graph_api = GraphAPI() + try: + graph_api.get_app_access_token( + os.environ.get('FACEBOOK_API_ID'), + os.environ.get('FACEBOOK_API_SECRET')) + except GraphAPIError: + raise ValueError( + 'Facebook app id and secret invalid or missing. Set your ' + + 'credentials as environment variables before testing.') + + def validate_google_credentials(self, app): + root_url = os.environ.get('ROOT_URL') + port = os.environ.get('PORT') + + # Ensure that our url parameters are present + if not root_url or not port: + raise ValueError( + 'Root url and port invalid or missing. Set your ' + + 'values as environment variables before testing.') + redirect_uri = ''.join((root_url, ':', port, '/google')) + + # Ensure that Google api id and secret are valid: + flow = OAuth2WebServerFlow( + client_id=os.environ.get('GOOGLE_CLIENT_ID'), + client_secret=os.environ.get('GOOGLE_CLIENT_SECRET'), + scope=( + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile'), + redirect_uri=redirect_uri) + url = flow.step1_get_authorize_url() + + resp = requests.get(url) + if resp.status_code != 200: + raise ValueError( + 'Google client id and secret invalid or missing. Set your ' + + 'credentials as environment variables before testing.') + + def validate_credentials(self, app, flask_app, client): + """ + Ensure that we have proper credentials needed to properly test our + social login stuff. + """ + try: + self.validate_stormpath_credentials(client) + self.validate_facebook_credentials(flask_app) + self.validate_google_credentials(flask_app) + except ValueError as error: + destroy_resources(app, client) + raise error + + def bootstrap_client(): """ Create a new Stormpath Client from environment variables. @@ -74,12 +232,12 @@ def bootstrap_client(): :returns: A new Stormpath Client, fully initialized. """ return Client( - id = environ.get('STORMPATH_API_KEY_ID'), - secret = environ.get('STORMPATH_API_KEY_SECRET'), + id=os.environ.get('STORMPATH_API_KEY_ID'), + secret=os.environ.get('STORMPATH_API_KEY_SECRET'), ) -def bootstrap_app(client): +def bootstrap_app(client, name): """ Create a new, uniquely named, Stormpath Application. @@ -95,8 +253,10 @@ def bootstrap_app(client): :returns: A new Stormpath Application, fully initialized. """ return client.applications.create({ - 'name': 'flask-stormpath-tests-%s' % uuid4().hex, - 'description': 'This application is ONLY used for testing the Flask-Stormpath library. Please do not use this for anything serious.', + 'name': name, + 'description': 'This application is ONLY used for testing the ' + + 'Flask-Stormpath library. Please do not use this for anything ' + + 'serious.', }, create_directory=True) @@ -111,10 +271,65 @@ def bootstrap_flask_app(app): a = Flask(__name__) a.config['DEBUG'] = True a.config['SECRET_KEY'] = uuid4().hex - a.config['STORMPATH_API_KEY_ID'] = environ.get('STORMPATH_API_KEY_ID') - a.config['STORMPATH_API_KEY_SECRET'] = environ.get('STORMPATH_API_KEY_SECRET') + a.config['STORMPATH_API_KEY_ID'] = os.environ.get('STORMPATH_API_KEY_ID') + a.config['STORMPATH_API_KEY_SECRET'] = os.environ.get( + 'STORMPATH_API_KEY_SECRET') a.config['STORMPATH_APPLICATION'] = app.name a.config['WTF_CSRF_ENABLED'] = False - StormpathManager(a) + a.config['STORMPATH_ENABLE_FACEBOOK'] = True + a.config['STORMPATH_ENABLE_GOOGLE'] = True + + # Set file path for yaml config file. We do this since we need to test out + # our application with different yaml configurations. + a.config['STORMPATH_CONFIG_PATH'] = create_config_path( + **json.loads(os.environ.get('TEST_CONFIG', '{}'))) + + # Add secrets and ids for social login stuff. + a.config['STORMPATH_SOCIAL'] = { + 'FACEBOOK': { + 'app_id': os.environ.get('FACEBOOK_API_ID'), + 'app_secret': os.environ.get('FACEBOOK_API_SECRET')}, + 'GOOGLE': { + 'client_id': os.environ.get('GOOGLE_CLIENT_ID'), + 'client_secret': os.environ.get('GOOGLE_CLIENT_SECRET')} + } return a + + +def create_config_path(filename='', default=True): + # Creates a path for yaml config files in our tests directory. + if default: + return os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'flask_stormpath', 'config', 'default-config' + '.yml') + else: + return os.path.join( + os.path.dirname(os.path.abspath(__file__)), + 'config', filename + '.yml') + + +def destroy_resources(app, client): + """Destroy all provisioned Stormpath resources.""" + # Clean up the application. + app_name = app.name + app.delete() + + # Clean up the directories. + for directory in client.directories.search(app_name): + directory.delete() + + +""" Stormpath and social login credentials validation. """ + +# Create resources needed for validation. +client = bootstrap_client() +app = bootstrap_app(client, 'flask-stormpath-test-social-%s' % uuid4().hex) +flask_app = bootstrap_flask_app(app) + +# Validate credentials. +cred_validator = CredentialsValidator() +cred_validator.validate_credentials(app, flask_app, client) + +# Destroy resources. +destroy_resources(app, client) diff --git a/tests/test_context_processors.py b/tests/test_context_processors.py index c5aefe1..18117fc 100644 --- a/tests/test_context_processors.py +++ b/tests/test_context_processors.py @@ -3,35 +3,10 @@ from flask_stormpath import User, user from flask_stormpath.context_processors import user_context_processor -from stormpath.error import Error - from .helpers import StormpathTestCase class TestUserContextProcessor(StormpathTestCase): - - def setUp(self): - """Provision a single user account for testing.""" - # Call the parent setUp method first -- this will bootstrap our tests. - super(TestUserContextProcessor, self).setUp() - - # Create our Stormpath user. - with self.app.app_context(): - self.user = User.create( - given_name = 'Randall', - surname = 'Degges', - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - ) - - def tearDown(self): - super(TestUserContextProcessor, self).tearDown() - try: - self.user.delete() - except Error: - # Resource not found - ignore. - pass - def test_raw_works(self): with self.app.test_client() as c: c.post('/login', data={ diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 33cb325..8152167 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,10 +1,7 @@ """Run tests against our custom decorators.""" -from flask_stormpath import User from flask_stormpath.decorators import groups_required -from stormpath.error import Error - from .helpers import StormpathTestCase @@ -16,15 +13,6 @@ def setUp(self): super(TestGroupsRequired, self).setUp() with self.app.app_context(): - - # Create our Stormpath user. - self.user = User.create( - given_name = 'Randall', - surname = 'Degges', - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - ) - # Create two groups. self.admins = self.application.groups.create({ 'name': 'admins', @@ -33,24 +21,6 @@ def setUp(self): 'name': 'developers', }) - def tearDown(self): - super(TestGroupsRequired, self).tearDown() - try: - self.user.delete() - except Error: - # Resource not found - ignore. - pass - try: - self.admins.delete() - except Error: - # Resource not found - ignore. - pass - try: - self.developers.delete() - except Error: - # Resource not found - ignore. - pass - def test_defaults_to_all(self): @self.app.route('/test') @groups_required(['admins', 'developers']) diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 0000000..7a5ef6b --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,261 @@ +"""Tests for our custom forms.""" + + +from .helpers import StormpathTestCase +from flask_stormpath.forms import StormpathForm +from wtforms.fields import PasswordField, StringField +from wtforms.validators import InputRequired, Email, EqualTo +from stormpath.resources import Resource +import json + + +class TestStormpathForm(StormpathTestCase): + """Test the StormpathForm.""" + + def assertFormFields(self, form, config): + # Iterate through form config and check if the settings are + # properly applied to our form class. + + for field in config['fieldOrder']: + # Convert the key to underscore format + form_field = Resource.from_camel_case(field) + + if config['fields'][field]['enabled']: + # Check if all enabled fields are set. + self.assertTrue(hasattr(form, form_field)) + + # Get validators. + validators = getattr(form, form_field).kwargs.get( + 'validators') + + # Check if field type is set. + if config['fields'][field]['type'] == 'text': + self.assertTrue(getattr( + form, form_field).field_class, StringField) + elif config['fields'][field]['type'] == 'password': + self.assertTrue(getattr( + form, form_field).field_class, PasswordField) + elif config['fields'][field]['type'] == 'email': + self.assertTrue(any(isinstance( + validator, Email) for validator in validators)) + + # Check if required validator is set. + if config['fields'][field]['required']: + self.assertTrue(any(isinstance( + validator, InputRequired) for validator in validators)) + + # If 'confirmPassword' field is enabled, check that the proper + # validator is applied. + if (field == 'confirmPassword' and config['fields'][field][ + 'enabled']): + self.assertTrue(any(isinstance( + validator, EqualTo) for validator in validators)) + + # Check if placeholders are set. + placeholder = config['fields'][field].get('placeholder') + if placeholder: + self.assertTrue(getattr( + form, form_field).kwargs['render_kw']['placeholder'], + config['fields'][field]['placeholder']) + + # Check if labels are set. + label = config['fields'][field].get('label') + if label: + self.assertTrue(getattr(form, form_field).args[0], config[ + 'fields'][field]['label']) + + def assertFormBuilding(self, form_config): + # Ensure that forms are built based on the config specs. + with self.app.app_context(): + form = StormpathForm.specialize_form(form_config) + self.assertFormFields(form, form_config) + + # Check to see if the StormpathFrom base class is left unaltered + # after form building. + new_form = StormpathForm() + field_diff = list(set(form_config['fieldOrder']) - set( + dir(new_form))) + self.assertEqual( + sorted(field_diff), sorted(form_config['fieldOrder'])) + + def test_login_form_building(self): + form_config = self.app.config['stormpath']['web']['login']['form'] + self.assertFormBuilding(form_config) + + def test_registration_form_building(self): + form_config = self.app.config['stormpath']['web']['register']['form'] + form_config['fields']['confirmPassword']['enabled'] = True + self.assertFormBuilding(form_config) + + def test_forgot_password_form_building(self): + form_config = self.app.config['stormpath']['web']['forgotPassword'][ + 'form'] + self.assertFormBuilding(form_config) + + def test_change_password_form_building(self): + form_config = self.app.config['stormpath']['web']['changePassword'][ + 'form'] + self.assertFormBuilding(form_config) + + def test_empty_form(self): + # Ensure that an empty config will return an empty form. + with self.app.app_context(): + form = StormpathForm.specialize_form({}) + self.assertEqual(form._json, []) + + def test_error_messages(self): + # We'll use register form fields for this test, since they cover + # every error message case. + form_config = self.app.config['stormpath']['web']['register']['form'] + + # We are creating requests, since wtforms pass request.form to form + # init automatically. + with self.app.test_client() as c: + # Ensure that an error is raised if a required field is left + # empty. + c.post('', data={ + 'username': 'rdegges', + 'surname': 'Degges', + 'email': 'r@rdegges.com', + 'password': 'woot1LoveCookies!', + }) + form = StormpathForm.specialize_form(form_config)() + self.assertFalse(form.validate_on_submit()) + self.assertTrue(form.errors, { + 'given_name': ['First Name is required.']}) + + # Ensure that an error is raised if the email format is invalid. + c.post('', data={ + 'username': 'rdegges', + 'given_name': 'Randall', + 'surname': 'Degges', + 'email': 'rrdegges.com', + 'password': 'woot1LoveCookies!', + }) + form = StormpathForm.specialize_form(form_config)() + self.assertFalse(form.validate_on_submit()) + self.assertTrue(form.errors, { + 'email': ['Email must be in valid format.']}) + + # Ensure that an error is raised if confirm password is enabled + # the two passwords mismatch. + form_config['fields']['confirmPassword']['enabled'] = True + + c.post('', data={ + 'username': 'rdegges', + 'given_name': 'Randall', + 'surname': 'Degges', + 'email': 'r@rdegges.com', + 'password': 'woot1LoveCookies!', + 'confirm_password': 'woot1LoveCookies!...NOT!!' + }) + form = StormpathForm.specialize_form(form_config)() + self.assertFalse(form.validate_on_submit()) + self.assertTrue(form.errors, { + 'confirm_password': ['Passwords do not match.']}) + + # Ensure that proper form will result in success. + c.post('', data={ + 'username': 'rdegges', + 'given_name': 'Randall', + 'surname': 'Degges', + 'email': 'r@rdegges.com', + 'password': 'woot1LoveCookies!', + 'confirm_password': 'woot1LoveCookies!' + }) + form = StormpathForm.specialize_form(form_config)() + self.assertTrue(form.validate_on_submit()) + + # Ensure that enabled but optional fields won't cause an error. + form_config['fields']['givenName']['required'] = False + + c.post('', data={ + 'username': 'rdegges2', + 'surname': 'Degges2', + 'email': 'r@rdegges2.com', + 'password': 'woot1LoveCookies!2', + 'confirm_password': 'woot1LoveCookies!2' + }) + form = StormpathForm.specialize_form(form_config)() + self.assertTrue(form.validate_on_submit()) + + def test_json_fields(self): + # Specify expected fields + expected_fields = [ + { + 'name': 'login', + 'type': 'text', + 'required': True, + 'visible': True, + 'label': 'Username or Email', + 'placeholder': 'Username or Email'}, + { + 'name': 'password', + 'type': 'password', + 'required': True, + 'visible': True, + 'label': 'Password', + 'placeholder': 'Password'} + ] + + with self.app.app_context(): + form_config = self.app.config['stormpath']['web']['login']['form'] + form = StormpathForm.specialize_form(form_config)() + + # Construct field settings from the config. + field_specs = [] + for key in form_config['fields'].keys(): + field = form_config['fields'][key].copy() + field.pop('enabled') + field['name'] = key + field_specs.append(field) + + # Ensure that _json fields are the same as expected fields. + self.assertDictList(form._json, expected_fields, 'name') + + # Ensure that _json fields are the same as config settings. + self.assertDictList(form._json, expected_fields, 'name') + + def test_json_property(self): + # Specify expected fields + expected_fields = [ + { + 'name': 'login', + 'type': 'text', + 'required': True, + 'visible': True, + 'label': 'Username or Email', + 'placeholder': 'Username or Email'}, + { + 'name': 'password', + 'type': 'password', + 'required': True, + 'visible': True, + 'label': 'Password', + 'placeholder': 'Password'} + ] + + # Ensure that json property returns a proper json value. + with self.app.app_context(): + form_config = self.app.config['stormpath']['web']['login']['form'] + form = StormpathForm.specialize_form(form_config)() + + # Construct field settings from the config. + field_specs = [] + for key in form_config['fields'].keys(): + field = form_config['fields'][key].copy() + field.pop('enabled') + field['name'] = Resource.from_camel_case(key) + field['visible'] = True + field_specs.append(field) + + # Ensure that json return value is the same as config settings. + self.assertDictList(json.loads(form.json), field_specs, 'name') + + # We cannot compare expected_fields directly, so we'll first + # compare that both values are strings. + self.assertEqual( + type(form.json), type(json.dumps(expected_fields))) + + # Then compare that they both contain the same values. + self.assertDictList(json.loads(form.json), expected_fields, 'name') diff --git a/tests/test_models.py b/tests/test_models.py index 2bc13ea..9e79cb5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,153 +1,110 @@ """Tests for our data models.""" +import sys from flask_stormpath.models import User +from flask_stormpath import StormpathError from stormpath.resources.account import Account - +from stormpath.resources.provider import Provider from .helpers import StormpathTestCase +from os import environ +import json + +if sys.version_info.major == 3: + from unittest.mock import patch +else: + from mock import patch class TestUser(StormpathTestCase): """Our User test suite.""" def test_subclass(self): - with self.app.app_context(): - user = User.create( - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - given_name = 'Randall', - surname = 'Degges', - ) - - # Ensure that our lazy construction of the subclass works as - # expected for users (a `User` should be a valid Stormpath - # `Account`. - self.assertTrue(user.writable_attrs) - self.assertIsInstance(user, Account) - self.assertIsInstance(user, User) + # Ensure that our lazy construction of the subclass works as + # expected for users (a `User` should be a valid Stormpath + # `Account`. + self.assertTrue(self.user.writable_attrs) + self.assertIsInstance(self.user, Account) + self.assertIsInstance(self.user, User) def test_repr(self): - with self.app.app_context(): + # Ensure `username` is shown in the output if specified. + self.assertTrue(self.user.username in self.user.__repr__()) - # Ensure `email` is shown in the output if no `username` is - # specified. - user = User.create( - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - given_name = 'Randall', - surname = 'Degges', - ) - self.assertTrue(user.email in user.__repr__()) + # Ensure Stormpath `href` is shown in the output. + self.assertTrue(self.user.href in self.user.__repr__()) - # Delete this user. - user.delete() + # Delete this user. + self.user.delete() - # Ensure `username` is shown in the output if specified. - user = User.create( - username = 'omgrandall', - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - given_name = 'Randall', - surname = 'Degges', + with self.app.app_context(): + self.user = User.create( + given_name='Randall', + surname='Degges', + email='r@rdegges.com', + password='woot1LoveCookies!', ) - self.assertTrue(user.username in user.__repr__()) - # Ensure Stormpath `href` is shown in the output. - self.assertTrue(user.href in user.__repr__()) + # Ensure `email` is shown in the output if no `username` is + # specified. + self.assertTrue(self.user.email in self.user.__repr__()) def test_get_id(self): - with self.app.app_context(): - user = User.create( - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - given_name = 'Randall', - surname = 'Degges', - ) - self.assertEqual(user.get_id(), user.href) + self.assertEqual(self.user.get_id(), self.user.href) def test_is_active(self): - with self.app.app_context(): + # Ensure users are active by default. + self.assertEqual(self.user.is_active, True) - # Ensure users are active by default. - user = User.create( - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - given_name = 'Randall', - surname = 'Degges', - ) - self.assertEqual(user.is_active, True) - - # Ensure users who have their accounts explicitly disabled actually - # return a proper status when `is_active` is called. - user.status = User.STATUS_DISABLED - self.assertEqual(user.is_active, False) + # Ensure users who have their accounts explicitly disabled actually + # return a proper status when `is_active` is called. + self.user.status = User.STATUS_DISABLED + self.assertEqual(self.user.is_active, False) - # Ensure users who have not verified their accounts return a proper - # status when `is_active` is called. - user.status = User.STATUS_UNVERIFIED - self.assertEqual(user.is_active, False) + # Ensure users who have not verified their accounts return a proper + # status when `is_active` is called. + self.user.status = User.STATUS_UNVERIFIED + self.assertEqual(self.user.is_active, False) def test_is_anonymous(self): - with self.app.app_context(): - - # There is no way we can be anonymous, as Stormpath doesn't support - # anonymous users (that is a job better suited for a cache or - # something). - user = User.create( - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - given_name = 'Randall', - surname = 'Degges', - ) - self.assertEqual(user.is_anonymous, False) + # There is no way we can be anonymous, as Stormpath doesn't support + # anonymous users (that is a job better suited for a cache or + # something). + self.assertEqual(self.user.is_anonymous, False) def test_is_authenticated(self): - with self.app.app_context(): - - # This should always return true. If a user account can be - # fetched, that means it must be authenticated. - user = User.create( - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - given_name = 'Randall', - surname = 'Degges', - ) - self.assertEqual(user.is_authenticated, True) + # This should always return true. If a user account can be + # fetched, that means it must be authenticated. + self.assertEqual(self.user.is_authenticated, True) def test_create(self): - with self.app.app_context(): - # Ensure all requied fields are properly set. - user = User.create( - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - given_name = 'Randall', - surname = 'Degges', - ) - self.assertEqual(user.email, 'r@testmail.stormpath.com') - self.assertEqual(user.given_name, 'Randall') - self.assertEqual(user.surname, 'Degges') - self.assertEqual(user.username, 'r@testmail.stormpath.com') - self.assertEqual(user.middle_name, None) - self.assertEqual( - dict(user.custom_data), - { - 'created_at': user.custom_data.created_at, - 'modified_at': user.custom_data.modified_at, - }) + # Ensure all requied fields are properly set. + self.assertEqual(self.user.email, 'r@rdegges.com') + self.assertEqual(self.user.given_name, 'Randall') + self.assertEqual(self.user.surname, 'Degges') + self.assertEqual(self.user.username, 'rdegges') + self.assertEqual(self.user.middle_name, None) + self.assertEqual( + dict(self.user.custom_data), + { + 'created_at': self.user.custom_data.created_at, + 'modified_at': self.user.custom_data.modified_at, + }) - # Delete this user. - user.delete() + # Delete this user. + self.user.delete() - # Ensure all optional parameters are properly set. + # Ensure all optional parameters are properly set. + with self.app.app_context(): user = User.create( - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - given_name = 'Randall', - surname = 'Degges', - username = 'rdegges', - middle_name = 'Clark', - custom_data = { + email='r@rdegges.com', + password='woot1LoveCookies!', + given_name='Randall', + surname='Degges', + username='rdegges', + middle_name='Clark', + custom_data={ 'favorite_shows': ['Code Monkeys', 'The IT Crowd'], 'friends': ['Sami', 'Alven'], 'favorite_place': { @@ -173,31 +130,243 @@ def test_create(self): 'modified_at': user.custom_data.modified_at, }) + def test_save(self): + # Ensure that save will save the new instance. + self.assertEqual(self.user.username, 'rdegges') + self.user.username = 'something else' + self.user.save() + self.assertEqual(self.user.username, 'something else') + + # Ensure that save will return a user instance. (Signal sent during + # save is tested in test_signals.py) + self.assertTrue(isinstance(self.user.save(), User)) + def test_from_login(self): with self.app.app_context(): - - # First we'll create a user. + # Create a user (we need a new user instance, one with a specific + # username). user = User.create( - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - given_name = 'Randall', - surname = 'Degges', - username = 'rdegges', - ) + username='rdegges2', + email='r2@rdegges.com', + password='woot1LoveCookies2!', + given_name='Randall2', + surname='Degges2') + + # Get user href original_href = user.href # Now we'll try to retrieve that user by specifing the user's # `email` and `password`. user = User.from_login( - 'r@testmail.stormpath.com', - 'woot1LoveCookies!', + 'r2@rdegges.com', + 'woot1LoveCookies2!', ) self.assertEqual(user.href, original_href) - # Now we'll try to retrieve that user by specifying the user's # `username` and `password`. user = User.from_login( - 'rdegges', - 'woot1LoveCookies!', + 'rdegges2', + 'woot1LoveCookies2!', ) self.assertEqual(user.href, original_href) + + def test_to_json(self): + # Ensure that to_json method returns user json representation. + self.assertTrue(isinstance(self.user.to_json(), str)) + json_data = json.loads(self.user.to_json()) + expected_json_data = {'account': { + 'href': self.user.href, + 'modified_at': self.user.modified_at.isoformat(), + 'created_at': self.user.created_at.isoformat(), + 'status': 'ENABLED', + 'username': 'rdegges', + 'email': 'r@rdegges.com', + 'given_name': 'Randall', + 'middle_name': None, + 'surname': 'Degges', + 'full_name': 'Randall Degges' + }} + self.assertEqual(json_data, expected_json_data) + + def test_to_json_datetime_handler(self): + # Ensure that our custom datetime_handler serializes only datetime + # objects. + self.user.surname = set([1, 2]) + with self.assertRaises(TypeError): + self.user.to_json() + + self.user.surname = 'foobar' + self.user.to_json() + + +class SocialMethodsTestMixin(object): + """Our mixin for testing User social methods.""" + + def __init__(self, social_name, *args, **kwargs): + # Validate social_name + if social_name not in ['facebook', 'google']: + raise ValueError('Wrong social name.') + self.social_name = social_name + + # Set our error message + self.error_message = ( + 'Stormpath was not able to complete the request to %s:' + % self.social_name) + + @property + def social_dir_name(self): + # Get directory name + with self.app.app_context(): + return ( + self.app.stormpath_manager.application.name + '-' + + self.social_name) + + @property + def search_query(self): + return self.app.stormpath_manager.client.tenant.directories.query( + name=self.social_dir_name) + + def user_from_social(self, access_token): + return getattr( + User, 'from_%s' % self.social_name)(access_token) + + @patch('stormpath.resources.application.Application.get_provider_account') + def test_from_social_supported_service(self, user_mock): + # Ensure that the proper social_name will continue processing the + # social login. + with self.app.app_context(): + self.assertTrue( + isinstance(self.user.from_social( + self.social_name, + 'mocked access token', self.provider), User)) + + # Ensure that the wrong social name will raise an error. + with self.assertRaises(ValueError) as error: + self.user.from_social( + 'foobar', 'mocked access token', self.provider) + + self.assertEqual( + str(error.exception), 'Social service is not supported.') + + @patch('stormpath.resources.application.Application.get_provider_account') + def test_from_social_valid(self, user_mock): + # We'll mock the social account getter since we cannot replicate the + # access token needed for social login. + user_mock.return_value = self.user + + # Ensure that from_ will return a User instance if access token + # is valid. + with self.app.app_context() and self.app.test_request_context( + ':%s' % environ.get('PORT')): + user = self.user_from_social('mocked access token') + self.assertTrue(isinstance(user, User)) + + @patch('stormpath.resources.application.Application.get_provider_account') + def test_from_social_create_directory(self, user_mock): + # We'll mock the social account getter since we cannot replicate the + # access token needed for social login. + user_mock.return_value = self.user + user_mock.side_effect = StormpathError( + {'developerMessage': 'Mocked message.'}) + + # Ensure that from_ will create a directory if the + # access token is valid but a directory doesn't exist. + with self.app.app_context(): + # Ensure that a social directory is not present. + if self.search_query.items: + self.search_query.items[0].delete() + + # We have to catch our exception since we're the one raising it + # with our mocking. + with self.assertRaises(StormpathError): + # Create a directory by creating the user for the first time. + with self.app.test_request_context( + ':%s' % environ.get('PORT')): + user = self.user_from_social('mocked access token') + self.assertTrue(isinstance(user, User)) + + # To ensure that this error is caught at the right time + # however, we will assert the number of mock calls. + self.assertEqual(user_mock.call_count, 2) + + # Ensure that the social directory is now present. + self.assertEqual(len(self.search_query.items), 1) + self.assertEqual( + self.search_query.items[0].name, self.social_dir_name) + + def test_from_social_invalid_access_token(self): + # Ensure that from_ will raise a StormpathError if access + # token is invalid. + with self.app.app_context() and self.app.test_request_context( + ':%s' % environ.get('PORT')): + with self.assertRaises(StormpathError) as error: + self.user_from_social('foobar') + + self.assertTrue(self.error_message in str(error.exception)) + + def test_from_social_invalid_access_token_with_existing_directory(self): + # First we will create a social directory if one doesn't already + # exist. + with self.app.app_context() and self.app.test_request_context( + ':%s' % environ.get('PORT')): + if not self.search_query.items: + self.create_social_directory( + social_name=self.social_name, provider=self.provider) + + # Ensure that from_ will raise a StormpathError if access + # token is invalid and social directory present. + with self.assertRaises(StormpathError) as error: + self.user_from_social('foobar') + + self.assertTrue(self.error_message in str(error.exception)) + + +class TestFacebookLogin(StormpathTestCase, SocialMethodsTestMixin): + """Our User facebook login test suite.""" + def __init__(self, *args, **kwargs): + super(TestFacebookLogin, self).__init__(*args, **kwargs) + SocialMethodsTestMixin.__init__(self, 'facebook') + + def setUp(self): + super(TestFacebookLogin, self).setUp() + + # Set a provider. + self.provider = { + 'client_id': environ.get('FACEBOOK_API_ID'), + 'client_secret': environ.get('FACEBOOK_API_SECRET'), + 'provider_id': Provider.FACEBOOK, + } + + # Set client ID and secret. + self.app.config['stormpath']['web']['social']['facebook'][ + 'clientId'] = environ.get('FACEBOOK_API_ID') + self.app.config['stormpath']['web']['social']['facebook'][ + 'clientSecret'] = environ.get('FACEBOOK_API_SECRET') + + +class TestGoogleLogin(StormpathTestCase, SocialMethodsTestMixin): + """Our User google login test suite.""" + def __init__(self, *args, **kwargs): + super(TestGoogleLogin, self).__init__(*args, **kwargs) + SocialMethodsTestMixin.__init__(self, 'google') + + def setUp(self): + super(TestGoogleLogin, self).setUp() + + with self.app.app_context(): + # Set a provider + self.provider = { + 'client_id': environ.get('GOOGLE_CLIENT_ID'), + 'client_secret': environ.get('GOOGLE_CLIENT_SECRET'), + 'redirect_uri': ( + ''.join( + (environ.get('ROOT_URL'), ':', environ.get('PORT'))) + + self.app.config['STORMPATH_GOOGLE_LOGIN_URL']), + 'provider_id': Provider.GOOGLE, + } + + # Set client ID and secret. + self.app.config['stormpath']['web']['social']['google'][ + 'clientId'] = environ.get('GOOGLE_CLIENT_ID') + self.app.config['stormpath']['web']['social']['google'][ + 'clientSecret'] = environ.get('GOOGLE_CLIENT_SECRET') diff --git a/tests/test_request_processors.py b/tests/test_request_processors.py new file mode 100644 index 0000000..90a0a36 --- /dev/null +++ b/tests/test_request_processors.py @@ -0,0 +1,75 @@ +"""Run tests against our custom request processors.""" + +from flask import current_app +from .helpers import StormpathTestCase, HttpAcceptWrapper +from stormpath_config.errors import ConfigurationError +from flask_stormpath.request_processors import ( + get_accept_header, + request_wants_json +) + + +class TestRequestProcessor(StormpathTestCase): + """Test request_processor functions.""" + + def test_get_accept_header(self): + # Ensure that get_accept_header will return a proper accept header. + + with self.app.app_context(): + allowed_types = current_app.config['stormpath']['web']['produces'] + + with self.app.test_client() as c: + # HTML header. + c.get('/') + self.assertEqual(get_accept_header(), 'text/html') + + # JSON header. + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, self.json_header) + c.get('/') + self.assertEqual(get_accept_header(), 'application/json') + + # Accept header empty. + self.app.wsgi_app = HttpAcceptWrapper(self.default_wsgi_app, None) + c.get('/') + self.assertEqual(get_accept_header(), allowed_types[0]) + + # Accept header */*. + self.app.wsgi_app = HttpAcceptWrapper(self.default_wsgi_app, '*/*') + c.get('/') + self.assertEqual(get_accept_header(), allowed_types[0]) + + # Ensure that get_accept_header will throw an error if called from + # outside application context or without stormpath config. + with self.assertRaises(ConfigurationError) as error: + get_accept_header() + self.assertEqual( + error.exception.message, + 'You must initialize flask app before calling this function.') + + def test_request_wants_json(self): + # Ensure that a request with a json accept header will return a + # json response. + + with self.app.test_client() as c: + # JSON header. + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, self.json_header) + c.get('/') + self.assertTrue(request_wants_json()) + + # HTML header + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, self.html_header) + c.get('/') + self.assertFalse(request_wants_json()) + + # If the accept header is empty, get_accept_header will return + # the first type in produces list, which is currently set to json. + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, None) + c.get('/') + self.assertEqual( + current_app.config['stormpath']['web']['produces'][0], + 'application/json') + self.assertTrue(request_wants_json()) diff --git a/tests/test_settings.py b/tests/test_settings.py index 2d9b419..cdb6857 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,132 +1,207 @@ """Tests for our settings stuff.""" -from datetime import timedelta -from os import close, environ, remove, write -from tempfile import mkstemp - -from flask_stormpath.errors import ConfigurationError -from flask_stormpath.settings import check_settings, init_settings - +from flask_stormpath.settings import ( + StormpathSettings) +from flask_stormpath import __version__ as stormpath_flask_version +from flask import __version__ as flask_version from .helpers import StormpathTestCase +try: + from mock import MagicMock, patch +except ImportError: + from unittest.mock import MagicMock, patch + class TestInitSettings(StormpathTestCase): """Ensure we can properly initialize Flask app settings.""" def test_works(self): - init_settings(self.app.config) + self.manager.init_settings(self.app.config) # Ensure a couple of settings exist that we didn't explicitly specify # anywhere. - self.assertEqual(self.app.config['STORMPATH_ENABLE_FACEBOOK'], False) - self.assertEqual(self.app.config['STORMPATH_ENABLE_GIVEN_NAME'], True) - - -class TestCheckSettings(StormpathTestCase): - """Ensure our settings checker is working properly.""" - - def setUp(self): - """Create an apiKey.properties file for testing.""" - super(TestCheckSettings, self).setUp() - - # Generate our file locally. - self.fd, self.file = mkstemp() - api_key_id = 'apiKey.id = %s\n' % environ.get('STORMPATH_API_KEY_ID') - api_key_secret = 'apiKey.secret = %s\n' % environ.get( - 'STORMPATH_API_KEY_SECRET') - write(self.fd, api_key_id.encode('utf-8') + b'\n') - write(self.fd, api_key_secret.encode('utf-8') + b'\n') - - def test_requires_api_credentials(self): - # We'll remove our default API credentials, and ensure we get an - # exception raised. - self.app.config['STORMPATH_API_KEY_ID'] = None - self.app.config['STORMPATH_API_KEY_SECRET'] = None - self.app.config['STORMPATH_API_KEY_FILE'] = None - self.assertRaises(ConfigurationError, check_settings, self.app.config) - - # Now we'll check to see that if we specify an API key ID and secret - # things work. - self.app.config['STORMPATH_API_KEY_ID'] = environ.get('STORMPATH_API_KEY_ID') - self.app.config['STORMPATH_API_KEY_SECRET'] = environ.get('STORMPATH_API_KEY_SECRET') - check_settings(self.app.config) - - # Now we'll check to see that if we specify an API key file things work. - self.app.config['STORMPATH_API_KEY_ID'] = None - self.app.config['STORMPATH_API_KEY_SECRET'] = None - self.app.config['STORMPATH_API_KEY_FILE'] = self.file - check_settings(self.app.config) - - def test_requires_application(self): - # We'll remove our default Application, and ensure we get an exception - # raised. - self.app.config['STORMPATH_APPLICATION'] = None - self.assertRaises(ConfigurationError, check_settings, self.app.config) - - def test_google_settings(self): - # Ensure that if the user has Google login enabled, they've specified - # the correct settings. - self.app.config['STORMPATH_ENABLE_GOOGLE'] = True - self.assertRaises(ConfigurationError, check_settings, self.app.config) - - # Ensure that things don't work if not all social configs are specified. - self.app.config['STORMPATH_SOCIAL'] = {} - self.assertRaises(ConfigurationError, check_settings, self.app.config) - - self.app.config['STORMPATH_SOCIAL'] = {'GOOGLE': {}} - self.assertRaises(ConfigurationError, check_settings, self.app.config) - - self.app.config['STORMPATH_SOCIAL']['GOOGLE']['client_id'] = 'xxx' - self.assertRaises(ConfigurationError, check_settings, self.app.config) - - # Now that we've configured things properly, it should work. - self.app.config['STORMPATH_SOCIAL']['GOOGLE']['client_secret'] = 'xxx' - check_settings(self.app.config) - - def test_facebook_settings(self): - # Ensure that if the user has Facebook login enabled, they've specified - # the correct settings. - self.app.config['STORMPATH_ENABLE_FACEBOOK'] = True - self.assertRaises(ConfigurationError, check_settings, self.app.config) - - # Ensure that things don't work if not all social configs are specified. - self.app.config['STORMPATH_SOCIAL'] = {} - self.assertRaises(ConfigurationError, check_settings, self.app.config) - - self.app.config['STORMPATH_SOCIAL'] = {'FACEBOOK': {}} - self.assertRaises(ConfigurationError, check_settings, self.app.config) - - self.app.config['STORMPATH_SOCIAL']['FACEBOOK']['app_id'] = 'xxx' - self.assertRaises(ConfigurationError, check_settings, self.app.config) - - # Now that we've configured things properly, it should work. - self.app.config['STORMPATH_SOCIAL']['FACEBOOK']['app_secret'] = 'xxx' - check_settings(self.app.config) - - def test_cookie_settings(self): - # Ensure that if a user specifies a cookie domain which isn't a string, - # an error is raised. - self.app.config['STORMPATH_COOKIE_DOMAIN'] = 1 - self.assertRaises(ConfigurationError, check_settings, self.app.config) - - # Now that we've configured things properly, it should work. - self.app.config['STORMPATH_COOKIE_DOMAIN'] = 'test' - check_settings(self.app.config) - - # Ensure that if a user specifies a cookie duration which isn't a - # timedelta object, an error is raised. - self.app.config['STORMPATH_COOKIE_DURATION'] = 1 - self.assertRaises(ConfigurationError, check_settings, self.app.config) - - # Now that we've configured things properly, it should work. - self.app.config['STORMPATH_COOKIE_DURATION'] = timedelta(minutes=1) - check_settings(self.app.config) - - def tearDown(self): - """Remove our apiKey.properties file.""" - super(TestCheckSettings, self).tearDown() - - # Remove our file. - close(self.fd) - remove(self.file) + self.assertEqual(self.app.config['stormpath'][ + 'STORMPATH_WEB_REGISTER_ENABLED'], True) + self.assertEqual(self.app.config['stormpath'][ + 'STORMPATH_WEB_LOGIN_ENABLED'], True) + + def test_helpers(self): + self.manager.init_settings(self.app.config) + settings = self.app.config['stormpath'] + + self.assertEqual(settings._from_camel('givenName'), 'GIVEN_NAME') + self.assertEqual(settings._from_camel('given_name'), 'GIVEN_NAME') + self.assertNotEqual(settings._from_camel('GivenName'), 'GIVEN_NAME') + + settings.store = { + 'application': { + 'name': 'StormpathApp' + } + } + + # test key search + node, child = settings.__search__( + settings.store, 'STORMPATH_APPLICATION_NAME', 'STORMPATH') + self.assertEqual(node, settings.store['application']) + self.assertEqual(node[child], settings.store['application']['name']) + + # key node matching with no direct mapping + node, child = settings.__nodematch__('STORMPATH_APPLICATION_NAME') + self.assertEqual(node, settings.store['application']) + self.assertEqual(node[child], settings.store['application']['name']) + + # key node matching with direct mapping + node, child = settings.__nodematch__('STORMPATH_APPLICATION') + self.assertEqual(node, settings.store['application']) + self.assertEqual(node[child], settings.store['application']['name']) + + def test_settings_init(self): + self.manager.init_settings(self.app.config) + settings = self.app.config['stormpath'] + + # flattened settings with direct mapping + settings['STORMPATH_APPLICATION'] = 'StormpathApp' + self.assertEqual(settings.store['application']['name'], 'StormpathApp') + self.assertEqual(settings.get('STORMPATH_APPLICATION'), 'StormpathApp') + self.assertEqual(settings['STORMPATH_APPLICATION'], 'StormpathApp') + self.assertEqual(settings.get('application')['name'], 'StormpathApp') + self.assertEqual(settings['application']['name'], 'StormpathApp') + + def test_set(self): + settings = StormpathSettings() + # flattened setting wasn't defined during init + with self.assertRaises(KeyError): + settings['STORMPATH_WEB_SETTING'] = 'StormWebSetting' + + # flattened setting defined during init + settings = StormpathSettings(web={'setting': 'StormSetting'}) + settings['STORMPATH_WEB_SETTING'] = 'StormWebSetting' + self.assertEqual( + settings['web']['setting'], 'StormWebSetting') + # dict setting defined during init + settings = StormpathSettings(web={'setting': 'StormSetting'}) + settings['web']['setting'] = 'StormWebSetting' + self.assertEqual( + settings['web']['setting'], 'StormWebSetting') + + # overriding flattened setting + settings = StormpathSettings(web={'setting': 'StormSetting'}) + settings['STORMPATH_WEB'] = 'StormWebSetting' + self.assertEqual(settings['web'], 'StormWebSetting') + # overriding dict setting + settings = StormpathSettings(web={'setting': 'StormSetting'}) + settings['web'] = 'StormWebSetting' + self.assertEqual(settings['web'], 'StormWebSetting') + + def test_get(self): + self.manager.init_settings(self.app.config) + settings = self.app.config['stormpath'] + + register_setting = { + 'enabled': True, + 'form': { + 'fields': { + 'givenName': { + 'enabled': True + } + } + } + } + + # flattened setting without mappings + settings['STORMPATH_WEB_REGISTER'] = register_setting + self.assertEqual( + settings.get('STORMPATH_WEB_REGISTER'), register_setting) + self.assertEqual(settings['STORMPATH_WEB_REGISTER'], register_setting) + self.assertEqual(settings.get('web')['register'], register_setting) + self.assertEqual(settings['web']['register'], register_setting) + + # dict setting without mappings + settings['web']['register'] = register_setting + self.assertEqual( + settings.get('STORMPATH_WEB_REGISTER'), register_setting) + self.assertEqual(settings['STORMPATH_WEB_REGISTER'], register_setting) + self.assertEqual(settings.get('web')['register'], register_setting) + self.assertEqual(settings['web']['register'], register_setting) + + def test_del(self): + self.manager.init_settings(self.app.config) + settings = self.app.config['stormpath'] + register_setting = { + 'enabled': True, + 'form': { + 'fields': { + 'givenName': { + 'enabled': True + } + } + } + } + settings['STORMPATH_WEB_REGISTER'] = register_setting + del settings['web']['register'] + with self.assertRaises(KeyError): + settings['STORMPATH_WEB_REGISTER'] + + def test_camel_case(self): + web_settings = { + 'register': { + 'enabled': True, + 'form': { + 'fields': { + 'givenName': { + 'enabled': True + } + } + } + } + } + + settings = StormpathSettings(web=web_settings) + self.assertTrue(settings['web']['register']['form']['fields'][ + 'givenName']['enabled']) + self.assertTrue( + settings['STORMPATH_WEB_REGISTER_FORM_FIELDS_GIVEN_NAME_ENABLED']) + settings[ + 'STORMPATH_WEB_REGISTER_FORM_FIELDS_GIVEN_NAME_ENABLED'] = False + self.assertFalse(settings['web']['register']['form']['fields'][ + 'givenName']['enabled']) + self.assertFalse( + settings['STORMPATH_WEB_REGISTER_FORM_FIELDS_GIVEN_NAME_ENABLED']) + settings[ + 'web']['register']['form']['fields']['givenName']['enabled'] = True + self.assertTrue(settings['web']['register']['form']['fields'][ + 'givenName']['enabled']) + self.assertTrue( + settings['STORMPATH_WEB_REGISTER_FORM_FIELDS_GIVEN_NAME_ENABLED']) + + @patch('requests.sessions.PreparedRequest') + def test_user_agent(self, PreparedRequest): + # Ensure that every request sent to the Stormpath API has a proper + # user agent header. + + # Set mock. + request_mock = PreparedRequest.return_value.prepare + request_mock.return_value = MagicMock() + + # Attempt a login using email and password. + with self.app.test_client() as c: + c.post('/login', data={ + 'login': 'r@rdegges.com', + 'password': 'woot1LoveCookies!', + }) + + # Ensure our login generated a request. + self.assertEqual(request_mock.call_count, 1) + call = request_mock._mock_call_args_list[0] + + # Extract the User-Agent header. + user_agent_header = tuple(call)[1]['headers']['User-Agent'] + + # Ensure that stormpath-flask and flask version are included in + # user-agent string. + stormpath_flask_version_str = ( + 'stormpath-flask/%s' % stormpath_flask_version) + flask_version_str = 'flask/%s' % flask_version + self.assertTrue(stormpath_flask_version_str in user_agent_header) + self.assertTrue(flask_version_str in user_agent_header) diff --git a/tests/test_signals.py b/tests/test_signals.py index 2184ce2..b3fed5a 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -7,25 +7,33 @@ user_deleted, user_updated ) - -from .helpers import StormpathTestCase, SignalReceiver +from .helpers import StormpathTestCase, SignalReceiver, HttpAcceptWrapper class TestSignals(StormpathTestCase): """Test signals.""" + def setUp(self): + super(TestSignals, self).setUp() + self.html_header = 'text/html,application/xhtml+xml,application/xml;' + self.app.wsgi_app = HttpAcceptWrapper( + self.app.wsgi_app, self.html_header) def test_user_created_signal(self): # Subscribe to signals for user creation signal_receiver = SignalReceiver() user_created.connect(signal_receiver.signal_user_receiver_function) + # Delete the user first, so we can create the same one again. + self.user.delete() + # Register new account with self.app.test_client() as c: resp = c.post('/register', data={ + 'username': 'rdegges', 'given_name': 'Randall', 'middle_name': 'Clark', 'surname': 'Degges', - 'email': 'r@testmail.stormpath.com', + 'email': 'r@rdegges.com', 'password': 'woot1LoveCookies!', }) self.assertEqual(resp.status_code, 302) @@ -37,7 +45,7 @@ def test_user_created_signal(self): self.assertIsInstance(received_signal[1], dict) # Correct user instance is received created_user = received_signal[1] - self.assertEqual(created_user['email'], 'r@testmail.stormpath.com') + self.assertEqual(created_user['email'], 'r@rdegges.com') self.assertEqual(created_user['surname'], 'Degges') def test_user_logged_in_signal(self): @@ -45,16 +53,6 @@ def test_user_logged_in_signal(self): signal_receiver = SignalReceiver() user_logged_in.connect(signal_receiver.signal_user_receiver_function) - # Create a user. - with self.app.app_context(): - User.create( - username = 'rdegges', - given_name = 'Randall', - surname = 'Degges', - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - ) - # Attempt a login using username and password. with self.app.test_client() as c: resp = c.post('/login', data={ @@ -70,7 +68,7 @@ def test_user_logged_in_signal(self): self.assertIsInstance(received_signal[1], User) # Correct user instance is received logged_in_user = received_signal[1] - self.assertEqual(logged_in_user.email, 'r@testmail.stormpath.com') + self.assertEqual(logged_in_user.email, 'r@rdegges.com') self.assertEqual(logged_in_user.surname, 'Degges') def test_user_is_updated_signal(self): @@ -78,18 +76,8 @@ def test_user_is_updated_signal(self): signal_receiver = SignalReceiver() user_updated.connect(signal_receiver.signal_user_receiver_function) - with self.app.app_context(): - - # Ensure all requied fields are properly set. - user = User.create( - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - given_name = 'Randall', - surname = 'Degges', - ) - - user.middle_name = 'Clark' - user.save() + self.user.middle_name = 'Clark' + self.user.save() # Check that signal for user update is received self.assertEqual(len(signal_receiver.received_signals), 1) @@ -98,7 +86,7 @@ def test_user_is_updated_signal(self): self.assertIsInstance(received_signal[1], dict) # Correct user instance is received updated_user = received_signal[1] - self.assertEqual(updated_user['email'], 'r@testmail.stormpath.com') + self.assertEqual(updated_user['email'], 'r@rdegges.com') self.assertEqual(updated_user['middle_name'], 'Clark') def test_user_is_deleted_signal(self): @@ -106,17 +94,7 @@ def test_user_is_deleted_signal(self): signal_receiver = SignalReceiver() user_deleted.connect(signal_receiver.signal_user_receiver_function) - with self.app.app_context(): - - # Ensure all requied fields are properly set. - user = User.create( - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - given_name = 'Randall', - surname = 'Degges', - ) - - user.delete() + self.user.delete() # Check that signal for user deletion is received self.assertEqual(len(signal_receiver.received_signals), 1) @@ -125,4 +103,4 @@ def test_user_is_deleted_signal(self): self.assertIsInstance(received_signal[1], dict) # Correct user instance is received deleted_user = received_signal[1] - self.assertEqual(deleted_user['email'], 'r@testmail.stormpath.com') + self.assertEqual(deleted_user['email'], 'r@rdegges.com') diff --git a/tests/test_stormpath.py b/tests/test_stormpath.py index 0a65966..5f0d128 100644 --- a/tests/test_stormpath.py +++ b/tests/test_stormpath.py @@ -15,7 +15,6 @@ from flask_stormpath import ( StormpathManager, User, - groups_required, login_user, logout_user, ) diff --git a/tests/test_views.py b/tests/test_views.py index 1aa8186..ec19669 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,204 +1,775 @@ """Run tests against our custom views.""" +import sys +import os from flask_stormpath.models import User +from .helpers import StormpathTestCase, HttpAcceptWrapper, create_config_path +from stormpath.resources import Resource +from stormpath.resources.provider import Provider +from stormpath.error import Error as StormpathError +from flask_stormpath.views import ( + StormpathView, FacebookLoginView, GoogleLoginView, VerifyEmailView, View) +from flask import session, url_for, Response +from flask_login import current_user +from werkzeug.exceptions import BadRequest +from ruamel.yaml import util, round_trip_dump +import json +import shutil -from .helpers import StormpathTestCase +if sys.version_info.major == 3: + from unittest.mock import patch +else: + from mock import patch -class TestRegister(StormpathTestCase): +class StormpathViewTestCase(StormpathTestCase): + """Base test class for Stormpath views.""" + + def check_header(self, st, headers): + return any(st in header for header in headers) + + def assertFormSettings(self, expected_fields): + """ + Expected response set in json tests is based on the default settings + specified in the config file. This method ensures that the developer + didn't change the config file before running tests. + """ + + # Build form fields from the config and compare them to those + # specified in the expected response. + form_fields = [] + for key in self.form_fields.keys(): + field = self.form_fields[key].copy() + + # Convert fields from config to json response format. + if field['enabled']: + field.pop('enabled') + field['name'] = Resource.from_camel_case(key) + field['visible'] = True + form_fields.append(field) + + # Sort and compare form fields + self.assertDictList(form_fields, expected_fields, 'name') + + def assertJsonResponse( + self, method, view, status_code, expected_response, + user_to_json=False, **kwargs): + """Custom assert for testing json responses on flask_stormpath + views.""" + + # Set our request type to json. + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, self.json_header) + + with self.app.test_client() as c: + # Create a request. + allowed_methods = { + 'get': c.get, + 'post': c.post} + + if method in allowed_methods: + resp = allowed_methods[method]('/%s' % view, **kwargs) + else: + raise ValueError('\'%s\' is not a supported method.' % method) + + # Ensure that the HTTP status code is correct. + self.assertEqual(resp.status_code, status_code) + + # If we're expecting a redirect, follow the redirect flow so we + # can access the final response data. + if status_code == 302: + resp = allowed_methods[method]( + '/%s' % view, follow_redirects=True) + self.assertEqual(resp.status_code, 200) + + # Check that response is json. + self.assertFalse(self.check_header('text/html', resp.headers[0])) + self.assertTrue(self.check_header( + 'application/json', resp.headers[0])) + + # If we're comparing json response with account info, make sure + # that the following values are present in the response and pop + # them, since we cannot predetermine these values in our expected + # response. + if user_to_json: + request_response = json.loads(resp.data.decode()) + undefined_data = ('href', 'modified_at', 'created_at') + self.assertTrue( + all(key in request_response['account'].keys() + for key in undefined_data)) + for key in undefined_data: + request_response['account'].pop(key) + else: + request_response = json.loads(resp.data.decode()) + + # Convert responses to dicts, sort them if necessary, and compare. + expected_response = json.loads(expected_response) + if hasattr(request_response, 'sort'): + self.assertDictList(request_response, expected_response, 'name') + else: + self.assertEqual(request_response, expected_response) + + def assertDisabledView(self, view_name, post_data): + # Ensure that a disabled view will always return a 404 response. + + # Create a config directory for storing different temporary yaml config + # files needed for testing. + self.config_dir = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'config') + if not os.path.exists(self.config_dir): + os.makedirs(self.config_dir) + + # Disable the view. + + # Create a new updated config file from the default one. + config, ind, bsi = util.load_yaml_guess_indent( + open(self.app.config['STORMPATH_CONFIG_PATH'])) + config['web'][view_name]['enabled'] = False + config_name = 'test-config-%s' % view_name + round_trip_dump( + config, open(create_config_path(config_name, default=False), 'w'), + indent=ind, block_seq_indent=bsi) + + # Set new config file before app init. + os.environ['TEST_CONFIG'] = json.dumps( + {'filename': config_name, 'default': False}) + + # Reinitialize the application. + self.reinit_app() + + # Ensure that both GET and POST will return a 404. + with self.app.test_client() as c: + resp = c.get('/' + view_name) + self.assertEqual(resp.status_code, 404) + + resp = c.post('/' + view_name, data=post_data) + self.assertEqual(resp.status_code, 404) + + # Revert to the default config. + os.environ['TEST_CONFIG'] = json.dumps({}) + + def tearDown(self): + super(StormpathViewTestCase, self).tearDown() + + # Destroy temporary yaml config resources. + if hasattr(self, 'config_dir') and os.path.exists(self.config_dir): + shutil.rmtree(self.config_dir) + + +class TestHelperMethods(StormpathViewTestCase): + """Test our helper functions.""" + + def setUp(self): + super(TestHelperMethods, self).setUp() + # We need a config for a StormpathView, so we'll use login form config. + self.config = self.app.config['stormpath']['web']['login'] + + # Create an 'invalid_request' view. This view has to be implemented by + # the developer/framework, so it is not part of the stormpath-flask + # library. We will create one for testing purposes. Flask requires + # this do be done in setUp, before the first request is handled. + class InvalidRequestView(View): + def dispatch_request(self): + xml = 'Invalid request.' + return Response(xml, mimetype='text/xml', status=400) + + self.app.add_url_rule( + self.app.config['stormpath']['web']['invalidRequest']['uri'], + 'stormpath.invalid_request', + InvalidRequestView.as_view('invalid_request'), + ) + + # Ensure that StormpathView.accept_header is properly set. + with self.app.test_client() as c: + # Create a request with html accept header + c.get('/') + + with self.app.app_context(): + self.view = StormpathView(self.config) + + def test_make_stormpath_response(self): + data = {'foo': 'bar'} + with self.app.test_client() as c: + # Ensure that stormpath_response is json if request wants json. + c.get('/') + resp = self.view.make_stormpath_response(json.dumps(data)) + self.assertFalse(self.check_header( + 'text/html', resp.headers[0])) + self.assertTrue(self.check_header( + 'application/json', resp.headers[0])) + self.assertEqual(resp.data.decode(), '{"foo": "bar"}') + + # Ensure that stormpath_response is html if request wants html. + c.get('/') + resp = self.view.make_stormpath_response( + data, template='flask_stormpath/base.html', return_json=False) + + # Python 3 support for testing html response. + if sys.version_info.major == 3: + self.assertTrue(isinstance(resp, str)) + else: + self.assertTrue(isinstance(resp, unicode)) + + def test_validate_request(self): + with self.app.test_client() as c: + # Ensure that a request with an html accept header will return an + # html response. + c.get('/') + with self.app.app_context(): + self.view.__init__(self.config) + self.assertEqual(self.view.accept_header, 'text/html') + + # Ensure that a request with a json accept header will return a + # json response. + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, self.json_header) + c.get('/') + with self.app.app_context(): + self.view.__init__(self.config) + self.assertEqual(self.view.accept_header, 'application/json') + + # Ensure that a request with no accept headers will return the + # first allowed type. + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, '') + c.get('/') + with self.app.app_context(): + self.view.__init__(self.config) + self.assertEqual( + self.view.accept_header, + self.app.config['stormpath']['web']['produces'][0]) + + # Ensure that a request with */* accept header will return the + # first allowed type. + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, '*/*') + c.get('/') + with self.app.app_context(): + self.view.__init__(self.config) + self.assertEqual( + self.view.accept_header, + self.app.config['stormpath']['web']['produces'][0]) + + # Ensure that an invalid accept header type will return None. + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, 'text/plain') + c.get('/') + with self.app.app_context(): + self.view.__init__(self.config) + self.assertEqual(self.view.accept_header, None) + + def test_accept_header_valid(self): + # Ensure that StormpathView.accept_header is properly set. + with self.app.test_client() as c: + # Create a request with html accept header + c.get('/') + + with self.app.app_context(): + view = StormpathView(self.config) + self.assertEqual(view.accept_header, 'text/html') + self.assertFalse(view.invalid_request) + + # Create a request with json accept header + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, self.json_header) + c.get('/') + + with self.app.app_context(): + view = StormpathView(self.config) + self.assertEqual(view.accept_header, 'application/json') + self.assertFalse(view.invalid_request) + + def test_accept_header_invalid(self): + # If a request type is not HTML, JSON, */* or empty, request is + # deemed invalid and is passed to the developer to handle the response. + # The developer handles the response via uri specified in the config + # file, in: + # web > invalidRequest. + with self.app.test_client() as c: + # Create a request with an accept header not supported by + # flask_stormpath. + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, 'text/plain') + # We'll use login since '/' is not an implemented route. + response = c.get('/login') + + # Ensure that accept header and invalid_request are properly set. + with self.app.app_context(): + view = StormpathView(self.config) + self.assertEqual(view.accept_header, None) + self.assertTrue(view.invalid_request) + + # If a view for 'invalid_request' uri is implemented, the response + # is determined in that view. (We've implemented that as our + # InvalidRequestView). + response = c.get('/login', follow_redirects=True) + self.assertEqual(response.status, '400 BAD REQUEST') + self.assertEqual(response.status_code, 400) + self.assertEqual(response.content_type, 'text/xml; charset=utf-8') + + # If view for that uri is not implemented, the response is 501. + self.app.config[ + 'stormpath']['web']['invalidRequest']['uri'] = None + response = c.get('/login', follow_redirects=True) + + self.assertEqual(response.status, '501 NOT IMPLEMENTED') + self.assertEqual(response.status_code, 501) + self.assertEqual(response.content_type, 'text/html') + + @patch('flask_stormpath.views.flash') + @patch('flask_stormpath.request_processors.get_accept_header') + def test_process_stormpath_error(self, accept_header, flash): + # Ensure that process_stormpath_error properly parses the error + # message and returns a proper response (json or html). + + error = StormpathError('This is a default message.') + + # Ensure that process_stormpath_error will return a proper response. + with self.app.test_request_context(): + # HTML (or other non JSON) response. + accept_header.return_value = 'text/html' + response = self.view.process_stormpath_error(error) + self.assertIsNone(response) + self.assertEqual(flash.call_count, 1) + + # JSON response. + accept_header.return_value = 'application/json' + response = self.view.process_stormpath_error(error) + self.assertEqual( + response.headers['Content-Type'], 'application/json') + json_response = json.loads(response.response[0].decode()) + self.assertEqual( + json_response['message'], 'This is a default message.') + + # Ensure that self.error_message will check for error.user_message + # first, but will default to error.message otherwise. + error.user_message = 'This is a user message.' + response = self.view.process_stormpath_error(error) + json_response = json.loads(response.response[0].decode()) + self.assertEqual( + json_response['message'], 'This is a user message.') + + # Ensure that certain error codes will return error.message, not + # error.user_message. + error.code = 7102 + response = self.view.process_stormpath_error(error) + json_response = json.loads(response.response[0].decode()) + self.assertEqual( + json_response['message'], 'This is a default message.') + + def test_csrf_disabled_on_json(self): + # Ensure that JSON requests have CSRF disabled. + + self.app.config['WTF_CSRF_ENABLED'] = True + with self.app.test_client() as c: + # Ensure that HTML will have CSRF enabled. + c.get('/') + with self.app.app_context(): + self.view = StormpathView(self.config) + self.assertTrue(self.view.form.meta.csrf) + + # Ensure that JSON will have CSRF disabled. + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, self.json_header) + c.get('/') + with self.app.app_context(): + self.view = StormpathView(self.config) + self.assertFalse(self.view.form.meta.csrf) + + # Ensure that non JSON will have CSRF enabled. + self.app.wsgi_app = HttpAcceptWrapper( + self.default_wsgi_app, 'text/plain') + c.get('/') + with self.app.app_context(): + self.view = StormpathView(self.config) + self.assertTrue(self.view.form.meta.csrf) + + +class TestRegister(StormpathViewTestCase): """Test our registration view.""" - def test_default_fields(self): - # By default, we'll register new users with first name, last name, - # email, and password. + def setUp(self): + super(TestRegister, self).setUp() + self.form_fields = self.app.config['stormpath']['web']['register'][ + 'form']['fields'] + + def test_get(self): + # Ensure that a get request will only render the template and skip + # form validation and users creation. with self.app.test_client() as c: + resp = c.get('/register') + self.assertEqual(resp.status_code, 200) + def test_default_fields(self): + # By default, we'll register new users with username, first name, + # last name, email, and password. + with self.app.test_client() as c: # Ensure that missing fields will cause a failure. resp = c.post('/register', data={ - 'email': 'r@testmail.stormpath.com', - 'password': 'woot1LoveCookies!', + 'email': 'r@rdegges2.com', + 'password': 'thisisMy0therpassword...', }) self.assertEqual(resp.status_code, 200) # Ensure that valid fields will result in a success. resp = c.post('/register', data={ - 'username': 'rdegges', - 'given_name': 'Randall', - 'middle_name': 'Clark', - 'surname': 'Degges', - 'email': 'r@testmail.stormpath.com', - 'password': 'woot1LoveCookies!', + 'username': 'randalldeg_registration', + 'given_name': 'Randall registration', + 'surname': 'Degges registration', + 'email': 'r_registration@rdegges.com', + 'password': 'thisisMy0therpassword...', + }) + self.assertEqual(resp.status_code, 302) + + def test_confirm_password(self): + # Register a user with confirmPassword enabled. + self.form_fields['confirmPassword']['enabled'] = True + + with self.app.test_client() as c: + # Ensure that confirmPassword will be popped from data before + # creating the new User instance. + resp = c.post('/register', data={ + 'username': 'randalldeg_registration', + 'given_name': 'Randall registration', + 'surname': 'Degges registration', + 'email': 'r_registration@rdegges.com', + 'password': 'thisisMy0therpassword...', + 'confirm_password': 'thisisMy0therpassword...' }) self.assertEqual(resp.status_code, 302) def test_disable_all_except_mandatory(self): # Here we'll disable all the fields except for the mandatory fields: # email and password. - self.app.config['STORMPATH_ENABLE_USERNAME'] = False - self.app.config['STORMPATH_ENABLE_GIVEN_NAME'] = False - self.app.config['STORMPATH_ENABLE_MIDDLE_NAME'] = False - self.app.config['STORMPATH_ENABLE_SURNAME'] = False + for field in ['givenName', 'middleName', 'surname', 'username']: + self.form_fields[field]['enabled'] = False with self.app.test_client() as c: - # Ensure that missing fields will cause a failure. resp = c.post('/register', data={ - 'email': 'r@testmail.stormpath.com', + 'email': 'r_registration@rdegges.com', }) self.assertEqual(resp.status_code, 200) # Ensure that valid fields will result in a success. resp = c.post('/register', data={ - 'email': 'r@testmail.stormpath.com', - 'password': 'woot1LoveCookies!', + 'email': 'r_registration@rdegges.com', + 'password': 'thisisMy0therpassword...', }) self.assertEqual(resp.status_code, 302) def test_require_settings(self): # Here we'll change our backend behavior such that users *can* enter a - # first and last name, but they aren't required server side. - # email and password. - self.app.config['STORMPATH_REQUIRE_GIVEN_NAME'] = False - self.app.config['STORMPATH_REQUIRE_SURNAME'] = False + # username, first and last name, but they aren't required server side. + for field in ['givenName', 'surname', 'username']: + self.form_fields[field]['required'] = False with self.app.test_client() as c: - # Ensure that registration works *without* given name and surname # since they aren't required. resp = c.post('/register', data={ - 'email': 'r@testmail.stormpath.com', - 'password': 'woot1LoveCookies!', + 'email': 'r_registration@rdegges.com', + 'password': 'thisisMy0therpassword...' }) self.assertEqual(resp.status_code, 302) # Find our user account that was just created, and ensure the given # name and surname fields were set to our default string. - user = User.from_login('r@testmail.stormpath.com', 'woot1LoveCookies!') + user = User.from_login( + 'r_registration@rdegges.com', 'thisisMy0therpassword...') self.assertEqual(user.given_name, 'Anonymous') self.assertEqual(user.surname, 'Anonymous') + self.assertEqual(user.username, user.email) def test_error_messages(self): + # We don't need a username field for this test. We'll disable it + # so the form can be valid. + self.form_fields['username']['enabled'] = False + with self.app.test_client() as c: + # Ensure that the form error is raised if the email already + # exists. + resp = c.post('/register', data={ + 'given_name': 'Randall registration', + 'surname': 'Degges registration', + 'email': 'r@rdegges.com', + 'password': 'Hilolsds1', + }) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + 'Account with that email already exists.' + in resp.data.decode('utf-8')) + self.assertFalse("developerMessage" in resp.data.decode('utf-8')) # Ensure that an error is raised if an invalid password is # specified. resp = c.post('/register', data={ - 'given_name': 'Randall', - 'surname': 'Degges', - 'email': 'r@testmail.stormpath.com', + 'given_name': 'Randall registration', + 'surname': 'Degges registration', + 'email': 'r_registration@rdegges.com', 'password': 'hilol', }) self.assertEqual(resp.status_code, 200) - - self.assertTrue('Account password minimum length not satisfied.' in resp.data.decode('utf-8')) - self.assertFalse('developerMessage' in resp.data.decode('utf-8')) + self.assertTrue( + 'Account password minimum length not satisfied.' in + resp.data.decode('utf-8')) + self.assertFalse("developerMessage" in resp.data.decode('utf-8')) resp = c.post('/register', data={ - 'given_name': 'Randall', - 'surname': 'Degges', - 'email': 'r@testmail.stormpath.com', + 'given_name': 'Randall registration', + 'surname': 'Degges registration', + 'email': 'r_registration@rdegges.com', 'password': 'hilolwoot1', }) self.assertEqual(resp.status_code, 200) - - self.assertTrue('Password requires at least 1 uppercase character.' in resp.data.decode('utf-8')) - self.assertFalse('developerMessage' in resp.data.decode('utf-8')) + self.assertTrue( + 'Password requires at least 1 uppercase character.' in + resp.data.decode('utf-8')) + self.assertFalse("developerMessage" in resp.data.decode('utf-8')) resp = c.post('/register', data={ - 'given_name': 'Randall', - 'surname': 'Degges', - 'email': 'r@testmail.stormpath.com', + 'given_name': 'Randall registration', + 'surname': 'Degges registration', + 'email': 'r_registration@rdegges.com', 'password': 'hilolwoothi', }) self.assertEqual(resp.status_code, 200) - self.assertTrue('Password requires at least 1 numeric character.' in resp.data.decode('utf-8')) - self.assertFalse('developerMessage' in resp.data.decode('utf-8')) + self.assertTrue( + 'Password requires at least 1 numeric character.' in + resp.data.decode('utf-8')) + self.assertFalse("developerMessage" in resp.data.decode('utf-8')) + + def test_autologin(self): + # If the autologin option is enabled the user must be logged in after + # successful registration. + self.app.config['stormpath']['web']['register']['autoLogin'] = True + stormpath_register_redirect_url = '/redirect_for_registration' + self.app.config['stormpath']['web']['register'][ + 'nextUri'] = stormpath_register_redirect_url + + with self.app.test_client() as c: + resp = c.get('/register') + self.assertFalse('user_id' in session) + + # Check that the user was redirected to the proper url and is + # logged in after successful registration + resp = c.post('/register', data={ + 'username': 'randalldeg_registration', + 'given_name': 'Randall registration', + 'surname': 'Degges registration', + 'email': 'r_registration@rdegges.com', + 'password': 'thisisMy0therpassword...', + }) + + # Get our user that was just created + user = User.from_login( + 'r_registration@rdegges.com', 'thisisMy0therpassword...') + self.assertEqual(resp.status_code, 302) + self.assertTrue(stormpath_register_redirect_url in resp.location) + self.assertEqual(session['user_id'], user.href) - def test_redirect_to_login_and_register_url(self): + resp = c.get('/logout') + self.assertFalse('user_id' in session) + + def test_redirect_to_login_or_register_url(self): # Setting redirect URL to something that is easy to check - stormpath_redirect_url = '/redirect_for_login_and_registration' - self.app.config['STORMPATH_REDIRECT_URL'] = stormpath_redirect_url + stormpath_login_redirect_url = '/redirect_for_login' + stormpath_register_redirect_url = '/redirect_for_registration' + self.app.config['stormpath']['web']['login'][ + 'nextUri'] = stormpath_login_redirect_url + self.app.config['stormpath']['web']['register'][ + 'nextUri'] = stormpath_register_redirect_url + + # We don't need a username field for this test. We'll disable it + # so the form can be valid. + self.form_fields['username']['enabled'] = False with self.app.test_client() as c: # Ensure that valid registration will redirect to - # STORMPATH_REDIRECT_URL - resp = c.post( - '/register', - data= - { - 'given_name': 'Randall', - 'middle_name': 'Clark', - 'surname': 'Degges', - 'email': 'r@testmail.stormpath.com', - 'password': 'woot1LoveCookies!', - }) + # register redirect url + resp = c.post('/register', data={ + 'given_name': 'Randall registration', + 'middle_name': 'Clark registration', + 'surname': 'Degges registration', + 'email': 'r_registration@rdegges.com', + 'password': 'thisisMy0therpassword...', + }) self.assertEqual(resp.status_code, 302) location = resp.headers.get('location') - self.assertTrue(stormpath_redirect_url in location) + self.assertTrue(stormpath_register_redirect_url in location) + self.assertFalse(stormpath_login_redirect_url in location) - def test_redirect_to_register_url(self): - # Setting redirect URLs to something that is easy to check - stormpath_redirect_url = '/redirect_for_login' - stormpath_registration_redirect_url = '/redirect_for_registration' - self.app.config['STORMPATH_REDIRECT_URL'] = stormpath_redirect_url - self.app.config['STORMPATH_REGISTRATION_REDIRECT_URL'] = \ - stormpath_registration_redirect_url + # We're disabling the default register redirect so we can check if + # the login redirect will be applied + self.app.config['stormpath']['web']['register']['nextUri'] = None - with self.app.test_client() as c: # Ensure that valid registration will redirect to - # STORMPATH_REGISTRATION_REDIRECT_URL if it exists - resp = c.post( - '/register', - data= - { - 'given_name': 'Randall', - 'middle_name': 'Clark', - 'surname': 'Degges', - 'email': 'r@testmail.stormpath.com', - 'password': 'woot1LoveCookies!', - }) + # login redirect url + resp = c.post('/register', data={ + 'given_name': 'Randall_registration2', + 'middle_name': 'Clark_registration2', + 'surname': 'Degges_registration2', + 'email': 'r_registration2@rdegges.com', + 'password': 'thisisMy0therpassword2...', + }) + + self.assertEqual(resp.status_code, 302) + location = resp.headers.get('location') + self.assertTrue(stormpath_login_redirect_url in location) + self.assertFalse(stormpath_register_redirect_url in location) + + # We're disabling the default login redirect so we can check if + # the default redirect will be applied + self.app.config['stormpath']['web']['login']['nextUri'] = None + + # Ensure that valid registration will redirect to + # default redirect url + resp = c.post('/register', data={ + 'given_name': 'Randall_registration3', + 'middle_name': 'Clark_registration3', + 'surname': 'Degges_registration3', + 'email': 'r_registration3@rdegges.com', + 'password': 'thisisMy0therpassword3...', + }) self.assertEqual(resp.status_code, 302) location = resp.headers.get('location') - self.assertFalse(stormpath_redirect_url in location) - self.assertTrue(stormpath_registration_redirect_url in location) + self.assertFalse(stormpath_login_redirect_url in location) + self.assertFalse(stormpath_register_redirect_url in location) + + def test_json_response_get(self): + # Here we'll disable all the fields except for the mandatory fields: + # email and password. + for field in ['givenName', 'middleName', 'surname', 'username']: + self.form_fields[field]['enabled'] = False + + # Specify expected response. + expected_response = [ + {'label': 'Email', + 'name': 'email', + 'placeholder': 'Email', + 'required': True, + 'visible': True, + 'type': 'email'}, + {'label': 'Password', + 'name': 'password', + 'placeholder': 'Password', + 'required': True, + 'visible': True, + 'type': 'password'}] + + # Ensure that the form fields specified in the expected response + # match those specified in the config file. + self.assertFormSettings(expected_response) + + self.assertJsonResponse( + 'get', 'register', 200, json.dumps(expected_response)) + + def test_json_response_valid_form(self): + # Specify user data + user_data = { + 'username': 'rdegges2', + 'email': 'r@rdegges2.com', + 'given_name': 'Randall2', + 'middle_name': None, + 'surname': 'Degges2', + 'password': 'woot1LoveCookies!2' + } + + # Specify expected response. + expected_response = {'account': user_data.copy()} + expected_response['account']['status'] = 'ENABLED' + expected_response['account']['full_name'] = 'Randall2 Degges2' + expected_response['account'].pop('password') + + # Specify post data + json_data = json.dumps(user_data) + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + + self.assertJsonResponse( + 'post', 'register', 200, json.dumps(expected_response), + user_to_json=True, **request_kwargs) + + def test_json_response_stormpath_error(self): + # Specify post data + json_data = json.dumps({ + 'username': 'rdegges', + 'email': 'r@rdegges.com', + 'given_name': 'Randall', + 'middle_name': 'Clark', + 'surname': 'Degges', + 'password': 'woot1LoveCookies!'}) + + # Specify expected response + expected_response = { + 'message': ( + 'Account with that email already exists.' + + ' Please choose another email.'), + 'status': 409} + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + + self.assertJsonResponse( + 'post', 'register', 409, json.dumps(expected_response), + **request_kwargs) + def test_json_response_form_error(self): + # Specify post data + json_data = json.dumps({ + 'username': 'rdegges', + 'email': 'r@rdegges.com', + 'middle_name': 'Clark', + 'surname': 'Degges', + 'password': 'woot1LoveCookies!'}) -class TestLogin(StormpathTestCase): + # Specify expected response + expected_response = { + 'message': {"given_name": ["First Name is required."]}, + 'status': 400} + + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + + self.assertJsonResponse( + 'post', 'register', 400, json.dumps(expected_response), + **request_kwargs) + + +class TestLogin(StormpathViewTestCase): """Test our login view.""" - def test_email_login(self): - # Create a user. - with self.app.app_context(): - User.create( - given_name = 'Randall', - surname = 'Degges', - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - ) + def setUp(self): + super(TestLogin, self).setUp() + # We need to set form fields to test out json stuff in the + # assertJsonResponse method. + self.form_fields = self.app.config['stormpath']['web']['login'][ + 'form']['fields'] + def test_enabled(self): + # Ensure that a disabled login will return 404. + data = { + 'login': 'r@rdegges.com', + 'password': 'woot1LoveCookies!' + } + self.assertDisabledView('login', data) + + def test_email_login(self): # Attempt a login using email and password. with self.app.test_client() as c: resp = c.post('/login', data={ - 'login': 'r@testmail.stormpath.com', + 'login': 'r@rdegges.com', 'password': 'woot1LoveCookies!', }) self.assertEqual(resp.status_code, 302) def test_username_login(self): - # Create a user. - with self.app.app_context(): - User.create( - username = 'rdegges', - given_name = 'Randall', - surname = 'Degges', - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - ) - # Attempt a login using username and password. with self.app.test_client() as c: resp = c.post('/login', data={ @@ -208,16 +779,6 @@ def test_username_login(self): self.assertEqual(resp.status_code, 302) def test_error_messages(self): - # Create a user. - with self.app.app_context(): - User.create( - username = 'rdegges', - given_name = 'Randall', - surname = 'Degges', - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - ) - # Ensure that an error is raised if an invalid username or password is # specified. with self.app.test_client() as c: @@ -227,66 +788,129 @@ def test_error_messages(self): }) self.assertEqual(resp.status_code, 200) - #self.assertTrue('Invalid username or password.' in resp.data.decode('utf-8')) - self.assertTrue('Login attempt failed because the specified password is incorrect.' in resp.data.decode('utf-8')) - self.assertFalse('developerMessage' in resp.data.decode('utf-8')) + self.assertTrue( + 'Invalid username or password.' in resp.data.decode('utf-8')) + self.assertFalse("developerMessage" in resp.data.decode('utf-8')) - def test_redirect_to_login_and_register_url(self): - # Create a user. - with self.app.app_context(): - User.create( - username = 'rdegges', - given_name = 'Randall', - surname = 'Degges', - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - ) + # Ensure that an error is raised if the account referencing + # login email address is not verified. + self.user.status = 'UNVERIFIED' + self.user.save() + + resp = c.post('/login', data={ + 'login': 'rdegges', + 'password': 'hilol', + }) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + 'Login attempt failed because the Account is not verified.' in + resp.data.decode('utf-8')) + self.assertFalse("developerMessage" in resp.data.decode('utf-8')) + def test_redirect_to_login_or_register_url(self): # Setting redirect URL to something that is easy to check - stormpath_redirect_url = '/redirect_for_login_and_registration' - self.app.config['STORMPATH_REDIRECT_URL'] = stormpath_redirect_url + stormpath_login_redirect_url = '/redirect_for_login' + stormpath_register_redirect_url = '/redirect_for_registration' + self.app.config['stormpath']['web']['login'][ + 'nextUri'] = stormpath_login_redirect_url + self.app.config['stormpath']['web']['register'][ + 'nextUri'] = stormpath_register_redirect_url with self.app.test_client() as c: # Attempt a login using username and password. - resp = c.post( - '/login', - data={'login': 'rdegges', 'password': 'woot1LoveCookies!',}) + resp = c.post('/login', data={ + 'login': 'rdegges', + 'password': 'woot1LoveCookies!' + }) self.assertEqual(resp.status_code, 302) location = resp.headers.get('location') - self.assertTrue(stormpath_redirect_url in location) + self.assertTrue(stormpath_login_redirect_url in location) + self.assertFalse(stormpath_register_redirect_url in location) - def test_redirect_to_register_url(self): - # Create a user. - with self.app.app_context(): - User.create( - username = 'rdegges', - given_name = 'Randall', - surname = 'Degges', - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - ) + def test_json_response_get(self): + # Specify expected response. + expected_response = [ + {'label': 'Username or Email', + 'name': 'login', + 'placeholder': 'Username or Email', + 'required': True, + 'visible': True, + 'type': 'text'}, + {'label': 'Password', + 'name': 'password', + 'placeholder': 'Password', + 'required': True, + 'visible': True, + 'type': 'password'}] - # Setting redirect URLs to something that is easy to check - stormpath_redirect_url = '/redirect_for_login' - stormpath_registration_redirect_url = '/redirect_for_registration' - self.app.config['STORMPATH_REDIRECT_URL'] = stormpath_redirect_url - self.app.config['STORMPATH_REGISTRATION_REDIRECT_URL'] = \ - stormpath_registration_redirect_url + # Ensure that the form fields specified in the expected response + # match those specified in the config file. + self.assertFormSettings(expected_response) - with self.app.test_client() as c: - # Attempt a login using username and password. - resp = c.post( - '/login', - data={'login': 'rdegges', 'password': 'woot1LoveCookies!',}) + self.assertJsonResponse( + 'get', 'login', 200, json.dumps(expected_response)) - self.assertEqual(resp.status_code, 302) - location = resp.headers.get('location') - self.assertTrue('redirect_for_login' in location) - self.assertFalse('redirect_for_registration' in location) + def test_json_response_valid_form(self): + # Specify expected response. + expected_response = {'account': { + 'username': 'rdegges', + 'email': 'r@rdegges.com', + 'given_name': 'Randall', + 'middle_name': None, + 'surname': 'Degges', + 'full_name': 'Randall Degges', + 'status': 'ENABLED'} + } + + # Specify post data + json_data = json.dumps({ + 'login': 'r@rdegges.com', + 'password': 'woot1LoveCookies!'}) + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + self.assertJsonResponse( + 'post', 'login', 200, json.dumps(expected_response), + user_to_json=True, **request_kwargs) + def test_json_response_stormpath_error(self): + # Specify post data + json_data = json.dumps({ + 'login': 'wrong@email.com', + 'password': 'woot1LoveCookies!'}) -class TestLogout(StormpathTestCase): + # Specify expected response + expected_response = { + 'message': 'Invalid username or password.', + 'status': 400} + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + self.assertJsonResponse( + 'post', 'login', 400, json.dumps(expected_response), + **request_kwargs) + + def test_json_response_form_error(self): + # Specify post data + json_data = json.dumps({ + 'password': 'woot1LoveCookies!'}) + + # Specify expected response + expected_response = { + 'message': {"login": ["Username or Email is required."]}, + 'status': 400} + + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + + self.assertJsonResponse( + 'post', 'login', 400, json.dumps(expected_response), + **request_kwargs) + + +class TestLogout(StormpathViewTestCase): """Test our logout view.""" def test_logout_works_with_anonymous_users(self): @@ -295,19 +919,10 @@ def test_logout_works_with_anonymous_users(self): self.assertEqual(resp.status_code, 302) def test_logout_works(self): - # Create a user. - with self.app.app_context(): - User.create( - given_name = 'Randall', - surname = 'Degges', - email = 'r@testmail.stormpath.com', - password = 'woot1LoveCookies!', - ) - with self.app.test_client() as c: # Log this user in. resp = c.post('/login', data={ - 'login': 'r@testmail.stormpath.com', + 'login': 'r@rdegges.com', 'password': 'woot1LoveCookies!', }) self.assertEqual(resp.status_code, 302) @@ -315,3 +930,1026 @@ def test_logout_works(self): # Log this user out. resp = c.get('/logout') self.assertEqual(resp.status_code, 302) + + def test_json_response_get(self): + # We'll use login form for our json response + self.form_fields = self.app.config['stormpath']['web']['login'][ + 'form']['fields'] + + # We'll set the redirect url login since test client cannot redirect + # to index view. + self.app.config['stormpath']['web']['logout']['nextUri'] = '/login' + + # Specify expected response. + expected_response = [ + {'label': 'Username or Email', + 'name': 'login', + 'placeholder': 'Username or Email', + 'required': True, + 'visible': True, + 'type': 'text'}, + {'label': 'Password', + 'name': 'password', + 'placeholder': 'Password', + 'required': True, + 'visible': True, + 'type': 'password'}] + + # Ensure that the form fields specified in the expected response + # match those specified in the config file. + self.assertFormSettings(expected_response) + + self.assertJsonResponse( + 'get', 'logout', 302, json.dumps(expected_response)) + + +class TestForgot(StormpathViewTestCase): + """Test our forgot view.""" + + def setUp(self): + super(TestForgot, self).setUp() + # We need to set form fields to test out json stuff in the + # assertJsonResponse method. + self.form_fields = self.app.config['stormpath']['web'][ + 'forgotPassword']['form']['fields'] + + def test_proper_template_rendering(self): + # Ensure that proper templates are rendered based on the request + # method. + with self.app.test_client() as c: + # Ensure request.GET will render the forgot_password.html template. + resp = c.get('/forgot') + self.assertEqual(resp.status_code, 200) + self.assertTrue( + 'Enter your email address below to reset your password.' in + resp.data.decode('utf-8')) + + # Ensure that request.POST will render the + # forgot_password_success.html + resp = c.post('/forgot', data={'email': 'r@rdegges.com'}) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + 'Your password reset email has been sent!' in + resp.data.decode('utf-8')) + + def test_error_messages(self): + with self.app.test_client() as c: + # Ensure than en email wasn't sent if an email that doesn't exist + # in our database was entered. + resp = c.post('/forgot', data={'email': 'idonot@exist.com'}) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + 'Invalid email address.' in resp.data.decode('utf-8')) + + # Ensure that an email was sent if a valid email was entered. + resp = c.post('/forgot', data={'email': 'r@rdegges.com'}) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + 'Your password reset email has been sent!' in + resp.data.decode('utf-8')) + + def test_json_response_get(self): + # Specify expected response. + expected_response = [ + {'label': 'Email', + 'name': 'email', + 'placeholder': 'Email', + 'required': True, + 'visible': True, + 'type': 'email'}] + + # Ensure that the form fields specified in the expected response + # match those specified in the config file. + self.assertFormSettings(expected_response) + + self.assertJsonResponse( + 'get', 'forgot', 200, json.dumps(expected_response)) + + def test_json_response_valid_form(self): + # Specify expected response. + expected_response = { + 'status': 200, + 'message': {"email": "r@rdegges.com"} + } + + # Specify post data + json_data = json.dumps({'email': 'r@rdegges.com'}) + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + self.assertJsonResponse( + 'post', 'forgot', 200, json.dumps(expected_response), + **request_kwargs) + + def test_json_response_stormpath_error(self): + # Specify post data + json_data = json.dumps({'email': 'wrong@email.com'}) + + # Specify expected response + expected_response = { + 'message': 'Invalid email address.', + 'status': 400} + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + self.assertJsonResponse( + 'post', 'forgot', 400, json.dumps(expected_response), + **request_kwargs) + + def test_json_response_form_error(self): + # Specify post data + json_data = json.dumps({'email': 'rdegges'}) + + # Specify expected response + expected_response = { + 'message': {"email": ["Email must be in valid format."]}, + 'status': 400} + + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + + self.assertJsonResponse( + 'post', 'forgot', 400, json.dumps(expected_response), + **request_kwargs) + + +class TestChange(StormpathViewTestCase): + """Test our change view.""" + + def setUp(self): + super(TestChange, self).setUp() + # We need to set form fields to test out json stuff in the + # assertJsonResponse method. + self.form_fields = self.app.config['stormpath']['web'][ + 'changePassword']['form']['fields'] + + # Generate a token + self.token = self.application.password_reset_tokens.create( + {'email': 'r@rdegges.com'}).token + + # Specify url for json + self.reset_password_url = ''.join(['change?sptoken=', self.token]) + + def test_proper_template_rendering(self): + # Ensure that proper templates are rendered based on the request + # method. + with self.app.test_client() as c: + # Ensure request.GET will render the change_password.html template. + resp = c.get(self.reset_password_url) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + 'Enter your new account password below.' in + resp.data.decode('utf-8')) + + # Ensure that request.POST will render the + # change_password_success.html + resp = c.post(self.reset_password_url, data={ + 'password': 'woot1DontLoveCookies!', + 'confirm_password': 'woot1DontLoveCookies!'}) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + 'Your password has been changed, and you have been logged' + + ' into' in resp.data.decode('utf-8')) + + def test_error_messages(self): + with self.app.test_client() as c: + # Ensure than en email wasn't changed if passwords don't satisfy + # minimum requirements (one number, one uppercase letter, minimum + # length). + resp = c.post( + self.reset_password_url, + data={ + 'password': 'woot', + 'confirm_password': 'woot'}) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + 'Account password minimum length not satisfied.' in + resp.data.decode('utf-8')) + + def test_sptoken(self): + # Ensure that a proper token will render the change view + with self.app.test_client() as c: + # Ensure request.GET will render the change_password.html template. + resp = c.get(self.reset_password_url) + self.assertEqual(resp.status_code, 200) + + # Ensure that a missing token will redirect to errorUri specified in + # the config. + with self.app.test_client() as c: + # Ensure request.GET will render the change_password.html template. + resp = c.get('/change') + self.assertEqual(resp.status_code, 302) + self.assertTrue( + 'You should be redirected automatically to target URL: ' + + '' % self.app.config[ + 'stormpath']['web']['changePassword']['errorUri'] + + '/forgot?status=invalid_sptoken.' in + resp.data.decode('utf-8')) + + # If errorUri is empty, we will redirect back to index page. + self.app.config[ + 'stormpath']['web']['changePassword']['errorUri'] = None + resp = c.get('/change') + self.assertEqual(resp.status_code, 302) + self.assertTrue( + 'You should be redirected automatically to target URL: ' + + '' % '/' + '/.' in resp.data.decode('utf-8')) + + def test_password_changed_and_logged_in(self): + with self.app.test_client() as c: + # Ensure that a user will be logged in after successful password + # reset. + self.assertFalse(current_user) + resp = c.post( + self.reset_password_url, + data={ + 'password': 'woot1DontLoveCookies!', + 'confirm_password': 'woot1DontLoveCookies!'}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(current_user.email, 'r@rdegges.com') + + # Ensure that our password changed. + with self.app.app_context(): + User.from_login('r@rdegges.com', 'woot1DontLoveCookies!') + + def test_json_response_get(self): + # Specify expected response. + expected_response = [ + {'label': 'Password', + 'name': 'password', + 'placeholder': 'Password', + 'required': True, + 'visible': True, + 'type': 'password'}, + {'label': 'Confirm Password', + 'name': 'confirm_password', + 'placeholder': 'Confirm Password', + 'required': True, + 'visible': True, + 'type': 'password'}] + + # Ensure that the form fields specified in the expected response + # match those specified in the config file. + self.assertFormSettings(expected_response) + + self.assertJsonResponse( + 'get', self.reset_password_url, 200, json.dumps(expected_response)) + + def test_json_response_valid_form(self): + # Specify expected response. + expected_response = {'account': { + 'username': 'rdegges', + 'email': 'r@rdegges.com', + 'given_name': 'Randall', + 'middle_name': None, + 'surname': 'Degges', + 'full_name': 'Randall Degges', + 'status': 'ENABLED'} + } + + # Specify post data + json_data = json.dumps({ + 'password': 'woot1DontLoveCookies!', + 'confirm_password': 'woot1DontLoveCookies!'}) + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + self.assertJsonResponse( + 'post', self.reset_password_url, 200, + json.dumps(expected_response), user_to_json=True, + **request_kwargs) + + # Ensure that our password changed. + with self.app.app_context(): + User.from_login('r@rdegges.com', 'woot1DontLoveCookies!') + + def test_json_response_stormpath_error(self): + # Specify post data + json_data = json.dumps({ + 'password': 'woot', + 'confirm_password': 'woot'}) + + # Specify expected response + expected_response = { + 'message': 'Account password minimum length not satisfied.', + 'status': 400} + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + self.assertJsonResponse( + 'post', self.reset_password_url, 400, + json.dumps(expected_response), **request_kwargs) + + def test_json_response_form_error(self): + # Specify post data + json_data = json.dumps({'password': 'woot1DontLoveCookies!'}) + + # Specify expected response + expected_response = { + 'message': {"confirm_password": ["Confirm Password is required."]}, + 'status': 400} + + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + + self.assertJsonResponse( + 'post', self.reset_password_url, 400, + json.dumps(expected_response), **request_kwargs) + + +class TestVerify(StormpathViewTestCase): + """ Test our verify view. """ + + def setUp(self): + super(TestVerify, self).setUp() + # We need to set form fields to test out json stuff in the + # assertJsonResponse method. + self.form_fields = self.app.config['stormpath']['web'][ + 'verifyEmail']['form']['fields'] + + # Set our verify route (by default is missing) + self.app.add_url_rule( + self.app.config['stormpath']['web']['verifyEmail']['uri'], + 'stormpath.verify', + VerifyEmailView.as_view('verify'), + methods=['GET', 'POST'], + ) + + # Enable verification flow. + self.directory = self.client.directories.search(self.name).items[0] + account_policy = self.directory.account_creation_policy + account_policy.verification_email_status = 'ENABLED' + account_policy.save() + + # Create a new account + with self.app.app_context(): + user = User.create( + username='rdegges_verify', + given_name='Randall', + surname='Degges', + email='r@verify.com', + password='woot1LoveCookies!', + ) + self.account = self.directory.accounts.search(user.email)[0] + + # Specify url for json + self.verify_url = ''.join([ + 'verify?sptoken=', self.get_verification_token()]) + + def get_verification_token(self): + # Retrieves an email verification token. + self.account.refresh() + if self.account.email_verification_token: + return self.account.email_verification_token.href.split('/')[-1] + return None + + def test_verify_token_valid(self): + # Ensure that a valid token will activate a users account. By default, + # autologin is set to false, so the response should be a redirect + # to verifyEmail next uri. + + # Setting redirect URL to something that is easy to check + stormpath_verify_redirect_url = '/redirect_for_verify' + self.app.config['stormpath']['web']['verifyEmail'][ + 'nextUri'] = stormpath_verify_redirect_url + + # Get activation token + sptoken = self.get_verification_token() + + # Ensure that a proper verify token will activate a user's account. + with self.app.test_client() as c: + resp = c.get('/verify', query_string={'sptoken': sptoken}) + self.assertEqual(resp.status_code, 302) + + # Ensure proper redirection if autologin is disabled + location = resp.headers.get('location') + self.assertTrue(stormpath_verify_redirect_url in location) + self.account.refresh() + self.assertEqual(self.account.status, 'ENABLED') + + def test_verify_token_invalid(self): + # If the verification token is invalid, render a template with a form + # used to resend an activation token. + + # Set invalid activation token + sptoken = 'foobar' + + # Ensure that a proper verify token will activate a user's account. + with self.app.test_client() as c: + resp = c.get('/verify', query_string={'sptoken': sptoken}) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + 'This verification link is no longer valid.' in + resp.data.decode('utf-8')) + self.account.refresh() + self.assertEqual(self.account.status, 'UNVERIFIED') + + def test_verify_token_missing(self): + # If the verification token is missing, render a template with a form + # used to resend an activation token. + + # Set missing activation token + sptoken = None + + # Ensure that a proper verify token will activate a user's account. + with self.app.test_client() as c: + resp = c.get('/verify', query_string={'sptoken': sptoken}) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + 'This verification link is no longer valid.' in + resp.data.decode('utf-8')) + self.account.refresh() + self.assertEqual(self.account.status, 'UNVERIFIED') + + def test_resend_verification_token(self): + # Ensure that submitting an email form will generate a new activation + # token. Make sure that a redirect uri will have an unverified status. + + # Get current activation token + sptoken = self.get_verification_token() + + with self.app.test_client() as c: + # Activate the account + resp = c.get('/verify', query_string={'sptoken': sptoken}) + self.assertEqual(resp.status_code, 302) + self.account.refresh() + self.assertEqual(self.account.status, 'ENABLED') + + # Set the account back to 'UNVERIFIED' + self.account.status = 'UNVERIFIED' + self.account.save() + self.account.refresh() + + # Submit a form that will resend a new activation token + resp = c.post('/verify', data={'email': 'r@verify.com'}) + self.assertEqual(resp.status_code, 302) + self.assertTrue( + 'You should be redirected automatically to target URL: ' + + '' % self.app.config[ + 'stormpath']['web']['verifyEmail']['unverifiedUri'] + + '/login?status=unverified.' in + resp.data.decode('utf-8')) + self.account.refresh() + self.assertEqual(self.account.status, 'UNVERIFIED') + + # Make sure that a new token has replaced the old one + new_sptoken = self.get_verification_token() + self.assertNotEqual(sptoken, new_sptoken) + + # Activate an account with a new token + resp = c.get('/verify', query_string={'sptoken': new_sptoken}) + self.assertEqual(resp.status_code, 302) + self.account.refresh() + self.assertEqual(self.account.status, 'ENABLED') + + def test_resend_verification_token_unassociated_email(self): + # Ensure that submitting an unassociated email form will not + # generate a new activation token. Make sure that a redirect uri will + # still have an unverified status. + + # Get current activation token + sptoken = self.get_verification_token() + + with self.app.test_client() as c: + # Activate the account + resp = c.get('/verify', query_string={'sptoken': sptoken}) + self.assertEqual(resp.status_code, 302) + self.account.refresh() + self.assertEqual(self.account.status, 'ENABLED') + + # Set the account back to 'UNVERIFIED' + self.account.status = 'UNVERIFIED' + self.account.save() + self.account.refresh() + + # Submit a form with an unassociated email + resp = c.post('/verify', data={'email': 'doesnot@exist.com'}) + self.assertEqual(resp.status_code, 302) + self.assertTrue( + 'You should be redirected automatically to target URL: ' + + '' % self.app.config[ + 'stormpath']['web']['verifyEmail']['unverifiedUri'] + + '/login?status=unverified.' in + resp.data.decode('utf-8')) + self.account.refresh() + self.assertEqual(self.account.status, 'UNVERIFIED') + + # Make sure that a new token was not generated + new_sptoken = self.get_verification_token() + self.assertIsNone(new_sptoken) + + def test_autologin_true(self): + # Ensure that the enabled autologin will log a user in and redirect + # him user to the uri specified in the login > nextUri. + + # Set autologin to true + self.app.config['stormpath']['web']['register']['autoLogin'] = True + + # Setting redirect URL to something that is easy to check + stormpath_login_redirect_url = '/redirect_for_login' + self.app.config['stormpath']['web']['login'][ + 'nextUri'] = stormpath_login_redirect_url + + # Get activation token + sptoken = self.get_verification_token() + + # Ensure that a proper verify token will activate a user's account. + with self.app.test_client() as c: + resp = c.get('/verify', query_string={'sptoken': sptoken}) + self.assertEqual(resp.status_code, 302) + location = resp.headers.get('location') + self.assertTrue(stormpath_login_redirect_url in location) + self.account.refresh() + self.assertEqual(self.account.status, 'ENABLED') + + def test_response_form_error_missing(self): + # Ensure that a missing email will render a proper error. + # Get current activation token + with self.app.test_client() as c: + resp = c.post('/verify', data={}, follow_redirects=True) + self.assertEqual(resp.status_code, 200) + self.assertTrue('Email is required.' in resp.data.decode('utf-8')) + + def test_response_form_error_invalid(self): + # Ensure that an invalid email will render a proper error. + with self.app.test_client() as c: + resp = c.post( + '/verify', data={'email': 'foobar'}, follow_redirects=True) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + 'Email must be in valid format.' in resp.data.decode('utf-8')) + + def test_verify_token_valid_json(self): + # Ensure that a valid token will activate a users account. By default, + # autologin is set to false, so the response should be an empty body + # with 200 status code. + + # Specify expected response. + expected_response = {} + + # Check the json response. + self.assertJsonResponse( + 'get', self.verify_url, 200, json.dumps(expected_response)) + + def test_verify_token_invalid_json(self): + # If the verification token is invalid, return an error from the + # REST API. + + # Set an invalid token + self.verify_url = 'verify?sptoken=foobar' + + # Specify expected response. + expected_response = { + 'status': 404, + 'message': 'The requested resource does not exist.' + } + + # Check the json response. + self.assertJsonResponse( + 'get', self.verify_url, 404, json.dumps(expected_response)) + + def test_verify_token_missing_json(self): + # If the verification token is missing, respond with our custom + # message and a 400 status code. + + # Specify expected response. + expected_response = { + 'status': 400, + 'message': 'sptoken parameter not provided.' + } + + # Check the json response. + self.assertJsonResponse( + 'get', 'verify', 400, json.dumps(expected_response)) + + def test_resend_verification_token_json(self): + # Ensure that submitting an email form will generate a new activation + # token. Response should be an empty body with a 200 status code. + + # First we will activate the account + + # Specify expected response. + expected_response = {} + + # Check the json response. + self.assertJsonResponse( + 'get', self.verify_url, 200, json.dumps(expected_response)) + + # Ensure that the account is enabled + self.account.refresh() + self.assertEqual(self.account.status, 'ENABLED') + + # Set the account back to 'UNVERIFIED' + self.account.status = 'UNVERIFIED' + self.account.save() + self.account.refresh() + + # Submit a form that will resend a new activation token + + # Specify expected response. + expected_response = {} + + # Post data + json_data = json.dumps({'email': 'r@verify.com'}) + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json' + } + + # Check the json response. + self.assertJsonResponse( + 'post', 'verify', 200, json.dumps(expected_response), + **request_kwargs) + + # Ensure that the account is still unverified. + self.account.refresh() + self.assertEqual(self.account.status, 'UNVERIFIED') + + # Retrieve a newly generated token + self.new_verify_url = ''.join([ + 'verify?sptoken=', self.get_verification_token()]) + self.assertNotEqual(self.verify_url, self.new_verify_url) + + # Activate an account with a new token + + # Specify expected response. + expected_response = {} + + # Check the json response. + self.assertJsonResponse( + 'get', self.new_verify_url, 200, json.dumps(expected_response)) + + # Ensure that the account is now enabled + self.account.refresh() + self.assertEqual(self.account.status, 'ENABLED') + + def test_resend_verification_token_unassociated_email_json(self): + # Ensure that submitting an unassociated email form will not + # generate a new activation token. Make sure that response will still + # be an empty body with a 200 status code. + + # First we will activate the account + + # Specify expected response. + expected_response = {} + + # Check the json response. + self.assertJsonResponse( + 'get', self.verify_url, 200, json.dumps(expected_response)) + + # Ensure that the account is enabled + self.account.refresh() + self.assertEqual(self.account.status, 'ENABLED') + + # Set the account back to 'UNVERIFIED' + self.account.status = 'UNVERIFIED' + self.account.save() + self.account.refresh() + + # Submit a form that will resend a new activation token + + # Specify expected response. + expected_response = {} + + # Post data + json_data = json.dumps({'email': 'doesnot@exist.com'}) + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json' + } + + # Check the json response. + self.assertJsonResponse( + 'post', 'verify', 200, json.dumps(expected_response), + **request_kwargs) + + # Ensure that the account is still unverified. + self.account.refresh() + self.assertEqual(self.account.status, 'UNVERIFIED') + + # Make sure that a new token was not generated + new_sptoken = self.get_verification_token() + self.assertIsNone(new_sptoken) + + def test_autologin_true_json(self): + # Ensure that the enabled autologin will log a user in and return an + # account json response. + + # Set autologin to true + self.app.config['stormpath']['web']['register']['autoLogin'] = True + + # Specify expected response. + expected_response = {'account': { + 'username': 'rdegges_verify', + 'email': 'r@verify.com', + 'given_name': 'Randall', + 'middle_name': None, + 'surname': 'Degges', + 'full_name': 'Randall Degges', + 'status': 'ENABLED'} + } + + # Check the json response. + self.assertJsonResponse( + 'get', self.verify_url, 200, json.dumps(expected_response), + user_to_json=True) + + def test_response_form_error_missing_json(self): + # Ensure that a missing email will render a proper error. + + # Specify expected response + expected_response = { + 'message': {"email": ["Email is required."]}, + 'status': 400} + + # Post data + json_data = json.dumps({}) + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json' + } + + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + + # Check the json response- + self.assertJsonResponse( + 'post', 'verify', 400, json.dumps(expected_response), + **request_kwargs) + + def test_response_form_error_invalid_json(self): + # Ensure that an invalid email will render a proper error. + + # Specify expected response + expected_response = { + 'message': {"email": ["Email must be in valid format."]}, + 'status': 400} + + # Post data + json_data = json.dumps({'email': 'foobar'}) + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json' + } + + request_kwargs = { + 'data': json_data, + 'content_type': 'application/json'} + + # Check the json response- + self.assertJsonResponse( + 'post', 'verify', 400, json.dumps(expected_response), + **request_kwargs) + + +class TestMe(StormpathViewTestCase): + """Test our me view.""" + def test_json_response(self): + with self.app.test_client() as c: + email = 'r@rdegges.com' + password = 'woot1LoveCookies!' + + # Authenticate our user. + resp = c.post('/login', data={ + 'login': email, + 'password': password, + }) + resp = c.get('/me') + account = User.from_login(email, password) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data.decode(), account.to_json()) + + def test_redirect_to_login(self): + + with self.app.test_client() as c: + # Ensure that the user will be redirected to login if he/she is not + # logged it. + resp = c.get('/me') + + redirect_url = url_for('stormpath.login', next='/me') + self.assertEqual(resp.status_code, 302) + location = resp.headers.get('location') + self.assertTrue(redirect_url in location) + + def test_added_expansion(self): + # NOTE: We're not testing expansion in models since we need to call + # the expanded me view. + + # Enable expanded info on our me view + me_expand = self.app.config['stormpath']['web']['me']['expand'] + for key in me_expand.keys(): + me_expand[key] = True + + with self.app.test_client() as c: + email = 'r@rdegges.com' + password = 'woot1LoveCookies!' + + # Authenticate our user. + resp = c.post('/login', data={ + 'login': email, + 'password': password, + }) + resp = c.get('/me') + self.assertEqual(resp.status_code, 200) + + # Get unexpanded account object + account = User.from_login(email, password) + + json_data = {'account': { + 'href': account.href, + 'modified_at': account.modified_at.isoformat(), + 'created_at': account.created_at.isoformat(), + 'email': 'r@rdegges.com', + 'full_name': 'Randall Degges', + 'given_name': 'Randall', + 'middle_name': None, + 'status': 'ENABLED', + 'surname': 'Degges', + 'username': 'rdegges' + }} + + # Ensure that the missing expanded info won't break + # User.to_json() flow. + self.assertEqual(json.loads(account.to_json()), json_data) + + json_data['account'].update({ + 'applications': {}, + 'customData': {}, + 'directory': {}, + 'tenant': {}, + 'providerData': {}, + 'groupMemberships': {}, + 'groups': {}, + 'apiKeys': {} + }) + + # Ensure that expanded me response will return proper data. + self.assertEqual(json.loads(resp.data.decode()), json_data) + + +class TestFacebookLogin(StormpathViewTestCase): + """ Test our Facebook login view. """ + + @patch('flask_stormpath.views.get_user_from_cookie') + def test_access_token(self, access_token_mock): + # Ensure that proper access code fetching will continue processing + # the view. + access_token_mock.return_value = { + 'access_token': 'mocked access token'} + with self.app.test_request_context(): + FacebookLoginView() + + # Ensure that invalid access code fetching will return a 400 BadRequest + # response. + access_token_mock.return_value = None + with self.app.test_request_context(): + with self.assertRaises(BadRequest) as error: + FacebookLoginView() + self.assertEqual(error.exception.name, 'Bad Request') + self.assertEqual(error.exception.code, 400) + + @patch('flask_stormpath.views.get_user_from_cookie') + @patch('flask_stormpath.views.SocialView.get_account') + def test_user_logged_in_and_redirect(self, user_mock, access_token_mock): + # Access token is retrieved on the front end of our applications, so + # we have to mock it. + access_token_mock.return_value = { + 'access_token': 'mocked access token'} + user_mock.return_value = self.user + + # Setting redirect URL to something that is easy to check + stormpath_login_redirect_url = '/redirect_for_login' + self.app.config['stormpath']['web']['login'][ + 'nextUri'] = stormpath_login_redirect_url + + # Ensure that the correct access token will log our user in and + # redirect him to the index page. + with self.app.test_client() as c: + self.assertFalse(current_user) + # Log this user in. + resp = c.get('/facebook') + self.assertEqual(resp.status_code, 302) + self.assertEqual(current_user, self.user) + location = resp.headers.get('location') + self.assertTrue(stormpath_login_redirect_url in location) + + @patch('flask_stormpath.views.get_user_from_cookie') + def test_error_retrieving_user(self, access_token_mock): + # Access token is retrieved on the front end of our applications, so + # we have to mock it. + access_token_mock.return_value = { + 'access_token': 'mocked access token'} + + # Ensure that the user will be redirected back to the login page with + # the proper error message rendered in case we fail to fetch the user + # account. + with self.app.test_client() as c: + # First we'll check the error message. + self.assertFalse(current_user) + # Try to log a user in. + resp = c.get('/facebook', follow_redirects=True) + self.assertEqual(resp.status_code, 200) + self.assertTrue(current_user.is_anonymous) + + self.assertTrue( + 'Oops! We encountered an unexpected error. Please contact ' + + 'support and explain what you were doing at the time this ' + + 'error occurred.' in + resp.data.decode('utf-8')) + + # Then we'll make the same request, but this time checking the + # redirect status code and location. + + # Setting redirect URL to something that is easy to check + facebook_login_redirect_url = '/redirect_for_facebook_login' + self.app.config['stormpath']['web']['login'][ + 'uri'] = facebook_login_redirect_url + + # Try to log a user in. + resp = c.get('/facebook') + self.assertEqual(resp.status_code, 302) + self.assertTrue(current_user.is_anonymous) + location = resp.headers.get('location') + self.assertTrue(facebook_login_redirect_url in location) + + +class TestGoogleLogin(StormpathViewTestCase): + """ Test our Google login view. """ + + def test_access_token(self): + # Ensure that proper access code fetching will continue processing + # the view. + with self.app.test_request_context() as req: + req.request.args = {'code': 'mocked access token'} + GoogleLoginView() + + # Ensure that invalid access code fetching will return a 400 BadRequest + # response. + with self.app.test_request_context() as req: + req.request.args = {} + with self.assertRaises(BadRequest) as error: + GoogleLoginView() + self.assertEqual(error.exception.name, 'Bad Request') + self.assertEqual(error.exception.code, 400) + + @patch('flask_stormpath.views.SocialView.get_account') + def test_user_logged_in_and_redirect(self, user_mock): + # Access token is retrieved on the front end of our applications, so + # we have to mock it. + user_mock.return_value = self.user + + # Setting redirect URL to something that is easy to check + stormpath_login_redirect_url = '/redirect_for_login' + self.app.config['stormpath']['web']['login'][ + 'nextUri'] = stormpath_login_redirect_url + + # Ensure that the correct access token will log our user in and + # redirect him to the index page. + with self.app.test_client() as c: + self.assertFalse(current_user) + # Log this user in. + resp = c.get( + '/google', query_string={'code': 'mocked access token'}) + self.assertEqual(resp.status_code, 302) + self.assertEqual(current_user, self.user) + location = resp.headers.get('location') + self.assertTrue(stormpath_login_redirect_url in location) + + def test_error_retrieving_user(self): + # Ensure that the user will be redirected back to the login page with + # the proper error message rendered in case we fail to fetch the user + # account. + with self.app.test_client() as c: + # First we'll check the error message. + self.assertFalse(current_user) + # Try to log a user in. + resp = c.get( + '/google', query_string={'code': 'mocked access token'}, + follow_redirects=True) + self.assertEqual(resp.status_code, 200) + self.assertTrue(current_user.is_anonymous) + + self.assertTrue( + 'Oops! We encountered an unexpected error. Please contact ' + + 'support and explain what you were doing at the time this ' + + 'error occurred.' in + resp.data.decode('utf-8')) + + # Then we'll make the same request, but this time checking the + # redirect status code and location. + + # Setting redirect URL to something that is easy to check + facebook_login_redirect_url = '/redirect_for_facebook_login' + self.app.config['stormpath']['web']['login'][ + 'uri'] = facebook_login_redirect_url + + # Try to log a user in. + resp = c.get( + '/google', query_string={'code': 'mocked access token'}) + self.assertEqual(resp.status_code, 302) + self.assertTrue(current_user.is_anonymous) + location = resp.headers.get('location') + self.assertTrue(facebook_login_redirect_url in location)