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 89d3fc6..941bde4 100644 --- a/flask_stormpath/__init__.py +++ b/flask_stormpath/__init__.py @@ -63,6 +63,11 @@ login, logout, register, + id_site_login, + id_site_logout, + id_site_register, + id_site_forgot_password, + id_site_callback, ) @@ -163,56 +168,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..f18b22f --- /dev/null +++ b/flask_stormpath/id_site.py @@ -0,0 +1,51 @@ +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): + """ + 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('/') + + +_handle_registered = _handle_authenticated + + +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) + 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..e2b21de 100644 --- a/flask_stormpath/models.py +++ b/flask_stormpath/models.py @@ -100,6 +100,17 @@ def from_login(self, login, password): return _user + @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 + + return _user + @classmethod def from_google(self, code): """ diff --git a/flask_stormpath/settings.py b/flask_stormpath/settings.py index c9441ca..21a87e2 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) diff --git a/flask_stormpath/views.py b/flask_stormpath/views.py index 0beaa9a..405e2eb 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(): @@ -396,6 +398,65 @@ 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')) + return redirect(rdr) + + +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'), + path="/#/register") + return redirect(rdr) + + +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'), + path="/#/forgot") + return redirect(rdr) + + +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'), + logout=True) + return redirect(rdr) + + +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) + 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='))