From 4ccab6407bf3b16798c948d3442d86e603432aa6 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Tue, 8 Mar 2016 10:51:00 +0100 Subject: [PATCH 01/19] added stormpath to requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 10ddb2b..0e88f46 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ Sphinx>=1.2.1 pytest>=2.5.2 pytest-xdist>=1.10 blinker==1.3 +stormpath==2.1.1 From f3f69ff54d5bd5e77a2d955ca29de7f534382be8 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Mon, 21 Mar 2016 16:10:24 +0100 Subject: [PATCH 02/19] dynamic form settings based on environment and settings files - work in progress --- flask_stormpath/__init__.py | 84 +++--- flask_stormpath/config/default-config.yml | 255 +++++++++++++++++ flask_stormpath/forms.py | 53 +++- flask_stormpath/settings.py | 265 ++++++++++++------ .../templates/flask_stormpath/login.html | 30 +- .../templates/flask_stormpath/register.html | 66 +---- flask_stormpath/views.py | 33 ++- requirements.txt | 6 +- setup.py | 30 +- tests/helpers.py | 4 +- tests/test_settings.py | 3 +- 11 files changed, 586 insertions(+), 243 deletions(-) create mode 100644 flask_stormpath/config/default-config.yml diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index a26b535..486885a 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -44,7 +44,6 @@ 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 ( @@ -110,7 +109,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 @@ -131,18 +131,20 @@ 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'] + # FIXME: not currently set in stormpath config init + # 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'] + # FIXME: not currently set in stormpath config init + # app.config['PERMANENT_SESSION_LIFETIME'] = app.config['STORMPATH_COOKIE_DURATION'] def init_routes(self, app): """ @@ -155,56 +157,57 @@ def init_routes(self, app): :param obj app: The Flask app. """ - if app.config['STORMPATH_ENABLE_REGISTRATION']: + if app.config['stormpath']['web']['register']['enabled']: app.add_url_rule( - app.config['STORMPATH_REGISTRATION_URL'], + app.config['stormpath']['web']['register']['uri'], 'stormpath.register', register, - methods = ['GET', 'POST'], + 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'], + app.config['stormpath']['web']['login']['uri'], 'stormpath.login', login, - methods = ['GET', 'POST'], + 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'], + app.config['stormpath']['web']['forgotPassword']['uri'], 'stormpath.forgot', forgot, - methods = ['GET', 'POST'], + methods=['GET', 'POST'], ) app.add_url_rule( - app.config['STORMPATH_FORGOT_PASSWORD_CHANGE_URL'], + app.config['stormpath']['web']['changePassword']['uri'], 'stormpath.forgot_change', forgot_change, - methods = ['GET', 'POST'], + methods=['GET', 'POST'], ) - if app.config['STORMPATH_ENABLE_LOGOUT']: + if app.config['stormpath']['web']['logout']['enabled']: app.add_url_rule( - app.config['STORMPATH_LOGOUT_URL'], + app.config['stormpath']['web']['logout']['uri'], 'stormpath.logout', logout, ) - if app.config['STORMPATH_ENABLE_GOOGLE']: - app.add_url_rule( - app.config['STORMPATH_GOOGLE_LOGIN_URL'], - 'stormpath.google_login', - google_login, - ) - - if app.config['STORMPATH_ENABLE_FACEBOOK']: - app.add_url_rule( - app.config['STORMPATH_FACEBOOK_LOGIN_URL'], - 'stormpath.facebook_login', - facebook_login, - ) + # FIXME: enable this in init_settings + # if app.config['STORMPATH_ENABLE_GOOGLE']: + # app.add_url_rule( + # app.config['STORMPATH_GOOGLE_LOGIN_URL'], + # 'stormpath.google_login', + # google_login, + # ) + + # if app.config['STORMPATH_ENABLE_FACEBOOK']: + # app.add_url_rule( + # app.config['STORMPATH_FACEBOOK_LOGIN_URL'], + # 'stormpath.facebook_login', + # facebook_login, + # ) @property def client(self): @@ -218,15 +221,16 @@ def client(self): # 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) + 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'], + 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 @@ -234,10 +238,10 @@ def client(self): # 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'], + 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 diff --git a/flask_stormpath/config/default-config.yml b/flask_stormpath/config/default-config.yml new file mode 100644 index 0000000..2d93503 --- /dev/null +++ b/flask_stormpath/config/default-config.yml @@ -0,0 +1,255 @@ +client: + apiKey: + file: null + id: null + secret: null + cacheManager: + defaultTtl: 300 + defaultTti: 300 + caches: + account: + ttl: 300 + tti: 300 + baseUrl: "https://api.stormpath.com/v1" + connectionTimeout: 30 + authenticationScheme: "SAUTHC1" + proxy: + port: null + host: null + username: null + password: null +application: + name: null + href: null + +web: + + basePath: null + + oauth2: + enabled: true + uri: "/oauth/token" + client_credentials: + enabled: true + accessToken: + ttl: 3600 + password: + enabled: true + validationStrategy: "local" + + accessTokenCookie: + name: "access_token" + httpOnly: true + + # See cookie-authentication.md for explanation of + # how `null` values behave for these properties. + secure: null + path: null + domain: null + + refreshTokenCookie: + name: "refresh_token" + httpOnly: true + + # See cookie-authentication.md for explanation of + # how `null` values behave for these properties. + secure: null + path: null + domain: null + + # By default the Stormpath integration must respond to JSON and HTML + # requests. If a requested type is not in this list, the response is 406. + # If the request does not specify an Accept header, or the preferred accept + # type is */*, the integration must respond with the first type in this + # list. + + produces: + - application/json + - text/html + + 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 + 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" + template: "flask_stormpath/register.html" + + # 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: null + uri: "/verify" + nextUri: "/login" + view: "verify" + + login: + enabled: true + uri: "/login" + nextUri: "/" + template: "flask_stormpath/login.html" + 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: "/" + + # 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: null + uri: "/forgot" + template: "flask_stormpath/forgot_change.html" + nextUri: "/login?status=forgot" + + # 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: null + autoLogin: false + uri: "/change" + nextUri: "/login?status=reset" + template: "flask_stormpath/forgot_change.html" + errorUri: "/forgot?status=invalid_sptoken" + + # 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 themeslves and remove 'text/html' + # from `stormpath.web.produces`, so that we don not serve our default + # HTML views. + spa: + enabled: false + view: index + + unauthorized: + view: "unauthorized" diff --git a/flask_stormpath/forms.py b/flask_stormpath/forms.py index 0b063eb..762eaf4 100644 --- a/flask_stormpath/forms.py +++ b/flask_stormpath/forms.py @@ -1,12 +1,41 @@ """Helper forms which make handling common operations simpler.""" - +from flask import current_app from flask.ext.wtf import Form from wtforms.fields import PasswordField, StringField from wtforms.validators import InputRequired, ValidationError - - -class RegistrationForm(Form): +from stormpath.resources import Resource + + +class StormpathForm(Form): + def __init__(self, config, *args, **kwargs): + super(StormpathForm, self).__init__(*args, **kwargs) + field_list = config['fields'] + field_order = config['fieldOrder'] + + for field in field_order: + if field_list[field]['enabled']: + validators = [] + if field_list[field]['required']: + validators.append(InputRequired()) + if field_list[field]['type'] == 'password': + field_class = PasswordField + else: + field_class = StringField + if 'label' in field_list[field] and isinstance( + field_list[field]['label'], str): + label = field_list[field]['label'] + else: + label = '' + placeholder = field_list[field]['placeholder'] + setattr( + self.__class__, Resource.from_camel_case(field), + field_class( + label, validators=validators, + render_kw={"placeholder": placeholder})) + + +class RegistrationForm(StormpathForm): """ Register a new user. @@ -23,15 +52,12 @@ class RegistrationForm(Form): 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()]) - password = PasswordField('Password', validators=[InputRequired()]) + def __init__(self, *args, **kwargs): + form_config = current_app.config['stormpath']['web']['register']['form'] + super(RegistrationForm, self).__init__(form_config, *args, **kwargs) -class LoginForm(Form): +class LoginForm(StormpathForm): """ Log in an existing user. @@ -48,8 +74,9 @@ class LoginForm(Form): 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()]) - password = PasswordField('Password', validators=[InputRequired()]) + def __init__(self, *args, **kwargs): + form_config = current_app.config['stormpath']['web']['login']['form'] + super(LoginForm, self).__init__(form_config, *args, **kwargs) class ForgotPasswordForm(Form): diff --git a/flask_stormpath/settings.py b/flask_stormpath/settings.py index 29c06c1..5c2d355 100644 --- a/flask_stormpath/settings.py +++ b/flask_stormpath/settings.py @@ -1,10 +1,120 @@ """Helper functions for dealing with Flask-Stormpath settings.""" - +import os from datetime import timedelta +from stormpath_config.loader import ConfigLoader +from stormpath_config.strategies import ( + LoadEnvConfigStrategy, LoadFileConfigStrategy, LoadAPIKeyConfigStrategy, + LoadAPIKeyFromConfigStrategy, ValidateClientConfigStrategy, + MoveAPIKeyToClientAPIKeyStrategy, EnrichClientFromRemoteConfigStrategy) + from .errors import ConfigurationError +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 __iter__(self): + return iter(self.store) + + def __len__(self): + return len(self.store) + def init_settings(config): """ @@ -15,86 +125,85 @@ def init_settings(config): :param dict config: The Flask app config. """ # Basic Stormpath credentials and configuration. + web_config_file = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'config/default-config.yml') + 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') + ], + post_processing_strategies=[ + LoadAPIKeyFromConfigStrategy(), MoveAPIKeyToClientAPIKeyStrategy() + ], + validation_strategies=[ValidateClientConfigStrategy()]) + config['stormpath'] = StormpathSettings(config_loader.load()) + + # Most of the settings are used for backwards compatibility. config.setdefault('STORMPATH_API_KEY_ID', None) config.setdefault('STORMPATH_API_KEY_SECRET', None) - config.setdefault('STORMPATH_API_KEY_FILE', None) + # FIXME: this breaks the code because it's not in the spec + # 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, + # FIXME: this breaks the code because it's not in the spec + # 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) + # FIXME: this breaks the code because it's not in the spec + # config.setdefault('STORMPATH_VERIFY_EMAIL', False) # 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') + # FIXME: this breaks the code because it's not in the spec + # 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) + # FIXME: this breaks the code because it's not in the spec + # 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') + # FIXME: some of the settings break the code because they're not in the spec + # 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_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') + # config.setdefault('STORMPATH_FORGOT_PASSWORD_COMPLETE_TEMPLATE', 'flask_stormpath/forgot_complete.html') # Social login configuration. - config.setdefault('STORMPATH_SOCIAL', {}) + # FIXME: this breaks the code because it's not in the spec + # config.setdefault('STORMPATH_SOCIAL', {}) # Cookie configuration. - config.setdefault('STORMPATH_COOKIE_DOMAIN', None) - config.setdefault('STORMPATH_COOKIE_DURATION', timedelta(days=365)) + # FIXME: this breaks the code because it's not in the spec + # 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') + for key, value in config.items(): + if key.startswith(config['stormpath'].STORMPATH_PREFIX): + config['stormpath'][key] = value + def check_settings(config): """ @@ -105,38 +214,28 @@ def check_settings(config): :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.') + # FIXME: this needs to be uncommented based on settings in init_settings + # 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.') diff --git a/flask_stormpath/templates/flask_stormpath/login.html b/flask_stormpath/templates/flask_stormpath/login.html index b4f924e..f0e6471 100644 --- a/flask_stormpath/templates/flask_stormpath/login.html +++ b/flask_stormpath/templates/flask_stormpath/login.html @@ -11,8 +11,8 @@
- {% if config['STORMPATH_ENABLE_LOGIN'] %} + {% if config['stormpath']['web']['login']['enabled'] %} {% endif %} diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index 5079585..e6fb31e 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -46,8 +46,10 @@ def register(): # flashing error messages if required. data = form.data for field in data.keys(): - if current_app.config['STORMPATH_ENABLE_%s' % field.upper()]: - if current_app.config['STORMPATH_REQUIRE_%s' % field.upper()] and not data[field]: + if current_app.config['stormpath']['web']['register']['form'][ + 'fields']['%s' % field.upper()]['enabled']: + if current_app.config['stormpath']['web']['register']['form'][ + '%s' % field.upper()]['required'] and not data[field]: fail = True # Manually override the terms for first / last name to make @@ -80,23 +82,24 @@ def register(): # 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. + # Stormpath login nextUri setting. login_user(account, remember=True) - if 'STORMPATH_REGISTRATION_REDIRECT_URL'\ - in current_app.config: + redirect_url = current_app.config[ + 'stormpath']['web']['register']['nextUri'] + if not redirect_url: redirect_url = current_app.config[ - 'STORMPATH_REGISTRATION_REDIRECT_URL'] + 'stormpath']['web']['login']['nextUri'] else: - redirect_url = current_app.config['STORMPATH_REDIRECT_URL'] + redirect_url = '/' return redirect(redirect_url) except StormpathError as err: flash(err.message.get('message')) return render_template( - current_app.config['STORMPATH_REGISTRATION_TEMPLATE'], - form = form, + current_app.config['stormpath']['web']['register']['template'], + form=form, ) @@ -124,17 +127,19 @@ def login(): # 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. + # 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']) + return redirect( + request.args.get('next') or + current_app.config['stormpath']['web']['login']['nextUri']) except StormpathError as err: flash(err.message.get('message')) return render_template( - current_app.config['STORMPATH_LOGIN_TEMPLATE'], - form = form, + current_app.config['stormpath']['web']['login']['template'], + form=form, ) @@ -165,7 +170,7 @@ def forgot(): # their inbox to complete the password reset process. return render_template( current_app.config['STORMPATH_FORGOT_PASSWORD_EMAIL_SENT_TEMPLATE'], - user = account, + user=account, ) except StormpathError as err: # If the error message contains 'https', it means something failed diff --git a/requirements.txt b/requirements.txt index 0e88f46..1a96de0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,9 @@ Sphinx>=1.2.1 pytest>=2.5.2 pytest-xdist>=1.10 -blinker==1.3 +Flask>=0.9.0 +Flask-Login==0.2.9 +Flask-WTF>=0.9.5 +facebook-sdk==0.4.0 +oauth2client==1.2 stormpath==2.1.1 diff --git a/setup.py b/setup.py index 96ba362..8257bd8 100644 --- a/setup.py +++ b/setup.py @@ -37,20 +37,20 @@ def run(self): setup( - name = 'Flask-Stormpath', - version = '0.4.4', - url = 'https://github.com/stormpath/stormpath-flask', - license = 'Apache', - author = 'Stormpath, Inc.', - author_email = 'python@stormpath.com', - description = 'Simple and secure user authentication for Flask via Stormpath.', - long_description = __doc__, - packages = ['flask_stormpath'], - cmdclass = {'test': RunTests}, - zip_safe = False, - include_package_data = True, - platforms = 'any', - install_requires = [ + name='Flask-Stormpath', + version='0.4.4', + url='https://github.com/stormpath/stormpath-flask', + license='Apache', + author='Stormpath, Inc.', + author_email='python@stormpath.com', + description='Simple and secure user authentication for Flask via Stormpath.', + long_description=__doc__, + packages=['flask_stormpath'], + cmdclass={'test': RunTests}, + zip_saf=False, + include_package_data=True, + platforms='any', + install_requires=[ 'Flask>=0.9.0', 'Flask-Login==0.2.9', 'Flask-WTF>=0.9.5', @@ -62,7 +62,7 @@ def run(self): dependency_links=[ 'git+git://github.com/pythonforfacebook/facebook-sdk.git@e65d06158e48388b3932563f1483ca77065951b3#egg=facebook-sdk-1.0.0-alpha', ], - classifiers = [ + classifiers=[ 'Environment :: Web Environment', 'Framework :: Flask', 'Intended Audience :: Developers', diff --git a/tests/helpers.py b/tests/helpers.py index 4e82fc9..403eedf 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -60,8 +60,8 @@ 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=environ.get('STORMPATH_API_KEY_ID'), + secret=environ.get('STORMPATH_API_KEY_SECRET'), ) diff --git a/tests/test_settings.py b/tests/test_settings.py index 0d9b0df..916c0ef 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -6,7 +6,8 @@ from tempfile import mkstemp from flask.ext.stormpath.errors import ConfigurationError -from flask.ext.stormpath.settings import check_settings, init_settings +from flask.ext.stormpath.settings import ( + StormpathSettings, check_settings, init_settings) from .helpers import StormpathTestCase From 22adfaffae82e721e5fa1b79c78660e3da2fb940 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Mon, 21 Mar 2016 16:12:02 +0100 Subject: [PATCH 03/19] tests for custom settings object --- tests/test_settings.py | 151 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 2 deletions(-) diff --git a/tests/test_settings.py b/tests/test_settings.py index 916c0ef..12d4694 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -20,8 +20,155 @@ def test_works(self): # 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) + 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): + 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): + 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): + 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): + 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']) class TestCheckSettings(StormpathTestCase): From c73eb55fc45b155aade965294f7724e61c052c16 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Tue, 22 Mar 2016 13:51:19 +0100 Subject: [PATCH 04/19] moved init settings to manager because we need the manager settings --- flask_stormpath/__init__.py | 175 +++++++++++++++++++++++++++++++++--- flask_stormpath/settings.py | 136 ---------------------------- 2 files changed, 165 insertions(+), 146 deletions(-) diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index 486885a..a706261 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -21,6 +21,7 @@ __license__ = 'Apache' __copyright__ = '(c) 2012 - 2015 Stormpath, Inc.' +import os from flask import ( Blueprint, @@ -40,12 +41,18 @@ 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, + MoveAPIKeyToClientAPIKeyStrategy, EnrichClientFromRemoteConfigStrategy, + EnrichIntegrationFromRemoteConfigStrategy) from werkzeug.local import LocalProxy from .context_processors import user_context_processor from .models import User -from .settings import check_settings, init_settings +from .settings import StormpathSettings from .views import ( google_login, facebook_login, @@ -96,11 +103,11 @@ def init_app(self, app): """ # Initialize all of the Flask-Stormpath configuration variables and # settings. - init_settings(app.config) + self.init_settings(app.config) # Check our user defined settings to ensure Flask-Stormpath is properly # configured. - check_settings(app.config) + self.check_settings(app.config) # Initialize the Flask-Login extension. self.init_login(app) @@ -122,6 +129,152 @@ 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 = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'config/default-config.yml') + 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') + ], + post_processing_strategies=[ + LoadAPIKeyFromConfigStrategy(), MoveAPIKeyToClientAPIKeyStrategy() + ], + validation_strategies=[ValidateClientConfigStrategy()]) + config['stormpath'] = StormpathSettings(config_loader.load()) + + # Most of the settings are used for backwards compatibility. + config.setdefault('STORMPATH_API_KEY_ID', None) + config.setdefault('STORMPATH_API_KEY_SECRET', None) + # FIXME: this breaks the code because it's not in the spec + # config.setdefault('STORMPATH_API_KEY_FILE', None) + config.setdefault('STORMPATH_APPLICATION', None) + + # Which fields should be displayed when registering new users? + # FIXME: this breaks the code because it's not in the spec + # 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. + + # Will new users be required to verify new accounts via email before + # they're made active? + # FIXME: this breaks the code because it's not in the spec + # config.setdefault('STORMPATH_VERIFY_EMAIL', False) + + # Configure URL mappings. These URL mappings control which URLs will be + # used by Flask-Stormpath views. + # FIXME: this breaks the code because it's not in the spec + # 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. + # FIXME: this breaks the code because it's not in the spec + # config.setdefault('STORMPATH_CACHE', None) + + # Configure templates. These template settings control which templates are + # used to render the Flask-Stormpath views. + # FIXME: some of the settings break the code because they're not in the spec + # 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') + + # Social login configuration. + # FIXME: this breaks the code because it's not in the spec + # config.setdefault('STORMPATH_SOCIAL', {}) + + # Cookie configuration. + # FIXME: this breaks the code because it's not in the spec + # 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') + + for key, value in config.items(): + if key.startswith(config['stormpath'].STORMPATH_PREFIX): + config['stormpath'][key] = value + + # If the user is specifying their credentials via a file path, + # we'll use this. + if self.app.config['stormpath']['client']['apiKey']['file']: + stormpath_client = Client( + api_key_file_location=self.app.config['stormpath']['client']['apiKey']['file'], + ) + + # 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: + stormpath_client = Client( + id=self.app.config['stormpath']['client']['apiKey']['id'], + secret=self.app.config['stormpath']['client']['apiKey']['secret'], + ) + + ecfrcs = EnrichClientFromRemoteConfigStrategy( + client_factory=lambda client: stormpath_client) + ecfrcs.process(self.app.config['stormpath']) + eifrcs = EnrichIntegrationFromRemoteConfigStrategy( + client_factory=lambda client: stormpath_client) + eifrcs.process(self.app.config['stormpath']) + + def check_settings(self, 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. + """ + # FIXME: this needs to be uncommented based on settings in init_settings + # 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.') + def init_login(self, app): """ Initialize the Flask-Login extension. @@ -226,11 +379,12 @@ def client(self): # If the user is specifying their credentials via a file path, # we'll use this. - if self.app.config['STORMPATH_API_KEY_FILE']: + if self.app.config['stormpath']['apiKey']['file']: ctx.stormpath_client = Client( - api_key_file_location=self.app.config['STORMPATH_API_KEY_FILE'], + api_key_file_location=self.app.config['stormpath']['apiKey']['file'], user_agent=user_agent, - cache_options=self.app.config['STORMPATH_CACHE'], + # FIXME: read cache from config + # cache_options=self.app.config['STORMPATH_CACHE'], ) # If the user isn't specifying their credentials via a file @@ -238,10 +392,11 @@ def client(self): # 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'], + id=self.app.config['stormpath']['apiKey']['id'], + secret=self.app.config['stormpath']['apiKey']['secret'], user_agent=user_agent, - cache_options=self.app.config['STORMPATH_CACHE'], + # FIXME: read cache from config + # cache_options=self.app.config['STORMPATH_CACHE'], ) return ctx.stormpath_client @@ -271,7 +426,7 @@ def application(self): if ctx is not None: if not hasattr(ctx, 'stormpath_application'): ctx.stormpath_application = self.client.applications.search( - self.app.config['STORMPATH_APPLICATION'] + self.app.config['stormpath']['application']['name'] )[0] return ctx.stormpath_application diff --git a/flask_stormpath/settings.py b/flask_stormpath/settings.py index 5c2d355..998df9d 100644 --- a/flask_stormpath/settings.py +++ b/flask_stormpath/settings.py @@ -1,16 +1,5 @@ """Helper functions for dealing with Flask-Stormpath settings.""" -import os -from datetime import timedelta -from stormpath_config.loader import ConfigLoader -from stormpath_config.strategies import ( - LoadEnvConfigStrategy, LoadFileConfigStrategy, LoadAPIKeyConfigStrategy, - LoadAPIKeyFromConfigStrategy, ValidateClientConfigStrategy, - MoveAPIKeyToClientAPIKeyStrategy, EnrichClientFromRemoteConfigStrategy) - - -from .errors import ConfigurationError - import collections @@ -114,128 +103,3 @@ def __iter__(self): def __len__(self): return len(self.store) - - -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. - web_config_file = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'config/default-config.yml') - 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') - ], - post_processing_strategies=[ - LoadAPIKeyFromConfigStrategy(), MoveAPIKeyToClientAPIKeyStrategy() - ], - validation_strategies=[ValidateClientConfigStrategy()]) - config['stormpath'] = StormpathSettings(config_loader.load()) - - # Most of the settings are used for backwards compatibility. - config.setdefault('STORMPATH_API_KEY_ID', None) - config.setdefault('STORMPATH_API_KEY_SECRET', None) - # FIXME: this breaks the code because it's not in the spec - # config.setdefault('STORMPATH_API_KEY_FILE', None) - config.setdefault('STORMPATH_APPLICATION', None) - - # Which fields should be displayed when registering new users? - # FIXME: this breaks the code because it's not in the spec - # 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. - - # Will new users be required to verify new accounts via email before - # they're made active? - # FIXME: this breaks the code because it's not in the spec - # config.setdefault('STORMPATH_VERIFY_EMAIL', False) - - # Configure URL mappings. These URL mappings control which URLs will be - # used by Flask-Stormpath views. - # FIXME: this breaks the code because it's not in the spec - # 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. - # FIXME: this breaks the code because it's not in the spec - # config.setdefault('STORMPATH_CACHE', None) - - # Configure templates. These template settings control which templates are - # used to render the Flask-Stormpath views. - # FIXME: some of the settings break the code because they're not in the spec - # 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') - - # Social login configuration. - # FIXME: this breaks the code because it's not in the spec - # config.setdefault('STORMPATH_SOCIAL', {}) - - # Cookie configuration. - # FIXME: this breaks the code because it's not in the spec - # 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') - - for key, value in config.items(): - if key.startswith(config['stormpath'].STORMPATH_PREFIX): - config['stormpath'][key] = value - - -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. - """ - # FIXME: this needs to be uncommented based on settings in init_settings - # 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.') From e7e1ad23f6ceaf829747621d374a4e7347e7a7a1 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Tue, 22 Mar 2016 14:26:34 +0100 Subject: [PATCH 05/19] removed redundant settings -> defined in default config --- flask_stormpath/__init__.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index a706261..470428d 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -157,13 +157,6 @@ def init_settings(self, config): validation_strategies=[ValidateClientConfigStrategy()]) config['stormpath'] = StormpathSettings(config_loader.load()) - # Most of the settings are used for backwards compatibility. - config.setdefault('STORMPATH_API_KEY_ID', None) - config.setdefault('STORMPATH_API_KEY_SECRET', None) - # FIXME: this breaks the code because it's not in the spec - # config.setdefault('STORMPATH_API_KEY_FILE', None) - config.setdefault('STORMPATH_APPLICATION', None) - # Which fields should be displayed when registering new users? # FIXME: this breaks the code because it's not in the spec # config.setdefault('STORMPATH_ENABLE_FACEBOOK', False) @@ -172,11 +165,6 @@ def init_settings(self, config): # only social login can # be used. - # Will new users be required to verify new accounts via email before - # they're made active? - # FIXME: this breaks the code because it's not in the spec - # config.setdefault('STORMPATH_VERIFY_EMAIL', False) - # Configure URL mappings. These URL mappings control which URLs will be # used by Flask-Stormpath views. # FIXME: this breaks the code because it's not in the spec @@ -193,9 +181,7 @@ def init_settings(self, config): # Configure templates. These template settings control which templates are # used to render the Flask-Stormpath views. # FIXME: some of the settings break the code because they're not in the spec - # 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_BASE_TEMPLATE', 'flask_stormpath/base.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') From fc3cc089948d92e84f1700f300ef67d3489238d7 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Wed, 23 Mar 2016 10:25:55 +0100 Subject: [PATCH 06/19] replaced several old style settings in templates with new ones -> commented the rest --- flask_stormpath/__init__.py | 6 ++---- flask_stormpath/settings.py | 7 +++++++ flask_stormpath/templates/flask_stormpath/forgot.html | 2 +- .../templates/flask_stormpath/forgot_complete.html | 2 +- flask_stormpath/templates/flask_stormpath/login.html | 2 +- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index 470428d..a0cd568 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -182,9 +182,7 @@ def init_settings(self, config): # used to render the Flask-Stormpath views. # FIXME: some of the settings break the code because they're not in the spec config.setdefault('STORMPATH_BASE_TEMPLATE', 'flask_stormpath/base.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') # Social login configuration. @@ -200,9 +198,9 @@ def init_settings(self, config): config.setdefault('REMEMBER_COOKIE_NAME', 'stormpath_token') for key, value in config.items(): - if key.startswith(config['stormpath'].STORMPATH_PREFIX): + if key.startswith(config['stormpath'].STORMPATH_PREFIX) and \ + key in config['stormpath']: config['stormpath'][key] = value - # If the user is specifying their credentials via a file path, # we'll use this. if self.app.config['stormpath']['client']['apiKey']['file']: diff --git a/flask_stormpath/settings.py b/flask_stormpath/settings.py index 998df9d..91cfe4a 100644 --- a/flask_stormpath/settings.py +++ b/flask_stormpath/settings.py @@ -98,6 +98,13 @@ def __delitem__(self, key): node, child = self.__keytransform__(key) del node[child] + def __contains__(self, key): + try: + self.__nodematch__(key) + return True + except KeyError: + return False + def __iter__(self): return iter(self.store) diff --git a/flask_stormpath/templates/flask_stormpath/forgot.html b/flask_stormpath/templates/flask_stormpath/forgot.html index b8352b7..120ae47 100644 --- a/flask_stormpath/templates/flask_stormpath/forgot.html +++ b/flask_stormpath/templates/flask_stormpath/forgot.html @@ -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_complete.html b/flask_stormpath/templates/flask_stormpath/forgot_complete.html index 0d83d1d..a3dbd99 100644 --- a/flask_stormpath/templates/flask_stormpath/forgot_complete.html +++ b/flask_stormpath/templates/flask_stormpath/forgot_complete.html @@ -4,7 +4,7 @@ {% 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/login.html b/flask_stormpath/templates/flask_stormpath/login.html index f0e6471..bb1e770 100644 --- a/flask_stormpath/templates/flask_stormpath/login.html +++ b/flask_stormpath/templates/flask_stormpath/login.html @@ -57,7 +57,7 @@ {% endif %} - {% if config['STORMPATH_ENABLE_FORGOT_PASSWORD'] %} + {% if config['stormpath']['web']['forgotPassword']['enabled'] %} Forgot Password? {% endif %} From 09dc0c2cd0b6675e2cce9f99c1b226ea7c32aa2a Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Wed, 23 Mar 2016 12:28:24 +0100 Subject: [PATCH 07/19] misc fixes to make standard forms show --- flask_stormpath/__init__.py | 39 ++++++++----------- flask_stormpath/forms.py | 3 +- .../templates/flask_stormpath/forgot.html | 2 +- flask_stormpath/views.py | 4 +- 4 files changed, 21 insertions(+), 27 deletions(-) diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index a0cd568..15ce557 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -22,6 +22,7 @@ __copyright__ = '(c) 2012 - 2015 Stormpath, Inc.' import os +from datetime import timedelta from flask import ( Blueprint, @@ -38,7 +39,6 @@ login_user, logout_user, ) - from stormpath.client import Client from stormpath.error import Error as StormpathError from stormpath_config.loader import ConfigLoader @@ -53,6 +53,7 @@ from .context_processors import user_context_processor from .models import User from .settings import StormpathSettings +from .errors import ConfigurationError from .views import ( google_login, facebook_login, @@ -158,21 +159,16 @@ def init_settings(self, config): config['stormpath'] = StormpathSettings(config_loader.load()) # Which fields should be displayed when registering new users? - # FIXME: this breaks the code because it's not in the spec - # config.setdefault('STORMPATH_ENABLE_FACEBOOK', False) - # config.setdefault('STORMPATH_ENABLE_GOOGLE', False) - # config.setdefault('STORMPATH_ENABLE_EMAIL', True) # If this is diabled, + 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. # Configure URL mappings. These URL mappings control which URLs will be # used by Flask-Stormpath views. - # FIXME: this breaks the code because it's not in the spec - # 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', '/') + config.setdefault('STORMPATH_GOOGLE_LOGIN_URL', '/google') + config.setdefault('STORMPATH_FACEBOOK_LOGIN_URL', '/facebook') # Cache configuration. # FIXME: this breaks the code because it's not in the spec @@ -190,9 +186,8 @@ def init_settings(self, config): # config.setdefault('STORMPATH_SOCIAL', {}) # Cookie configuration. - # FIXME: this breaks the code because it's not in the spec - # config.setdefault('STORMPATH_COOKIE_DOMAIN', None) - # config.setdefault('STORMPATH_COOKIE_DURATION', timedelta(days=365)) + 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') @@ -253,11 +248,11 @@ def check_settings(self, config): # ]): # 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_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.') + if config['STORMPATH_COOKIE_DURATION'] and not isinstance(config['STORMPATH_COOKIE_DURATION'], timedelta): + raise ConfigurationError('STORMPATH_COOKIE_DURATION must be a timedelta object.') def init_login(self, app): """ @@ -268,9 +263,8 @@ def init_login(self, app): :param obj app: The Flask app. """ - # FIXME: not currently set in stormpath config init - # 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 @@ -280,8 +274,7 @@ def init_login(self, app): app.login_manager.login_view = 'stormpath.login' # Make this Flask session expire automatically. - # FIXME: not currently set in stormpath config init - # 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): """ diff --git a/flask_stormpath/forms.py b/flask_stormpath/forms.py index 762eaf4..5282226 100644 --- a/flask_stormpath/forms.py +++ b/flask_stormpath/forms.py @@ -97,7 +97,8 @@ class ChangePasswordForm(Form): before making a change. """ password = PasswordField('Password', validators=[InputRequired()]) - password_again = PasswordField('Password (again)', validators=[InputRequired()]) + password_again = PasswordField( + 'Password (again)', validators=[InputRequired()]) def validate_password_again(self, field): """ diff --git a/flask_stormpath/templates/flask_stormpath/forgot.html b/flask_stormpath/templates/flask_stormpath/forgot.html index 120ae47..0470a24 100644 --- a/flask_stormpath/templates/flask_stormpath/forgot.html +++ b/flask_stormpath/templates/flask_stormpath/forgot.html @@ -41,7 +41,7 @@ - {% if config['stormpath']['web'][login]['enabled'] %} + {% if config['stormpath']['web']['login']['enabled'] %} Back to Log In {% endif %} diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index 055a863..bc7ac92 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -191,8 +191,8 @@ def forgot(): flash('Invalid email address.') return render_template( - current_app.config['STORMPATH_FORGOT_PASSWORD_TEMPLATE'], - form = form, + current_app.config['stormpath']['web']['forgotPassword']['template'], + form=form, ) From 3c065c81d80bf8c56b3d5551d024aa018557f5de Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Wed, 23 Mar 2016 15:07:24 +0100 Subject: [PATCH 08/19] fixed registration --- flask_stormpath/__init__.py | 10 ++++++---- flask_stormpath/views.py | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index 15ce557..412bd90 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -356,9 +356,10 @@ def client(self): # If the user is specifying their credentials via a file path, # we'll use this. - if self.app.config['stormpath']['apiKey']['file']: + if self.app.config['stormpath']['client']['apiKey']['file']: ctx.stormpath_client = Client( - api_key_file_location=self.app.config['stormpath']['apiKey']['file'], + api_key_file_location=self.app.config['stormpath'] + ['client']['apiKey']['file'], user_agent=user_agent, # FIXME: read cache from config # cache_options=self.app.config['STORMPATH_CACHE'], @@ -369,8 +370,9 @@ def client(self): # try to grab those values. else: ctx.stormpath_client = Client( - id=self.app.config['stormpath']['apiKey']['id'], - secret=self.app.config['stormpath']['apiKey']['secret'], + id=self.app.config['stormpath']['client']['apiKey']['id'], + secret=self.app.config['stormpath'] + ['client']['apiKey']['secret'], user_agent=user_agent, # FIXME: read cache from config # cache_options=self.app.config['STORMPATH_CACHE'], diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index bc7ac92..ce1838f 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -20,6 +20,7 @@ from flask.ext.login import login_user from six import string_types from stormpath.resources.provider import Provider +from stormpath.resources import Resource from . import StormpathError, logout_user from .forms import ( @@ -54,9 +55,10 @@ def register(): data = form.data for field in data.keys(): if current_app.config['stormpath']['web']['register']['form'][ - 'fields']['%s' % field.upper()]['enabled']: + 'fields'][Resource.to_camel_case(field)]['enabled']: if current_app.config['stormpath']['web']['register']['form'][ - '%s' % field.upper()]['required'] and not data[field]: + 'fields'][Resource.to_camel_case(field)]['required'] \ + and not data[field]: fail = True # Manually override the terms for first / last name to make From b325f9ab3ce1063e9267b7b812f446aafafb63ba Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Wed, 23 Mar 2016 15:40:00 +0100 Subject: [PATCH 09/19] change logout redirect based on settings --- flask_stormpath/context_processors.py | 1 - flask_stormpath/models.py | 12 ++++++------ flask_stormpath/views.py | 3 ++- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/flask_stormpath/context_processors.py b/flask_stormpath/context_processors.py index 4dea621..74ff356 100644 --- a/flask_stormpath/context_processors.py +++ b/flask_stormpath/context_processors.py @@ -1,7 +1,6 @@ """Custom context processors to make template development simpler.""" -from flask import current_app from flask.ext.login import _get_user diff --git a/flask_stormpath/models.py b/flask_stormpath/models.py index 43df24b..e0b7729 100644 --- a/flask_stormpath/models.py +++ b/flask_stormpath/models.py @@ -140,8 +140,8 @@ def from_google(self, code): a `StormpathError` (flask.ext.stormpath.StormpathError). """ _user = current_app.stormpath_manager.application.get_provider_account( - code = code, - provider = Provider.GOOGLE, + code=code, + provider=Provider.GOOGLE, ) _user.__class__ = User @@ -152,15 +152,15 @@ 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.ext.stormpath.StormpathError). """ _user = current_app.stormpath_manager.application.get_provider_account( - access_token = access_token, - provider = Provider.FACEBOOK, + access_token=access_token, + provider=Provider.FACEBOOK, ) _user.__class__ = User diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index ce1838f..f2b4304 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -425,4 +425,5 @@ def logout(): then redirect the user to the home page of the site. """ logout_user() - return redirect('/') + return redirect( + current_app.config['stormpath']['web']['logout']['nextUri']) From 1d99c01727fd81415a8fe77d9efa32d20c4b1d00 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Fri, 25 Mar 2016 14:32:43 +0100 Subject: [PATCH 10/19] misc work on verification flow --- flask_stormpath/__init__.py | 97 +++++++++++++---------- flask_stormpath/config/default-config.yml | 3 +- flask_stormpath/forms.py | 9 +++ flask_stormpath/settings.py | 1 + flask_stormpath/views.py | 46 ++++++++++- tests/test_settings.py | 26 ++++++ 6 files changed, 137 insertions(+), 45 deletions(-) diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index 412bd90..046c47c 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -45,7 +45,7 @@ from stormpath_config.strategies import ( LoadEnvConfigStrategy, LoadFileConfigStrategy, LoadAPIKeyConfigStrategy, LoadAPIKeyFromConfigStrategy, ValidateClientConfigStrategy, - MoveAPIKeyToClientAPIKeyStrategy, EnrichClientFromRemoteConfigStrategy, + EnrichClientFromRemoteConfigStrategy, # MoveAPIKeyToClientAPIKeyStrategy EnrichIntegrationFromRemoteConfigStrategy) from werkzeug.local import LocalProxy @@ -62,6 +62,7 @@ login, logout, register, + verify ) @@ -153,7 +154,7 @@ def init_settings(self, config): LoadEnvConfigStrategy(prefix='STORMPATH') ], post_processing_strategies=[ - LoadAPIKeyFromConfigStrategy(), MoveAPIKeyToClientAPIKeyStrategy() + LoadAPIKeyFromConfigStrategy(), # MoveAPIKeyToClientAPIKeyStrategy() ], validation_strategies=[ValidateClientConfigStrategy()]) config['stormpath'] = StormpathSettings(config_loader.load()) @@ -196,28 +197,47 @@ def init_settings(self, config): if key.startswith(config['stormpath'].STORMPATH_PREFIX) and \ key in config['stormpath']: config['stormpath'][key] = value + + # 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']['client']['apiKey']['file']: - stormpath_client = Client( - api_key_file_location=self.app.config['stormpath']['client']['apiKey']['file'], + self.stormpath_client = Client( + api_key_file_location=self.app.config['stormpath'] + ['client']['apiKey']['file'], + user_agent=user_agent, + # FIXME: read cache from config + # 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: - stormpath_client = Client( + self.stormpath_client = Client( id=self.app.config['stormpath']['client']['apiKey']['id'], - secret=self.app.config['stormpath']['client']['apiKey']['secret'], + secret=self.app.config['stormpath'] + ['client']['apiKey']['secret'], + user_agent=user_agent, + # FIXME: read cache from config + # cache_options=self.app.config['STORMPATH_CACHE'], ) ecfrcs = EnrichClientFromRemoteConfigStrategy( - client_factory=lambda client: stormpath_client) - ecfrcs.process(self.app.config['stormpath']) + client_factory=lambda client: self.stormpath_client) + ecfrcs.process(self.app.config['stormpath'].store) eifrcs = EnrichIntegrationFromRemoteConfigStrategy( - client_factory=lambda client: stormpath_client) - eifrcs.process(self.app.config['stormpath']) + client_factory=lambda client: self.stormpath_client) + eifrcs.process(self.app.config['stormpath'].store) + # import pprint + # pprint.PrettyPrinter(indent=2).pprint(self.app.config['stormpath'].store) + + self.stormpath_application = self.stormpath_client.applications.get( + self.app.config['stormpath']['application']['href']) def check_settings(self, config): """ @@ -248,6 +268,25 @@ def check_settings(self, config): # ]): # raise ConfigurationError('You must define your Facebook app settings.') + if not all([ + config['stormpath']['web']['register']['enabled'], + self.stormpath_application.default_account_store_mapping]): + raise ConfigurationError( + "No default account store is mapped to the specified " + "application. A default account store is required for " + "registration.") + + if all([config['stormpath']['web']['register']['autoLogin'], + config['stormpath']['web']['verifyEmail']['enabled']]): + raise ConfigurationError( + "Invalid configuration: stormpath.web.register.autoLogin " + "is true, but the default account store of the " + "specified application has the email verification " + "workflow enabled. Auto login is only possible if email " + "verification is disabled. " + "Please disable this workflow on this application's default " + "account store.") + if config['STORMPATH_COOKIE_DOMAIN'] and not isinstance(config['STORMPATH_COOKIE_DOMAIN'], str): raise ConfigurationError('STORMPATH_COOKIE_DOMAIN must be a string.') @@ -324,6 +363,13 @@ def init_routes(self, app): logout, ) + if app.config['stormpath']['web']['verifyEmail']['enabled']: + app.add_url_rule( + app.config['stormpath']['web']['verifyEmail']['uri'], + 'stormpath.verify', + verify, + ) + # FIXME: enable this in init_settings # if app.config['STORMPATH_ENABLE_GOOGLE']: # app.add_url_rule( @@ -348,36 +394,7 @@ def client(self): 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']['client']['apiKey']['file']: - ctx.stormpath_client = Client( - api_key_file_location=self.app.config['stormpath'] - ['client']['apiKey']['file'], - user_agent=user_agent, - # FIXME: read cache from config - # 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']['client']['apiKey']['id'], - secret=self.app.config['stormpath'] - ['client']['apiKey']['secret'], - user_agent=user_agent, - # FIXME: read cache from config - # cache_options=self.app.config['STORMPATH_CACHE'], - ) - + return self.stormpath_client return ctx.stormpath_client @property diff --git a/flask_stormpath/config/default-config.yml b/flask_stormpath/config/default-config.yml index 2d93503..2e3a9e5 100644 --- a/flask_stormpath/config/default-config.yml +++ b/flask_stormpath/config/default-config.yml @@ -137,7 +137,7 @@ web: enabled: null uri: "/verify" nextUri: "/login" - view: "verify" + template: "flask_stormpath/verify.html" login: enabled: true @@ -198,7 +198,6 @@ web: 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 diff --git a/flask_stormpath/forms.py b/flask_stormpath/forms.py index 5282226..5d57153 100644 --- a/flask_stormpath/forms.py +++ b/flask_stormpath/forms.py @@ -108,3 +108,12 @@ def validate_password_again(self, field): """ if self.password.data != field.data: raise ValidationError("Passwords don't match.") + + +class VerificationForm(Form): + """ + Verify a user's email. + + This class is used to Verify a user's email address + """ + email = StringField('Email', validators=[InputRequired()]) diff --git a/flask_stormpath/settings.py b/flask_stormpath/settings.py index 91cfe4a..d3a2d4b 100644 --- a/flask_stormpath/settings.py +++ b/flask_stormpath/settings.py @@ -1,6 +1,7 @@ """Helper functions for dealing with Flask-Stormpath settings.""" import collections +import json class StormpathSettings(collections.MutableMapping): diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index f2b4304..f701484 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -28,6 +28,7 @@ ForgotPasswordForm, LoginForm, RegistrationForm, + VerificationForm ) from .models import User @@ -210,7 +211,8 @@ def forgot_change(): 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')) + account = current_app.stormpath_manager.application.verify_password_reset_token( + request.args.get('sptoken')) except StormpathError as err: abort(400) @@ -241,8 +243,8 @@ def forgot_change(): flash("Passwords don't match.") return render_template( - current_app.config['STORMPATH_FORGOT_PASSWORD_CHANGE_TEMPLATE'], - form = form, + current_app.config['web']['changePassword']['template'], + form=form, ) @@ -427,3 +429,41 @@ def logout(): logout_user() return redirect( current_app.config['stormpath']['web']['logout']['nextUri']) + + +def verify(): + """ + Log in an existing Stormpath user. + + This view will render a login template, then redirect the user to the next + page (if authentication is successful). + + The fields that are asked for, 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 = VerificationForm() + + 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 login nextUri setting. + login_user(account, remember=True) + + return redirect( + request.args.get('next') or + current_app.config['stormpath']['web']['login']['nextUri']) + + except StormpathError as err: + flash(err.message.get('message')) + + return render_template( + current_app.config['stormpath']['web']['login']['template'], + form=form, + ) diff --git a/tests/test_settings.py b/tests/test_settings.py index 12d4694..4034908 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -271,6 +271,32 @@ def test_cookie_settings(self): self.app.config['STORMPATH_COOKIE_DURATION'] = timedelta(minutes=1) check_settings(self.app.config) + def test_verify_email_autologin(self): + # stormpath.web.register.autoLogin is true, but the default account + # store of the specified application has the email verification + # workflow enabled. Auto login is only possible if email verification + # is disabled + self.app.config['stormpath']['verifyEmail']['enabled'] = True + self.app.config['stormpath']['register']['autoLogin'] = True + self.assertRaises(ConfigurationError, check_settings, self.app.config) + + # Now that we've configured things properly, it should work. + self.app.config['stormpath']['register']['autoLogin'] = True + check_settings(self.app.config) + + def test_register_default_account_store(self): + # stormpath.web.register.autoLogin is true, but the default account + # store of the specified application has the email verification + # workflow enabled. Auto login is only possible if email verification + # is disabled + self.app.config['stormpath']['verifyEmail']['enabled'] = True + self.app.config['stormpath']['register']['autoLogin'] = True + self.assertRaises(ConfigurationError, check_settings, self.app.config) + + # Now that we've configured things properly, it should work. + self.app.config['stormpath']['register']['autoLogin'] = True + check_settings(self.app.config) + def tearDown(self): """Remove our apiKey.properties file.""" super(TestCheckSettings, self).tearDown() From 1998fb781755de7d093a9e96c955ee77aa802028 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Wed, 30 Mar 2016 10:17:00 +0200 Subject: [PATCH 11/19] first stab at me view --- flask_stormpath/__init__.py | 20 ++++++--- flask_stormpath/views.py | 86 +++++++++++++++++++++---------------- 2 files changed, 62 insertions(+), 44 deletions(-) diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index 046c47c..375dc6d 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -37,8 +37,9 @@ _get_user, login_required, login_user, - logout_user, + logout_user ) + from stormpath.client import Client from stormpath.error import Error as StormpathError from stormpath_config.loader import ConfigLoader @@ -62,7 +63,7 @@ login, logout, register, - verify + me ) @@ -363,13 +364,20 @@ def init_routes(self, app): logout, ) - if app.config['stormpath']['web']['verifyEmail']['enabled']: + if app.config['stormpath']['web']['me']['enabled']: app.add_url_rule( - app.config['stormpath']['web']['verifyEmail']['uri'], - 'stormpath.verify', - verify, + app.config['stormpath']['web']['me']['uri'], + 'stormpath.me', + me, ) + # if app.config['stormpath']['web']['verifyEmail']['enabled']: + # app.add_url_rule( + # app.config['stormpath']['web']['verifyEmail']['uri'], + # 'stormpath.verify', + # verify, + # ) + # FIXME: enable this in init_settings # if app.config['STORMPATH_ENABLE_GOOGLE']: # app.add_url_rule( diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index f701484..384b6a0 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -17,10 +17,10 @@ render_template, request, ) -from flask.ext.login import login_user +from flask.ext.login import login_user, login_required, current_user from six import string_types from stormpath.resources.provider import Provider -from stormpath.resources import Resource +from stormpath.resources import Resource, Expansion from . import StormpathError, logout_user from .forms import ( @@ -431,39 +431,49 @@ def logout(): current_app.config['stormpath']['web']['logout']['nextUri']) -def verify(): - """ - Log in an existing Stormpath user. - - This view will render a login template, then redirect the user to the next - page (if authentication is successful). - - The fields that are asked for, 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 = VerificationForm() - - 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 login nextUri setting. - login_user(account, remember=True) - - return redirect( - request.args.get('next') or - current_app.config['stormpath']['web']['login']['nextUri']) - - except StormpathError as err: - flash(err.message.get('message')) - - return render_template( - current_app.config['stormpath']['web']['login']['template'], - form=form, - ) +@login_required +def me(): + expansion = Expansion() + expanded_attrs = [] + for attr, flag in current_app.config['stormpath']['web']['me']['expand'].items(): + if flag: + expansion.add_property(Resource.from_camel_case(attr)) + expanded_attrs.append(attr) + if expansion.items: + current_user._expand = expansion + current_user.refresh() + + import json + import datetime + from isodate import duration_isoformat + + user_data = {} + for user_attr_name in dir(current_user): + user_attr = getattr(current_user, user_attr_name) + if user_attr: + if user_attr_name in expanded_attrs: + user_data[user_attr_name] = {} + for attr_name in dir(user_attr): + attr = getattr(user_attr, attr_name) + if not isinstance(attr, Resource) and attr: + # FIXME: handle datetimes + print attr_name, type(attr) + if isinstance(attr, datetime.datetime): + continue + if attr_name in user_attr.timedelta_attrs and \ + isinstance(attr, datetime.timedelta): + attr = duration_isoformat(attr) + user_data[user_attr_name][ + Resource.to_camel_case(attr_name)] = attr + elif not isinstance(user_attr, Resource) and user_attr: + # FIXME: handle datetimes + if isinstance(user_attr, datetime.datetime): + continue + if (user_attr_name in current_user.timedelta_attrs and + isinstance(user_attr, datetime.timedelta)) or \ + isinstance(user_attr, datetime.datetime): + attr = duration_isoformat(attr) + user_data[ + Resource.to_camel_case(user_attr_name)] = user_attr + + return json.dumps({'account': user_data}), 200, {'Content-Type': 'application/json'} From 666f8b85edc2246e3102d5757601f05da243ab85 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Wed, 13 Apr 2016 11:28:55 +0200 Subject: [PATCH 12/19] don't lazily load stormpath client and application since it's already loaded during strategy enriching --- flask_stormpath/__init__.py | 39 ++++++------------------------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index 375dc6d..341e8ee 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -207,7 +207,7 @@ def init_settings(self, config): # If the user is specifying their credentials via a file path, # we'll use this. if self.app.config['stormpath']['client']['apiKey']['file']: - self.stormpath_client = Client( + self.client = Client( api_key_file_location=self.app.config['stormpath'] ['client']['apiKey']['file'], user_agent=user_agent, @@ -219,7 +219,7 @@ def init_settings(self, config): # path, it means they're using environment variables, so we'll # try to grab those values. else: - self.stormpath_client = Client( + self.client = Client( id=self.app.config['stormpath']['client']['apiKey']['id'], secret=self.app.config['stormpath'] ['client']['apiKey']['secret'], @@ -229,15 +229,15 @@ def init_settings(self, config): ) ecfrcs = EnrichClientFromRemoteConfigStrategy( - client_factory=lambda client: self.stormpath_client) + client_factory=lambda client: self.client) ecfrcs.process(self.app.config['stormpath'].store) eifrcs = EnrichIntegrationFromRemoteConfigStrategy( - client_factory=lambda client: self.stormpath_client) + client_factory=lambda client: self.client) eifrcs.process(self.app.config['stormpath'].store) # import pprint # pprint.PrettyPrinter(indent=2).pprint(self.app.config['stormpath'].store) - self.stormpath_application = self.stormpath_client.applications.get( + self.application = self.client.applications.get( self.app.config['stormpath']['application']['href']) def check_settings(self, config): @@ -271,7 +271,7 @@ def check_settings(self, config): if not all([ config['stormpath']['web']['register']['enabled'], - self.stormpath_application.default_account_store_mapping]): + self.application.default_account_store_mapping]): raise ConfigurationError( "No default account store is mapped to the specified " "application. A default account store is required for " @@ -393,18 +393,6 @@ def init_routes(self, app): # facebook_login, # ) - @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'): - return self.stormpath_client - return ctx.stormpath_client - @property def login_view(self): """ @@ -420,21 +408,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'): - ctx.stormpath_application = self.client.applications.search( - self.app.config['stormpath']['application']['name'] - )[0] - - return ctx.stormpath_application - @staticmethod def load_user(account_href): """ From 34ad692300361005d57c98922ce10f470747eeb7 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Wed, 13 Apr 2016 11:39:49 +0200 Subject: [PATCH 13/19] moving imports to a more appropriate location --- flask_stormpath/views.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index 384b6a0..4a06301 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -1,6 +1,9 @@ """Our pluggable views.""" import sys +import json +import datetime +from isodate import duration_isoformat if sys.version_info.major == 3: FACEBOOK = False @@ -443,10 +446,6 @@ def me(): current_user._expand = expansion current_user.refresh() - import json - import datetime - from isodate import duration_isoformat - user_data = {} for user_attr_name in dir(current_user): user_attr = getattr(current_user, user_attr_name) From 673a18906977de34f315e7846cf58fb8cc3d3f76 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Wed, 13 Apr 2016 17:16:24 +0200 Subject: [PATCH 14/19] moved json representation login to stormpath python sdk --- flask_stormpath/views.py | 38 ++------------------------------------ 1 file changed, 2 insertions(+), 36 deletions(-) diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index 4a06301..7a98ef9 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -1,9 +1,6 @@ """Our pluggable views.""" import sys -import json -import datetime -from isodate import duration_isoformat if sys.version_info.major == 3: FACEBOOK = False @@ -437,42 +434,11 @@ def logout(): @login_required def me(): expansion = Expansion() - expanded_attrs = [] for attr, flag in current_app.config['stormpath']['web']['me']['expand'].items(): if flag: - expansion.add_property(Resource.from_camel_case(attr)) - expanded_attrs.append(attr) + expansion.add_property(attr) if expansion.items: current_user._expand = expansion current_user.refresh() - user_data = {} - for user_attr_name in dir(current_user): - user_attr = getattr(current_user, user_attr_name) - if user_attr: - if user_attr_name in expanded_attrs: - user_data[user_attr_name] = {} - for attr_name in dir(user_attr): - attr = getattr(user_attr, attr_name) - if not isinstance(attr, Resource) and attr: - # FIXME: handle datetimes - print attr_name, type(attr) - if isinstance(attr, datetime.datetime): - continue - if attr_name in user_attr.timedelta_attrs and \ - isinstance(attr, datetime.timedelta): - attr = duration_isoformat(attr) - user_data[user_attr_name][ - Resource.to_camel_case(attr_name)] = attr - elif not isinstance(user_attr, Resource) and user_attr: - # FIXME: handle datetimes - if isinstance(user_attr, datetime.datetime): - continue - if (user_attr_name in current_user.timedelta_attrs and - isinstance(user_attr, datetime.timedelta)) or \ - isinstance(user_attr, datetime.datetime): - attr = duration_isoformat(attr) - user_data[ - Resource.to_camel_case(user_attr_name)] = user_attr - - return json.dumps({'account': user_data}), 200, {'Content-Type': 'application/json'} + return current_user.to_json(), 200, {'Content-Type': 'application/json'} From e756b4ed03731e43c51d8ea62575bf2c2f8059e0 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Mon, 25 Apr 2016 12:08:51 +0200 Subject: [PATCH 15/19] json vs html response basic functionality --- flask_stormpath/__init__.py | 65 ++++++++++++++++++------------------- flask_stormpath/forms.py | 56 ++++++++++++++++++++++++++++++++ flask_stormpath/views.py | 43 +++++++++++++++++------- 3 files changed, 119 insertions(+), 45 deletions(-) diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index 341e8ee..1ab65de 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -234,8 +234,6 @@ def init_settings(self, config): eifrcs = EnrichIntegrationFromRemoteConfigStrategy( client_factory=lambda client: self.client) eifrcs.process(self.app.config['stormpath'].store) - # import pprint - # pprint.PrettyPrinter(indent=2).pprint(self.app.config['stormpath'].store) self.application = self.client.applications.get( self.app.config['stormpath']['application']['href']) @@ -249,25 +247,25 @@ def check_settings(self, config): :param dict config: The Flask app config. """ - # FIXME: this needs to be uncommented based on settings in init_settings - # 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_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 not all([ config['stormpath']['web']['register']['enabled'], @@ -378,20 +376,19 @@ def init_routes(self, app): # verify, # ) - # FIXME: enable this in init_settings - # if app.config['STORMPATH_ENABLE_GOOGLE']: - # app.add_url_rule( - # app.config['STORMPATH_GOOGLE_LOGIN_URL'], - # 'stormpath.google_login', - # google_login, - # ) + if app.config['STORMPATH_ENABLE_GOOGLE']: + app.add_url_rule( + app.config['STORMPATH_GOOGLE_LOGIN_URL'], + 'stormpath.google_login', + google_login, + ) - # if app.config['STORMPATH_ENABLE_FACEBOOK']: - # app.add_url_rule( - # app.config['STORMPATH_FACEBOOK_LOGIN_URL'], - # 'stormpath.facebook_login', - # facebook_login, - # ) + if app.config['STORMPATH_ENABLE_FACEBOOK']: + app.add_url_rule( + app.config['STORMPATH_FACEBOOK_LOGIN_URL'], + 'stormpath.facebook_login', + facebook_login, + ) @property def login_view(self): diff --git a/flask_stormpath/forms.py b/flask_stormpath/forms.py index 5d57153..6fadee6 100644 --- a/flask_stormpath/forms.py +++ b/flask_stormpath/forms.py @@ -1,14 +1,27 @@ """Helper forms which make handling common operations simpler.""" +import json +from collections import OrderedDict + from flask import current_app from flask.ext.wtf import Form +from flask.ext.login import current_user from wtforms.fields import PasswordField, StringField from wtforms.validators import InputRequired, ValidationError from stormpath.resources import Resource class StormpathForm(Form): + def __init__(self, config, *args, **kwargs): + self._json = OrderedDict({ + 'form': { + 'fields': [] + }, + 'account_stores': [] + }) + self.set_account_store() + super(StormpathForm, self).__init__(*args, **kwargs) field_list = config['fields'] field_order = config['fieldOrder'] @@ -16,24 +29,67 @@ def __init__(self, config, *args, **kwargs): for field in field_order: if field_list[field]['enabled']: validators = [] + json_field = {'name': field} + if field_list[field]['required']: validators.append(InputRequired()) + json_field['required'] = field_list[field]['required'] + if field_list[field]['type'] == 'password': field_class = PasswordField else: field_class = StringField + json_field['type'] = field_list[field]['type'] + 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'] + placeholder = field_list[field]['placeholder'] + json_field['placeholder'] = placeholder + + self._json['form']['fields'].append(json_field) + setattr( self.__class__, Resource.from_camel_case(field), field_class( label, validators=validators, render_kw={"placeholder": placeholder})) + @property + def json(self): + return json.dumps(self._json) + + @property + def account_stores(self): + return self.json['account_stores'] + + def set_account_store(self): + for account_store_mapping in current_app.stormpath_manager.application. \ + account_store_mappings: + account_store = { + 'href': account_store_mapping.account_store.href, + 'name': account_store_mapping.account_store.name, + } + + provider = { + 'href': account_store_mapping.account_store.provider.href, + 'provider_id': account_store_mapping.account_store.provider.provider_id, + } + if hasattr( + account_store_mapping.account_store.provider, 'client_id'): + provider['client_id'] = account_store_mapping.account_store.\ + provider.client_id + provider_web = current_app.config['stormpath']['web']['social'].\ + get(account_store_mapping.account_store.provider.provider_id) + if provider_web: + provider['scope'] = provider_web.get('scope') + account_store['provider'] = provider + self._json['account_stores'].append(account_store) + class RegistrationForm(StormpathForm): """ diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index 7a98ef9..983fc16 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -16,6 +16,7 @@ redirect, render_template, request, + make_response ) from flask.ext.login import login_user, login_required, current_user from six import string_types @@ -33,6 +34,26 @@ from .models import User +def make_stormpath_response(data, template=None): + if request_wants_json(): + stormpath_response = make_response(data['form'].json, 200) + stormpath_response.mimetype = 'application/json' + else: + stormpath_response = render_template( + current_app.config['stormpath']['web']['login']['template'], + **data) + return stormpath_response + + +def request_wants_json(): + best = request.accept_mimetypes \ + .best_match(current_app.config['stormpath']['web']['produces']) + if best is None and current_app.config['stormpath']['web']['produces']: + best = current_app.config['stormpath']['web']['produces'][0] + + return best == 'application/json' + + def register(): """ Register a new user with Stormpath. @@ -107,10 +128,9 @@ def register(): except StormpathError as err: flash(err.message.get('message')) - return render_template( - current_app.config['stormpath']['web']['register']['template'], - form=form, - ) + return make_stormpath_response( + template=current_app.config['stormpath']['web']['register']['template'], + data={'form': form}) def login(): @@ -145,12 +165,11 @@ def login(): current_app.config['stormpath']['web']['login']['nextUri']) except StormpathError as err: - flash(err.message.get('message')) + flash(err.message) - return render_template( - current_app.config['stormpath']['web']['login']['template'], - form=form, - ) + return make_stormpath_response( + template=current_app.config['stormpath']['web']['login']['template'], + data={'form': form}) def forgot(): @@ -338,7 +357,8 @@ def facebook_login(): # 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']) + return redirect(request.args.get('next') or + current_app.config['stormpath']['web']['login']['nextUri']) def google_login(): @@ -416,7 +436,8 @@ def google_login(): # 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']) + return redirect(request.args.get('next') or + current_app.config['stormpath']['web']['login']['nextUri']) def logout(): From e87aebecfa7de0b16fb6108c428fe3d3cf0994ff Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Mon, 25 Apr 2016 15:02:53 +0200 Subject: [PATCH 16/19] error handling for login and register --- flask_stormpath/forms.py | 1 - flask_stormpath/views.py | 33 ++++++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/flask_stormpath/forms.py b/flask_stormpath/forms.py index 6fadee6..fe1adea 100644 --- a/flask_stormpath/forms.py +++ b/flask_stormpath/forms.py @@ -5,7 +5,6 @@ from flask import current_app from flask.ext.wtf import Form -from flask.ext.login import current_user from wtforms.fields import PasswordField, StringField from wtforms.validators import InputRequired, ValidationError from stormpath.resources import Resource diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index 983fc16..25c523d 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -1,6 +1,7 @@ """Our pluggable views.""" import sys +import json if sys.version_info.major == 3: FACEBOOK = False @@ -34,9 +35,9 @@ from .models import User -def make_stormpath_response(data, template=None): - if request_wants_json(): - stormpath_response = make_response(data['form'].json, 200) +def make_stormpath_response(data, template=None, return_json=True): + if return_json: + stormpath_response = make_response(data, 200) stormpath_response.mimetype = 'application/json' else: stormpath_response = render_template( @@ -65,7 +66,7 @@ def register(): template that is used to render this page can all be controlled via Flask-Stormpath settings. """ - form = RegistrationForm() + form = RegistrationForm(csrf_enabled=False) # If we received a POST request with valid information, we'll continue # processing. @@ -126,11 +127,17 @@ def register(): return redirect(redirect_url) except StormpathError as err: + if request_wants_json(): + return make_stormpath_response( + json.dumps({ + 'error': err.status if err.status else 400, + 'message': err.user_message + })) flash(err.message.get('message')) return make_stormpath_response( template=current_app.config['stormpath']['web']['register']['template'], - data={'form': form}) + data={'form': form}, return_json=False) def login(): @@ -144,7 +151,7 @@ def login(): template that is used to render this page can all be controlled via Flask-Stormpath settings. """ - form = LoginForm() + form = LoginForm(csrf_enabled=False) # If we received a POST request with valid information, we'll continue # processing. @@ -160,16 +167,28 @@ def login(): # query parameter, or the Stormpath login nextUri setting. login_user(account, remember=True) + if request_wants_json(): + return make_stormpath_response(data=current_user.to_json()) + return redirect( request.args.get('next') or current_app.config['stormpath']['web']['login']['nextUri']) except StormpathError as err: + if request_wants_json(): + return make_stormpath_response( + json.dumps({ + 'error': err.status if err.status else 400, + 'message': err.user_message + })) flash(err.message) + if request_wants_json(): + return make_stormpath_response(data=form.json) + return make_stormpath_response( template=current_app.config['stormpath']['web']['login']['template'], - data={'form': form}) + data={'form': form}, return_json=False) def forgot(): From 4d140efd9db6027ea0c7da4cb5137ed9adf6c566 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Wed, 27 Apr 2016 12:34:23 +0200 Subject: [PATCH 17/19] registration via json --- flask_stormpath/forms.py | 8 +++++-- flask_stormpath/models.py | 3 ++- flask_stormpath/views.py | 47 ++++++++++++++++++++++++++++++--------- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/flask_stormpath/forms.py b/flask_stormpath/forms.py index fe1adea..4d73803 100644 --- a/flask_stormpath/forms.py +++ b/flask_stormpath/forms.py @@ -6,7 +6,7 @@ from flask import current_app from flask.ext.wtf import Form from wtforms.fields import PasswordField, StringField -from wtforms.validators import InputRequired, ValidationError +from wtforms.validators import InputRequired, ValidationError, EqualTo from stormpath.resources import Resource @@ -28,7 +28,7 @@ def __init__(self, config, *args, **kwargs): for field in field_order: if field_list[field]['enabled']: validators = [] - json_field = {'name': field} + json_field = {'name': Resource.from_camel_case(field)} if field_list[field]['required']: validators.append(InputRequired()) @@ -50,6 +50,10 @@ def __init__(self, config, *args, **kwargs): placeholder = field_list[field]['placeholder'] json_field['placeholder'] = placeholder + if field == 'confirmPassword': + validators.append( + EqualTo('password', message='Passwords must match')) + self._json['form']['fields'].append(json_field) setattr( diff --git a/flask_stormpath/models.py b/flask_stormpath/models.py index e0b7729..a6b8771 100644 --- a/flask_stormpath/models.py +++ b/flask_stormpath/models.py @@ -71,7 +71,8 @@ def delete(self): return return_value @classmethod - def create(self, 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. diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index 25c523d..9dbf274 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -51,7 +51,6 @@ def request_wants_json(): .best_match(current_app.config['stormpath']['web']['produces']) if best is None and current_app.config['stormpath']['web']['produces']: best = current_app.config['stormpath']['web']['produces'][0] - return best == 'application/json' @@ -71,6 +70,21 @@ def register(): # If we received a POST request with valid information, we'll continue # processing. if form.validate_on_submit(): + given_name_enabled = current_app.config['stormpath']['web'] \ + ['register']['form']['fields']['givenName']['enabled'] + given_name_required = current_app.config['stormpath']['web'] \ + ['register']['form']['fields']['givenName']['required'] + username_enabled = current_app.config['stormpath']['web'] \ + ['register']['form']['fields']['username'] + username_required = current_app.config['stormpath']['web'] \ + ['register']['form']['fields']['username'] + + if 'username' not in form.data: + if not username_enabled or not username_required: + form.data['username'] = 'UNKNOWN' + if 'given_name' not in form.data: + if not given_name_enabled or not given_name_required: + form.data['given_name'] = 'UNKNOWN' fail = False # Iterate through all fields, grabbing the necessary form data and @@ -99,14 +113,9 @@ def register(): # Attempt to create the user's account on Stormpath. try: - - # 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['given_name'] = data['given_name'] or 'Anonymous' - data['surname'] = data['surname'] or 'Anonymous' - + # Remove the confirmation password so it won't cause an error + if 'confirm_password' in data: + data.pop('confirm_password') # Create the user account on Stormpath. If this fails, an # exception will be raised. account = User.create(**data) @@ -117,6 +126,12 @@ def register(): # Stormpath login nextUri setting. login_user(account, remember=True) + if request_wants_json(): + account_data = { + 'account': json.loads(account.to_json())} + return make_stormpath_response( + data=json.dumps(account_data)) + redirect_url = current_app.config[ 'stormpath']['web']['register']['nextUri'] if not redirect_url: @@ -130,11 +145,19 @@ def register(): if request_wants_json(): return make_stormpath_response( json.dumps({ - 'error': err.status if err.status else 400, + 'status': err.status if err.status else 400, 'message': err.user_message })) flash(err.message.get('message')) + if request_wants_json(): + if form.errors: + return make_stormpath_response( + data=json.dumps({ + 'status': 400, + 'message': form.errors})) + return make_stormpath_response(data=form.json) + return make_stormpath_response( template=current_app.config['stormpath']['web']['register']['template'], data={'form': form}, return_json=False) @@ -168,7 +191,9 @@ def login(): login_user(account, remember=True) if request_wants_json(): - return make_stormpath_response(data=current_user.to_json()) + account_data = {'account': json.loads(current_user.to_json())} + return make_stormpath_response( + data={'account': account_data}) return redirect( request.args.get('next') or From df7957376d55e9447d1b5950f00b58fa0bd90d29 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Wed, 27 Apr 2016 14:40:05 +0200 Subject: [PATCH 18/19] autologin if verify --- flask_stormpath/views.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index 9dbf274..d74c4d2 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -123,8 +123,11 @@ def register(): # 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. - login_user(account, remember=True) + # Stormpath login nextUri setting but only if autoLogin. + if (current_app.config['stormpath']['web']['register'] + ['autoLogin'] and not current_app.config['stormpath'] + ['web']['register']['verifyEmail']['enabled']): + login_user(account, remember=True) if request_wants_json(): account_data = { @@ -506,4 +509,4 @@ def me(): current_user._expand = expansion current_user.refresh() - return current_user.to_json(), 200, {'Content-Type': 'application/json'} + return make_stormpath_response(current_user.to_json()) From 33987f5ed6983af4aa2c4893beb495dd1c6109b5 Mon Sep 17 00:00:00 2001 From: Goran Cetusic Date: Wed, 27 Apr 2016 15:57:50 +0200 Subject: [PATCH 19/19] basePath setting taken into account --- flask_stormpath/__init__.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index 1ab65de..45e825f 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -325,9 +325,16 @@ def init_routes(self, app): :param obj app: The Flask app. """ + 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']['web']['register']['uri'], + os.path.join( + base_path, + app.config['stormpath']['web']['register']['uri'].strip('/')), 'stormpath.register', register, methods=['GET', 'POST'], @@ -335,7 +342,8 @@ def init_routes(self, app): if app.config['stormpath']['web']['login']['enabled']: app.add_url_rule( - app.config['stormpath']['web']['login']['uri'], + os.path.join( + base_path, app.config['stormpath']['web']['login']['uri'].strip('/')), 'stormpath.login', login, methods=['GET', 'POST'], @@ -343,13 +351,17 @@ def init_routes(self, app): if app.config['stormpath']['web']['forgotPassword']['enabled']: app.add_url_rule( - app.config['stormpath']['web']['forgotPassword']['uri'], + os.path.join( + base_path, + app.config['stormpath']['web']['forgotPassword']['uri'].strip('/')), 'stormpath.forgot', forgot, methods=['GET', 'POST'], ) app.add_url_rule( - app.config['stormpath']['web']['changePassword']['uri'], + os.path.join( + base_path, + app.config['stormpath']['web']['changePassword']['uri'].strip('/')), 'stormpath.forgot_change', forgot_change, methods=['GET', 'POST'], @@ -357,14 +369,18 @@ def init_routes(self, app): if app.config['stormpath']['web']['logout']['enabled']: app.add_url_rule( - app.config['stormpath']['web']['logout']['uri'], + os.path.join( + base_path, + app.config['stormpath']['web']['logout']['uri'].strip('/')), 'stormpath.logout', logout, ) if app.config['stormpath']['web']['me']['enabled']: app.add_url_rule( - app.config['stormpath']['web']['me']['uri'], + os.path.join( + base_path, + app.config['stormpath']['web']['me']['uri'].strip('/')), 'stormpath.me', me, ) @@ -378,14 +394,16 @@ def init_routes(self, app): if app.config['STORMPATH_ENABLE_GOOGLE']: app.add_url_rule( - app.config['STORMPATH_GOOGLE_LOGIN_URL'], + os.path.join( + base_path, app.config['STORMPATH_GOOGLE_LOGIN_URL']), 'stormpath.google_login', google_login, ) if app.config['STORMPATH_ENABLE_FACEBOOK']: app.add_url_rule( - app.config['STORMPATH_FACEBOOK_LOGIN_URL'], + os.path.join( + base_path, app.config['STORMPATH_FACEBOOK_LOGIN_URL']), 'stormpath.facebook_login', facebook_login, )