Initial commit: OCA Server Auth packages (29 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:06 +02:00
commit 3ed80311c4
1325 changed files with 127292 additions and 0 deletions

View file

@ -0,0 +1,11 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import (
auth_saml_attribute_mapping,
auth_saml_provider,
auth_saml_request,
ir_config_parameter,
res_config_settings,
res_users,
res_users_saml,
)

View file

@ -0,0 +1,37 @@
from odoo import api, fields, models
class AuthSamlAttributeMapping(models.Model):
"""
Attributes to copy from SAML provider on logon, into Odoo
"""
_name = "auth.saml.attribute.mapping"
_description = "SAML2 attribute mapping"
provider_id = fields.Many2one(
"auth.saml.provider",
index=True,
required=True,
)
attribute_name = fields.Char(
string="IDP Response Attribute",
required=True,
)
field_name = fields.Selection(
string="Odoo Field",
selection="_field_name_selection",
required=True,
)
@api.model
def _field_name_selection(self):
user_fields = self.env["res.users"].fields_get().items()
def valid_field(f, d):
return d.get("type") == "char" and not d.get("readonly")
result = [(f, d.get("string")) for f, d in user_fields if valid_field(f, d)]
result.sort(key=lambda r: r[1])
return result

View file

@ -0,0 +1,445 @@
# Copyright (C) 2020 Glodo UK <https://www.glodo.uk/>
# Copyright (C) 2010-2016, 2022 XCG Consulting <https://xcg-consulting.fr/>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import base64
import json
import logging
import os
import tempfile
import urllib.parse
import requests
# dependency name is pysaml2 # pylint: disable=W7936
import saml2
import saml2.xmldsig as ds
from saml2.client import Saml2Client
from saml2.config import Config as Saml2Config
from saml2.sigver import SignatureError
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class AuthSamlProvider(models.Model):
"""Configuration values of a SAML2 provider"""
_name = "auth.saml.provider"
_description = "SAML2 Provider"
_order = "sequence, name"
name = fields.Char("Provider Name", required=True, index="trigram")
entity_id = fields.Char(
"Entity ID",
help="EntityID passed to IDP, used to identify the Odoo",
required=True,
default="odoo",
)
idp_metadata = fields.Text(
string="Identity Provider Metadata",
help=(
"Configuration for this Identity Provider. Supplied by the"
" provider, in XML format."
),
required=True,
)
idp_metadata_url = fields.Char(
string="Identity Provider Metadata URL",
help="Some SAML providers, notably Office365 can have a metadata "
"document which changes over time, and they provide a URL to the "
"document instead. When this field is set, the metadata can be "
"fetched from the provided URL.",
)
sp_baseurl = fields.Text(
string="Override Base URL",
help="""Base URL sent to Odoo with this, rather than automatically
detecting from request or system parameter web.base.url""",
)
sp_pem_public = fields.Binary(
string="Odoo Public Certificate",
attachment=True,
required=True,
)
sp_pem_public_filename = fields.Char("Odoo Public Certificate File Name")
sp_pem_private = fields.Binary(
string="Odoo Private Key",
attachment=True,
required=True,
)
sp_pem_private_filename = fields.Char("Odoo Private Key File Name")
sp_metadata_url = fields.Char(
compute="_compute_sp_metadata_url",
string="Metadata URL",
readonly=True,
)
matching_attribute = fields.Char(
string="Identity Provider matching attribute",
default="subject.nameId",
required=True,
help=(
"Attribute to look for in the returned IDP response to match"
" against an Odoo user."
),
)
matching_attribute_to_lower = fields.Boolean(
string="Lowercase IDP Matching Attribute",
help="Force matching_attribute to lower case before passing back to Odoo.",
)
attribute_mapping_ids = fields.One2many(
"auth.saml.attribute.mapping",
"provider_id",
string="Attribute Mapping",
)
active = fields.Boolean(default=True)
sequence = fields.Integer(index=True)
css_class = fields.Char(
string="Button Icon CSS class",
help="Add a CSS class that serves you to style the login button.",
default="fa fa-fw fa-sign-in text-primary",
)
body = fields.Char(
string="Login button label", help="Link text in Login Dialog", translate=True
)
autoredirect = fields.Boolean(
"Automatic Redirection",
default=False,
help="Only the provider with the higher priority will be automatically "
"redirected",
)
sig_alg = fields.Selection(
selection=lambda s: s._sig_alg_selection(),
required=True,
string="Signature Algorithm",
)
# help string is from pysaml2 documentation
authn_requests_signed = fields.Boolean(
default=True,
help="Indicates if the Authentication Requests sent by this SP should be signed"
" by default.",
)
logout_requests_signed = fields.Boolean(
default=True,
help="Indicates if this entity will sign the Logout Requests originated from it"
".",
)
want_assertions_signed = fields.Boolean(
default=True,
help="Indicates if this SP wants the IdP to send the assertions signed.",
)
want_response_signed = fields.Boolean(
default=True,
help="Indicates that Authentication Responses to this SP must be signed.",
)
want_assertions_or_response_signed = fields.Boolean(
default=True,
help="Indicates that either the Authentication Response or the assertions "
"contained within the response to this SP must be signed.",
)
# this one is used in Saml2Client.prepare_for_authenticate
sign_authenticate_requests = fields.Boolean(
default=True,
help="Whether the request should be signed or not",
)
sign_metadata = fields.Boolean(
default=True,
help="Whether metadata should be signed or not",
)
@api.model
def _sig_alg_selection(self):
return [(sig[0], sig[0]) for sig in ds.SIG_ALLOWED_ALG]
@api.onchange("name")
def _onchange_name(self):
if not self.body:
self.body = self.name
@api.depends("sp_baseurl")
def _compute_sp_metadata_url(self):
icp_base_url = (
self.env["ir.config_parameter"].sudo().get_param("web.base.url", "")
)
for record in self:
if isinstance(record.id, models.NewId):
record.sp_metadata_url = False
continue
base_url = icp_base_url
if record.sp_baseurl:
base_url = record.sp_baseurl
qs = urllib.parse.urlencode({"p": record.id, "d": self.env.cr.dbname})
record.sp_metadata_url = urllib.parse.urljoin(
base_url, ("/auth_saml/metadata?%s" % qs)
)
def _get_cert_key_path(self, field="sp_pem_public"):
self.ensure_one()
model_attachment = self.env["ir.attachment"].sudo()
keys = model_attachment.search(
[
("res_model", "=", self._name),
("res_field", "=", field),
("res_id", "=", self.id),
],
limit=1,
)
if model_attachment._storage() != "file":
# For non-file locations we need to create a temp file to pass to pysaml.
fd, keys_path = tempfile.mkstemp()
with open(keys_path, "wb") as f:
f.write(base64.b64decode(keys.datas))
os.close(fd)
else:
keys_path = model_attachment._full_path(keys.store_fname)
return keys_path
def _get_config_for_provider(self, base_url: str = None) -> Saml2Config:
"""
Internal helper to get a configured Saml2Client
"""
self.ensure_one()
if self.sp_baseurl:
base_url = self.sp_baseurl
if not base_url:
base_url = (
self.env["ir.config_parameter"].sudo().get_param("web.base.url", "")
)
acs_url = urllib.parse.urljoin(base_url, "/auth_saml/signin")
settings = {
"metadata": {"inline": [self.idp_metadata]},
"entityid": self.entity_id,
"service": {
"sp": {
"endpoints": {
"assertion_consumer_service": [
(acs_url, saml2.BINDING_HTTP_REDIRECT),
(acs_url, saml2.BINDING_HTTP_POST),
(acs_url, saml2.BINDING_HTTP_REDIRECT),
(acs_url, saml2.BINDING_HTTP_POST),
],
},
"allow_unsolicited": False,
"authn_requests_signed": self.authn_requests_signed,
"logout_requests_signed": self.logout_requests_signed,
"want_assertions_signed": self.want_assertions_signed,
"want_response_signed": self.want_response_signed,
"want_assertions_or_response_signed": (
self.want_assertions_or_response_signed
),
},
},
"cert_file": self._get_cert_key_path("sp_pem_public"),
"key_file": self._get_cert_key_path("sp_pem_private"),
}
try:
sp_config = Saml2Config()
sp_config.load(settings)
sp_config.allow_unknown_attributes = True
return sp_config
except saml2.SAMLError:
if self.env.context.get("saml2_retry_after_refresh_metadata", False):
raise
# Retry after refresh metadata
self.action_refresh_metadata_from_url()
return self.with_context(
saml2_retry_after_refresh_metatata=1
)._get_config_for_provider(base_url)
def _get_client_for_provider(self, base_url: str = None) -> Saml2Client:
sp_config = self._get_config_for_provider(base_url)
saml_client = Saml2Client(config=sp_config)
return saml_client
def _get_auth_request(self, extra_state=None, url_root=None):
"""
build an authentication request and give it back to our client
"""
self.ensure_one()
if extra_state is None:
extra_state = {}
state = {
"d": self.env.cr.dbname,
"p": self.id,
}
state.update(extra_state)
sig_alg = ds.SIG_RSA_SHA1
if self.sig_alg:
sig_alg = getattr(ds, self.sig_alg)
saml_client = self._get_client_for_provider(url_root)
reqid, info = saml_client.prepare_for_authenticate(
sign=self.sign_authenticate_requests,
relay_state=json.dumps(state),
sigalg=sig_alg,
)
redirect_url = None
# Select the IdP URL to send the AuthN request to
for key, value in info["headers"]:
if key == "Location":
redirect_url = value
self._store_outstanding_request(reqid)
return redirect_url
def _validate_auth_response(self, token: str, base_url: str = None):
"""return the validation data corresponding to the access token"""
self.ensure_one()
try:
client = self._get_client_for_provider(base_url)
response = client.parse_authn_request_response(
token,
saml2.entity.BINDING_HTTP_POST,
self._get_outstanding_requests_dict(),
)
except SignatureError:
# we have a metadata url: try to refresh the metadata document
if self.idp_metadata_url:
self.action_refresh_metadata_from_url()
# retry: if it fails again, we let the exception flow
client = self._get_client_for_provider(base_url)
response = client.parse_authn_request_response(
token,
saml2.entity.BINDING_HTTP_POST,
self._get_outstanding_requests_dict(),
)
else:
raise
matching_value = None
if self.matching_attribute == "subject.nameId":
matching_value = response.name_id.text
else:
attrs = response.get_identity()
for k, v in attrs.items():
if k == self.matching_attribute:
matching_value = v
break
if not matching_value:
raise Exception(
"Matching attribute %s not found in user attrs: %s"
% (self.matching_attribute, attrs)
)
if matching_value and isinstance(matching_value, list):
matching_value = next(iter(matching_value), None)
if isinstance(matching_value, str) and self.matching_attribute_to_lower:
matching_value = matching_value.lower()
vals = {"user_id": matching_value}
post_vals = self._hook_validate_auth_response(response, matching_value)
if post_vals:
vals.update(post_vals)
return vals
def _get_outstanding_requests_dict(self):
self.ensure_one()
requests = (
self.env["auth_saml.request"]
.sudo()
.search([("saml_provider_id", "=", self.id)])
)
return {record.saml_request_id: record.id for record in requests}
def _store_outstanding_request(self, reqid):
self.ensure_one()
self.env["auth_saml.request"].sudo().create(
{"saml_provider_id": self.id, "saml_request_id": reqid}
)
def _metadata_string(self, valid=None, base_url: str = None):
self.ensure_one()
sp_config = self._get_config_for_provider(base_url)
return saml2.metadata.create_metadata_string(
None,
config=sp_config,
valid=valid,
cert=self._get_cert_key_path("sp_pem_public"),
keyfile=self._get_cert_key_path("sp_pem_private"),
sign=self.sign_metadata,
)
def _hook_validate_auth_response(self, response, matching_value):
self.ensure_one()
vals = {}
attrs = response.get_identity()
for attribute in self.attribute_mapping_ids:
if attribute.attribute_name not in attrs:
_logger.debug(
"SAML attribute '%s' not found in response %s",
attribute.attribute_name,
attrs,
)
continue
attribute_value = attrs[attribute.attribute_name]
if isinstance(attribute_value, list):
attribute_value = attribute_value[0]
vals[attribute.field_name] = attribute_value
return {"mapped_attrs": vals}
def action_refresh_metadata_from_url(self):
providers = self.search(
[("idp_metadata_url", "ilike", "http%"), ("id", "in", self.ids)]
)
if not providers:
return False
providers_to_update = {}
for provider in providers:
document = requests.get(provider.idp_metadata_url, timeout=5)
if document.status_code != 200:
raise UserError(
f"Unable to download the metadata for {provider.name}: {document.reason}"
)
if document.text != provider.idp_metadata:
providers_to_update[provider.id] = document.text
if not providers_to_update:
return False
# lock the records we might update, so that multiple simultaneous login
# attempts will not cause concurrent updates
provider_ids = tuple(providers_to_update.keys())
self.env.cr.execute(
"SELECT id FROM auth_saml_provider WHERE id in %s FOR UPDATE",
(provider_ids,),
)
updated = False
for provider in providers:
if provider.id in providers_to_update:
provider.idp_metadata = providers_to_update[provider.id]
_logger.info(
"Updated metadata for provider %s from %s",
provider.name,
)
updated = True
return updated

View file

@ -0,0 +1,21 @@
# Copyright (C) 2020 GlodoUK <https://glodo.uk/>
# Copyright (C) 2022 XCG Consulting <https://xcg-consulting.fr/>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class AuthSamlRequest(models.TransientModel):
_name = "auth_saml.request"
_description = "SAML Outstanding Requests"
_rec_name = "saml_request_id"
saml_provider_id = fields.Many2one(
"auth.saml.provider",
string="SAML Provider that issued the token",
required=True,
)
saml_request_id = fields.Char(
"Current Request ID",
required=True,
)

View file

@ -0,0 +1,39 @@
# Copyright (C) 2022 XCG Consulting <https://xcg-consulting.fr/>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
ALLOW_SAML_UID_AND_PASSWORD = "auth_saml.allow_saml_uid_and_internal_password"
class IrConfigParameter(models.Model):
"""Redefined to update users when our parameter is changed."""
_inherit = "ir.config_parameter"
@api.model_create_multi
def create(self, vals_list):
"""Redefined to update users when our parameter is changed."""
result = super().create(vals_list)
if result.filtered(lambda param: param.key == ALLOW_SAML_UID_AND_PASSWORD):
self.env["res.users"].allow_saml_and_password_changed()
return result
def write(self, vals):
"""Redefined to update users when our parameter is changed."""
result = super().write(vals)
if self.filtered(lambda param: param.key == ALLOW_SAML_UID_AND_PASSWORD):
self.env["res.users"].allow_saml_and_password_changed()
return result
def unlink(self):
"""Redefined to update users when our parameter is deleted."""
param_saml = self.filtered(
lambda param: param.key == ALLOW_SAML_UID_AND_PASSWORD
)
result = super().unlink()
if result and param_saml:
self.env["res.users"].allow_saml_and_password_changed()
return result

View file

@ -0,0 +1,15 @@
# Copyright (C) 2010-2016, 2022 XCG Consulting <http://odoo.consulting>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
from .ir_config_parameter import ALLOW_SAML_UID_AND_PASSWORD
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
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,
)

