diff --git a/auth_session_scheduled_logout/README.rst b/auth_session_scheduled_logout/README.rst new file mode 100644 index 0000000000..56cb42a8b0 --- /dev/null +++ b/auth_session_scheduled_logout/README.rst @@ -0,0 +1,107 @@ +======================== +Scheduled Session Logout +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:e95d136b9f41fab8af17e33dc5863aa6ee89f13c45cc001d4617cbbc86c88359 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |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/18.0/auth_session_scheduled_logout + :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-18-0/server-auth-18-0-auth_session_scheduled_logout + :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=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module forces logout of all active user sessions on a schedule +(defaults to every Sunday at 23:00), regardless of user activity. It's +not an inactivity timeout: even users actively working are logged out at +scheduled time. At the scheduled time every targeted browser session +becomes invalid. On next request the user is redirected to the login +page. + +Installing or upgrading the module logs nobody out: the timestamp is +empty for existing users, and leaves the token untouched while it is +empty, so current sessions keep working until the scheduled job runs. +Users can be excluded from the logout by adding them to the security +group *Exempt from Scheduled Session Logout*. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +**Schedule** + +Go to *Settings > Technical > Automation > Scheduled Actions* and open +*Scheduled Session Logout: revoke all sessions*. + +- By default it runs **every Sunday at 23:00 UTC**. +- Adjust *Next Execution Date* and the interval to fit your needs. +- Deactivate the scheduled action to disable the feature entirely. + +**Excluding users** + +To keep some users logged in when the job runs, add them to the security +group *Exempt from Scheduled Session Logout*. The group appears as a +checkbox in the *Other* section of the user form (*Settings > Users*). + +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 +------- + +* ForgeFlow + +Contributors +------------ + +- ForgeFlow S.L. + + - Laura Cazorla + +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. + +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_session_scheduled_logout/__init__.py b/auth_session_scheduled_logout/__init__.py new file mode 100644 index 0000000000..cc6b6354ad --- /dev/null +++ b/auth_session_scheduled_logout/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import post_init_hook diff --git a/auth_session_scheduled_logout/__manifest__.py b/auth_session_scheduled_logout/__manifest__.py new file mode 100644 index 0000000000..8ce43552c5 --- /dev/null +++ b/auth_session_scheduled_logout/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Scheduled Session Logout", + "summary": "Force logout of all active user sessions on a schedule", + "version": "18.0.1.0.0", + "category": "Tools", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-auth", + "license": "AGPL-3", + "depends": ["base"], + "data": [ + "security/auth_session_scheduled_logout_groups.xml", + "data/ir_cron_data.xml", + ], + "post_init_hook": "post_init_hook", + "installable": True, +} diff --git a/auth_session_scheduled_logout/data/ir_cron_data.xml b/auth_session_scheduled_logout/data/ir_cron_data.xml new file mode 100644 index 0000000000..632a240855 --- /dev/null +++ b/auth_session_scheduled_logout/data/ir_cron_data.xml @@ -0,0 +1,16 @@ + + + + + Scheduled Session Logout: revoke all sessions + + code + model._cron_revoke_all_sessions() + 1 + weeks + 2099-01-04 23:00:00 + + + + diff --git a/auth_session_scheduled_logout/hooks.py b/auth_session_scheduled_logout/hooks.py new file mode 100644 index 0000000000..291dfbfe89 --- /dev/null +++ b/auth_session_scheduled_logout/hooks.py @@ -0,0 +1,23 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging +from datetime import datetime, time, timedelta + +from odoo import fields + +_logger = logging.getLogger(__name__) + + +def post_init_hook(env): + # Schedule the first run at the next Sunday 23:00 (UTC) + cron = env.ref("auth_session_scheduled_logout.cron_revoke_all_sessions") + if not cron: + return + now = fields.Datetime.now() + ahead = (6 - now.weekday()) % 7 + next_call = datetime.combine(now.date() + timedelta(days=ahead), time(23, 0)) + if next_call <= now: + next_call += timedelta(days=7) + cron.nextcall = next_call + _logger.info("Scheduled first session logout for %s UTC.", next_call) diff --git a/auth_session_scheduled_logout/models/__init__.py b/auth_session_scheduled_logout/models/__init__.py new file mode 100644 index 0000000000..8835165330 --- /dev/null +++ b/auth_session_scheduled_logout/models/__init__.py @@ -0,0 +1 @@ +from . import res_users diff --git a/auth_session_scheduled_logout/models/res_users.py b/auth_session_scheduled_logout/models/res_users.py new file mode 100644 index 0000000000..79c2db5512 --- /dev/null +++ b/auth_session_scheduled_logout/models/res_users.py @@ -0,0 +1,62 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import hmac +import logging +from hashlib import sha256 + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class ResUsers(models.Model): + _inherit = "res.users" + + auth_session_valid_from = fields.Datetime( + string="Sessions Valid From", + copy=False, + readonly=True, + help="Any browser session opened before this datetime is invalid: the " + "user is redirected to the login page on their next request. The value " + "is bumped by scheduled logout job and folded into the session token.", + ) + + def _compute_session_token(self, sid): + token = super()._compute_session_token(sid) + if not token: + return token + valid_from = self.sudo().auth_session_valid_from + if valid_from: + token_encoded = token.encode("utf-8") + valid_from_encoded = valid_from.isoformat().encode("utf-8") + encoded = hmac.new(token_encoded, valid_from_encoded, sha256) + token = encoded.hexdigest() + return token + + @api.model + def _get_scheduled_logout_exempt_group(self): + group_id = "auth_session_scheduled_logout.group_auth_session_no_sched_logout" + group = self.env.ref(group_id, raise_if_not_found=False) + return group or self.env["res.groups"] + + @api.model + def _get_users_to_logout(self): + domain = [] + exempt_group = self._get_scheduled_logout_exempt_group() + if exempt_group: + domain.append(("groups_id", "not in", exempt_group.ids)) + users = self.search(domain) + tech_users = self.browse() + for xmlid in ("base.public_user", "base.default_user"): + if self.env.ref(xmlid, raise_if_not_found=False): + tech_users |= self.env.ref(xmlid, raise_if_not_found=False) + return users - tech_users + + @api.model + def _cron_revoke_all_sessions(self): + users = self._get_users_to_logout() + if not users: + return + users.write({"auth_session_valid_from": fields.Datetime.now()}) + _logger.info("Scheduled logout: revoked sessions for %d user(s).", len(users)) diff --git a/auth_session_scheduled_logout/pyproject.toml b/auth_session_scheduled_logout/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/auth_session_scheduled_logout/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/auth_session_scheduled_logout/readme/CONFIGURE.md b/auth_session_scheduled_logout/readme/CONFIGURE.md new file mode 100644 index 0000000000..6efa820d8c --- /dev/null +++ b/auth_session_scheduled_logout/readme/CONFIGURE.md @@ -0,0 +1,14 @@ +**Schedule** + +Go to *Settings > Technical > Automation > Scheduled Actions* and open +*Scheduled Session Logout: revoke all sessions*. + +- By default it runs **every Sunday at 23:00 UTC**. +- Adjust *Next Execution Date* and the interval to fit your needs. +- Deactivate the scheduled action to disable the feature entirely. + +**Excluding users** + +To keep some users logged in when the job runs, add them to the security group +*Exempt from Scheduled Session Logout*. The group appears as a checkbox in the +*Other* section of the user form (*Settings > Users*). diff --git a/auth_session_scheduled_logout/readme/CONTRIBUTORS.md b/auth_session_scheduled_logout/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..c14e159d67 --- /dev/null +++ b/auth_session_scheduled_logout/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- ForgeFlow S.L. \<\> + - Laura Cazorla \<\> diff --git a/auth_session_scheduled_logout/readme/DESCRIPTION.md b/auth_session_scheduled_logout/readme/DESCRIPTION.md new file mode 100644 index 0000000000..4cd993cf25 --- /dev/null +++ b/auth_session_scheduled_logout/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +This module forces logout of all active user sessions on a schedule (defaults +to every Sunday at 23:00), regardless of user activity. It's not an inactivity +timeout: even users actively working are logged out at scheduled time. At the +scheduled time every targeted browser session becomes invalid. On next request +the user is redirected to the login page. + +Installing or upgrading the module logs nobody out: the timestamp is empty for +existing users, and leaves the token untouched while it is empty, so current +sessions keep working until the scheduled job runs. Users can be excluded from +the logout by adding them to the security group *Exempt from Scheduled Session +Logout*. diff --git a/auth_session_scheduled_logout/security/auth_session_scheduled_logout_groups.xml b/auth_session_scheduled_logout/security/auth_session_scheduled_logout_groups.xml new file mode 100644 index 0000000000..906b7e3ddf --- /dev/null +++ b/auth_session_scheduled_logout/security/auth_session_scheduled_logout_groups.xml @@ -0,0 +1,11 @@ + + + + + Exempt from Scheduled Session Logout + Members of this group keep their sessions when the scheduled logout job runs. + + diff --git a/auth_session_scheduled_logout/static/description/index.html b/auth_session_scheduled_logout/static/description/index.html new file mode 100644 index 0000000000..055713bc4f --- /dev/null +++ b/auth_session_scheduled_logout/static/description/index.html @@ -0,0 +1,452 @@ + + + + + +Scheduled Session Logout + + + +
+

