Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions auth_session_logout_api/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
=========================
Force User Session Logout
=========================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:placeholder
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |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/16.0/auth_session_logout_api
: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-16-0/server-auth-16-0-auth_session_logout_api
: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=16.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

This module provides a secure API endpoint to force logout user sessions remotely.

**Features:**

* Token-based authentication via HTTP headers (prevents token exposure in logs)
* Supports both custom header and standard Bearer authentication
* Lookup users by login or email (case insensitive)
* Comprehensive audit logging for all API requests
* Session invalidation via Odoo's session token mechanism

When a force logout is triggered, the module updates a special field that is part of the session token computation.
This invalidates all existing sessions for the target user, forcing them to re-authenticate.

**Table of contents**

.. contents::
:local:

Configuration
=============

This module uses the standard **Administration / Settings** group (``base.group_system``)
for access control. Only users with this group can:

* View and generate the force logout API token
* View all audit logs

To generate the API token:

#. Go to **Settings** → **General Settings**
#. Find the **Force Session Logout** section
#. Click **Generate Token** to create a new secure token
#. Copy the token and store it securely for use in API calls

**Security considerations:**

* The token is transmitted via HTTP headers (not URL) to prevent exposure in logs
* Store the token securely and rotate it periodically
* Consider implementing rate limiting at the reverse proxy level
* All API calls are logged for auditing purposes

To view audit logs:

#. Go to **Settings** → **Technical** → **Security** → **Force Logout Audit**

Usage
=====

API Endpoint
~~~~~~~~~~~~

To force logout a user, make a POST request to::

POST /web/session/force_logout?user=LOGIN_OR_EMAIL

**Authentication:**

The API uses token-based authentication via HTTP headers. You can use either:

* ``X-Force-Logout-Token: TOKEN`` - Custom header
* ``Authorization: Bearer TOKEN`` - Standard Bearer authentication

**Parameters:**

* ``user`` (required): User login or email address to force logout (query parameter)

**Example using cURL:**

.. code-block:: bash

# Using X-Force-Logout-Token header
curl -X POST "https://your-odoo.com/web/session/force_logout?user=john.doe" \
-H "X-Force-Logout-Token: your-secure-token"

# Using Authorization Bearer header
curl -X POST "https://your-odoo.com/web/session/force_logout?user=john@example.com" \
-H "Authorization: Bearer your-secure-token"

**Response Codes:**

* ``200 OK`` - User successfully logged out

.. code-block:: json

{"success": true, "message": "User \"john.doe\" has been logged out successfully"}

* ``401 Unauthorized`` - Invalid or missing token

.. code-block:: json

{"error": "Unauthorized", "message": "Invalid or missing authentication token"}

* ``404 Not Found`` - User not found

.. code-block:: json

{"error": "User not found", "message": "User with login or email \"unknown\" not found"}

* ``500 Internal Server Error`` - Server error

Viewing Audit Logs
~~~~~~~~~~~~~~~~~~

#. Go to **Settings** → **Technical** → **Security** → **Force Logout Audit**
#. View the list of all force logout operations
#. Use filters to search by status, date, or target user

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-auth/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 <https://github.com/OCA/server-auth/issues/new?body=module:%20auth_session_logout_api%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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

Credits
=======

Authors
~~~~~~~

* Kencove

Contributors
~~~~~~~~~~~~

* Thien Vo <thienvh@trobz.com>
* Chau Le <chaulb@trobz.com>

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 <https://github.com/OCA/server-auth/tree/16.0/auth_session_logout_api>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
2 changes: 2 additions & 0 deletions auth_session_logout_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import models
from . import controllers
21 changes: 21 additions & 0 deletions auth_session_logout_api/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copyright 2026 Kencove (https://www.kencove.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Force User Session Logout",
"summary": "Force logout user sessions via secure API endpoint",
"version": "16.0.1.0.0",
"category": "Tools",
"website": "https://github.com/OCA/server-auth",
"author": "Kencove, Odoo Community Association (OCA)",
"license": "AGPL-3",
"installable": True,
"depends": [
"base_setup",
],
"data": [
"security/security.xml",
"security/ir.model.access.csv",
"views/auth_session_logout_audit_views.xml",
"views/res_config_settings_views.xml",
],
}
1 change: 1 addition & 0 deletions auth_session_logout_api/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import main
188 changes: 188 additions & 0 deletions auth_session_logout_api/controllers/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# Copyright 2026 Kencove (https://www.kencove.com).
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

import logging
import secrets

from odoo import http
from odoo.http import request

