diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index a26b535..45e825f 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -21,6 +21,8 @@ __license__ = 'Apache' __copyright__ = '(c) 2012 - 2015 Stormpath, Inc.' +import os +from datetime import timedelta from flask import ( Blueprint, @@ -35,18 +37,24 @@ _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 +from stormpath_config.strategies import ( + LoadEnvConfigStrategy, LoadFileConfigStrategy, LoadAPIKeyConfigStrategy, + LoadAPIKeyFromConfigStrategy, ValidateClientConfigStrategy, + EnrichClientFromRemoteConfigStrategy, # MoveAPIKeyToClientAPIKeyStrategy + EnrichIntegrationFromRemoteConfigStrategy) 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 .settings import StormpathSettings +from .errors import ConfigurationError from .views import ( google_login, facebook_login, @@ -55,6 +63,7 @@ login, logout, register, + me ) @@ -97,11 +106,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) @@ -110,7 +119,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 @@ -122,6 +132,166 @@ 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()) + + # Which fields should be displayed when registering new users? + config.setdefault('STORMPATH_ENABLE_FACEBOOK', False) + config.setdefault('STORMPATH_ENABLE_GOOGLE', False) + config.setdefault('STORMPATH_ENABLE_EMAIL', True) # If this is diabled, + # only social login can + # be used. + + # Configure URL mappings. These URL mappings control which URLs will be + # used by Flask-Stormpath views. + config.setdefault('STORMPATH_GOOGLE_LOGIN_URL', '/google') + config.setdefault('STORMPATH_FACEBOOK_LOGIN_URL', '/facebook') + + # Cache configuration. + # 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_FORGOT_PASSWORD_EMAIL_SENT_TEMPLATE', 'flask_stormpath/forgot_email_sent.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. + 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) 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']: + self.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: + self.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'], + ) + + ecfrcs = EnrichClientFromRemoteConfigStrategy( + client_factory=lambda client: self.client) + ecfrcs.process(self.app.config['stormpath'].store) + eifrcs = EnrichIntegrationFromRemoteConfigStrategy( + client_factory=lambda client: self.client) + eifrcs.process(self.app.config['stormpath'].store) + + self.application = self.client.applications.get( + self.app.config['stormpath']['application']['href']) + + def 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. + """ + + 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'], + 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 " + "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.') + + 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. @@ -138,7 +308,7 @@ def init_login(self, 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. @@ -155,93 +325,89 @@ def init_routes(self, app): :param obj app: The Flask app. """ - if app.config['STORMPATH_ENABLE_REGISTRATION']: + if app.config['stormpath']['web']['basePath']: + base_path = app.config['stormpath']['web']['basePath'] + else: + base_path = '/' + + if app.config['stormpath']['web']['register']['enabled']: app.add_url_rule( - app.config['STORMPATH_REGISTRATION_URL'], + os.path.join( + base_path, + app.config['stormpath']['web']['register']['uri'].strip('/')), 'stormpath.register', register, - methods = ['GET', 'POST'], + methods=['GET', 'POST'], ) - if app.config['STORMPATH_ENABLE_LOGIN']: + if app.config['stormpath']['web']['login']['enabled']: app.add_url_rule( - app.config['STORMPATH_LOGIN_URL'], + os.path.join( + base_path, app.config['stormpath']['web']['login']['uri'].strip('/')), 'stormpath.login', login, - methods = ['GET', 'POST'], + methods=['GET', 'POST'], ) - if app.config['STORMPATH_ENABLE_FORGOT_PASSWORD']: + if app.config['stormpath']['web']['forgotPassword']['enabled']: app.add_url_rule( - app.config['STORMPATH_FORGOT_PASSWORD_URL'], + os.path.join( + base_path, + app.config['stormpath']['web']['forgotPassword']['uri'].strip('/')), 'stormpath.forgot', forgot, - methods = ['GET', 'POST'], + methods=['GET', 'POST'], ) app.add_url_rule( - app.config['STORMPATH_FORGOT_PASSWORD_CHANGE_URL'], + os.path.join( + base_path, + app.config['stormpath']['web']['changePassword']['uri'].strip('/')), 'stormpath.forgot_change', forgot_change, - methods = ['GET', 'POST'], + 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'], + 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( + os.path.join( + base_path, + app.config['stormpath']['web']['me']['uri'].strip('/')), + 'stormpath.me', + me, + ) + + # if app.config['stormpath']['web']['verifyEmail']['enabled']: + # app.add_url_rule( + # app.config['stormpath']['web']['verifyEmail']['uri'], + # 'stormpath.verify', + # verify, + # ) + 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, ) - @property - def client(self): - """ - Lazily load the Stormpath Client object we need to access the raw - Stormpath SDK. - """ - ctx = stack.top.app - if ctx is not None: - if not hasattr(ctx, 'stormpath_client'): - - # Create our custom user agent. This allows us to see which - # version of this SDK are out in the wild! - user_agent = 'stormpath-flask/%s flask/%s' % (__version__, flask_version) - - # If the user is specifying their credentials via a file path, - # we'll use this. - if self.app.config['STORMPATH_API_KEY_FILE']: - ctx.stormpath_client = Client( - api_key_file_location = self.app.config['STORMPATH_API_KEY_FILE'], - user_agent = user_agent, - cache_options = self.app.config['STORMPATH_CACHE'], - ) - - # If the user isn't specifying their credentials via a file - # path, it means they're using environment variables, so we'll - # try to grab those values. - else: - ctx.stormpath_client = Client( - id = self.app.config['STORMPATH_API_KEY_ID'], - secret = self.app.config['STORMPATH_API_KEY_SECRET'], - user_agent = user_agent, - cache_options = self.app.config['STORMPATH_CACHE'], - ) - - return ctx.stormpath_client - @property def login_view(self): """ @@ -257,21 +423,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'] - )[0] - - return ctx.stormpath_application - @staticmethod def load_user(account_href): """ diff --git a/flask_stormpath/config/default-config.yml b/flask_stormpath/config/default-config.yml new file mode 100644 index 0000000..2e3a9e5 --- /dev/null +++ b/flask_stormpath/config/default-config.yml @@ -0,0 +1,254 @@ +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" + template: "flask_stormpath/verify.html" + + 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/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/forms.py b/flask_stormpath/forms.py index 0b063eb..4d73803 100644 --- a/flask_stormpath/forms.py +++ b/flask_stormpath/forms.py @@ -1,12 +1,100 @@ """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 wtforms.fields import PasswordField, StringField -from wtforms.validators import InputRequired, ValidationError - - -class RegistrationForm(Form): +from wtforms.validators import InputRequired, ValidationError, EqualTo +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'] + + for field in field_order: + if field_list[field]['enabled']: + validators = [] + json_field = {'name': Resource.from_camel_case(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 + + if field == 'confirmPassword': + validators.append( + EqualTo('password', message='Passwords must match')) + + 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): """ Register a new user. @@ -23,15 +111,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 +133,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): @@ -70,7 +156,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): """ @@ -80,3 +167,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/models.py b/flask_stormpath/models.py index 43df24b..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. @@ -140,8 +141,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 +153,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/settings.py b/flask_stormpath/settings.py index 29c06c1..d3a2d4b 100644 --- a/flask_stormpath/settings.py +++ b/flask_stormpath/settings.py @@ -1,142 +1,113 @@ """Helper functions for dealing with Flask-Stormpath settings.""" - -from datetime import timedelta - -from .errors import ConfigurationError - - -def init_settings(config): - """ - Initialize the Flask-Stormpath settings. - - This function sets all default configuration values. - - :param dict config: The Flask app config. - """ - # Basic Stormpath credentials and configuration. - config.setdefault('STORMPATH_API_KEY_ID', None) - config.setdefault('STORMPATH_API_KEY_SECRET', None) - config.setdefault('STORMPATH_API_KEY_FILE', None) - config.setdefault('STORMPATH_APPLICATION', None) - - # Which fields should be displayed when registering new users? - config.setdefault('STORMPATH_ENABLE_FACEBOOK', False) - config.setdefault('STORMPATH_ENABLE_GOOGLE', False) - config.setdefault('STORMPATH_ENABLE_EMAIL', True) # If this is diabled, - # only social login can - # be used. - config.setdefault('STORMPATH_ENABLE_USERNAME', False) - config.setdefault('STORMPATH_ENABLE_EMAIL', True) # This MUST be True! - config.setdefault('STORMPATH_ENABLE_PASSWORD', True) # This MUST be True! - config.setdefault('STORMPATH_ENABLE_GIVEN_NAME', True) - config.setdefault('STORMPATH_ENABLE_MIDDLE_NAME', True) - config.setdefault('STORMPATH_ENABLE_SURNAME', True) - - # If the user attempts to create a non-social account, which fields should - # we require? (Email and password are always required, so those are not - # mentioned below.) - config.setdefault('STORMPATH_REQUIRE_USERNAME', True) - config.setdefault('STORMPATH_REQUIRE_EMAIL', True) # This MUST be True! - config.setdefault('STORMPATH_REQUIRE_PASSWORD', True) # This MUST be True! - config.setdefault('STORMPATH_REQUIRE_GIVEN_NAME', True) - config.setdefault('STORMPATH_REQUIRE_MIDDLE_NAME', False) - config.setdefault('STORMPATH_REQUIRE_SURNAME', True) - - # Will new users be required to verify new accounts via email before - # they're made active? - config.setdefault('STORMPATH_VERIFY_EMAIL', False) - - # Configure views. These views can be enabled or disabled. If they're - # enabled (default), then you automatically get URL routes, working views, - # and working templates for common operations: registration, login, logout, - # forgot password, and changing user settings. - config.setdefault('STORMPATH_ENABLE_REGISTRATION', True) - config.setdefault('STORMPATH_ENABLE_LOGIN', True) - config.setdefault('STORMPATH_ENABLE_LOGOUT', True) - config.setdefault('STORMPATH_ENABLE_FORGOT_PASSWORD', False) - config.setdefault('STORMPATH_ENABLE_SETTINGS', True) - - # Configure URL mappings. These URL mappings control which URLs will be - # used by Flask-Stormpath views. - config.setdefault('STORMPATH_REGISTRATION_URL', '/register') - config.setdefault('STORMPATH_LOGIN_URL', '/login') - config.setdefault('STORMPATH_LOGOUT_URL', '/logout') - config.setdefault('STORMPATH_FORGOT_PASSWORD_URL', '/forgot') - config.setdefault('STORMPATH_FORGOT_PASSWORD_CHANGE_URL', '/forgot/change') - config.setdefault('STORMPATH_SETTINGS_URL', '/settings') - config.setdefault('STORMPATH_GOOGLE_LOGIN_URL', '/google') - config.setdefault('STORMPATH_FACEBOOK_LOGIN_URL', '/facebook') - - # After a successful login, where should users be redirected? - config.setdefault('STORMPATH_REDIRECT_URL', '/') - - # Cache configuration. - config.setdefault('STORMPATH_CACHE', None) - - # Configure templates. These template settings control which templates are - # used to render the Flask-Stormpath views. - config.setdefault('STORMPATH_BASE_TEMPLATE', 'flask_stormpath/base.html') - config.setdefault('STORMPATH_REGISTRATION_TEMPLATE', 'flask_stormpath/register.html') - config.setdefault('STORMPATH_LOGIN_TEMPLATE', 'flask_stormpath/login.html') - config.setdefault('STORMPATH_FORGOT_PASSWORD_TEMPLATE', 'flask_stormpath/forgot.html') - config.setdefault('STORMPATH_FORGOT_PASSWORD_EMAIL_SENT_TEMPLATE', 'flask_stormpath/forgot_email_sent.html') - config.setdefault('STORMPATH_FORGOT_PASSWORD_CHANGE_TEMPLATE', 'flask_stormpath/forgot_change.html') - config.setdefault('STORMPATH_FORGOT_PASSWORD_COMPLETE_TEMPLATE', 'flask_stormpath/forgot_complete.html') - config.setdefault('STORMPATH_SETTINGS_TEMPLATE', 'flask_stormpath/settings.html') - - # Social login configuration. - config.setdefault('STORMPATH_SOCIAL', {}) - - # Cookie configuration. - config.setdefault('STORMPATH_COOKIE_DOMAIN', None) - config.setdefault('STORMPATH_COOKIE_DURATION', timedelta(days=365)) - - # Cookie name (this is not overridable by users, at least not explicitly). - config.setdefault('REMEMBER_COOKIE_NAME', 'stormpath_token') - - -def check_settings(config): - """ - Ensure the user-specified settings are valid. - - This will raise a ConfigurationError if anything mandatory is not - specified. - - :param dict config: The Flask app config. - """ - if not ( - all([ - config['STORMPATH_API_KEY_ID'], - config['STORMPATH_API_KEY_SECRET'], - ]) or config['STORMPATH_API_KEY_FILE'] - ): - raise ConfigurationError('You must define your Stormpath credentials.') - - if not config['STORMPATH_APPLICATION']: - raise ConfigurationError('You must define your Stormpath application.') - - if config['STORMPATH_ENABLE_GOOGLE']: - google_config = config['STORMPATH_SOCIAL'].get('GOOGLE') - - if not google_config or not all([ - google_config.get('client_id'), - google_config.get('client_secret'), - ]): - raise ConfigurationError('You must define your Google app settings.') - - if config['STORMPATH_ENABLE_FACEBOOK']: - facebook_config = config['STORMPATH_SOCIAL'].get('FACEBOOK') - - if not facebook_config or not all([ - facebook_config, - facebook_config.get('app_id'), - facebook_config.get('app_secret'), - ]): - raise ConfigurationError('You must define your Facebook app settings.') - - if config['STORMPATH_COOKIE_DOMAIN'] and not isinstance(config['STORMPATH_COOKIE_DOMAIN'], str): - raise ConfigurationError('STORMPATH_COOKIE_DOMAIN must be a string.') - - if config['STORMPATH_COOKIE_DURATION'] and not isinstance(config['STORMPATH_COOKIE_DURATION'], timedelta): - raise ConfigurationError('STORMPATH_COOKIE_DURATION must be a timedelta object.') +import collections +import json + + +class StormpathSettings(collections.MutableMapping): + STORMPATH_PREFIX = 'STORMPATH' + DELIMITER = '_' + REGEX_SIGN = '*' + MAPPINGS = { # used for backwards compatibility + 'API_KEY_ID': 'client_apiKey_id', + 'API_KEY_SECRET': 'client_apiKey_secret', + 'APPLICATION': 'application_name', + + 'ENABLE_LOGIN': 'web_login_enabled', + 'ENABLE_REGISTRATION': 'web_register_enabled', + 'ENABLE_FORGOT_PASSWORD': 'web_forgotPassword_enabled', + + 'LOGIN_URL': 'web_login_uri', + 'REGISTRATION_URL': 'web_register_uri', + 'LOGOUT_URL': 'web_logout_uri', + + 'REDIRECT_URL': 'web_login_nextUri', + + 'REGISTRATION_TEMPLATE': 'web_register_template', + 'LOGIN_TEMPLATE': 'web_login_template', + + 'REGISTRATION_REDIRECT_URL': 'web_register_nextUri', + 'REQUIRE_*': 'web_register_form_fields_*_required', + 'ENABLE_*': 'web_register_form_fields_*_enabled', + + 'FORGOT_PASSWORD_TEMPLATE': 'web_forgotPassword_template', + 'FORGOT_PASSWORD_CHANGE_TEMPLATE': 'web_changePassword_template' + # 'FORGOT_PASSWORD_EMAIL_SENT_TEMPLATE' + # 'FORGOT_PASSWORD_COMPLETE_TEMPLATE' + # 'ENABLE_FACEBOOK' + # 'ENABLE_GOOGLE' + # 'SOCIAL' + # 'CACHE' + } + + def __init__(self, *args, **kwargs): + self.store = dict(*args, **kwargs) + + @staticmethod + def _from_camel(key): + cs = [] + for c in key: + cl = c.lower() + if c == cl: + cs.append(c) + else: + cs.append('_') + cs.append(c.lower()) + return ''.join(cs).upper() + + def __search__(self, root, key, root_string): + for node in root.keys(): + search_string = '%s%s%s' % ( + root_string, self.DELIMITER, + self._from_camel(node) + ) + if key == search_string: + return root, node + if key.startswith(search_string): + return self.__search__(root[node], key, search_string) + raise KeyError + + def __traverse__(self, parent, descendants): + child = descendants.pop(0) + if descendants: + if child not in parent: + parent[child] = {} + return self.__traverse__(parent[child], descendants) + return parent, child + + def __nodematch__(self, key): + if key.startswith(self.STORMPATH_PREFIX): + store_key = key.lstrip(self.STORMPATH_PREFIX).strip(self.DELIMITER) + if store_key in self.MAPPINGS: + members = self.MAPPINGS[store_key].split(self.DELIMITER) + store = self.__traverse__(self.store, members) + else: + store = self.__search__(self.store, key, self.STORMPATH_PREFIX) + else: + store = self.store, key + return store + + def __getitem__(self, key): + node, child = self.__nodematch__(key) + return node[child] + + def __setitem__(self, key, value): + node, child = self.__nodematch__(key) + node[child] = value + + def __delitem__(self, key): + node, child = self.__keytransform__(key) + del node[child] + + def __contains__(self, key): + try: + self.__nodematch__(key) + return True + except KeyError: + return False + + def __iter__(self): + return iter(self.store) + + def __len__(self): + return len(self.store) diff --git a/flask_stormpath/templates/flask_stormpath/forgot.html b/flask_stormpath/templates/flask_stormpath/forgot.html index b8352b7..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_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 b4f924e..bb1e770 100644 --- a/flask_stormpath/templates/flask_stormpath/login.html +++ b/flask_stormpath/templates/flask_stormpath/login.html @@ -11,8 +11,8 @@