From e9cbb67d2082d94a7919dfc9e7ee45b1390a1009 Mon Sep 17 00:00:00 2001 From: Vincent Hatakeyama Date: Tue, 13 May 2025 13:37:21 +0200 Subject: [PATCH 1/2] [IMP] auth_saml: user provisioning on login - custom message when response is too old - avoid using werkzeug.urls method, they are deprecated - add missing ondelete cascade when user is deleted - attribute mapping is now also duplicated when the provider is duplicated - factorize getting SAML attribute value, allowing using subject.nameId in mapping attributes too --- auth_saml/controllers/main.py | 16 ++- .../models/auth_saml_attribute_mapping.py | 1 + auth_saml/models/auth_saml_provider.py | 105 ++++++++++++------ auth_saml/models/res_users.py | 37 +++++- auth_saml/models/res_users_saml.py | 4 +- auth_saml/readme/CONFIGURE.md | 3 +- auth_saml/readme/HISTORY.md | 2 +- .../newsfragments/+user-creation.feature | 6 + auth_saml/tests/fake_idp.py | 13 ++- auth_saml/tests/test_pysaml.py | 73 ++++++++++-- auth_saml/views/auth_saml.xml | 10 ++ 11 files changed, 215 insertions(+), 55 deletions(-) create mode 100644 auth_saml/readme/newsfragments/+user-creation.feature diff --git a/auth_saml/controllers/main.py b/auth_saml/controllers/main.py index 6cdd118cd8..1498c49288 100644 --- a/auth_saml/controllers/main.py +++ b/auth_saml/controllers/main.py @@ -5,10 +5,11 @@ import functools import json import logging +from urllib.parse import quote_plus, unquote_plus, urlencode import werkzeug.utils +from saml2.validate import ResponseLifetimeExceed from werkzeug.exceptions import BadRequest -from werkzeug.urls import url_quote_plus from odoo import ( SUPERUSER_ID, @@ -100,7 +101,7 @@ def _auth_saml_request_link(self, provider: models.Model): redirect = request.params.get("redirect") if redirect: params["redirect"] = redirect - return "/auth_saml/get_auth_request?%s" % werkzeug.urls.url_encode(params) + return "/auth_saml/get_auth_request?%s" % urlencode(params) @http.route() def web_client(self, s_action=None, **kw): @@ -136,6 +137,8 @@ def web_login(self, *args, **kw): error = _("Sign up is not allowed on this database.") elif error == "access-denied": error = _("Access Denied") + elif error == "response-lifetime-exceed": + error = _("Response Lifetime Exceeded") elif error == "expired": error = _( "You do not have access to this database. Please contact" @@ -169,7 +172,7 @@ def _get_saml_extra_relaystate(self): ) state = { - "r": url_quote_plus(redirect), + "r": quote_plus(redirect), } return state @@ -231,9 +234,7 @@ def signin(self, **kw): ) action = state.get("a") menu = state.get("m") - redirect = ( - werkzeug.urls.url_unquote_plus(state["r"]) if state.get("r") else False - ) + redirect = unquote_plus(state["r"]) if state.get("r") else False url = "/web" if redirect: url = redirect @@ -255,6 +256,9 @@ def signin(self, **kw): redirect = werkzeug.utils.redirect(url, 303) redirect.autocorrect_location_header = False return redirect + except ResponseLifetimeExceed as e: + _logger.debug("Response Lifetime Exceed - %s", str(e)) + url = "/web/login?saml_error=response-lifetime-exceed" except Exception as e: # signup error diff --git a/auth_saml/models/auth_saml_attribute_mapping.py b/auth_saml/models/auth_saml_attribute_mapping.py index 6fb6190538..ec9537b6b2 100644 --- a/auth_saml/models/auth_saml_attribute_mapping.py +++ b/auth_saml/models/auth_saml_attribute_mapping.py @@ -13,6 +13,7 @@ class AuthSamlAttributeMapping(models.Model): "auth.saml.provider", index=True, required=True, + ondelete="cascade", ) attribute_name = fields.Char( string="IDP Response Attribute", diff --git a/auth_saml/models/auth_saml_provider.py b/auth_saml/models/auth_saml_provider.py index f1c0cb0de8..33e6897303 100644 --- a/auth_saml/models/auth_saml_provider.py +++ b/auth_saml/models/auth_saml_provider.py @@ -81,6 +81,7 @@ class AuthSamlProvider(models.Model): "auth.saml.attribute.mapping", "provider_id", string="Attribute Mapping", + copy=True, ) active = fields.Boolean(default=True) sequence = fields.Integer(index=True) @@ -136,6 +137,20 @@ class AuthSamlProvider(models.Model): default=True, help="Whether metadata should be signed or not", ) + # User creation fields + create_user = fields.Boolean( + default=False, + help="Create user if not found. The login and name will defaults to the SAML " + "user matching attribute. Use the mapping attributes to change the value " + "used.", + ) + create_user_template_id = fields.Many2one( + comodel_name="res.users", + # Template users, like base.default_user, are disabled by default so allow them + domain="[('active', 'in', (True, False))]", + default=lambda self: self.env.ref("base.default_user"), + help="When creating user, this user is used as a template", + ) @api.model def _sig_alg_selection(self): @@ -256,9 +271,7 @@ def _get_auth_request(self, extra_state=None, url_root=None): } state.update(extra_state) - sig_alg = ds.SIG_RSA_SHA1 - if self.sig_alg: - sig_alg = getattr(ds, self.sig_alg) + sig_alg = getattr(ds, self.sig_alg) saml_client = self._get_client_for_provider(url_root) reqid, info = saml_client.prepare_for_authenticate( @@ -272,6 +285,7 @@ def _get_auth_request(self, extra_state=None, url_root=None): for key, value in info["headers"]: if key == "Location": redirect_url = value + break self._store_outstanding_request(reqid) @@ -287,27 +301,15 @@ def _validate_auth_response(self, token: str, base_url: str = None): saml2.entity.BINDING_HTTP_POST, self._get_outstanding_requests_dict(), ) - matching_value = None - - if self.matching_attribute == "subject.nameId": - matching_value = response.name_id.text - else: - attrs = response.get_identity() - - for k, v in attrs.items(): - if k == self.matching_attribute: - matching_value = v - break - - if not matching_value: - raise Exception( - f"Matching attribute {self.matching_attribute} not found " - f"in user attrs: {attrs}" - ) - - if matching_value and isinstance(matching_value, list): - matching_value = next(iter(matching_value), None) - + try: + matching_value = self._get_attribute_value( + response, self.matching_attribute + ) + except KeyError: + raise KeyError( + f"Matching attribute {self.matching_attribute} not found " + f"in user attrs: {response.get_identity()}" + ) from None if isinstance(matching_value, str) and self.matching_attribute_to_lower: matching_value = matching_value.lower() @@ -349,24 +351,59 @@ def _metadata_string(self, valid=None, base_url: str = None): sign=self.sign_metadata, ) + @staticmethod + def _get_attribute_value(response, attribute_name: str): + """ + + :raise: KeyError if attribute is not in the response + :param response: + :param attribute_name: + :return: value of the attribute. if the value is an empty list, return None + otherwise return the first element of the list + """ + if attribute_name == "subject.nameId": + return response.name_id.text + attrs = response.get_identity() + attribute_value = attrs[attribute_name] + if isinstance(attribute_value, list): + attribute_value = next(iter(attribute_value), None) + return attribute_value + def _hook_validate_auth_response(self, response, matching_value): self.ensure_one() vals = {} - attrs = response.get_identity() for attribute in self.attribute_mapping_ids: - if attribute.attribute_name not in attrs: - _logger.debug( + try: + vals[attribute.field_name] = self._get_attribute_value( + response, attribute.attribute_name + ) + except KeyError: + _logger.warning( "SAML attribute '%s' not found in response %s", attribute.attribute_name, - attrs, + response.get_identity(), ) - continue - attribute_value = attrs[attribute.attribute_name] - if isinstance(attribute_value, list): - attribute_value = attribute_value[0] + return {"mapped_attrs": vals} - vals[attribute.field_name] = attribute_value + def _user_copy_defaults(self, validation): + """ + Returns defaults when copying the template user. - return {"mapped_attrs": vals} + Can be overridden with extra information. + :param validation: validation result + :return: a dictionary for copying template user, empty to avoid copying + """ + self.ensure_one() + if not self.create_user: + return {} + saml_uid = validation["user_id"] + return { + "name": saml_uid, + "login": saml_uid, + "active": True, + # if signature is not provided by mapped_attrs, it will be computed + # due to call to compute method in calling method. + "signature": None, + } | validation.get("mapped_attrs", {}) diff --git a/auth_saml/models/res_users.py b/auth_saml/models/res_users.py index e7514d1092..073cf51e0d 100644 --- a/auth_saml/models/res_users.py +++ b/auth_saml/models/res_users.py @@ -7,7 +7,7 @@ import passlib -from odoo import SUPERUSER_ID, _, api, fields, models, registry, tools +from odoo import SUPERUSER_ID, Command, _, api, fields, models, registry, tools from odoo.exceptions import AccessDenied, ValidationError from .ir_config_parameter import ALLOW_SAML_UID_AND_PASSWORD @@ -45,11 +45,42 @@ def _auth_saml_signin(self, provider: int, validation: dict, saml_response) -> s limit=1, ) user = user_saml.user_id - if len(user) != 1: - raise AccessDenied() + user_copy_defaults = {} + if not user: + user_copy_defaults = ( + self.env["auth.saml.provider"] + .browse(provider) + ._user_copy_defaults(validation) + ) + if not user_copy_defaults: + raise AccessDenied() with registry(self.env.cr.dbname).cursor() as new_cr: new_env = api.Environment(new_cr, self.env.uid, self.env.context) + if user_copy_defaults: + new_user = ( + new_env["auth.saml.provider"] + .browse(provider) + .create_user_template_id.with_context(no_reset_password=True) + .copy( + { + **user_copy_defaults, + "saml_ids": [ + Command.create( + { + "saml_provider_id": provider, + "saml_uid": saml_uid, + "saml_access_token": saml_response, + } + ) + ], + } + ) + ) + # Update signature as needed. + new_user._compute_signature() + return new_user.login + # Update the token. Need to be committed, otherwise the token is not visible # to other envs, like the one used in login_and_redirect user_saml.with_env(new_env).write({"saml_access_token": saml_response}) diff --git a/auth_saml/models/res_users_saml.py b/auth_saml/models/res_users_saml.py index d7cbd308d3..a60f493535 100644 --- a/auth_saml/models/res_users_saml.py +++ b/auth_saml/models/res_users_saml.py @@ -7,7 +7,9 @@ class ResUserSaml(models.Model): _name = "res.users.saml" _description = "User to SAML Provider Mapping" - user_id = fields.Many2one("res.users", index=True, required=True) + user_id = fields.Many2one( + "res.users", index=True, required=True, ondelete="cascade" + ) saml_provider_id = fields.Many2one( "auth.saml.provider", string="SAML Provider", index=True ) diff --git a/auth_saml/readme/CONFIGURE.md b/auth_saml/readme/CONFIGURE.md index 68072d142c..5a0f1ea84b 100644 --- a/auth_saml/readme/CONFIGURE.md +++ b/auth_saml/readme/CONFIGURE.md @@ -2,7 +2,8 @@ To use this module, you need an IDP server, properly set up. 1. Configure the module according to your IdP’s instructions (Settings \> Users & Companies \> SAML Providers). -2. Pre-create your users and set the SAML information against the user. +2. Pre-create your users and set the SAML information against the user, + or use the module ability to create users as they log in. By default, the module let users have both a password and SAML ids. To increase security, disable passwords by using the option in Settings. diff --git a/auth_saml/readme/HISTORY.md b/auth_saml/readme/HISTORY.md index b8cac59791..ef2f355550 100644 --- a/auth_saml/readme/HISTORY.md +++ b/auth_saml/readme/HISTORY.md @@ -5,7 +5,7 @@ - Avoid redirecting when there is a SAML error. -## 17.0.1.1.0 +## 17.0.1.0.1 When using attribute mapping, only write value that changes. No writing the value systematically avoids getting security mail on login/email diff --git a/auth_saml/readme/newsfragments/+user-creation.feature b/auth_saml/readme/newsfragments/+user-creation.feature new file mode 100644 index 0000000000..ecac38aad7 --- /dev/null +++ b/auth_saml/readme/newsfragments/+user-creation.feature @@ -0,0 +1,6 @@ +- custom message when response is too old +- avoid using werkzeug.urls method, they are deprecated +- add missing ondelete cascade when user is deleted +- attribute mapping is now also duplicated when the provider is duplicated +- factorize getting SAML attribute value, allowing using subject.nameId in mapping attributes too +- allow creating user if not found by copying a template user diff --git a/auth_saml/tests/fake_idp.py b/auth_saml/tests/fake_idp.py index f2865b403d..6e4c91ef2e 100644 --- a/auth_saml/tests/fake_idp.py +++ b/auth_saml/tests/fake_idp.py @@ -73,13 +73,21 @@ } +class DummyNameId: + """Dummy name id with text value""" + + def __init__(self, text): + self.text = text + + class DummyResponse: - def __init__(self, status, data, headers=None): + def __init__(self, status, data, headers=None, name_id: str = ""): self.status_code = status self.text = data self.headers = headers or [] self.content = data self._identity = {} + self.name_id = DummyNameId(name_id) def _unpack(self, ver="SAMLResponse"): """ @@ -127,6 +135,7 @@ def __init__(self, metadatas=None): config.load(settings) config.allow_unknown_attributes = True Server.__init__(self, config=config) + self.mail = "test@example.com" def get_metadata(self): return create_metadata_string( @@ -163,7 +172,7 @@ def authn_request_endpoint(self, req, binding, relay_state): "surName": "Example", "givenName": "Test", "title": "Ind", - "mail": "test@example.com", + "mail": self.mail, } resp_args.update({"sign_assertion": True, "sign_response": True}) diff --git a/auth_saml/tests/test_pysaml.py b/auth_saml/tests/test_pysaml.py index 78d87b0b20..368788c2aa 100644 --- a/auth_saml/tests/test_pysaml.py +++ b/auth_saml/tests/test_pysaml.py @@ -7,6 +7,7 @@ from odoo.exceptions import AccessDenied, UserError, ValidationError from odoo.tests import HttpCase, tagged +from odoo.tools import mute_logger from .fake_idp import DummyResponse, FakeIDP @@ -101,6 +102,8 @@ def test_ensure_provider_appears_on_login_with_redirect_param(self): def test__onchange_name(self): temp = self.saml_provider.body + r = self.saml_provider._onchange_name() + self.assertEqual(self.saml_provider.body, temp) self.saml_provider.body = "" r = self.saml_provider._onchange_name() self.assertEqual(r, None) @@ -153,9 +156,10 @@ def test__hook_validate_auth_response(self): ) self._add_mapping_to_provider() # Call the method - result = self.saml_provider._hook_validate_auth_response( - fake_response, "test@example.com" - ) + with mute_logger("odoo.addons.auth_saml.models.auth_saml_provider"): + result = self.saml_provider._hook_validate_auth_response( + fake_response, "test@example.com" + ) # Check the result self.assertIn("mapped_attrs", result) @@ -236,7 +240,9 @@ def add_provider_to_user(self): def test_login_with_saml(self): self.add_provider_to_user() + self._login_with_saml() + def _login_with_saml(self): redirect_url = self.saml_provider._get_auth_request() self.assertIn("http://localhost:8000/sso/redirect?SAMLRequest=", redirect_url) @@ -253,7 +259,7 @@ def test_login_with_saml(self): ) self.assertEqual(database, self.env.cr.dbname) - self.assertEqual(login, self.user.login) + self.assertEqual(login, "test@example.com") # We should not be able to log in with the wrong token with self.assertRaises(AccessDenied): @@ -273,6 +279,34 @@ def test_login_with_saml_mapping_attributes(self): # Not changed self.assertEqual(self.user.login, "test@example.com") + def test_login_with_saml_mapping_attributes_subject(self): + """Test login with SAML on a provider with mapping attributes""" + self.assertEqual(self.user.email, "test@example.com") + self.saml_provider.attribute_mapping_ids = [ + (0, 0, {"attribute_name": "subject.nameId", "field_name": "email"}), + ] + self.test_login_with_saml() + # The value is changed, but FakeIDP returns a random value so + # only test that the value is changed. + self.assertNotEqual(self.user.email, "test@example.com") + + def test_login_with_saml_to_lower(self): + self.add_provider_to_user() + self.saml_provider.matching_attribute_to_lower = True + self.idp.mail = "TEST@example.com" + self._login_with_saml() + + def test_login_with_saml_non_existing_mapping_attribute(self): + self.saml_provider.matching_attribute = "nick_name" + self.add_provider_to_user() + with self.assertRaises(KeyError): + self._login_with_saml() + + def test_create_user(self): + self.user.unlink() + self.saml_provider.create_user = True + self._login_with_saml() + def test_disallow_user_password_when_changing_ir_config_parameter(self): """Test that disabling users from having both a password and SAML ids remove users password.""" @@ -317,9 +351,10 @@ def test_disallow_user_password_no_password_set(self): """Test that a new user with SAML ids can not have its password set up when the disallow option is set.""" # change the option - self.browse_ref( - "auth_saml.allow_saml_uid_and_internal_password" - ).value = "False" + self.browse_ref("auth_saml.allow_saml_uid_and_internal_password").unlink() + self.env["ir.config_parameter"].set_param( + "auth_saml.allow_saml_uid_and_internal_password", "False" + ) # Create a new user with only SAML ids user = ( self.env["res.users"] @@ -353,12 +388,36 @@ def test_disallow_user_password(self): self.browse_ref( "auth_saml.allow_saml_uid_and_internal_password" ).value = "False" + # Test that existing user password still works if no saml uid is set + self.authenticate(user="test@example.com", password="Lu,ums-7vRU>0i]=YDLa") # Test that existing user password is deleted when adding an SAML provider + self.add_provider_to_user() + with self.assertRaises(AccessDenied): + self.authenticate(user="test@example.com", password="Lu,ums-7vRU>0i]=YDLa") + + def test_disallow_user_password_unlink(self): + """Test that existing user password is deleted when adding an SAML provider when + the disallow option is not present (and defaults to false).""" + # change the option + self.browse_ref("auth_saml.allow_saml_uid_and_internal_password").unlink() + # Test that existing user password still works if no saml uid is set self.authenticate(user="test@example.com", password="Lu,ums-7vRU>0i]=YDLa") + # Test that existing user password is deleted when adding an SAML provider self.add_provider_to_user() with self.assertRaises(AccessDenied): self.authenticate(user="test@example.com", password="Lu,ums-7vRU>0i]=YDLa") + def test_change_unrelated_ir_config_parameter(self): + """Test another branch, creating or writing another ir.config_parameter""" + param = self.env["ir.config_parameter"].create( + [{"key": "test", "value": "unrelated"}] + ) + self.authenticate(user="test@example.com", password="Lu,ums-7vRU>0i]=YDLa") + self.env["ir.config_parameter"].set_param("test", "False") + self.authenticate(user="test@example.com", password="Lu,ums-7vRU>0i]=YDLa") + param.unlink() + self.authenticate(user="test@example.com", password="Lu,ums-7vRU>0i]=YDLa") + def test_disallow_user_admin_can_have_password(self): """Test that admin can have its password set even if the disallow option is set.""" diff --git a/auth_saml/views/auth_saml.xml b/auth_saml/views/auth_saml.xml index 9ee7dc0335..11f0582d8b 100644 --- a/auth_saml/views/auth_saml.xml +++ b/auth_saml/views/auth_saml.xml @@ -182,6 +182,16 @@ + + + + From 885ea9c5af2ea73617b386b9e4b2aef0bc1e5626 Mon Sep 17 00:00:00 2001 From: Vincent Hatakeyama Date: Tue, 20 May 2025 13:54:17 +0200 Subject: [PATCH 2/2] [ADD] auth_logout_redirect: redirect after log out --- .../tests/test_login.py | 10 +- auth_logout_redirect/README.rst | 108 +++++ auth_logout_redirect/__init__.py | 3 + auth_logout_redirect/__manifest__.py | 22 + auth_logout_redirect/controllers/__init__.py | 5 + auth_logout_redirect/controllers/home.py | 15 + auth_logout_redirect/controllers/session.py | 15 + auth_logout_redirect/pyproject.toml | 3 + auth_logout_redirect/readme/CONFIGURE.md | 1 + auth_logout_redirect/readme/CONTEXT.md | 3 + auth_logout_redirect/readme/CONTRIBUTORS.md | 2 + auth_logout_redirect/readme/DESCRIPTION.md | 1 + auth_logout_redirect/readme/USAGE.md | 1 + .../readme/newsfragments/.gitkeep | 0 .../static/description/icon.png | Bin 0 -> 10254 bytes .../static/description/icon.svg | 1 + .../static/description/index.html | 451 ++++++++++++++++++ auth_logout_redirect/tests/__init__.py | 4 + auth_logout_redirect/tests/test_logout.py | 15 + .../views/webclient_templates.xml | 58 +++ 20 files changed, 716 insertions(+), 2 deletions(-) create mode 100644 auth_logout_redirect/README.rst create mode 100644 auth_logout_redirect/__init__.py create mode 100644 auth_logout_redirect/__manifest__.py create mode 100644 auth_logout_redirect/controllers/__init__.py create mode 100644 auth_logout_redirect/controllers/home.py create mode 100644 auth_logout_redirect/controllers/session.py create mode 100644 auth_logout_redirect/pyproject.toml create mode 100644 auth_logout_redirect/readme/CONFIGURE.md create mode 100644 auth_logout_redirect/readme/CONTEXT.md create mode 100644 auth_logout_redirect/readme/CONTRIBUTORS.md create mode 100644 auth_logout_redirect/readme/DESCRIPTION.md create mode 100644 auth_logout_redirect/readme/USAGE.md create mode 100644 auth_logout_redirect/readme/newsfragments/.gitkeep create mode 100644 auth_logout_redirect/static/description/icon.png create mode 100644 auth_logout_redirect/static/description/icon.svg create mode 100644 auth_logout_redirect/static/description/index.html create mode 100644 auth_logout_redirect/tests/__init__.py create mode 100644 auth_logout_redirect/tests/test_logout.py create mode 100644 auth_logout_redirect/views/webclient_templates.xml diff --git a/auth_admin_passkey_totp_mail_enforce/tests/test_login.py b/auth_admin_passkey_totp_mail_enforce/tests/test_login.py index 7f0c302edb..94d54985d3 100644 --- a/auth_admin_passkey_totp_mail_enforce/tests/test_login.py +++ b/auth_admin_passkey_totp_mail_enforce/tests/test_login.py @@ -52,7 +52,10 @@ def test_01_web_login_with_user_password_and_2fa(self): # Reset session (login page displayed) response = self.url_open("/web/session/logout") - self.assertEqual(response.request.path_url, "/web/login") + # Response url changed by module auth_logout_redirect + self.assertIn( + response.request.path_url, ["/web/login", "/web/logout_successful"] + ) # Enable passkey and set auth_admin_passkey_ignore_totp = True config["auth_admin_passkey_password"] = self.sysadmin_passkey @@ -76,7 +79,10 @@ def test_02_web_login_with_passkey_and_2fa(self): # Reset session (login page displayed) response = self.url_open("/web/session/logout") - self.assertEqual(response.request.path_url, "/web/login") + # Response url changed by module auth_logout_redirect + self.assertIn( + response.request.path_url, ["/web/login", "/web/logout_successful"] + ) # Enable passkey and set auth_admin_passkey_ignore_totp = True config["auth_admin_passkey_password"] = self.sysadmin_passkey diff --git a/auth_logout_redirect/README.rst b/auth_logout_redirect/README.rst new file mode 100644 index 0000000000..f4931b602d --- /dev/null +++ b/auth_logout_redirect/README.rst @@ -0,0 +1,108 @@ +=============== +Logout Redirect +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:3303bcf2401ebc8059631f92c8cdefdbdea276313f93086ffeeacc571856e32a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github + :target: https://github.com/OCA/server-auth/tree/17.0/auth_logout_redirect + :alt: OCA/server-auth +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-auth-17-0/server-auth-17-0-auth_logout_redirect + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds a page displayed when users log out. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +This is useful when a method redirects log in automatically, but an +option to stay logged out is still needed. Automatic redirection is +offered in the modules auth_oauth_autologin and auth_saml. + +Configuration +============= + +No configuration is needed when this module is used. + +Usage +===== + +The changes are visible once logged out of the application. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* XCG SAS + +Contributors +------------ + +- XCG SAS, part of `Orbeet `__ + + - Vincent Hatakeyama vincent.hatakeyama@orbeet.io + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-vincent-hatakeyama| image:: https://github.com/vincent-hatakeyama.png?size=40px + :target: https://github.com/vincent-hatakeyama + :alt: vincent-hatakeyama + +Current `maintainer `__: + +|maintainer-vincent-hatakeyama| + +This module is part of the `OCA/server-auth `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/auth_logout_redirect/__init__.py b/auth_logout_redirect/__init__.py new file mode 100644 index 0000000000..9613a24bd3 --- /dev/null +++ b/auth_logout_redirect/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import controllers diff --git a/auth_logout_redirect/__manifest__.py b/auth_logout_redirect/__manifest__.py new file mode 100644 index 0000000000..095dd6f9d3 --- /dev/null +++ b/auth_logout_redirect/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2025 XCG SAS +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Logout Redirect", + "summary": "Redirect on logout", + "version": "17.0.1.0.0", + "development_status": "Alpha", + "category": "Tools", + "website": "https://github.com/OCA/server-auth", + "author": "XCG SAS, Odoo Community Association (OCA)", + "maintainers": ["vincent-hatakeyama"], + "license": "AGPL-3", + "application": False, + "installable": True, + "preloadable": True, + "depends": [ + "web", + ], + "data": [ + "views/webclient_templates.xml", + ], +} diff --git a/auth_logout_redirect/controllers/__init__.py b/auth_logout_redirect/controllers/__init__.py new file mode 100644 index 0000000000..eb0e76aafa --- /dev/null +++ b/auth_logout_redirect/controllers/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 XCG SAS +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import home +from . import session diff --git a/auth_logout_redirect/controllers/home.py b/auth_logout_redirect/controllers/home.py new file mode 100644 index 0000000000..b4f60796e1 --- /dev/null +++ b/auth_logout_redirect/controllers/home.py @@ -0,0 +1,15 @@ +# Copyright © 2025 XCG SAS +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import http + + +class Home(http.Controller): + @http.route( + "/web/logout_successful", type="http", auth="none", website=False, sitemap=False + ) + def logout_successful(self): + """Landing page after successful logout. + Log out user if they were still logged in.""" + if http.request.session.uid: + return http.request.redirect("/web/session/logout", 303) + return http.request.render("auth_logout_redirect.logout_successful") diff --git a/auth_logout_redirect/controllers/session.py b/auth_logout_redirect/controllers/session.py new file mode 100644 index 0000000000..6aa66a33db --- /dev/null +++ b/auth_logout_redirect/controllers/session.py @@ -0,0 +1,15 @@ +# Copyright © 2025 XCG SAS +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import http + +from odoo.addons.web.controllers.session import Session + + +class SessionLogout(Session): + # If it was possible the default redirect would be changed. + # That does not work when http_routing is installed or any module that would change + # logout. + @http.route("/web/session/logout", type="http", auth="none") + def logout(self, redirect="/web"): # pylint: disable=unused-argument + return super().logout("web/logout_successful") diff --git a/auth_logout_redirect/pyproject.toml b/auth_logout_redirect/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/auth_logout_redirect/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/auth_logout_redirect/readme/CONFIGURE.md b/auth_logout_redirect/readme/CONFIGURE.md new file mode 100644 index 0000000000..6b0223bd75 --- /dev/null +++ b/auth_logout_redirect/readme/CONFIGURE.md @@ -0,0 +1 @@ +No configuration is needed when this module is used. diff --git a/auth_logout_redirect/readme/CONTEXT.md b/auth_logout_redirect/readme/CONTEXT.md new file mode 100644 index 0000000000..bf892eb98e --- /dev/null +++ b/auth_logout_redirect/readme/CONTEXT.md @@ -0,0 +1,3 @@ +This is useful when a method redirects log in automatically, +but an option to stay logged out is still needed. +Automatic redirection is offered in the modules auth_oauth_autologin and auth_saml. diff --git a/auth_logout_redirect/readme/CONTRIBUTORS.md b/auth_logout_redirect/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..faf3ab5e5e --- /dev/null +++ b/auth_logout_redirect/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- XCG SAS, part of [Orbeet](https://orbeet.io/) + - Vincent Hatakeyama diff --git a/auth_logout_redirect/readme/DESCRIPTION.md b/auth_logout_redirect/readme/DESCRIPTION.md new file mode 100644 index 0000000000..0884f37d0d --- /dev/null +++ b/auth_logout_redirect/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module adds a page displayed when users log out. diff --git a/auth_logout_redirect/readme/USAGE.md b/auth_logout_redirect/readme/USAGE.md new file mode 100644 index 0000000000..c93ff2db8b --- /dev/null +++ b/auth_logout_redirect/readme/USAGE.md @@ -0,0 +1 @@ +The changes are visible once logged out of the application. diff --git a/auth_logout_redirect/readme/newsfragments/.gitkeep b/auth_logout_redirect/readme/newsfragments/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/auth_logout_redirect/static/description/icon.png b/auth_logout_redirect/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1dcc49c24f364e9adf0afbc6fc0bac6dbecdeb11 GIT binary patch literal 10254 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 \ No newline at end of file diff --git a/auth_logout_redirect/static/description/index.html b/auth_logout_redirect/static/description/index.html new file mode 100644 index 0000000000..65da3268ff --- /dev/null +++ b/auth_logout_redirect/static/description/index.html @@ -0,0 +1,451 @@ + + + + + +Logout Redirect + + + +
+

