Skip to content

Commit 1dbeee7

Browse files
[IMP] auth_saml: user provisioning on login
- custom message when response is too old - avoid using werkzeug.urls method, they are deprecated - add missing ondelete cascade when user is deleted - attribute mapping is now also duplicated when the provider is duplicated - factorize getting SAML attribute value, allowing using subject.nameId in mapping attributes too - add an opton to reactivate user when finding an user and creation is enabled
1 parent 57bc71b commit 1dbeee7

11 files changed

Lines changed: 250 additions & 65 deletions

File tree

auth_saml/controllers/main.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
import functools
66
import json
77
import logging
8+
from urllib.parse import quote_plus, unquote_plus, urlencode
89

910
import werkzeug.utils
11+
from saml2.validate import ResponseLifetimeExceed
1012
from werkzeug.exceptions import BadRequest
11-
from werkzeug.urls import url_quote_plus
1213

1314
from odoo import (
1415
SUPERUSER_ID,
@@ -101,7 +102,7 @@ def _auth_saml_request_link(self, provider: models.Model):
101102
redirect = request.params.get("redirect")
102103
if redirect:
103104
params["redirect"] = redirect
104-
return f"/auth_saml/get_auth_request?{werkzeug.urls.url_encode(params)}"
105+
return f"/auth_saml/get_auth_request?{urlencode(params)}"
105106

106107
@http.route()
107108
def web_client(self, s_action=None, **kw):
@@ -137,6 +138,8 @@ def web_login(self, *args, **kw):
137138
error = request.env._("Sign up is not allowed on this database.")
138139
elif error == "access-denied":
139140
error = request.env._("Access Denied")
141+
elif error == "response-lifetime-exceed":
142+
error = request.env._("Response Lifetime Exceeded")
140143
elif error == "expired":
141144
error = request.env._(
142145
"You do not have access to this database. Please contact support."
@@ -169,7 +172,7 @@ def _get_saml_extra_relaystate(self):
169172
)
170173

171174
state = {
172-
"r": url_quote_plus(redirect),
175+
"r": quote_plus(redirect),
173176
}
174177
return state
175178

@@ -237,9 +240,7 @@ def signin(self, **kw):
237240
request.env.cr.commit()
238241
action = state.get("a")
239242
menu = state.get("m")
240-
redirect = (
241-
werkzeug.urls.url_unquote_plus(state["r"]) if state.get("r") else False
242-
)
243+
redirect = unquote_plus(state["r"]) if state.get("r") else False
243244
url = "/odoo"
244245
if redirect:
245246
url = redirect
@@ -265,6 +266,9 @@ def signin(self, **kw):
265266
redirect = werkzeug.utils.redirect(url, 303)
266267
redirect.autocorrect_location_header = False
267268
return redirect
269+
except ResponseLifetimeExceed as e:
270+
_logger.debug("Response Lifetime Exceed - %s", str(e))
271+
url = "/web/login?saml_error=response-lifetime-exceed"
268272

269273
except Exception as e:
270274
# signup error

auth_saml/models/auth_saml_attribute_mapping.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class AuthSamlAttributeMapping(models.Model):
1313
"auth.saml.provider",
1414
index=True,
1515
required=True,
16+
ondelete="cascade",
1617
)
1718
attribute_name = fields.Char(
1819
string="IDP Response Attribute",

auth_saml/models/auth_saml_provider.py

Lines changed: 83 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Copyright (C) 2020 Glodo UK <https://www.glodo.uk/>
2-
# Copyright (C) 2010-2016, 2022 XCG Consulting <https://xcg-consulting.fr/>
2+
# Copyright (C) 2010-2016, 2022, 2025-2026 XCG SAS <https://orbeet.io/>
33
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
44

55
import base64
@@ -91,6 +91,7 @@ class AuthSamlProvider(models.Model):
9191
"auth.saml.attribute.mapping",
9292
"provider_id",
9393
string="Attribute Mapping",
94+
copy=True,
9495
)
9596
active = fields.Boolean(default=True)
9697
sequence = fields.Integer(index=True)
@@ -146,6 +147,27 @@ class AuthSamlProvider(models.Model):
146147
default=True,
147148
help="Whether metadata should be signed or not",
148149
)
150+
# User creation fields
151+
create_user = fields.Boolean(
152+
default=False,
153+
help="Create user if not found. The login and name will defaults to the SAML "
154+
"user matching attribute. Use the mapping attributes to change the value "
155+
"used. If a deactivated user has a matching saml uid, activate it rather than"
156+
"create a new one.",
157+
)
158+
create_user_template_id = fields.Many2one(
159+
comodel_name="res.users",
160+
# Template users should be disabled so allow them too
161+
domain="[('active', 'in', (True, False))]",
162+
help="When creating user, this user is used as a template",
163+
)
164+
create_user_reactivate = fields.Boolean(
165+
"Reactivate when Creating Users",
166+
default=False,
167+
help="If a deactivated user has a matching SAML uid when trying to create the "
168+
"user, and this is checked, then the user is reactivated. Otherwise,"
169+
"access is denied.",
170+
)
149171

