mirror of
https://github.com/bringout/oca-server-auth.git
synced 2026-04-18 23:12:02 +02:00
Initial commit: OCA Server Auth packages (29 packages)
This commit is contained in:
commit
3ed80311c4
1325 changed files with 127292 additions and 0 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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})
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue