diff --git a/access_restriction_by_ip/__manifest__.py b/access_restriction_by_ip/__manifest__.py index e48840aed..c0604695d 100644 --- a/access_restriction_by_ip/__manifest__.py +++ b/access_restriction_by_ip/__manifest__.py @@ -21,7 +21,7 @@ ################################################################################ { 'name': 'Access Restriction By IP', - 'version': '18.0.1.0.0', + 'version': '18.0.1.0.1', 'category': 'Extra Tools', 'summary': """User can be restricted from logging in from different Ip""", 'description': """This module enhances security by allowing administrators diff --git a/access_restriction_by_ip/controllers/__init__.py b/access_restriction_by_ip/controllers/__init__.py index 77356f208..7350b843b 100644 --- a/access_restriction_by_ip/controllers/__init__.py +++ b/access_restriction_by_ip/controllers/__init__.py @@ -20,3 +20,4 @@ # ################################################################################ from . import access_restriction_by_ip +from . import session \ No newline at end of file diff --git a/access_restriction_by_ip/controllers/access_restriction_by_ip.py b/access_restriction_by_ip/controllers/access_restriction_by_ip.py index c0bb72ad2..e19dc8e90 100644 --- a/access_restriction_by_ip/controllers/access_restriction_by_ip.py +++ b/access_restriction_by_ip/controllers/access_restriction_by_ip.py @@ -22,103 +22,93 @@ import odoo from odoo import http from odoo.addons.web.controllers import home -from odoo.addons.web.controllers.utils import ensure_db, _get_login_redirect_url, is_user_internal +from odoo.addons.web.controllers.utils import ensure_db from odoo.http import request, route from odoo.tools.translate import _ +from odoo.exceptions import AccessDenied +# reuse helpers from session module +from .session import _check_user_ip, _get_client_ip -SIGN_UP_REQUEST_PARAMS = {'db', 'login', 'debug', 'token', 'message', 'error', - 'scope', 'mode', - 'redirect', 'redirect_hostname', 'email', 'name', - 'partner_id', - 'password', 'confirm_password', 'city', 'country_id', - 'lang', 'signup_email'} -LOGIN_SUCCESSFUL_PARAMS = set() +SIGN_UP_REQUEST_PARAMS = { + 'db', 'login', 'debug', 'token', 'message', 'error', + 'scope', 'mode', 'redirect', 'redirect_hostname', 'email', + 'name', 'partner_id', 'password', 'confirm_password', 'city', + 'country_id', 'lang', 'signup_email' +} CREDENTIAL_PARAMS = ['login', 'password', 'type'] + class Home(home.Home): - """Custom Home class for handling web login and authentication. - Extends the base Home class. - Methods: - web_login(self, redirect=None, **kw): Handles web login and - authentication.""" + """Custom Home controller that enforces IP restriction on web login.""" - @route('/web/login', type='http', auth="none", readonly=False) + @route('/web/login', type='http', auth="none", methods=['GET', 'POST'], csrf=False) def web_login(self, redirect=None, **kw): - """Handle web login and authentication. - Args: - redirect (str): URL to redirect after successful login. - **kw: Additional keyword arguments. - Returns: - http.Response: The HTTP response.""" ensure_db() request.params['login_success'] = False + + # If already logged in, and GET with redirect, go there if request.httprequest.method == 'GET' and redirect and request.session.uid: return request.redirect(redirect) + + # prepare env for public if request.env.uid is None: if request.session.uid is None: request.env["ir.http"]._auth_method_public() else: request.update_env(user=request.session.uid) - values = {k: v for k, v in request.params.items() if - k in SIGN_UP_REQUEST_PARAMS} + + values = {k: v for k, v in request.params.items() if k in SIGN_UP_REQUEST_PARAMS} try: values['databases'] = http.db_list() except odoo.exceptions.AccessDenied: values['databases'] = None + if request.httprequest.method == 'POST': old_uid = request.uid - ip_address = request.httprequest.environ['REMOTE_ADDR'] - if request.params['login']: - user_rec = request.env['res.users'].sudo().search( - [('login', '=', request.params['login'])]) - if user_rec.allowed_ip_ids: - ip_list = [] - for rec in user_rec.allowed_ip_ids: - ip_list.append(rec.ip_address) - if ip_address in ip_list: - try: - credential = {key: value for key, value in - request.params.items() if - key in CREDENTIAL_PARAMS} - credential.setdefault('type', 'password') - auth_info = request.session.authenticate(request.db, credential) - request.params['login_success'] = True - return request.redirect(self._login_redirect(auth_info['uid'], redirect=redirect)) - except odoo.exceptions.AccessDenied as e: - request.update_env = old_uid - if e.args == odoo.exceptions.AccessDenied().args: - values['error'] = _("Wrong login/password") - else: - request.update_env = old_uid - values['error'] = _("Not allowed to login from this IP") - else: - try: - credential = {key: value for key, value in - request.params.items() if - key in CREDENTIAL_PARAMS} - credential.setdefault('type', 'password') - auth_info = request.session.authenticate(request.db, - credential) - request.params['login_success'] = True - return request.redirect( - self._login_redirect(auth_info['uid'], - redirect=redirect)) - except odoo.exceptions.AccessDenied as e: - if e.args == odoo.exceptions.AccessDenied().args: - values['error'] = _("Wrong login/password") - else: - values['error'] = e.args[0] + + # get client ip in a proxy-safe way + ip_address = _get_client_ip(request) + login = request.params.get('login') + password = request.params.get('password') + + if login and password: + user = request.env['res.users'].sudo().search([('login', '=', login)], limit=1) + + try: + # check IP restriction + _check_user_ip(user, ip_address) + + # collect credentials dict + credential = {k: v for k, v in request.params.items() if k in CREDENTIAL_PARAMS} + credential.setdefault('type', 'password') + + auth_info = request.session.authenticate(request.db, credential) + uid = auth_info.get('uid') + + # successful login, redirect + request.params['login_success'] = True + return request.redirect(self._login_redirect(uid, redirect=redirect)) + + except AccessDenied as e: + request.update_env(user=old_uid) + values['error'] = str(e) if str(e) else _("Not allowed to login from this IP") + except odoo.exceptions.AccessDenied as e: + request.update_env(user=old_uid) + # generic wrong credentials message + values['error'] = _("Wrong login/password") if e.args == odoo.exceptions.AccessDenied().args else e.args[0] + else: - if 'error' in request.params and request.params.get( - 'error') == 'access': + if request.params.get('error') == 'access': values['error'] = _( - 'Only employees can access this database.' - 'Please contact the administrator.') + 'Only employees can access this database. Please contact the administrator.' + ) + if 'login' not in values and request.session.get('auth_login'): values['login'] = request.session.get('auth_login') if not odoo.tools.config['list_db']: values['disable_database_manager'] = True + response = request.render('web.login', values) response.headers['X-Frame-Options'] = 'SAMEORIGIN' response.headers['Content-Security-Policy'] = "frame-ancestors 'self'" - return response + return response \ No newline at end of file diff --git a/access_restriction_by_ip/controllers/session.py b/access_restriction_by_ip/controllers/session.py new file mode 100644 index 000000000..bfc1519c9 --- /dev/null +++ b/access_restriction_by_ip/controllers/session.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Cybrosys Technologies Pvt. Ltd. +# Copyright (C) 2025-TODAY Cybrosys Technologies(). +# Author: Bhagyadev KP (odoo@cybrosys.com) +# +# This program is free software: you can modify +# it under the terms of the GNU Affero General Public License (AGPL) 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 odoo +from odoo.modules.registry import Registry +from odoo import http +from odoo.http import request +from odoo.exceptions import AccessError, AccessDenied +from odoo.addons.web.controllers.session import Session +from odoo.tools.translate import _ + +def _get_client_ip(req): + """ + Extract the client IP address in a proxy-safe way. + Prefer X-Forwarded-For (first item) if present, otherwise use REMOTE_ADDR. + """ + forwarded_for = req.httprequest.environ.get('HTTP_X_FORWARDED_FOR') + if forwarded_for: + # X-Forwarded-For can be a comma-separated list: client, proxy1, proxy2... + ip = forwarded_for.split(',')[0].strip() + return ip + return req.httprequest.remote_addr + + +def _check_user_ip(user, ip_address): + """ + Check if the user is allowed to login from the given ip_address. + Raises AccessDenied if not allowed. + """ + if user and user.allowed_ip_ids: + allowed_ips = set(user.allowed_ip_ids.mapped('ip_address')) + if ip_address not in allowed_ips: + raise AccessDenied(_("Not allowed to login from this IP address.")) + +class Session(Session): + @http.route('/web/session/authenticate', type='json', auth="none") + def authenticate(self, db, login, password, base_location=None, **kwargs): + + if request.db and request.db != db: + request.env.cr.close() + elif request.db: + request.env.cr.rollback() + if not http.db_filter([db]): + raise AccessError("Database not found.") + credential = {'login': login, 'password': password, 'type': 'password'} + auth_info = request.session.authenticate(db, credential) + ip_address = request.httprequest.environ['REMOTE_ADDR'] + registry = Registry(db) + with registry.cursor() as cr: + env = odoo.api.Environment(cr, auth_info['uid'], {}) + wsgienv = { + 'interactive': True, + 'base_location': request.httprequest.url_root.rstrip('/'), + 'HTTP_HOST': request.httprequest.environ['HTTP_HOST'], + 'REMOTE_ADDR': request.httprequest.environ['REMOTE_ADDR'], + } + + # if 2FA is disabled we finalize immediately + user = env['res.users'].browse(auth_info['uid']) + auth_info = registry['res.users'].authenticate(db, credential, wsgienv) + if user and user.allowed_ip_ids: + ip_list = set(user.allowed_ip_ids.mapped('ip_address')) + if ip_address not in ip_list: + raise AccessError("Not allowed to login from this IP") + if auth_info['uid'] != request.session.uid: + # Crapy workaround for unupdatable Odoo Mobile App iOS (Thanks Apple :@) and Android + # Correct behavior should be to raise AccessError("Renewing an expired session for user that has multi-factor-authentication is not supported. Please use /web/login instead.") + return {'uid': None} + + request.session.db = db + registry = odoo.modules.registry.Registry(db) + with registry.cursor() as cr: + env = odoo.api.Environment(cr, request.session.uid, request.session.context) + if not request.db: + # request._save_session would not update the session_token + # as it lacks an environment, rotating the session myself + http.root.session_store.rotate(request.session, env) + request.future_response.set_cookie( + 'session_id', request.session.sid, + max_age=http.get_session_max_inactivity(env), httponly=True + ) + return env['ir.http'].session_info() + Session.authenticate = authenticate diff --git a/access_restriction_by_ip/doc/RELEASE_NOTES.md b/access_restriction_by_ip/doc/RELEASE_NOTES.md index cc15d901d..33c275a30 100644 --- a/access_restriction_by_ip/doc/RELEASE_NOTES.md +++ b/access_restriction_by_ip/doc/RELEASE_NOTES.md @@ -4,3 +4,8 @@ #### Version 18.0.1.0.0 ##### ADD - Initial Commit for Access Restriction By IP + +#### 29.09.2025 +#### Version 18.0.1.0.1 +##### BUG FIX +Updated the module workflow to fix the issue that occurred during mobile authentication in the Odoo app.