_logger = logging.getLogger(__name__)


class SessionLogoutController(http.Controller):

_USER_AGENT_MAX_LENGTH = 200
_ERROR_MESSAGE_MAX_LENGTH = 500

def _get_request_info(self):
"""Get common request information for audit logging"""
return {
"request_ip": request.httprequest.environ.get("REMOTE_ADDR", "Unknown"),
"user_agent": request.httprequest.environ.get("HTTP_USER_AGENT", "")[
: self._USER_AGENT_MAX_LENGTH
],
}

def _create_audit_log(self, status, target_user=None, error_message=None):
"""Create audit log entry"""
vals = {
**self._get_request_info(),
"status": status,
}
if target_user:
vals["target_user_id"] = target_user.id
if error_message:
vals["error_message"] = str(error_message)[: self._ERROR_MESSAGE_MAX_LENGTH]
return request.env["auth.session.logout.audit"].sudo().create(vals)

def _get_token_from_header(self):
"""Extract token from HTTP header.

Supports both 'X-Force-Logout-Token' header and 'Authorization: Bearer' header.
"""
# Check X-Force-Logout-Token header first
token = request.httprequest.headers.get("X-Force-Logout-Token")
if token:
return token

# Fall back to Authorization header with Bearer scheme
auth_header = request.httprequest.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
return auth_header[7:] # Remove "Bearer " prefix

return None

def _validate_token(self, token):
"""Validate the provided token against system parameter"""
if not token:
return False

system_token = (
request.env["ir.config_parameter"]
.sudo()
.get_param("auth_session_logout_api.token")
)
if not system_token:
_logger.error("Logout token not configured")
return False

return secrets.compare_digest(token, system_token)

def _find_user(self, user_identifier):
"""Find user by login or email (case insensitive)"""
if not user_identifier:
return None

ResUsers = request.env["res.users"].sudo()

# Try by login first (case insensitive)
user = ResUsers.search([("login", "=ilike", user_identifier)], limit=1)

if not user:
# Try by email (case insensitive)
user = ResUsers.search([("email", "=ilike", user_identifier)], limit=1)

return user

def _force_user_logout(self, user):
"""Force logout of all sessions for the user"""
try:
# Use dedicated method to handle logout and counter increment
user.with_context(
auth_session_logout_api_call=True
).sudo().action_force_logout()

self._create_audit_log("success", target_user=user)
_logger.info(
"Force logout triggered for user %s from IP %s",
user.login,
request.httprequest.environ.get("REMOTE_ADDR"),
)
return True

except Exception as e:
_logger.exception("Failed to force logout for user %s", user.login)
self._create_audit_log("error", target_user=user, error_message=str(e))
return False

@http.route(
"/web/session/force_logout",
type="http",
auth="none",
methods=["POST"],
csrf=False,
)
def force_logout(self, user=None, **kwargs):
"""Force logout of user sessions

Authentication is done via HTTP headers:
- X-Force-Logout-Token: TOKEN
- Or: Authorization: Bearer TOKEN

Args:
user (str): User login or email to logout (query param or form data)

Returns:
JSON response with success/error status
"""
try:
# Get token from header and validate
# Avoid using the token from request parameters to prevent it
# from being exposed in the URL and logs.
token = self._get_token_from_header()
if not self._validate_token(token):
self._create_audit_log(
"unauthorized", error_message="Invalid or missing token"
)
return request.make_json_response(
{
"error": "Unauthorized",
"message": "Invalid or missing authentication token",
},
status=401,
)

# Find user
target_user = self._find_user(user)
if not target_user:
self._create_audit_log(
"user_not_found",
error_message=f"User not found: {user}",
)
return request.make_json_response(
{
"error": "User not found",
"message": f'User with login or email "{user}" not found',
},
status=404,
)

# Force logout
if self._force_user_logout(target_user):
return request.make_json_response(
{
"success": True,
"message": f"User '{target_user.login}' has been logged out"
" successfully",
}
)
else:
return request.make_json_response(
{
"error": "Internal error",
"message": "Failed to logout user. Please check logs for details.",
},
status=500,
)

except Exception as e:
_logger.exception("Unexpected error in force_logout")
self._create_audit_log("error", error_message=str(e))
return request.make_json_response(
{
"error": "Internal server error",
"message": "An unexpected error occurred. Please contact administrator.",
},
status=500,
)
3 changes: 3 additions & 0 deletions auth_session_logout_api/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import res_config_settings
from . import res_users
from . import auth_session_logout_audit
Loading