# Copyright (C) 2020 GlodoUK # Copyright (C) 2010-2016, 2022-2023 XCG Consulting # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import functools import json import logging import werkzeug.utils from werkzeug.exceptions import BadRequest from werkzeug.urls import url_quote_plus from odoo import ( SUPERUSER_ID, _, api, exceptions, http, models, registry as registry_get, ) from odoo.http import request from odoo.tools.misc import clean_context from odoo.addons.web.controllers.home import Home from odoo.addons.web.controllers.utils import _get_login_redirect_url, ensure_db _logger = logging.getLogger(__name__) # ---------------------------------------------------------- # helpers # ---------------------------------------------------------- def fragment_to_query_string(func): @functools.wraps(func) def wrapper(self, **kw): if not kw: return """""" return func(self, **kw) return wrapper # ---------------------------------------------------------- # Controller # ---------------------------------------------------------- class SAMLLogin(Home): # Disable pylint self use as the method is meant to be reused in other modules def _list_saml_providers_domain(self): # pylint: disable=no-self-use return [] def list_saml_providers(self, with_autoredirect: bool = False) -> models.Model: """Return available providers :param with_autoredirect: True to only list providers with automatic redirection :return: a recordset of providers """ domain = self._list_saml_providers_domain() if with_autoredirect: domain.append(("autoredirect", "=", True)) providers = request.env["auth.saml.provider"].sudo().search_read(domain) for provider in providers: provider["auth_link"] = self._auth_saml_request_link(provider) return providers def _saml_autoredirect(self): # automatically redirect if any provider is set up to do that autoredirect_providers = self.list_saml_providers(True) # do not redirect if asked too or if a SAML error has been found disable_autoredirect = ( "disable_autoredirect" in request.params or "saml_error" in request.params ) if autoredirect_providers and not disable_autoredirect: return werkzeug.utils.redirect( self._auth_saml_request_link(autoredirect_providers[0]), 303, ) return None def _auth_saml_request_link(self, provider: models.Model): """Return the auth request link for the provided provider""" params = { "pid": provider["id"], } redirect = request.params.get("redirect") if redirect: params["redirect"] = redirect return "/auth_saml/get_auth_request?%s" % werkzeug.urls.url_encode(params) @http.route() def web_client(self, s_action=None, **kw): ensure_db() if not request.session.uid: result = self._saml_autoredirect() if result: return result return super().web_client(s_action, **kw) @http.route() def web_login(self, *args, **kw): ensure_db() if ( request.httprequest.method == "GET" and request.session.uid and request.params.get("redirect") ): # Redirect if already logged in and redirect param is present return request.redirect(request.params.get("redirect")) if request.httprequest.method == "GET": result = self._saml_autoredirect() if result: return result providers = self.list_saml_providers() response = super().web_login(*args, **kw) if response.is_qweb: error = request.params.get("saml_error") if error == "no-signup": error = _("Sign up is not allowed on this database.") elif error == "access-denied": error = _("Access Denied") elif error == "expired": error = _( "You do not have access to this database. Please contact" " support." ) else: error = None response.qcontext["saml_providers"] = providers if error: response.qcontext["error"] = error return response class AuthSAMLController(http.Controller): def _get_saml_extra_relaystate(self): """ Compute any additional extra state to be sent to the IDP so it can forward it back to us. This is called RelayState. The provider will automatically set things like the dbname, provider id, etc. """ redirect = request.params.get("redirect") or "web" if not redirect.startswith(("//", "http://", "https://")): redirect = "{}{}".format( request.httprequest.url_root, redirect[1:] if redirect[0] == "/" else redirect, ) state = { "r": url_quote_plus(redirect), } return state @http.route("/auth_saml/get_auth_request", type="http", auth="none") def get_auth_request(self, pid): provider_id = int(pid) provider = request.env["auth.saml.provider"].sudo().browse(provider_id) redirect_url = provider._get_auth_request( self._get_saml_extra_relaystate(), request.httprequest.url_root.rstrip("/") ) if not redirect_url: raise Exception( "Failed to get auth request from provider. " "Either misconfigured SAML provider or unknown provider." ) redirect = werkzeug.utils.redirect(redirect_url, 303) redirect.autocorrect_location_header = True return redirect @http.route("/auth_saml/signin", type="http", auth="none", csrf=False) @fragment_to_query_string def signin(self, **kw): """ Client obtained a saml token and passed it back to us... we need to validate it """ saml_response = kw.get("SAMLResponse") if not kw.get("RelayState"): # here we are in front of a client that went through # some routes that "lost" its relaystate... this can happen # if the client visited his IDP and successfully logged in # then the IDP gave him a portal with his available applications # but the provided link does not include the necessary relaystate url = "/?type=signup" redirect = werkzeug.utils.redirect(url, 303) redirect.autocorrect_location_header = True return redirect state = json.loads(kw["RelayState"]) provider = state["p"] dbname = state["d"] if not http.db_filter([dbname]): return BadRequest() ensure_db(db=dbname) request.update_context(**clean_context(state.get("c", {}))) try: credentials = ( request.env["res.users"] .with_user(SUPERUSER_ID) .auth_saml( provider, saml_response, request.httprequest.url_root.rstrip("/"), ) ) action = state.get("a") menu = state.get("m") redirect = ( werkzeug.urls.url_unquote_plus(state["r"]) if state.get("r") else False ) url = "/web" if redirect: url = redirect elif action: url = "/#action=%s" % action elif menu: url = "/#menu_id=%s" % menu pre_uid = request.session.authenticate(*credentials) resp = request.redirect(_get_login_redirect_url(pre_uid, url), 303) resp.autocorrect_location_header = False return resp except exceptions.AccessDenied: # saml credentials not valid, # user could be on a temporary session _logger.info("SAML2: access denied") url = "/web/login?saml_error=expired" redirect = werkzeug.utils.redirect(url, 303) redirect.autocorrect_location_header = False return redirect except Exception as e: # signup error _logger.exception("SAML2: failure - %s", str(e)) url = "/web/login?saml_error=access-denied" redirect = request.redirect(url, 303) redirect.autocorrect_location_header = False return redirect @http.route("/auth_saml/metadata", type="http", auth="none", csrf=False) def saml_metadata(self, **kw): provider = kw.get("p") dbname = kw.get("d") valid = kw.get("valid", None) if not dbname or not provider: _logger.debug("Metadata page asked without database name or provider id") return request.not_found(_("Missing parameters")) provider = int(provider) registry = registry_get(dbname) with registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) client = env["auth.saml.provider"].sudo().browse(provider) if not client.exists(): return request.not_found(_("Unknown provider")) return request.make_response( client._metadata_string( valid, request.httprequest.url_root.rstrip("/") ), [("Content-Type", "text/xml")], )