Scheduled Session Logout

+ + +

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

+

This module forces logout of all active user sessions on a schedule +(defaults to every Sunday at 23:00), regardless of user activity. It’s +not an inactivity timeout: even users actively working are logged out at +scheduled time. At the scheduled time every targeted browser session +becomes invalid. On next request the user is redirected to the login +page.

+

Installing or upgrading the module logs nobody out: the timestamp is +empty for existing users, and leaves the token untouched while it is +empty, so current sessions keep working until the scheduled job runs. +Users can be excluded from the logout by adding them to the security +group Exempt from Scheduled Session Logout.

+

Table of contents

+ +
+

Configuration

+

Schedule

+

Go to Settings > Technical > Automation > Scheduled Actions and open +Scheduled Session Logout: revoke all sessions.

+
    +
  • By default it runs every Sunday at 23:00 UTC.
  • +
  • Adjust Next Execution Date and the interval to fit your needs.
  • +
  • Deactivate the scheduled action to disable the feature entirely.
  • +
+

Excluding users

+

To keep some users logged in when the job runs, add them to the security +group Exempt from Scheduled Session Logout. The group appears as a +checkbox in the Other section of the user form (Settings > Users).

+
+
+

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

+
    +
  • ForgeFlow
  • +
+
+
+

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.

+

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_session_scheduled_logout/tests/__init__.py b/auth_session_scheduled_logout/tests/__init__.py new file mode 100644 index 0000000000..b37dbbd091 --- /dev/null +++ b/auth_session_scheduled_logout/tests/__init__.py @@ -0,0 +1 @@ +from . import test_scheduled_logout diff --git a/auth_session_scheduled_logout/tests/test_scheduled_logout.py b/auth_session_scheduled_logout/tests/test_scheduled_logout.py new file mode 100644 index 0000000000..d4231e7d75 --- /dev/null +++ b/auth_session_scheduled_logout/tests/test_scheduled_logout.py @@ -0,0 +1,76 @@ +# Copyright 2026 ForgeFlow S.L. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields +from odoo.tests.common import TransactionCase + + +class TestScheduledLogout(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + group_id = "auth_session_scheduled_logout.group_auth_session_no_sched_logout" + cls.exempt_group = cls.env.ref(group_id) + Users = cls.env["res.users"].with_context(no_reset_password=True) + cls.user = Users.create( + { + "name": "Session User", + "login": "session_user@test.example", + } + ) + cls.exempt_user = Users.create( + { + "name": "Exempt User", + "login": "exempt_user@test.example", + "groups_id": [(4, cls.exempt_group.id)], + } + ) + + def test_field_not_in_session_token_fields(self): + # The field must NOT extend the native token formula + field = self.env["res.users"]._get_session_token_fields() + self.assertNotIn("auth_session_valid_from", field) + + def test_token_stable_while_timestamp_unset(self): + # With no timestamp set, the token is the plain one + # Clearing the timestamp again restores the original token, so on + # installation, where field is empty everywhere, invalidates nothing + sid = "a" * 42 + original = self.user._compute_session_token(sid) + self.user.auth_session_valid_from = fields.Datetime.now() + self.assertNotEqual(self.user._compute_session_token(sid), original) + self.user.auth_session_valid_from = False + self.assertEqual(self.user._compute_session_token(sid), original) + + def test_token_changes_on_revoke(self): + # Bumping the field creates different token for a given sid + sid = "a" * 42 + token_before = self.user._compute_session_token(sid) + self.assertTrue(token_before) + self.user.auth_session_valid_from = fields.Datetime.now() + # The value computed for the same sid now differs + token_after = self.user._compute_session_token(sid) + self.assertTrue(token_after) + self.assertNotEqual(token_before, token_after) + + def test_token_false_for_missing_user(self): + # A stale session cookie may point at a user that no longer exists + # (e.g. deleted, or rolled back between requests) + ghost = self.env["res.users"].browse(999999999) + self.assertFalse(ghost._compute_session_token("a" * 42)) + + def test_scope_includes_internal_excludes_exempt(self): + users = self.env["res.users"]._get_users_to_logout() + self.assertIn(self.user, users) + self.assertNotIn(self.exempt_user, users) + + def test_scope_excludes_technical_users(self): + users = self.env["res.users"]._get_users_to_logout() + public_user = self.env.ref("base.public_user", raise_if_not_found=False) + if public_user: + self.assertNotIn(public_user, users) + + def test_cron_revokes_expected_users(self): + self.env["res.users"]._cron_revoke_all_sessions() + self.assertTrue(self.user.auth_session_valid_from) + self.assertFalse(self.exempt_user.auth_session_valid_from)