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
3 changes: 2 additions & 1 deletion auth_saml/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Copyright (C) 2020 GlodoUK <https://www.glodo.uk/>
# Copyright (C) 2010-2016, 2022 XCG Consulting <http://odoo.consulting>
# Copyright (C) 2010-2016, 2022, 2026 XCG Consulting <https://orbeet.io/>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

{
Expand All @@ -21,6 +21,7 @@
"data": [
"data/ir_config_parameter.xml",
"security/ir.model.access.csv",
"templates/webclient.xml",
"views/auth_saml.xml",
"views/res_config_settings.xml",
"views/res_users.xml",
Expand Down
56 changes: 40 additions & 16 deletions auth_saml/controllers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import logging

import werkzeug.utils
from saml2.validate import ResponseLifetimeExceed
from werkzeug.exceptions import BadRequest
from werkzeug.urls import url_quote_plus

Expand Down Expand Up @@ -54,6 +55,16 @@ def wrapper(self, **kw):
return wrapper


def _error_message(error: str) -> str | None:
if error == "access-denied":
return _("Access Denied")
if error == "response-lifetime-exceed":
return _("Response Lifetime Exceeded")
if error == "expired":
return _("You do not have access to this database. Please contact support.")
return None


# ----------------------------------------------------------
# Controller
# ----------------------------------------------------------
Expand Down Expand Up @@ -131,19 +142,7 @@ def web_login(self, *args, **kw):

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

error = _error_message(request.params.get("saml_error"))
response.qcontext["saml_providers"] = providers

if error:
Expand Down Expand Up @@ -173,6 +172,17 @@ def _get_saml_extra_relaystate(self):
}
return state

def _get_saml_error_url(self, saml_error: str) -> str:
"""Return the URL of the SAML error page.
This module provides a configuration option to use another page.
"""
base = (
request.env["ir.config_parameter"]
.sudo()
.get_param("auth_saml.saml_error_page", "/web/login")
)
return f"{base}?saml_error={saml_error}"

@http.route("/auth_saml/get_auth_request", type="http", auth="none")
def get_auth_request(self, pid):
provider_id = int(pid)
Expand Down Expand Up @@ -251,15 +261,17 @@ def signin(self, **kw):
# 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 = werkzeug.utils.redirect(self._get_saml_error_url("expired"), 303)
redirect.autocorrect_location_header = False
return redirect
except ResponseLifetimeExceed as e:
_logger.debug("Response Lifetime Exceed - %s", str(e))
url = self._get_saml_error_url("response-lifetime-exceed")

except Exception as e:
# signup error
_logger.exception("SAML2: failure - %s", str(e))
url = "/web/login?saml_error=access-denied"
url = self._get_saml_error_url("access-denied")

redirect = request.redirect(url, 303)
redirect.autocorrect_location_header = False
Expand Down Expand Up @@ -291,3 +303,15 @@ def saml_metadata(self, **kw):
),
[("Content-Type", "text/xml")],
)

@http.route("/web/login/saml_error", type="http", auth="none", csrf=False)
def saml_error(self, redirect=None, **kw):
saml_error = request.params.get("saml_error")
error = _error_message(saml_error)
if not error:
return request.redirect(redirect or "/")
response = request.render("auth_saml.login_error", {"error": error})
response.headers["Cache-Control"] = "no-cache"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
return response
4 changes: 4 additions & 0 deletions auth_saml/data/ir_config_parameter.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@
<field name="key">auth_saml.allow_saml_uid_and_internal_password</field>
<field name="value">True</field>
</record>
<record id="saml_error_page_config_parameter" model="ir.config_parameter">
<field name="key">auth_saml.saml_error_page</field>
<field name="value">/web/login</field>
</record>
</odoo>
7 changes: 7 additions & 0 deletions auth_saml/models/res_config_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"

module_auth_saml = fields.Boolean("SAML Authentication")
allow_saml_uid_and_internal_password = fields.Boolean(
"Allow SAML users to possess an Odoo password (warning: decreases security)",
config_parameter=ALLOW_SAML_UID_AND_PASSWORD,
)
saml_error_page = fields.Selection(
[("/web/login", "Login Page"), ("/web/login/saml_error", "Error Page")],
"SAML Error Page",
config_parameter="auth_saml.saml_error_page",
required=True,
)
7 changes: 6 additions & 1 deletion auth_saml/readme/CONFIGURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ automatic redirection in the provider settings. The autoredirection will
only be done on the active provider with the highest priority. It is
still possible to access the login without redirection by using the
query parameter `disable_autoredirect`, as in
`https://example.com/web/login?disable_autoredirect=` The login is also
`https://example.com/web/login?disable_autoredirect=`.

The login is also
displayed if there is an error with SAML login, in order to display any
error message.
There is an option to use a page that only displays the error message
in a separate page instead of the login form.
This is useful when displaying the login form is not desired.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add an option to use another page for displaying SAML error.
43 changes: 43 additions & 0 deletions auth_saml/templates/webclient.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="auth_saml.login_error" name="SAML Login Error">
<t t-call="web.layout">
<t t-set="head">
<t t-call-assets="web.assets_frontend" t-js="false" />
</t>
<t t-set="body_classname" t-value="'bg-100'" />

