oca-server-auth/odoo-bringout-oca-server-auth-auth_saml/auth_saml/models/res_users.py
2025-08-29 15:43:06 +02:00

184 lines
7 KiB
Python

# 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})