150172
@api.model
151173
def _sig_alg_selection(self):
@@ -275,9 +297,7 @@ def _get_auth_request(self, extra_state=None, url_root=None):
275297
}
276298
state.update(extra_state)
277299

278-
sig_alg = ds.SIG_RSA_SHA1
279-
if self.sig_alg:
280-
sig_alg = getattr(ds, self.sig_alg)
300+
sig_alg = getattr(ds, self.sig_alg)
281301

282302
saml_client = self._get_client_for_provider(url_root)
283303
reqid, info = saml_client.prepare_for_authenticate(
@@ -291,6 +311,7 @@ def _get_auth_request(self, extra_state=None, url_root=None):
291311
for key, value in info["headers"]:
292312
if key == "Location":
293313
redirect_url = value
314+
break
294315

295316
self._store_outstanding_request(reqid)
296317

@@ -319,30 +340,19 @@ def _validate_auth_response(self, token: str, base_url: str = None):
319340
)
320341
else:
321342
raise
322-
matching_value = None
323-
324-
if self.matching_attribute == "subject.nameId":
325-
matching_value = response.name_id.text
326-
else:
327-
attrs = response.get_identity()
328-
329-
for k, v in attrs.items():
330-
if k == self.matching_attribute:
331-
matching_value = v
332-
break
333-
334-
if not matching_value:
335-
raise Exception(
336-
self.env._(
337-
"Matching attribute %(matching_attribute)s"
338-
" not found in user attrs: %(attrs)s",
339-
matching_attribute=self.matching_attribute,
340-
attrs=attrs,
341-
)
343+
try:
344+
matching_value = self._get_attribute_value(
345+
response, self.matching_attribute
346+
)
347+
except KeyError:
348+
raise KeyError(
349+
self.env._(
350+
"Matching attribute %(matching_attribute)s not found "
351+
"in user attrs: %(attrs)s",
352+
matching_attribute=self.matching_attribute,
353+
attrs=response.get_identity(),
342354
)
343-
344-
if matching_value and isinstance(matching_value, list):
345-
matching_value = next(iter(matching_value), None)
355+
) from None
346356

347357
if isinstance(matching_value, str) and self.matching_attribute_to_lower:
348358
matching_value = matching_value.lower()
@@ -385,25 +395,39 @@ def _metadata_string(self, valid=None, base_url: str = None):
385395
sign=self.sign_metadata,
386396
)
387397

398+
@staticmethod
399+
def _get_attribute_value(response, attribute_name: str):
400+
"""
401+
402+
:raise: KeyError if attribute is not in the response
403+
:param response:
404+
:param attribute_name:
405+
:return: value of the attribute. if the value is an empty list, return None
406+
otherwise return the first element of the list
407+
"""
408+
if attribute_name == "subject.nameId":
409+
return response.name_id.text
410+
attrs = response.get_identity()
411+
attribute_value = attrs[attribute_name]
412+
if isinstance(attribute_value, list):
413+
attribute_value = next(iter(attribute_value), None)
414+
return attribute_value
415+
388416
def _hook_validate_auth_response(self, response, matching_value):
389417
self.ensure_one()
390418
vals = {}
391-
attrs = response.get_identity()
392419