Logout Redirect

+ + +

Alpha License: AGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

+

This module adds a page displayed when users log out.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Use Cases / Context

+

This is useful when a method redirects log in automatically, but an +option to stay logged out is still needed. Automatic redirection is +offered in the modules auth_oauth_autologin and auth_saml.

+
+
+

Configuration

+

No configuration is needed when this module is used.

+
+
+

Usage

+

The changes are visible once logged out of the application.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • XCG SAS
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

vincent-hatakeyama

+

This module is part of the OCA/server-auth project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/auth_logout_redirect/tests/__init__.py b/auth_logout_redirect/tests/__init__.py new file mode 100644 index 0000000000..4915d9c22c --- /dev/null +++ b/auth_logout_redirect/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 XCG SAS +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_logout diff --git a/auth_logout_redirect/tests/test_logout.py b/auth_logout_redirect/tests/test_logout.py new file mode 100644 index 0000000000..714dda5ba3 --- /dev/null +++ b/auth_logout_redirect/tests/test_logout.py @@ -0,0 +1,15 @@ +# Copyright © 2025 XCG SAS +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.addons.web.tests.test_login import TestWebLoginCommon + + +class TestWebLogin(TestWebLoginCommon): + def test_web_logout(self): + self.login("internal_user", "internal_user") + res = self.url_open("/web/session/logout") + self.assertEqual(res.request.path_url, "/web/logout_successful") + + def test_web_logout_page_while_logged_in(self): + self.login("internal_user", "internal_user") + res = self.url_open("/web/logout_successful") + self.assertEqual(res.request.path_url, "/web/logout_successful") diff --git a/auth_logout_redirect/views/webclient_templates.xml b/auth_logout_redirect/views/webclient_templates.xml new file mode 100644 index 0000000000..3df15b7d2c --- /dev/null +++ b/auth_logout_redirect/views/webclient_templates.xml @@ -0,0 +1,58 @@ + + + +