From 61754a907f595b5975b29c53e1e63dff7b1c77a1 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Wed, 21 Apr 2021 14:36:15 +0200 Subject: [PATCH 1/8] Add IDP logout functionality when user logs out from CKAN --- README.md | 8 ++++ ckanext/saml2auth/cache.py | 44 +++++++++++++++++++++ ckanext/saml2auth/client.py | 35 +++++++++++++++++ ckanext/saml2auth/helpers.py | 4 +- ckanext/saml2auth/plugin.py | 58 ++++++++++++++++++++++++++-- ckanext/saml2auth/spconfig.py | 9 ++++- ckanext/saml2auth/views/saml2auth.py | 6 ++- setup.py | 2 +- 8 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 ckanext/saml2auth/cache.py create mode 100644 ckanext/saml2auth/client.py diff --git a/README.md b/README.md index 406534a3..b1247188 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Required: # Corresponding SAML user field for email ckanext.saml2auth.user_email = email + Optional: # URL route of the endpoint where the SAML assertion is sent, also known as Assertion Consumer Service (ACS). @@ -128,6 +129,13 @@ Optional: # Comparison could be one of this: exact, minimum, maximum or better ckanext.saml2auth.requested_authn_context_comparison = exact + # Indicates if this entity will sign the Logout Requests originated from it + ckanext.saml2auth.logout_requests_signed = False + + # Saml logout request preferred binding settings variable + # Default: urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST + ckanext.saml2auth.logout_expected_binding = urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST + ## Plugin interface This extension provides the [ISaml2Auth]{.title-ref} interface that diff --git a/ckanext/saml2auth/cache.py b/ckanext/saml2auth/cache.py new file mode 100644 index 00000000..b169e6f4 --- /dev/null +++ b/ckanext/saml2auth/cache.py @@ -0,0 +1,44 @@ +# encoding: utf-8 +""" +Copyright (c) 2020 Keitaro AB + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" +import logging + +from saml2.ident import code, decode + +log = logging.getLogger(__name__) + + +def set_subject_id(session, subject_id): + session['_saml2_subject_id'] = code(subject_id) + + +def get_subject_id(session): + try: + return decode(session['_saml2_subject_id']) + except KeyError: + return None + + +def set_saml_session_info(session, saml_session_info): + session['_saml_session_info'] = saml_session_info + + +def get_saml_session_info(session): + try: + return session['_saml_session_info'] + except KeyError: + return None diff --git a/ckanext/saml2auth/client.py b/ckanext/saml2auth/client.py new file mode 100644 index 00000000..69d58289 --- /dev/null +++ b/ckanext/saml2auth/client.py @@ -0,0 +1,35 @@ +# encoding: utf-8 +""" +Copyright (c) 2020 Keitaro AB + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +""" +import logging +from saml2.client import Saml2Client + +from ckanext.saml2auth.spconfig import get_config as sp_config + +log = logging.getLogger(__name__) + + +class Saml2Client(Saml2Client): + + def do_logout(self, *args, **kwargs): + if not kwargs.get('expected_binding'): + try: + kwargs['expected_binding'] = sp_config()[u'logout_expected_binding'] + except AttributeError: + log.warning('ckanext.saml2auth.logout_expected_binding' + 'is not defined. Default binding will be used.') + return super().do_logout(*args, **kwargs) diff --git a/ckanext/saml2auth/helpers.py b/ckanext/saml2auth/helpers.py index cab219cf..935bcd4d 100644 --- a/ckanext/saml2auth/helpers.py +++ b/ckanext/saml2auth/helpers.py @@ -23,13 +23,15 @@ import secrets from six import text_type -from saml2.client import Saml2Client +from ckanext.saml2auth.client import Saml2Client + from saml2.config import Config as Saml2Config import ckan.model as model import ckan.authz as authz from ckan.common import config, asbool, aslist + log = logging.getLogger(__name__) diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index 7bebd1c5..e562efe8 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -16,18 +16,31 @@ """ # encoding: utf-8 +import logging + +from saml2.client_base import LogoutError + +from flask import session + import ckan.plugins as plugins import ckan.plugins.toolkit as toolkit +from ckan.common import g from ckanext.saml2auth.views.saml2auth import saml2auth +from ckanext.saml2auth.cache import get_subject_id, get_saml_session_info +from ckanext.saml2auth.spconfig import get_config as sp_config from ckanext.saml2auth import helpers as h +log = logging.getLogger(__name__) + + class Saml2AuthPlugin(plugins.SingletonPlugin): plugins.implements(plugins.IConfigurer) plugins.implements(plugins.IBlueprint) plugins.implements(plugins.IConfigurable) plugins.implements(plugins.ITemplateHelpers) + plugins.implements(plugins.IAuthenticator) # ITemplateHelpers @@ -59,9 +72,8 @@ def configure(self, config): full_name = config.get('ckanext.saml2auth.user_fullname') if not first_and_last_name and not full_name: - raise RuntimeError(''' -You need to provide both ckanext.saml2auth.user_firstname + -ckanext.saml2auth.user_lastname or ckanext.saml2auth.user_fullname'''.strip()) + raise RuntimeError('''You need to provide both ckanext.saml2auth.user_firstname + + ckanext.saml2auth.user_lastname or ckanext.saml2auth.user_fullname'''.strip()) acs_endpoint = config.get('ckanext.saml2auth.acs_endpoint') if acs_endpoint and not acs_endpoint.startswith('/'): @@ -78,3 +90,43 @@ def update_config(self, config_): toolkit.add_template_directory(config_, 'templates') toolkit.add_public_directory(config_, 'public') toolkit.add_resource('fanstatic', 'saml2auth') + + # IAuthenticator + + def identify(self): + pass + + def login(self): + pass + + def logout(self): + + # We need to logout from IDP as well + client = h.saml_client( + sp_config() + ) + saml_session_info = get_saml_session_info(session) + subject_id = get_subject_id(session) + client.users.add_information_about_person(saml_session_info) + + if subject_id is None: + log.warning( + 'The session does not contain the subject id for user %s', g.user) + + try: + result = client.global_logout(name_id=subject_id) + print(result) + except LogoutError as e: + log.exception( + 'Error Handled - SLO not supported by IDP: {}'.format(e)) + # clear session + + if not result: + log.error( + "Looks like the user {} is not logged in any IdP/AA".format(subject_id)) + + if len(result) > 1: + log.error( + 'Sorry, I do not know how to logout from several sources.' + ' I will logout just from the first one') + diff --git a/ckanext/saml2auth/spconfig.py b/ckanext/saml2auth/spconfig.py index 0d0eae8b..5428ca8c 100644 --- a/ckanext/saml2auth/spconfig.py +++ b/ckanext/saml2auth/spconfig.py @@ -17,6 +17,7 @@ # encoding: utf-8 from saml2.saml import NAME_FORMAT_URI +from saml2 import entity from ckan.common import config as ckan_config from ckan.common import asbool, aslist @@ -54,6 +55,10 @@ def get_config(): cert_file = ckan_config.get(u'ckanext.saml2auth.cert_file_path', None) attribute_map_dir = ckan_config.get(u'ckanext.saml2auth.attribute_map_dir', None) acs_endpoint = ckan_config.get('ckanext.saml2auth.acs_endpoint', '/acs') + logout_requests_signed = \ + asbool(ckan_config.get(u'ckanext.saml2auth.logout_requests_signed', False)) + logout_expected_binding = ckan_config.get(u'ckanext.saml2auth.logout_expected_binding', + entity.BINDING_HTTP_POST) config = { u'entityid': entity_id, @@ -70,9 +75,11 @@ def get_config(): u'name_id_format': name_id_format, u'want_response_signed': response_signed, u'want_assertions_signed': assertion_signed, - u'want_assertions_or_response_signed': any_signed + u'want_assertions_or_response_signed': any_signed, + u'logout_requests_signed': logout_requests_signed } }, + u'logout_expected_binding': logout_expected_binding, u'metadata': {}, u'debug': 1 if debug else 0, u'name_form': NAME_FORMAT_URI diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index 8a8faf32..ade49511 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -19,7 +19,7 @@ import logging import copy -from flask import Blueprint +from flask import Blueprint, session from saml2 import entity from saml2.authn_context import requested_authn_context @@ -34,6 +34,7 @@ from ckanext.saml2auth.spconfig import get_config as sp_config from ckanext.saml2auth import helpers as h from ckanext.saml2auth.interfaces import ISaml2Auth +from ckanext.saml2auth.cache import set_subject_id, set_saml_session_info log = logging.getLogger(__name__) @@ -229,6 +230,7 @@ def acs(): auth_response.get_identity() user_info = auth_response.get_subject() + session_info = auth_response.session_info() # SAML username - unique saml_id = user_info.text @@ -261,6 +263,8 @@ def acs(): # log the user in programmatically set_repoze_user(g.user, resp) + set_saml_session_info(session, session_info) + set_subject_id(session, session_info['name_id']) for plugin in plugins.PluginImplementations(ISaml2Auth): resp = plugin.after_saml2_login(resp, auth_response.ava) diff --git a/setup.py b/setup.py index 63d10f11..d8c8fd15 100644 --- a/setup.py +++ b/setup.py @@ -78,7 +78,7 @@ packages=find_packages(exclude=['contrib', 'docs', 'tests*']), namespace_packages=['ckanext'], - install_requires=['pysaml2>=6.3.0'], + install_requires=['pysaml2>=6.5.1'], # If there are data files included in your packages that need to be # installed, specify them here. If using Python 2.6 or less, then these From 1a5a800312c36be76de23641f6f3e150f2671f3b Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Wed, 21 Apr 2021 14:38:49 +0200 Subject: [PATCH 2/8] PEP8 style fixes --- ckanext/saml2auth/plugin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index e562efe8..edc91aff 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -72,7 +72,7 @@ def configure(self, config): full_name = config.get('ckanext.saml2auth.user_fullname') if not first_and_last_name and not full_name: - raise RuntimeError('''You need to provide both ckanext.saml2auth.user_firstname + raise RuntimeError('''You need to provide both ckanext.saml2auth.user_firstname + ckanext.saml2auth.user_lastname or ckanext.saml2auth.user_fullname'''.strip()) acs_endpoint = config.get('ckanext.saml2auth.acs_endpoint') @@ -129,4 +129,3 @@ def logout(self): log.error( 'Sorry, I do not know how to logout from several sources.' ' I will logout just from the first one') - From 22bc2aac6c714050e49c67119ffd9a30c080284c Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Thu, 22 Apr 2021 11:55:52 +0200 Subject: [PATCH 3/8] Handle different Idp logout methods --- ckanext/saml2auth/helpers.py | 9 +++++++++ ckanext/saml2auth/plugin.py | 28 ++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/ckanext/saml2auth/helpers.py b/ckanext/saml2auth/helpers.py index 935bcd4d..73cd9cf7 100644 --- a/ckanext/saml2auth/helpers.py +++ b/ckanext/saml2auth/helpers.py @@ -97,3 +97,12 @@ def ensure_unique_username_from_email(email): return name return cleaned_localpart + + +def get_location(http_info): + '''Extract the redirect URL from a pysaml2 http_info object''' + try: + headers = dict(http_info['headers']) + return headers['Location'] + except KeyError: + return http_info['url'] diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index edc91aff..9617a042 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -17,14 +17,18 @@ # encoding: utf-8 import logging +import requests from saml2.client_base import LogoutError +from saml2 import entity from flask import session import ckan.plugins as plugins import ckan.plugins.toolkit as toolkit from ckan.common import g +import ckan.lib.helpers as ch +import ckan.lib.base as base from ckanext.saml2auth.views.saml2auth import saml2auth from ckanext.saml2auth.cache import get_subject_id, get_saml_session_info @@ -111,21 +115,37 @@ def logout(self): if subject_id is None: log.warning( - 'The session does not contain the subject id for user %s', g.user) + 'The session does not contain the subject id for user {}'.format(g.user)) try: result = client.global_logout(name_id=subject_id) - print(result) except LogoutError as e: log.exception( - 'Error Handled - SLO not supported by IDP: {}'.format(e)) + 'SLO not supported by IDP: {}'.format(e)) # clear session if not result: log.error( - "Looks like the user {} is not logged in any IdP/AA".format(subject_id)) + 'Looks like the user {} is not logged in any IdP/AA'.format(subject_id)) if len(result) > 1: log.error( 'Sorry, I do not know how to logout from several sources.' ' I will logout just from the first one') + + for entity_id, logout_info in result.items(): + if isinstance(logout_info, tuple): + binding, http_info = logout_info + if binding == entity.BINDING_HTTP_POST: + log.debug( + 'Returning form to the IdP to continue the logout process') + body = ''.join(http_info['data']) + print('-------------------------', entity.BINDING_HTTP_POST) + print(body) + elif binding == entity.BINDING_HTTP_REDIRECT: + log.debug( + 'Redirecting to the IdP to continue the logout process') + print('-------------------------', entity.BINDING_HTTP_REDIRECT) + print(h.get_location(http_info)) + else: + log.error('Failed to log out from Idp. Unknown binding: {}'.format(binding)) From cf131311765444a5a1ec44e41a94c4ca45025e30 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Thu, 22 Apr 2021 11:56:50 +0200 Subject: [PATCH 4/8] Remove imports --- ckanext/saml2auth/plugin.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index 9617a042..71714924 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -17,7 +17,6 @@ # encoding: utf-8 import logging -import requests from saml2.client_base import LogoutError from saml2 import entity @@ -27,8 +26,6 @@ import ckan.plugins as plugins import ckan.plugins.toolkit as toolkit from ckan.common import g -import ckan.lib.helpers as ch -import ckan.lib.base as base from ckanext.saml2auth.views.saml2auth import saml2auth from ckanext.saml2auth.cache import get_subject_id, get_saml_session_info From c7f170ced407d78f31b64236af983be7d5e7aa84 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Tue, 27 Apr 2021 21:04:31 +0200 Subject: [PATCH 5/8] Try to logout from IDP only when saml user in session, add IDP response handler to finish the log out process --- ckanext/saml2auth/plugin.py | 77 ++++++++++--------- .../templates/saml2auth/idp_logout.html | 1 + ckanext/saml2auth/views/saml2auth.py | 10 +++ 3 files changed, 52 insertions(+), 36 deletions(-) create mode 100644 ckanext/saml2auth/templates/saml2auth/idp_logout.html diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index 499da38c..db246bfd 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -21,11 +21,12 @@ from saml2.client_base import LogoutError from saml2 import entity -from flask import session +from flask import session, redirect import ckan.plugins as plugins import ckan.plugins.toolkit as toolkit from ckan.common import g +import ckan.lib.base as base from ckanext.saml2auth.views.saml2auth import saml2auth from ckanext.saml2auth.cache import get_subject_id, get_saml_session_info @@ -102,47 +103,51 @@ def login(self): def logout(self): - # We need to logout from IDP as well client = h.saml_client( sp_config() ) saml_session_info = get_saml_session_info(session) subject_id = get_subject_id(session) - client.users.add_information_about_person(saml_session_info) if subject_id is None: log.warning( 'The session does not contain the subject id for user {}'.format(g.user)) - - try: - result = client.global_logout(name_id=subject_id) - except LogoutError as e: - log.exception( - 'SLO not supported by IDP: {}'.format(e)) - # clear session - - if not result: - log.error( - 'Looks like the user {} is not logged in any IdP/AA'.format(subject_id)) - - if len(result) > 1: - log.error( - 'Sorry, I do not know how to logout from several sources.' - ' I will logout just from the first one') - - for entity_id, logout_info in result.items(): - if isinstance(logout_info, tuple): - binding, http_info = logout_info - if binding == entity.BINDING_HTTP_POST: - log.debug( - 'Returning form to the IdP to continue the logout process') - body = ''.join(http_info['data']) - print('-------------------------', entity.BINDING_HTTP_POST) - print(body) - elif binding == entity.BINDING_HTTP_REDIRECT: - log.debug( - 'Redirecting to the IdP to continue the logout process') - print('-------------------------', entity.BINDING_HTTP_REDIRECT) - print(h.get_location(http_info)) - else: - log.error('Failed to log out from Idp. Unknown binding: {}'.format(binding)) + else: + try: + client.users.add_information_about_person(saml_session_info) + result = client.global_logout(name_id=subject_id) + except LogoutError as e: + log.exception( + 'SLO not supported by IDP: {}'.format(e)) + # clear session + + if not result: + log.error( + 'Looks like the user {} is not logged in any IdP/AA'.format(subject_id)) + + if len(result) > 1: + log.error( + 'Sorry, I do not know how to logout from several sources.' + ' I will logout just from the first one') + + for entity_id, logout_info in result.items(): + if isinstance(logout_info, tuple): + binding, http_info = logout_info + if binding == entity.BINDING_HTTP_POST: + log.debug( + 'Returning form to the IdP to continue the logout process') + body = ''.join(http_info['data']) + print('------------------------- BINDING_HTTP_POST') + print(body) + extra_vars = { + u'body': body + } + return base.render(u'saml2auth/idp_logout.html', extra_vars) + elif binding == entity.BINDING_HTTP_REDIRECT: + log.debug( + 'Redirecting to the IdP to continue the logout process') + print('------------------------- BINDING_HTTP_REDIRECT') + print(h.get_location(http_info)) + return redirect(h.get_location(http_info), code=302) + else: + log.error('Failed to log out from Idp. Unknown binding: {}'.format(binding)) diff --git a/ckanext/saml2auth/templates/saml2auth/idp_logout.html b/ckanext/saml2auth/templates/saml2auth/idp_logout.html new file mode 100644 index 00000000..37fa2911 --- /dev/null +++ b/ckanext/saml2auth/templates/saml2auth/idp_logout.html @@ -0,0 +1 @@ +{{ body | safe }} \ No newline at end of file diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index dbe9305e..7f1bbd22 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -315,6 +315,15 @@ def disable_default_login_register(): return base.render(u'error_document_template.html', extra_vars), 403 +def slo(): + u'''View function that handles the IDP logout + request response and finish with logging out the user from CKAN + ''' + saml_response = request.form.get(u'SAMLResponse', None) + print(saml_response) + return toolkit.redirect_to(u'user.logout') + + acs_endpoint = config.get('ckanext.saml2auth.acs_endpoint', '/acs') saml2auth.add_url_rule(acs_endpoint, view_func=acs, methods=[u'GET', u'POST']) saml2auth.add_url_rule(u'/user/saml2login', view_func=saml2login) @@ -323,3 +332,4 @@ def disable_default_login_register(): u'/user/login', view_func=disable_default_login_register) saml2auth.add_url_rule( u'/user/register', view_func=disable_default_login_register) +saml2auth.add_url_rule(u'/slo', view_func=slo, methods=[u'GET', u'POST']) From 6cec95792c3b9993287e227900d0e89c012d571b Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Mon, 31 May 2021 13:08:53 +0200 Subject: [PATCH 6/8] Remove prints --- ckanext/saml2auth/plugin.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ckanext/saml2auth/plugin.py b/ckanext/saml2auth/plugin.py index db246bfd..cb8162dd 100644 --- a/ckanext/saml2auth/plugin.py +++ b/ckanext/saml2auth/plugin.py @@ -137,8 +137,6 @@ def logout(self): log.debug( 'Returning form to the IdP to continue the logout process') body = ''.join(http_info['data']) - print('------------------------- BINDING_HTTP_POST') - print(body) extra_vars = { u'body': body } @@ -146,8 +144,6 @@ def logout(self): elif binding == entity.BINDING_HTTP_REDIRECT: log.debug( 'Redirecting to the IdP to continue the logout process') - print('------------------------- BINDING_HTTP_REDIRECT') - print(h.get_location(http_info)) return redirect(h.get_location(http_info), code=302) else: log.error('Failed to log out from Idp. Unknown binding: {}'.format(binding)) From 68a70c1df592fa61c7fd0fdabe2287f90f70eafd Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Mon, 31 May 2021 13:12:48 +0200 Subject: [PATCH 7/8] Remove prints --- ckanext/saml2auth/views/saml2auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index 7f1bbd22..dc5978ad 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -320,7 +320,6 @@ def slo(): request response and finish with logging out the user from CKAN ''' saml_response = request.form.get(u'SAMLResponse', None) - print(saml_response) return toolkit.redirect_to(u'user.logout') From d83466b45b28e79b107e47c7a7951095f5407704 Mon Sep 17 00:00:00 2001 From: duskobogdanovski Date: Mon, 31 May 2021 13:15:16 +0200 Subject: [PATCH 8/8] Remove not used variables --- ckanext/saml2auth/views/saml2auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ckanext/saml2auth/views/saml2auth.py b/ckanext/saml2auth/views/saml2auth.py index dc5978ad..68ee268c 100644 --- a/ckanext/saml2auth/views/saml2auth.py +++ b/ckanext/saml2auth/views/saml2auth.py @@ -319,7 +319,6 @@ def slo(): u'''View function that handles the IDP logout request response and finish with logging out the user from CKAN ''' - saml_response = request.form.get(u'SAMLResponse', None) return toolkit.redirect_to(u'user.logout')