From e9bd9e646fcde7e87ac940014bda3aba2bcea125 Mon Sep 17 00:00:00 2001 From: Deni Bertovic Date: Wed, 3 Dec 2014 15:53:46 +0100 Subject: [PATCH 1/7] enable STORMPATH_APPLICATION setting to be name or href this fixes the scenario when the web app blows up if STORMPATH_APPLICAITON is a href. --- flask_stormpath/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index 4ae984d..6006373 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -264,9 +264,13 @@ def application(self): ctx = stack.top if ctx is not None: if not hasattr(ctx, 'stormpath_application'): - ctx.stormpath_application = self.client.applications.search( - self.app.config['STORMPATH_APPLICATION'] - )[0] + if self.app.config['STORMPATH_APPLICATION'].startswith('http'): + ctx.stormpath_application = self.client.applications.get( + self.app.config['STORMPATH_APPLICATION']) + else: + ctx.stormpath_application = self.client.applications.search( + self.app.config['STORMPATH_APPLICATION'] + )[0] return ctx.stormpath_application From b9221a8cb11ff4d8d44b8660558b6bc2f7751e30 Mon Sep 17 00:00:00 2001 From: Deni Bertovic Date: Wed, 3 Dec 2014 15:56:26 +0100 Subject: [PATCH 2/7] add ID Site support to flask - Added a setting STORMPATH_ENABLE_ID_SITE which basically switches the built in manual views/forms for the ID site ones. Another settings called STORMPATH_ID_SITE_CALLBACK_URL was added as well. - Updated the stormpath sdk dependency to point to the latest version. I was hitting the search issue with '/' and '&' charaters resulting in a bad error message informing me of invalid API credentials, and this has been resolved in the latest sdk version. - Added quickstart docs for this feature --- docs/quickstart.rst | 20 +++++++ flask_stormpath/__init__.py | 107 +++++++++++++++++++++++++----------- flask_stormpath/id_site.py | 39 +++++++++++++ flask_stormpath/models.py | 7 +++ flask_stormpath/views.py | 40 ++++++++++++++ setup.py | 2 +- 6 files changed, 183 insertions(+), 32 deletions(-) create mode 100644 flask_stormpath/id_site.py diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 2ee3642..dc932cd 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -98,6 +98,26 @@ walk you through the basics: - Navigate to ``/login``. You will see a login page. You can now re-enter your user credentials and log into the site again. +ID Site +------- + +If you'd like to not worry about using your own registration and login +screens at all, you can use Stormpath's new `ID site feature +`_. This is a hosted login +subdomain which handles authentication for you automatically. + +To make this work, you need to specify a few additional settings: + + app.config['STORMPATH_ENABLE_ID_SITE'] = True + app.config['STORMPATH_ID_SITE_CALLBACK_URL'] = '/id-site-callback' + +.. note:: + Please note that the ID Site callback URL must be a relative path and it must + match the one set in the Stormpath ID Site Dashboard. + For production pruposes your will probably also want to set app.config['SERVER_NAME'] + for the relative callback url to be properly generated to match the absolute URL + specified in the Stormpath ID Site Dashboard. + Wasn't that easy?! .. note:: diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index 6006373..7547ed6 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -55,6 +55,11 @@ login, logout, register, + id_site_login, + id_site_logout, + id_site_register, + id_site_forgot_password, + id_site_callback, ) @@ -155,56 +160,96 @@ def init_routes(self, app): :param obj app: The Flask app. """ - if app.config['STORMPATH_ENABLE_REGISTRATION']: - app.add_url_rule( - app.config['STORMPATH_REGISTRATION_URL'], - 'stormpath.register', - register, - methods = ['GET', 'POST'], - ) - if app.config['STORMPATH_ENABLE_LOGIN']: + if app.config['STORMPATH_ENABLE_ID_SITE']: + app.add_url_rule( app.config['STORMPATH_LOGIN_URL'], 'stormpath.login', - login, - methods = ['GET', 'POST'], + id_site_login, + methods = ['GET'], ) - if app.config['STORMPATH_ENABLE_FORGOT_PASSWORD']: app.add_url_rule( - app.config['STORMPATH_FORGOT_PASSWORD_URL'], - 'stormpath.forgot', - forgot, - methods = ['GET', 'POST'], + app.config['STORMPATH_REGISTRATION_URL'], + 'stormpath.register', + id_site_register, + methods = ['GET'], ) + app.add_url_rule( - app.config['STORMPATH_FORGOT_PASSWORD_CHANGE_URL'], - 'stormpath.forgot_change', - forgot_change, - methods = ['GET', 'POST'], + app.config['STORMPATH_FORGOT_PASSWORD_URL'], + 'stormpath.forgot', + id_site_forgot_password, + methods = ['GET'], ) - if app.config['STORMPATH_ENABLE_LOGOUT']: app.add_url_rule( app.config['STORMPATH_LOGOUT_URL'], 'stormpath.logout', - logout, + id_site_logout, + methods = ['GET'], ) - if app.config['STORMPATH_ENABLE_GOOGLE']: app.add_url_rule( - app.config['STORMPATH_GOOGLE_LOGIN_URL'], - 'stormpath.google_login', - google_login, + app.config['STORMPATH_ID_SITE_CALLBACK_URL'], + 'stormpath.id_site_callback', + id_site_callback, + methods = ['GET'], ) - if app.config['STORMPATH_ENABLE_FACEBOOK']: - app.add_url_rule( - app.config['STORMPATH_FACEBOOK_LOGIN_URL'], - 'stormpath.facebook_login', - facebook_login, - ) + else: + + if app.config['STORMPATH_ENABLE_REGISTRATION']: + app.add_url_rule( + app.config['STORMPATH_REGISTRATION_URL'], + 'stormpath.register', + register, + methods = ['GET', 'POST'], + ) + + if app.config['STORMPATH_ENABLE_LOGIN']: + app.add_url_rule( + app.config['STORMPATH_LOGIN_URL'], + 'stormpath.login', + login, + methods = ['GET', 'POST'], + ) + + if app.config['STORMPATH_ENABLE_FORGOT_PASSWORD']: + app.add_url_rule( + app.config['STORMPATH_FORGOT_PASSWORD_URL'], + 'stormpath.forgot', + forgot, + methods = ['GET', 'POST'], + ) + app.add_url_rule( + app.config['STORMPATH_FORGOT_PASSWORD_CHANGE_URL'], + 'stormpath.forgot_change', + forgot_change, + methods = ['GET', 'POST'], + ) + + if app.config['STORMPATH_ENABLE_LOGOUT']: + app.add_url_rule( + app.config['STORMPATH_LOGOUT_URL'], + '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, + ) @property def client(self): diff --git a/flask_stormpath/id_site.py b/flask_stormpath/id_site.py new file mode 100644 index 0000000..a40109c --- /dev/null +++ b/flask_stormpath/id_site.py @@ -0,0 +1,39 @@ +from flask.ext.login import login_user, logout_user +from flask import redirect, current_app, request + +from .models import User + + +ID_SITE_STATUS_AUTHENTICATED = 'AUTHENTICATED' +ID_SITE_STATUS_LOGOUT = 'LOGOUT' +ID_SITE_STATUS_REGISTERED = 'REGISTERED' + + +def _handle_authenticated(id_site_response): + login_user(User.from_id_site(id_site_response.account), + remember=True) + return redirect(request.args.get('next') or current_app.config['STORMPATH_REDIRECT_URL']) + + +def _handle_logout(id_site_response): + logout_user() + return redirect('/') + + +_handle_registered = _handle_authenticated + + +def handle_id_site_callback(id_site_response): + if id_site_response: + action = CALLBACK_ACTIONS[id_site_response.status] + return action(id_site_response) + else: + return None + + +CALLBACK_ACTIONS = { + ID_SITE_STATUS_AUTHENTICATED: _handle_authenticated, + ID_SITE_STATUS_LOGOUT: _handle_logout, + ID_SITE_STATUS_REGISTERED: _handle_registered +} + diff --git a/flask_stormpath/models.py b/flask_stormpath/models.py index 8417ccd..0dc4b55 100644 --- a/flask_stormpath/models.py +++ b/flask_stormpath/models.py @@ -100,6 +100,13 @@ def from_login(self, login, password): return _user + @classmethod + def from_id_site(self, account): + _user = account + _user.__class__ = User + + return _user + @classmethod def from_google(self, code): """ diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index 0beaa9a..75ca407 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -7,6 +7,7 @@ current_app, flash, redirect, + url_for, render_template, request, ) @@ -21,6 +22,7 @@ RegistrationForm, ) from .models import User +from .id_site import handle_id_site_callback def register(): @@ -399,3 +401,41 @@ def logout(): """ logout_user() return redirect('/') + + +def id_site_login(): + rdr = current_app.stormpath_manager.application.build_id_site_redirect_url( + callback_uri=url_for('stormpath.id_site_callback', _external=True), + state=request.args.get('state')) + return redirect(rdr) + + +def id_site_register(): + rdr = current_app.stormpath_manager.application.build_id_site_redirect_url( + callback_uri=url_for('stormpath.id_site_callback', _external=True), + state=request.args.get('state'), + path="/#/register") + return redirect(rdr) + + +def id_site_forgot_password(): + rdr = current_app.stormpath_manager.application.build_id_site_redirect_url( + callback_uri=url_for('stormpath.id_site_callback', _external=True), + state=request.args.get('state'), + path="/#/forgot") + return redirect(rdr) + + +def id_site_logout(): + rdr = current_app.stormpath_manager.application.build_id_site_redirect_url( + callback_uri=url_for('stormpath.id_site_callback', _external=True), + state=request.args.get('state'), + logout=True) + return redirect(rdr) + + +def id_site_callback(): + ret = current_app.stormpath_manager.application.handle_id_site_callback( + request.url) + return handle_id_site_callback(ret) + diff --git a/setup.py b/setup.py index f74c105..85afeff 100644 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def run(self): 'Flask-WTF>=0.9.5', 'facebook-sdk==0.4.0', 'oauth2client==1.2', - 'stormpath==1.2.4', + 'stormpath==1.2.6', ], classifiers = [ 'Environment :: Web Environment', From 7b20d09bb166e75c43c9d6b5d7bbf4be6897ea74 Mon Sep 17 00:00:00 2001 From: Deni Bertovic Date: Wed, 3 Dec 2014 21:11:04 +0100 Subject: [PATCH 3/7] forgot to add default settings for id site --- flask_stormpath/settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flask_stormpath/settings.py b/flask_stormpath/settings.py index ab0df17..ac3d2e7 100644 --- a/flask_stormpath/settings.py +++ b/flask_stormpath/settings.py @@ -19,6 +19,8 @@ def init_settings(config): config.setdefault('STORMPATH_API_KEY_SECRET', None) config.setdefault('STORMPATH_API_KEY_FILE', None) config.setdefault('STORMPATH_APPLICATION', None) + config.setdefault('STORMPATH_ENABLE_ID_SITE', False) + config.setdefault('STORMPATH_ID_SITE_CALLBACK_URL', None) # Which fields should be displayed when registering new users? config.setdefault('STORMPATH_ENABLE_FACEBOOK', False) From 75db757daf8e5016a2c2cd84dc6890f85d70b7e9 Mon Sep 17 00:00:00 2001 From: Deni Bertovic Date: Wed, 3 Dec 2014 16:17:46 +0100 Subject: [PATCH 4/7] another fix for running live tests on PRs --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 3eaf011..2001f25 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ install: - pip install -r requirements.txt - python setup.py develop script: - - python setup.py test + - test -z "$STORMPATH_API_KEY_SECRET" || python setup.py test - cd docs && make html env: global: From dc330e4fd34224a9bb8b96f9d4a09ce989268131 Mon Sep 17 00:00:00 2001 From: Ana Vojnovic Date: Fri, 24 Apr 2015 16:42:36 +0200 Subject: [PATCH 5/7] Revert "enable STORMPATH_APPLICATION setting to be name or href" This reverts commit e9bd9e646fcde7e87ac940014bda3aba2bcea125. --- flask_stormpath/__init__.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/flask_stormpath/__init__.py b/flask_stormpath/__init__.py index ab0ebef..941bde4 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -319,13 +319,9 @@ def application(self): ctx = stack.top if ctx is not None: if not hasattr(ctx, 'stormpath_application'): - if self.app.config['STORMPATH_APPLICATION'].startswith('http'): - ctx.stormpath_application = self.client.applications.get( - self.app.config['STORMPATH_APPLICATION']) - else: - ctx.stormpath_application = self.client.applications.search( - self.app.config['STORMPATH_APPLICATION'] - )[0] + ctx.stormpath_application = self.client.applications.search( + self.app.config['STORMPATH_APPLICATION'] + )[0] return ctx.stormpath_application From 8eb9614021cf0763e5df8c8b1427ed119ac65f82 Mon Sep 17 00:00:00 2001 From: Ana Vojnovic Date: Fri, 24 Apr 2015 19:05:16 +0200 Subject: [PATCH 6/7] Added docstrings for ID Site. --- flask_stormpath/id_site.py | 12 ++++++++++++ flask_stormpath/models.py | 4 ++++ flask_stormpath/views.py | 23 ++++++++++++++++++++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/flask_stormpath/id_site.py b/flask_stormpath/id_site.py index a40109c..f18b22f 100644 --- a/flask_stormpath/id_site.py +++ b/flask_stormpath/id_site.py @@ -10,12 +10,20 @@ def _handle_authenticated(id_site_response): + """ + Get user using :class:`stormpath.id_site.IdSiteCallbackResult`'s + :class:`stormpath.resources.account.Account` object. Login that + user. + """ login_user(User.from_id_site(id_site_response.account), remember=True) return redirect(request.args.get('next') or current_app.config['STORMPATH_REDIRECT_URL']) def _handle_logout(id_site_response): + """ + Logout current user. + """ logout_user() return redirect('/') @@ -24,6 +32,10 @@ def _handle_logout(id_site_response): def handle_id_site_callback(id_site_response): + """ + Handle different actions depending on + :class:`stormpath.id_site.IdSiteCallbackResult`'s status. + """ if id_site_response: action = CALLBACK_ACTIONS[id_site_response.status] return action(id_site_response) diff --git a/flask_stormpath/models.py b/flask_stormpath/models.py index 0dc4b55..e2b21de 100644 --- a/flask_stormpath/models.py +++ b/flask_stormpath/models.py @@ -102,6 +102,10 @@ def from_login(self, login, password): @classmethod def from_id_site(self, account): + """ + Create a new User class given a + :class:`stormpath.resources.account.Account` object. + """ _user = account _user.__class__ = User diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index 75ca407..405e2eb 100644 --- a/flask_stormpath/views.py +++ b/flask_stormpath/views.py @@ -398,12 +398,16 @@ def logout(): This view will log a user out of their account (destroying their session), then redirect the user to the home page of the site. - """ + """ logout_user() return redirect('/') def id_site_login(): + """ + Use Stormpath SDK to generate the redirection URL for the ID Site + login page. Redirect the user to the ID Site URL. + """ rdr = current_app.stormpath_manager.application.build_id_site_redirect_url( callback_uri=url_for('stormpath.id_site_callback', _external=True), state=request.args.get('state')) @@ -411,6 +415,10 @@ def id_site_login(): def id_site_register(): + """ + Use Stormpath SDK to generate the redirection URL for the ID Site + registration page. Redirect the user to the ID Site URL. + """ rdr = current_app.stormpath_manager.application.build_id_site_redirect_url( callback_uri=url_for('stormpath.id_site_callback', _external=True), state=request.args.get('state'), @@ -419,6 +427,10 @@ def id_site_register(): def id_site_forgot_password(): + """ + Use Stormpath SDK to generate the redirection URL for the ID Site + forgot password page. Redirect the user to the ID Site URL. + """ rdr = current_app.stormpath_manager.application.build_id_site_redirect_url( callback_uri=url_for('stormpath.id_site_callback', _external=True), state=request.args.get('state'), @@ -427,6 +439,10 @@ def id_site_forgot_password(): def id_site_logout(): + """ + Use Stormpath SDK to generate the redirection URL for the ID Site + logout page. Redirect the user to the ID Site URL. + """ rdr = current_app.stormpath_manager.application.build_id_site_redirect_url( callback_uri=url_for('stormpath.id_site_callback', _external=True), state=request.args.get('state'), @@ -435,6 +451,11 @@ def id_site_logout(): def id_site_callback(): + """ + Use Stormpath SDK to get the + :class:`stormpath.id_site.IdSiteCallbackResult` object based on + callback URL. Handle the result. + """ ret = current_app.stormpath_manager.application.handle_id_site_callback( request.url) return handle_id_site_callback(ret) From 9379161a41bbedfc11618c970e10d2df88693eff Mon Sep 17 00:00:00 2001 From: Ana Vojnovic Date: Fri, 24 Apr 2015 19:05:53 +0200 Subject: [PATCH 7/7] Added tests for ID Site. --- tests/helpers.py | 16 +++++++++++++++- tests/test_views.py | 44 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 16ecc90..64bf6eb 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -43,6 +43,17 @@ def tearDown(self): directory.delete() +class StormpathIdSiteTestCase(StormpathTestCase): + """ + StormpathTestCase with ID Site. + """ + def setUp(self): + """Provision a new Client, Application, and Flask app with ID Site.""" + self.client = bootstrap_client() + self.application = bootstrap_app(self.client) + self.app = bootstrap_flask_app(self.application, True) + + def bootstrap_client(): """ Create a new Stormpath Client from environment variables. @@ -77,7 +88,7 @@ def bootstrap_app(client): }, create_directory=True) -def bootstrap_flask_app(app): +def bootstrap_flask_app(app, use_id_site=False): """ Create a new, fully initialized Flask app. @@ -92,6 +103,9 @@ def bootstrap_flask_app(app): a.config['STORMPATH_API_KEY_SECRET'] = environ.get('STORMPATH_API_KEY_SECRET') a.config['STORMPATH_APPLICATION'] = app.name a.config['WTF_CSRF_ENABLED'] = False + a.config['STORMPATH_ENABLE_ID_SITE'] = use_id_site + if use_id_site: + a.config['STORMPATH_ID_SITE_CALLBACK_URL'] = '/' StormpathManager(a) return a diff --git a/tests/test_views.py b/tests/test_views.py index cb299db..f2cddcc 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,9 +1,7 @@ """Run tests against our custom views.""" - - from flask.ext.stormpath.models import User -from .helpers import StormpathTestCase +from .helpers import StormpathTestCase, StormpathIdSiteTestCase class TestRegister(StormpathTestCase): @@ -147,3 +145,43 @@ def test_logout_works(self): # Log this user out. resp = c.get('/logout') self.assertEqual(resp.status_code, 302) + + +class TestIdSite(StormpathIdSiteTestCase): + """Test our ID Site views.""" + + def test_id_site_login(self): + # Attempt a login redirects to ID Site + with self.app.test_client() as c: + resp = c.get('/login') + self.assertEqual(resp.status_code, 302) + self.assertTrue( + resp.headers['location'].startswith( + 'https://api.stormpath.com/sso?jwtRequest=')) + + def test_id_site_register(self): + # Attempt a registration redirects to ID Site + with self.app.test_client() as c: + resp = c.get('/register') + self.assertEqual(resp.status_code, 302) + self.assertTrue( + resp.headers['location'].startswith( + 'https://api.stormpath.com/sso?jwtRequest=')) + + def test_id_site_logout(self): + # Attempt a logout redirects to ID Site logout + with self.app.test_client() as c: + resp = c.get('/logout') + self.assertEqual(resp.status_code, 302) + self.assertTrue( + resp.headers['location'].startswith( + 'https://api.stormpath.com/sso/logout?jwtRequest=')) + + def test_id_site_forgot_password(self): + # Attempt a logout redirects to ID Site + with self.app.test_client() as c: + resp = c.get('/forgot') + self.assertEqual(resp.status_code, 302) + self.assertTrue( + resp.headers['location'].startswith( + 'https://api.stormpath.com/sso?jwtRequest='))