View file

@ -0,0 +1,184 @@
# Copyright (C) 2020 GlodoUK <https://www.glodo.uk>
# Copyright (C) 2010-2016 XCG Consulting <http://odoo.consulting>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from typing import Set
import passlib
from odoo import SUPERUSER_ID, _, api, fields, models, registry, tools
from odoo.exceptions import AccessDenied, ValidationError
from .ir_config_parameter import ALLOW_SAML_UID_AND_PASSWORD
_logger = logging.getLogger(__name__)
class ResUser(models.Model):
"""
Add SAML login capabilities to Odoo users.
"""
_inherit = "res.users"
saml_ids = fields.One2many("res.users.saml", "user_id")
def _auth_saml_validate(self, provider_id: int, token: str, base_url: str = None):
provider = self.env["auth.saml.provider"].sudo().browse(provider_id)
return provider._validate_auth_response(token, base_url)
def _auth_saml_signin(self, provider: int, validation: dict, saml_response) -> str:
"""Sign in Odoo user corresponding to provider and validated access token.
:param provider: SAML provider id
:param validation: result of validation of access token
:param saml_response: saml parameters response from the IDP
:return: user login
:raise: odoo.exceptions.AccessDenied if signin failed
This method can be overridden to add alternative signin methods.
"""
saml_uid = validation["user_id"]
user_saml = self.env["res.users.saml"].search(
[("saml_uid", "=", saml_uid), ("saml_provider_id", "=", provider)],
limit=1,
)
user = user_saml.user_id
if len(user) != 1:
raise AccessDenied()
with registry(self.env.cr.dbname).cursor() as new_cr:
new_env = api.Environment(new_cr, self.env.uid, self.env.context)
# Update the token. Need to be committed, otherwise the token is not visible
# to other envs, like the one used in login_and_redirect
user_saml.with_env(new_env).write({"saml_access_token": saml_response})
if validation.get("mapped_attrs", {}):
user.write(validation.get("mapped_attrs", {}))
return user.login
@api.model
def auth_saml(self, provider: int, saml_response: str, base_url: str = None):
validation = self._auth_saml_validate(provider, saml_response, base_url)
# required check
if not validation.get("user_id"):
raise AccessDenied()
# retrieve and sign in user
login = self._auth_saml_signin(provider, validation, saml_response)
if not login:
raise AccessDenied()
# return user credentials
return self.env.cr.dbname, login, saml_response
def _check_credentials(self, password, env):
"""Override to handle SAML auths.
The token can be a password if the user has used the normal form...
but we are more interested in the case when they are tokens
and the interesting code is inside the "except" clause.
"""
try:
# Attempt a regular login (via other auth addons) first.
return super()._check_credentials(password, env)
except (AccessDenied, passlib.exc.PasswordSizeError):
passwd_allowed = (
env["interactive"] or not self.env.user._rpc_api_keys_only()
)
if passwd_allowed and self.env.user.active:
# since normal auth did not succeed we now try to find if the user
# has an active token attached to his uid
token = (
self.env["res.users.saml"]
.sudo()
.search(
[
("user_id", "=", self.env.user.id),
("saml_access_token", "=", password),
]
)
)
if token:
return
raise AccessDenied() from None
@api.model
def _saml_allowed_user_ids(self) -> Set[int]:
"""Users that can have a password even if the option to disallow it is set.
It includes superuser and the admin if it exists.
"""
allowed_users = {SUPERUSER_ID}
user_admin = self.env.ref("base.user_admin", False)
if user_admin:
allowed_users.add(user_admin.id)
return allowed_users
@api.model
def allow_saml_and_password(self) -> bool:
"""Can both SAML and local password auth methods coexist."""
return tools.str2bool(
self.env["ir.config_parameter"]
.sudo()
.get_param(ALLOW_SAML_UID_AND_PASSWORD)
)
def _set_password(self):
"""Inverse method of the password field."""
# Redefine base method to block setting password on users with SAML ids
# And also to be able to set password to a blank value
if not self.allow_saml_and_password():
saml_users = self.filtered(
lambda user: user.sudo().saml_ids
and user.id not in self._saml_allowed_user_ids()
and user.password
)
if saml_users:
# same error as an api.constrains because it is a constraint.
# a standard constrains would require the use of SQL as the password
# field is obfuscated by the base module.
raise ValidationError(
_(
"This database disallows users to "
"have both passwords and SAML IDs. "
"Error for logins %s"
)
% saml_users.mapped("login")
)
# handle setting password to NULL
blank_password_users = self.filtered(lambda user: user.password is False)
non_blank_password_users = self - blank_password_users
if non_blank_password_users:
# pylint: disable=protected-access
super(ResUser, non_blank_password_users)._set_password()
if blank_password_users:
# similar to what Odoo does in Users._set_encrypted_password
self.env.cr.execute(
"UPDATE res_users SET password = NULL WHERE id IN %s",
(tuple(blank_password_users.ids),),
)
blank_password_users.invalidate_recordset(fnames=["password"])
return
def allow_saml_and_password_changed(self):
"""Called after the parameter is changed."""
if not self.allow_saml_and_password():
# sudo because the user doing the parameter change might not have the right
# to search or write users
users_to_blank_password = self.sudo().search(
[
"&",
("saml_ids", "!=", False),
("id", "not in", list(self._saml_allowed_user_ids())),
]
)
_logger.debug(
"Removing password from %s user(s)", len(users_to_blank_password)
)
users_to_blank_password.write({"password": False})

View file

@ -0,0 +1,36 @@
# Copyright (C) 2022 XCG Consulting <https://xcg-consulting.fr/>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models
class ResUserSaml(models.Model):
_name = "res.users.saml"
_description = "User to SAML Provider Mapping"
user_id = fields.Many2one("res.users", index=True, required=True)
saml_provider_id = fields.Many2one(
"auth.saml.provider", string="SAML Provider", index=True
)
saml_uid = fields.Char("SAML User ID", help="SAML Provider user_id", required=True)
saml_access_token = fields.Char(
"Current SAML token for this user",
required=False,
help="The current SAML token in use",
)
_sql_constraints = [
(
"uniq_users_saml_provider_saml_uid",
"unique(saml_provider_id, saml_uid)",
"SAML UID must be unique per provider",
)
]
@api.model_create_multi
def create(self, vals_list):
"""Creates new records for the res.users.saml model"""
# Redefined to remove password if necessary
result = super().create(vals_list)
if not self.env["res.users"].allow_saml_and_password():
result.mapped("user_id").write({"password": False})
return result