393420
for attribute in self.attribute_mapping_ids:
394-
if attribute.attribute_name not in attrs:
395-
_logger.debug(
421+
try:
422+
vals[attribute.field_name] = self._get_attribute_value(
423+
response, attribute.attribute_name
424+
)
425+
except KeyError:
426+
_logger.warning(
396427
"SAML attribute '%s' not found in response %s",
397428
attribute.attribute_name,
398-
attrs,
429+
response.get_identity(),
399430
)
400-
continue
401-
402-
attribute_value = attrs[attribute.attribute_name]
403-
if isinstance(attribute_value, list):
404-
attribute_value = attribute_value[0]
405-
406-
vals[attribute.field_name] = attribute_value
407431

408432
return {"mapped_attrs": vals}
409433

@@ -450,3 +474,24 @@ def action_refresh_metadata_from_url(self):
450474
updated = True
451475

452476
return updated
477+
478+
def _user_copy_defaults(self, validation):
479+
"""
480+
Returns defaults when copying the template user.
481+
482+
Can be overridden with extra information.
483+
:param validation: validation result
484+
:return: a dictionary for copying template user, empty to avoid copying
485+
"""
486+
self.ensure_one()
487+
if not self.create_user:
488+
return {}
489+
saml_uid = validation["user_id"]
490+
return {
491+
"name": saml_uid,
492+
"login": saml_uid,
493+
"active": True,
494+
# if signature is not provided by mapped_attrs, it will be computed
495+
# due to call to compute method in calling method.
496+
"signature": None,
497+
} | validation.get("mapped_attrs", {})

auth_saml/models/res_users.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77

88
import passlib
99

10-
from odoo import SUPERUSER_ID, api, fields, models, modules, tools
10+
from odoo import SUPERUSER_ID, Command, api, fields, models, tools
1111
from odoo.exceptions import AccessDenied, ValidationError
12+
from odoo.modules.registry import Registry
1213

1314
from .ir_config_parameter import ALLOW_SAML_UID_AND_PASSWORD
1415

@@ -44,12 +45,43 @@ def _auth_saml_signin(self, provider: int, validation: dict, saml_response) -> s
4445
[("saml_uid", "=", saml_uid), ("saml_provider_id", "=", provider)],
4546
limit=1,
4647
)
48+
saml_provider = self.env["auth.saml.provider"].browse(provider)
4749
user = user_saml.user_id
48-
if len(user) != 1:
49-
raise AccessDenied()
50-
51-
with modules.registry.Registry(self.env.cr.dbname).cursor() as new_cr:
50+
user_copy_defaults = {}
51+
if not user.active and saml_provider.create_user:
52+
if saml_provider.create_user_reactivate:
53+
user.active = True
54+
if not user:
55+
user_copy_defaults = saml_provider._user_copy_defaults(validation)
56+
if not user_copy_defaults:
57+
raise AccessDenied()
58+
59+
with Registry(self.env.cr.dbname).cursor(False) as new_cr:
5260
new_env = api.Environment(new_cr, self.env.uid, self.env.context)
61+
if user_copy_defaults:
62+
new_user = (
63+
new_env["auth.saml.provider"]
64+
.browse(provider)
65+
.create_user_template_id.with_context(no_reset_password=True)
66+
.copy(
67+
{
68+
**user_copy_defaults,
69+
"saml_ids": [
70+
Command.create(
71+
{
72+
"saml_provider_id": provider,
73+
"saml_uid": saml_uid,
74+
"saml_access_token": saml_response,
75+
}
76+
)
77+
],
78+
}
79+
)
80+
)
81+
# Update signature as needed.
82+
new_user._compute_signature()
83+
return new_user.login
84+
5385
# Update the token. Need to be committed, otherwise the token is not visible
5486
# to other envs, like the one used in login_and_redirect
5587
user_saml.with_env(new_env).write({"saml_access_token": saml_response})