<div class="container py-5">
<div
t-attf-class="card border-0 mx-auto bg-100 {{login_card_classes}} o_database_list"
style="max-width: 300px;"
>
<div class="card-body">
<div
t-attf-class="text-center pb-3 border-bottom {{'mb-3' if form_small else 'mb-4'}}"
>
<img
t-attf-src="/web/binary/company_logo{{ '?dbname='+db if db else '' }}"
alt="Logo"
style="max-height:120px; max-width: 100%; width:auto"
/>
</div>
<div class="oe_login_form">
<p class="alert alert-danger" role="alert">
<t t-out="error" />
</p>
<div
t-attf-class="clearfix oe_login_buttons text-center mb-1 {{'pt-2' if form_small else 'pt-3'}}"
>
<a
class="btn btn-primary btn-block"
href="/web/login"
>Log in</a>
</div>
</div>
</div>
</div>
</div>
</t>
</template>
</odoo>
79 changes: 74 additions & 5 deletions auth_saml/tests/test_pysaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
import base64
import html
import os
import urllib
from unittest.mock import patch
from urllib.parse import urlencode, urljoin, urlparse

from saml2.validate import ResponseLifetimeExceed

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

Expand Down Expand Up @@ -123,10 +126,8 @@ def test__compute_sp_metadata_url__provider_has_sp_baseurl(self):
temp = self.saml_provider.sp_baseurl
self.saml_provider.sp_baseurl = "http://example.com"
self.saml_provider._compute_sp_metadata_url()
expected_qs = urllib.parse.urlencode(
{"p": self.saml_provider.id, "d": self.env.cr.dbname}
)
expected_url = urllib.parse.urljoin(
expected_qs = urlencode({"p": self.saml_provider.id, "d": self.env.cr.dbname})
expected_url = urljoin(
"http://example.com", ("/auth_saml/metadata?%s" % expected_qs)
)
# Assert that sp_metadata_url is set correctly
Expand Down Expand Up @@ -412,6 +413,74 @@ def test_redirect_after_login(self):
+ "/web#action=37&model=ir.module.module&view_type=kanban&menu_id=5",
)

def test_auth_errors(self):
self.add_provider_to_user()

auth_request = self.saml_provider._get_auth_request()
response = self.idp.fake_login(auth_request)
unpacked_response = response._unpack()

for key in unpacked_response:
unpacked_response[key] = html.unescape(unpacked_response[key])
self.user.active = False
with mute_logger("odoo.addons.auth_saml.controllers"):
response = self.url_open(
"/auth_saml/signin",
data=unpacked_response,
allow_redirects=True,
)
self.assertEqual(
response.url, f"{self.base_url()}/web/login?saml_error=expired"
)
self.user.active = True
# invalidate saml response
unpacked_response["SAMLResponse"] = ""
with mute_logger("odoo.addons.auth_saml.controllers"):
response = self.url_open(
"/auth_saml/signin",
data=unpacked_response,
allow_redirects=True,
)
self.assertEqual(
response.url, f"{self.base_url()}/web/login?saml_error=access-denied"
)
self.browse_ref(
"auth_saml.saml_error_page_config_parameter"
).value = "/web/login/saml_error"
with mute_logger("odoo.addons.auth_saml.controllers"):
response = self.url_open(
"/auth_saml/signin",
data=unpacked_response,
allow_redirects=True,
)
self.assertEqual(
response.url,
f"{self.base_url()}/web/login/saml_error?saml_error=access-denied",
)

# Not an error easy to reproduce so use a patch to raise it.
def auth_saml(*args, **kwargs):
raise ResponseLifetimeExceed()

with mute_logger("odoo.addons.auth_saml.controllers"), patch(
"odoo.addons.auth_saml.models.res_users.ResUser.auth_saml", auth_saml
):
response = self.url_open(
"/auth_saml/signin",
data=unpacked_response,
allow_redirects=True,
)
self.assertEqual(
response.url,
f"{self.base_url()}/web/login/saml_error?saml_error=response-lifetime-exceed",
)

def test_saml_error_page_redirect(self):
"""Test that accessing the page without an error redirects to root page."""
response = self.url_open("/web/login/saml_error")
path = urlparse(response.url).path
self.assertEqual(path, "/web/login")

def test_disallow_user_password_when_changing_settings(self):
"""Test that disabling the setting will remove passwords from related users"""
# We activate the settings to allow password login
Expand Down
36 changes: 30 additions & 6 deletions auth_saml/views/res_config_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,36 @@
<field name="inherit_id" ref="base.res_config_settings_view_form" />
<field name="arch" type="xml">
<xpath expr="//block[@name='integration']" position="inside">
<setting
string="SAML"
help="Allow SAML users to possess an Odoo password (warning: decreases security)"
id="module_auth_saml"
>
<field name="allow_saml_uid_and_internal_password" />
<setting string="SAML Authentication" id="module_auth_saml">
<field name="module_auth_saml" />
<div class="content-group" invisible="not module_auth_saml">
<div class="content-group mt16">
<div
class="alert alert-danger"
role="alert"
invisible="not allow_saml_uid_and_internal_password"
>Allowing users with SAML to have a password decreases security!</div>
<field name="allow_saml_uid_and_internal_password" />
<label
for="allow_saml_uid_and_internal_password"
class="o_light_label mr8"
string="Allow SAML users to possess an Odoo password"
/>
</div>
<div class="content-group mt16">
<label for="saml_error_page" class="o_light_label mr8" />
<field name="saml_error_page" />
</div>
<div class="content-group mt16">
<button
type="action"
name="%(auth_saml.action_saml_provider)d"
string="SAML Providers"
icon="oi-arrow-right"
class="btn-link"
/>
</div>
</div>
</setting>
</xpath>
</field>
Expand Down
Loading