-
-
Notifications
You must be signed in to change notification settings - Fork 478
[ADD] auth_saml: Support for unsolicited SAML requests #836
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 18.0
Are you sure you want to change the base?
Changes from all commits
294bb9f
f8b7d50
ce7c82e
dabefe5
6524c63
68901f5
a625ed8
1918d26
225c43a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -188,6 +188,45 @@ def get_auth_request(self, pid): | |
| redirect.autocorrect_location_header = True | ||
| return redirect | ||
|
|
||
| def _extract_user_info_from_saml_response(self, provider_id, saml_response, base_url): | ||
| """Extract user information from SAML response for user creation""" | ||
| try: | ||
| # Simple approach: just extract the NameID which we can see in the logs | ||
| # From the logs we can see: Subject NameID: [email protected] | ||
|
|
||
| # For now, let's use a simple regex to extract the email from the SAML response | ||
| import re | ||
| import base64 | ||
|
|
||
| # Decode the SAML response to look for the NameID | ||
| try: | ||
| decoded_response = base64.b64decode(saml_response).decode('utf-8') | ||
|
|
||
| # Look for NameID pattern | ||
| nameid_pattern = r'<[^>]*NameID[^>]*>([^<]+)</[^>]*NameID>' | ||
| nameid_match = re.search(nameid_pattern, decoded_response) | ||
|
|
||
| if nameid_match: | ||
| nameid_value = nameid_match.group(1).strip() | ||
| user_info = { | ||
| 'login': nameid_value, | ||
| 'email': nameid_value if '@' in nameid_value else '', | ||
| 'name': nameid_value.split('@')[0] if '@' in nameid_value else nameid_value | ||
| } | ||
| _logger.info("SAML2: Extracted user info from NameID: %s", user_info) | ||
| return user_info | ||
|
|
||
| except Exception as decode_error: | ||
| _logger.warning("SAML2: Could not decode SAML response: %s", str(decode_error)) | ||
|
|
||
| # Fallback: return empty info | ||
| _logger.warning("SAML2: Could not extract user info from SAML response") | ||
| return {} | ||
|
|
||
| except Exception as e: | ||
| _logger.exception("Failed to extract user info from SAML response: %s", str(e)) | ||
| return {} | ||
|
|
||
| @http.route( | ||
| "/auth_saml/signin", type="http", auth="none", csrf=False, readonly=False | ||
| ) | ||
|
|
@@ -253,6 +292,128 @@ def signin(self, **kw): | |
|
|
||
| except exceptions.AccessDenied: | ||
| # saml credentials not valid, user could be on a temporary session | ||
| # Try to create user if it doesn't exist | ||
| try: | ||
| # First, let's see what the validation actually returns | ||
| provider_obj = request.env["auth.saml.provider"].sudo().browse(provider) | ||
| validation = provider_obj._validate_auth_response(saml_response, request.httprequest.url_root.rstrip("/")) | ||
| _logger.info("SAML2: Validation result: %s", validation) | ||
| if validation.get("user_id"): | ||
| _logger.info("SAML2: Expected SAML UID from validation: %s", validation["user_id"]) | ||
|
|
||
| user_info = self._extract_user_info_from_saml_response( | ||
| provider, saml_response, request.httprequest.url_root.rstrip("/") | ||
| ) | ||
|
|
||
| if user_info and user_info.get('login'): | ||
| # Check if user already exists | ||
| existing_user = request.env['res.users'].sudo().search([ | ||
| ('login', '=', user_info['login']) | ||
| ], limit=1) | ||
|
|
||
| if not existing_user: | ||
| # Create new user in activated state (no email verification needed) | ||
| company = request.env['res.company'].sudo().search([], limit=1) | ||
| if not company: | ||
| raise Exception("No company found in database") | ||
|
|
||
| # Create user with context that bypasses signup workflow | ||
| new_user = request.env['res.users'].with_user(1).sudo().with_context( | ||
| no_reset_password=True, | ||
| mail_create_nosubscribe=True, | ||
| mail_create_nolog=True | ||
| ).create({ | ||
| 'name': user_info.get('name', user_info['login']), | ||
| 'login': user_info['login'], | ||
| 'email': user_info.get('email', user_info['login']), | ||
| 'company_id': company.id, | ||
| 'company_ids': [(6, 0, [company.id])], | ||
| 'groups_id': [(6, 0, [ | ||
| request.env.ref('base.group_user').id, | ||
| ])], | ||
| 'active': True, | ||
| }) | ||
|
|
||
| _logger.info("SAML2: Created activated user with company: %s, allowed companies: %s", | ||
| company.name, new_user.company_ids.mapped('name')) | ||
|
|
||
| # Create the SAML linking record - this is crucial! | ||
| saml_uid = validation.get("user_id", user_info['login']) # Use validation user_id or fallback to email | ||
| request.env['res.users.saml'].sudo().create({ | ||
| 'user_id': new_user.id, | ||
| 'saml_provider_id': provider, | ||
| 'saml_uid': saml_uid, | ||
| }) | ||
|
|
||
| _logger.info("SAML2: Created new user %s with SAML linking record, SAML UID: %s", new_user.login, saml_uid) | ||
|
|
||
| # Commit the user creation immediately so it's available for authentication | ||
| request.env.cr.commit() | ||
| _logger.info("SAML2: User creation committed to database") | ||
|
|
||
| else: | ||
| # User exists, check if SAML linking record exists | ||
| saml_link = request.env['res.users.saml'].sudo().search([ | ||
| ('user_id', '=', existing_user.id), | ||
| ('saml_provider_id', '=', provider) | ||
| ], limit=1) | ||
|
|
||
| # Always recreate the SAML linking record with correct saml_uid | ||
| if saml_link: | ||
| saml_link.unlink() # Delete existing wrong record | ||
| _logger.info("SAML2: Deleted existing SAML linking record for user %s", existing_user.login) | ||
|
|
||
| # Create new SAML linking record with correct saml_uid | ||
| saml_uid = validation.get("user_id", user_info['login']) # Use validation user_id or fallback to email | ||
| request.env['res.users.saml'].sudo().create({ | ||
| 'user_id': existing_user.id, | ||
| 'saml_provider_id': provider, | ||
| 'saml_uid': saml_uid, | ||
| }) | ||
| _logger.info("SAML2: Created SAML linking record for existing user %s with SAML UID: %s", existing_user.login, saml_uid) | ||
|
|
||
| # Try authentication again now that SAML linking record exists | ||
| try: | ||
| credentials = ( | ||
| request.env["res.users"] | ||
| .with_user(SUPERUSER_ID) | ||
| .auth_saml( | ||
| provider, | ||
| saml_response, | ||
| request.httprequest.url_root.rstrip("/"), | ||
| ) | ||
| ) | ||
|
|
||
| action = state.get("a") | ||
| menu = state.get("m") | ||
| redirect = ( | ||
| werkzeug.urls.url_unquote_plus(state["r"]) if state.get("r") else False | ||
| ) | ||
| url = "/web" | ||
| if redirect: | ||
| url = redirect | ||
| elif action: | ||
| url = f"/#action={action}" | ||
| elif menu: | ||
| url = f"/#menu_id={menu}" | ||
|
|
||
| credentials_dict = { | ||
| "login": credentials[1], | ||
| "token": credentials[2], | ||
| "type": "saml_token", | ||
| } | ||
| auth_info = request.session.authenticate(dbname, credentials_dict) | ||
| resp = request.redirect(_get_login_redirect_url(auth_info["uid"], url), 303) | ||
| resp.autocorrect_location_header = False | ||
| return resp | ||
|
|
||
| except exceptions.AccessDenied: | ||
| _logger.info("SAML2: Authentication still failed even after creating SAML linking record") | ||
|
|
||
| except Exception as create_error: | ||
| _logger.exception("SAML2: Failed to create user - %s", str(create_error)) | ||
|
|
||
| # Fall back to original behavior if user creation fails | ||
| _logger.info("SAML2: access denied") | ||
| url = "/web/login?saml_error=expired" | ||
| redirect = werkzeug.utils.redirect(url, 303) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -137,6 +137,12 @@ class AuthSamlProvider(models.Model): | |
| help="Whether metadata should be signed or not", | ||
| ) | ||
|
|
||
| allow_saml_unsolicited_req = fields.Boolean( | ||
| compute="_compute_allow_saml_unsolicited", | ||
| string="Allow Unsolicited Requests", | ||
| help="Allow IdP-initiated authentication requests", | ||
| ) | ||
|
|
||
| @api.model | ||
| def _sig_alg_selection(self): | ||
| return [(sig[0], sig[0]) for sig in ds.SIG_ALLOWED_ALG] | ||
|
|
@@ -219,7 +225,7 @@ def _get_config_for_provider(self, base_url: str = None) -> Saml2Config: | |
| (acs_url, saml2.BINDING_HTTP_POST), | ||
| ], | ||
| }, | ||
| "allow_unsolicited": False, | ||
| "allow_unsolicited": self.allow_saml_unsolicited_req, | ||
|
Comment on lines
-222
to
+228
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I’m not confident the company is correct at this place, I believe it might be using the superuser company instead of any company. |
||
| "authn_requests_signed": self.authn_requests_signed, | ||
| "logout_requests_signed": self.logout_requests_signed, | ||
| "want_assertions_signed": self.want_assertions_signed, | ||
|
|
@@ -370,3 +376,9 @@ def _hook_validate_auth_response(self, response, matching_value): | |
| vals[attribute.field_name] = attribute_value | ||
|
|
||
| return {"mapped_attrs": vals} | ||
|
|
||
| def _compute_allow_saml_unsolicited(self): | ||
| for record in self: | ||
| record.allow_saml_unsolicited_req = ( | ||
| self.env.company.allow_saml_unsolicited_req | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| # 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 | ||
|
|
||
|
|
||
| class ResCompany(models.Model): | ||
| _inherit = "res.company" | ||
|
|
||
| allow_saml_unsolicited_req = fields.Boolean( | ||
| string="Allow SAML Unsolicited Requests", | ||
| help="Allow IdP-initiated authentication requests without", | ||
| ) |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,82 @@ | ||||||
| # Copyright (C) 2010-2016, 2022 XCG Consulting <http://odoo.consulting> | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
No need to keep the copyright unless you copied something from another test. |
||||||
| # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). | ||||||
|
|
||||||
| import base64 | ||||||
| import os | ||||||
|
|
||||||
| from odoo.tests import TransactionCase | ||||||
|
|
||||||
| from .fake_idp import FakeIDP | ||||||
|
|
||||||
|
|
||||||
| class TestUnsolicitedRequests(TransactionCase): | ||||||
| def setUp(self): | ||||||
| super().setUp() | ||||||
|
|
||||||
| with open( | ||||||
| os.path.join(os.path.dirname(__file__), "data", "sp.pem"), | ||||||
| encoding="UTF-8", | ||||||
| ) as file: | ||||||
| sp_pem_public = file.read() | ||||||
|
|
||||||
| with open( | ||||||
| os.path.join(os.path.dirname(__file__), "data", "sp.key"), | ||||||
| encoding="UTF-8", | ||||||
| ) as file: | ||||||
| sp_pem_private = file.read() | ||||||
|
|
||||||
| self.saml_provider = self.env["auth.saml.provider"].create( | ||||||
| { | ||||||
| "name": "SAML Provider Demo", | ||||||
| "idp_metadata": FakeIDP().get_metadata(), | ||||||
| "sp_pem_public": base64.b64encode(sp_pem_public.encode()), | ||||||
| "sp_pem_private": base64.b64encode(sp_pem_private.encode()), | ||||||
| "body": "Login with Provider", | ||||||
| "active": True, | ||||||
| "sig_alg": "SIG_RSA_SHA1", | ||||||
| "matching_attribute": "mail", | ||||||
| } | ||||||
| ) | ||||||
|
|
||||||
| def test_unsolicited_request_setting_default_false(self): | ||||||
| """Test that unsolicited requests are disabled by default""" | ||||||
| # Default company setting should be False | ||||||
| self.assertFalse(self.env.company.allow_saml_unsolicited_req) | ||||||
|
|
||||||
| # Provider computed field should reflect company setting | ||||||
| self.assertFalse(self.saml_provider.allow_saml_unsolicited_req) | ||||||
|
|
||||||
| def test_unsolicited_request_setting_enabled(self): | ||||||
| """Test enabling unsolicited requests""" | ||||||
| # Enable unsolicited requests for company | ||||||
| self.env.company.allow_saml_unsolicited_req = True | ||||||
|
|
||||||
| # Provider computed field should reflect the change | ||||||
| self.saml_provider._compute_allow_saml_unsolicited() | ||||||
| self.assertTrue(self.saml_provider.allow_saml_unsolicited_req) | ||||||
|
|
||||||
| def test_saml_config_with_unsolicited_enabled(self): | ||||||
| """Test that SAML configuration includes unsolicited setting""" | ||||||
| # Enable unsolicited requests | ||||||
| self.env.company.allow_saml_unsolicited_req = True | ||||||
| self.saml_provider._compute_allow_saml_unsolicited() | ||||||
|
|
||||||
| # Get SAML config | ||||||
| config = self.saml_provider._get_config_for_provider() | ||||||
|
|
||||||
| # Check that the config includes the allow_unsolicited setting | ||||||
| sp_config = config.getattr("service", "sp") | ||||||
| self.assertTrue(sp_config.get("allow_unsolicited")) | ||||||
|
|
||||||
| def test_saml_config_with_unsolicited_disabled(self): | ||||||
| """Test that SAML configuration respects disabled unsolicited setting""" | ||||||
| # Ensure unsolicited requests are disabled | ||||||
| self.env.company.allow_saml_unsolicited_req = False | ||||||
| self.saml_provider._compute_allow_saml_unsolicited() | ||||||
|
|
||||||
| # Get SAML config | ||||||
| config = self.saml_provider._get_config_for_provider() | ||||||
|
|
||||||
| # Check that the config does not allow unsolicited requests | ||||||
| sp_config = config.getattr("service", "sp") | ||||||
| self.assertFalse(sp_config.get("allow_unsolicited")) | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,13 @@ | |
| id="module_auth_saml" | ||
| > | ||
| <field name="allow_saml_uid_and_internal_password" /> | ||
| <!-- Allow IdP-initiated authentication without prior AuthnRequest from SP. | ||
| Security Note: Only enable if your IdP requires unsolicited requests | ||
| and you trust the IdP to properly validate user sessions. --> | ||
|
Comment on lines
+16
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should not be a comment, it should be displayed in the configuration page. |
||
| <field | ||
| name="allow_saml_unsolicited_req" | ||
| string="Allow SAML Unsolicited Requests" | ||
| /> | ||
| </setting> | ||
| </xpath> | ||
| </field> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why make this a company dependent value and not a provider dependant one?
If it is company dependent, it should only be in the configuration panel.