auth_saml/models/res_users_saml.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ class ResUserSaml(models.Model):
77
_name = "res.users.saml"
88
_description = "User to SAML Provider Mapping"
99

10-
user_id = fields.Many2one("res.users", index=True, required=True)
10+
user_id = fields.Many2one(
11+
"res.users", index=True, required=True, ondelete="cascade"
12+
)
1113
saml_provider_id = fields.Many2one(
1214
"auth.saml.provider", string="SAML Provider", index=True
1315
)

auth_saml/readme/CONFIGURE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ To use this module, you need an IDP server, properly set up.
22

33
1. Configure the module according to your IdP’s instructions (Settings
44
\> Users & Companies \> SAML Providers).
5-
2. Pre-create your users and set the SAML information against the user.
5+
2. Pre-create your users and set the SAML information against the user,
6+
or use the module ability to create users as they log in.
67

78
By default, the module let users have both a password and SAML ids. To
89
increase security, disable passwords by using the option in Settings.

auth_saml/readme/CONTRIBUTORS.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
- [XCG Consulting](https://xcg-consulting.fr/):
2-
- Florent Aide \<<florent.aide@xcg-consulting.fr>\>
3-
- Vincent Hatakeyama \<<vincent.hatakeyama@xcg-consulting.fr>\>
1+
- XCG SAS part of [Orbeet](https://orbeet.io/):
2+
- Florent Aide \<<florent.aide@orbeet.io>\>
3+
- Vincent Hatakeyama \<<vincent.hatakeyama@orbeet.io>\>
44
- Alexandre Brun
5-
- Houzéfa Abbasbhay \<<houzefa.abba@xcg-consulting.fr>\>
6-
- Szeka Wong \<<szeka.wong@xcg-consulting.fr>\>
5+
- Houzéfa Abbasbhay \<<houzefa.abba@orbeet.io>\>
6+
- Szeka Wong \<<szeka.wong@orbeet.io>\>
77
- Jeremy Co Kim Len \<<jeremy.cokimlen@vinci-concessions.com>\>
88
- Jeffery Chen Fan \<<jeffery9@gmail.com>\>
99
- Bhavesh Odedra \<<bodedra@opensourceintegrators.com>\>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
- custom message when response is too old
2+
- avoid using werkzeug.urls method, they are deprecated
3+
- add missing ondelete cascade when user is deleted
4+
- attribute mapping is now also duplicated when the provider is duplicated
5+
- factorize getting SAML attribute value, allowing using subject.nameId in mapping attributes too
6+
- allow creating user if not found by copying a template user, or activating a deactivated user.

auth_saml/tests/fake_idp.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,21 @@
7171
}
7272

7373

74+
class DummyNameId:
75+
"""Dummy name id with text value"""
76+
77+
def __init__(self, text):
78+
self.text = text
79+
80+
7481
class DummyResponse:
75-
def __init__(self, status, data, headers=None):
82+
def __init__(self, status, data, headers=None, name_id: str = ""):
7683
self.status_code = status
7784
self.text = data
7885
self.headers = headers or []
7986
self.content = data
8087
self._identity = {}
88+
self.name_id = DummyNameId(name_id)
8189

8290
def _unpack(self, ver="SAMLResponse"):
8391
"""
@@ -126,6 +134,7 @@ def __init__(self, metadatas=None, settings=None):
126134
config.load(settings)
127135
config.allow_unknown_attributes = True
128136
Server.__init__(self, config=config)
137+
self.mail = "test@example.com"
129138

130139
def get_metadata(self):
131140
return create_metadata_string(
@@ -159,7 +168,7 @@ def authn_request_endpoint(self, req, binding, relay_state):
159168
"surName": "Example",
160169
"givenName": "Test",
161170
"title": "Ind",
162-
"mail": "test@example.com",
171+
"mail": self.mail,
163172
}
164173

165174
resp_args.update({"sign_assertion": True, "sign_response": True})

0 commit comments

Comments
 (0)