diff --git a/auth_oidc/__manifest__.py b/auth_oidc/__manifest__.py index 5e046a73f3..50c8524e47 100644 --- a/auth_oidc/__manifest__.py +++ b/auth_oidc/__manifest__.py @@ -16,6 +16,10 @@ "summary": "Allow users to login through OpenID Connect Provider", "external_dependencies": {"python": ["python-jose"]}, "depends": ["auth_oauth"], - "data": ["views/auth_oauth_provider.xml", "data/auth_oauth_data.xml"], + "data": [ + "security/ir.model.access.csv", + "views/auth_oauth_provider.xml", + "data/auth_oauth_data.xml", + ], "demo": ["demo/local_keycloak.xml"], } diff --git a/auth_oidc/demo/local_keycloak.xml b/auth_oidc/demo/local_keycloak.xml index 919754db99..8a2f97a404 100644 --- a/auth_oidc/demo/local_keycloak.xml +++ b/auth_oidc/demo/local_keycloak.xml @@ -17,4 +17,20 @@ name="jwks_uri" >http://localhost:8080/auth/realms/master/protocol/openid-connect/certs + + + + token['name'] == 'test' + + + + + 'erp_manager' in token['groups'] + diff --git a/auth_oidc/models/auth_oauth_provider.py b/auth_oidc/models/auth_oauth_provider.py index ac498a7cdb..338f21a22d 100644 --- a/auth_oidc/models/auth_oauth_provider.py +++ b/auth_oidc/models/auth_oauth_provider.py @@ -2,12 +2,13 @@ # Copyright 2021 ACSONE SA/NV # License: AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import collections import logging import secrets import requests -from odoo import fields, models, tools +from odoo import api, exceptions, fields, models, tools try: from jose import jwt @@ -46,6 +47,11 @@ class AuthOauthProvider(models.Model): string="Token URL", help="Required for OpenID Connect authorization code flow." ) jwks_uri = fields.Char(string="JWKS URL", help="Required for OpenID Connect.") + group_line_ids = fields.One2many( + "auth.oauth.provider.group_line", + "provider_id", + string="Group mappings", + ) @tools.ormcache("self.jwks_uri", "kid") def _get_keys(self, kid): @@ -104,3 +110,39 @@ def _decode_id_token(self, access_token, id_token, kid): if error: raise error return {} + + +class AuthOauthProviderGroupLine(models.Model): + _name = "auth.oauth.provider.group_line" + _description = "OAuth mapping between an Odoo group and an expression" + + provider_id = fields.Many2one("auth.oauth.provider", required=True) + group_id = fields.Many2one("res.groups", required=True) + expression = fields.Char(required=True, help="Variables: user, token") + + @api.constrains("expression") + def _check_expression(self): + for this in self: + try: + this._eval_expression(self.env.user, {}) + except (AttributeError, KeyError, NameError, ValueError) as e: + # AttributeError: user object can be accessed via attributes: user.email + # KeyError: token is a dict of dicts + # NameError: only user and token can be used + # ValueError: for inexistant variables or attributes + raise exceptions.ValidationError(e) from e + + def _eval_expression(self, user, token): + self.ensure_one() + + class Defaultdict2(collections.defaultdict): + def __init__(self, *args, **kwargs): + super().__init__(Defaultdict2, *args, **kwargs) + + return tools.safe_eval.safe_eval( + self.expression, + { + "user": user, + "token": Defaultdict2(token), + }, + ) diff --git a/auth_oidc/models/res_users.py b/auth_oidc/models/res_users.py index 1684480fa4..fdb8c9c5f3 100644 --- a/auth_oidc/models/res_users.py +++ b/auth_oidc/models/res_users.py @@ -8,6 +8,7 @@ from odoo import api, models from odoo.exceptions import AccessDenied +from odoo.fields import Command from odoo.http import request _logger = logging.getLogger(__name__) @@ -64,6 +65,13 @@ def auth_oauth(self, provider, params): _logger.error("No id_token in response.") raise AccessDenied() validation = oauth_provider._parse_id_token(id_token, access_token) + if oauth_provider.data_endpoint: + data = requests.get( + oauth_provider.data_endpoint, + headers={"Authorization": "Bearer %s" % access_token}, + timeout=10, + ).json() + validation.update(data) # required check if "sub" in validation and "user_id" not in validation: # set user_id for auth_oauth, user_id is not an OpenID Connect standard @@ -80,3 +88,22 @@ def auth_oauth(self, provider, params): raise AccessDenied() # return user credentials return (self.env.cr.dbname, login, access_token) + + @api.model + def _auth_oauth_signin(self, provider, validation, params): + login = super()._auth_oauth_signin(provider, validation, params) + user = self.search([("login", "=", login)]) + if user: + group_updates = [] + for group_line in ( + self.env["auth.oauth.provider"].browse(provider).group_line_ids + ): + if group_line._eval_expression(user, validation): + if group_line.group_id not in user.groups_id: + group_updates.append((Command.LINK, group_line.group_id.id)) + else: + if group_line.group_id in user.groups_id: + group_updates.append((Command.UNLINK, group_line.group_id.id)) + if group_updates: + user.write({"groups_id": group_updates}) + return login diff --git a/auth_oidc/security/ir.model.access.csv b/auth_oidc/security/ir.model.access.csv new file mode 100644 index 0000000000..503e4c7529 --- /dev/null +++ b/auth_oidc/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_auth_oauth_provider_group_line,auth_oauth_provider,model_auth_oauth_provider_group_line,base.group_system,1,1,1,1 diff --git a/auth_oidc/tests/test_auth_oidc_auth_code.py b/auth_oidc/tests/test_auth_oidc_auth_code.py index a1a08b0a71..8115898cc5 100644 --- a/auth_oidc/tests/test_auth_oidc_auth_code.py +++ b/auth_oidc/tests/test_auth_oidc_auth_code.py @@ -13,7 +13,8 @@ from jose.utils import long_to_base64 import odoo -from odoo.exceptions import AccessDenied +from odoo.exceptions import AccessDenied, ValidationError +from odoo.fields import Command from odoo.tests import common from odoo.addons.website.tools import MockRequest as _MockRequest @@ -21,6 +22,7 @@ from ..controllers.main import OpenIDLogin BASE_URL = "http://localhost:%s" % odoo.tools.config["http_port"] +KEYCLOAK_URL = "http://localhost:8080" @contextlib.contextmanager @@ -69,7 +71,7 @@ def _generate_key(): def setUp(self): super().setUp() - # search our test provider and bind the demo user to it + # search our only test provider self.provider_rec = self.env["auth.oauth.provider"].search( [("client_id", "=", "auth_oidc-test")] ) @@ -97,6 +99,7 @@ def test_auth_link(self): self.assertEqual(params["redirect_uri"], [BASE_URL + "/auth_oauth/signin"]) def _prepare_login_test_user(self): + # bind the demo user to our test provider it user = self.env.ref("base.user_demo") user.write({"oauth_provider_id": self.provider_rec.id, "oauth_uid": user.login}) return user @@ -110,7 +113,7 @@ def _prepare_login_test_responses( id_token_headers = {"kid": "the_key_id"} responses.add( responses.POST, - "http://localhost:8080/auth/realms/master/protocol/openid-connect/token", + KEYCLOAK_URL + "/auth/realms/master/protocol/openid-connect/token", json={ "access_token": access_token, "id_token": jwt.encode( @@ -128,7 +131,7 @@ def _prepare_login_test_responses( keys = [{"keys": [self.rsa_key_public_pem]}] responses.add( responses.GET, - "http://localhost:8080/auth/realms/master/protocol/openid-connect/certs", + KEYCLOAK_URL + "/auth/realms/master/protocol/openid-connect/certs", json={"keys": keys}, ) @@ -147,6 +150,44 @@ def test_login(self): self.assertEqual(token, "42") self.assertEqual(login, user.login) + @responses.activate + def test_manager_login(self): + """Test that login works and assigns the user to a manager group""" + user = self._prepare_login_test_user() + self._prepare_login_test_responses( + id_token_body={"user_id": user.login, "groups": ["erp_manager"]} + ) + + params = {"state": json.dumps({})} + with MockRequest(self.env): + db, login, token = self.env["res.users"].auth_oauth( + self.provider_rec.id, + params, + ) + self.assertTrue(user.has_group("base.group_erp_manager")) + + @responses.activate + def test_ex_manager_login(self): + """Test that login works and de-assigns the user from a manager group""" + user = self._prepare_login_test_user() + # Make them a manager + user.write( + {"groups_id": [Command.link(self.env.ref("base.group_erp_manager").id)]} + ) + self.assertTrue(user.has_group("base.group_erp_manager")) + + self._prepare_login_test_responses( + id_token_body={"user_id": user.login, "groups": ["not_erp_manager"]} + ) + + params = {"state": json.dumps({})} + with MockRequest(self.env): + db, login, token = self.env["res.users"].auth_oauth( + self.provider_rec.id, + params, + ) + self.assertFalse(user.has_group("base.group_erp_manager")) + @responses.activate def test_login_without_kid(self): """Test that login works when ID Token has no kid in header""" @@ -308,3 +349,47 @@ def test_login_with_jwk_format(self): ) self.assertEqual(token, "122/3") self.assertEqual(login, user.login) + + def test_group_expression_empty_token(self): + """Test that group expression with an empty token evaluate correctly""" + group_line = self.env.ref("auth_oidc.local_keycloak").group_line_ids[:1] + group_line.expression = 'token["test"]["test"] == 1' + self.assertFalse(group_line._eval_expression(self.env.user, {})) + + def test_group_expressions_with_token(self): + """Test that group expression with token with groups evaluate correctly""" + group_line = self.env.ref("auth_oidc.local_keycloak").group_line_ids[:1] + + group_line.expression = "'group-a' in token['groups']" + self.assertFalse(group_line._eval_expression(self.env.user, {})) + self.assertTrue( + group_line._eval_expression( + self.env.user, {"groups": ["group-a", "group-b"]} + ) + ) + self.assertFalse( + group_line._eval_expression(self.env.user, {"groups": ["group-c"]}) + ) + + def test_group_expression_with_inexistant_variable(self): + """Test that group expression with inexistant variable fails""" + group_line = self.env.ref("auth_oidc.local_keycloak").group_line_ids[:1] + + with self.assertRaises(ValidationError): + group_line.expression = "inexistant_variable" + + def test_group_expression_with_inexistant_attribute(self): + """Test that group expression with inexistant attribute (on user) fails""" + group_line = self.env.ref("auth_oidc.local_keycloak").group_line_ids[:1] + + with self.assertRaises(ValidationError): + group_line.expression = "user.not_an_attribute" + + def test_realistic_group_expression(self): + """Test that group expression with inexistant attribute (on user) fails""" + group_line = self.env.ref("auth_oidc.local_keycloak").group_line_ids[:1] + + group_line.expression = "user.email == token['mail']" + self.assertTrue( + group_line._eval_expression(self.env.user, {"mail": self.env.user.email}) + ) diff --git a/auth_oidc/views/auth_oauth_provider.xml b/auth_oidc/views/auth_oauth_provider.xml index 90c931b417..dbdeadd8ef 100644 --- a/auth_oidc/views/auth_oauth_provider.xml +++ b/auth_oidc/views/auth_oauth_provider.xml @@ -19,6 +19,16 @@ + + + + + + + + + +