From 63aadfc3e30c07b96aacb48bf24705e5a672569d Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 25 Oct 2018 15:09:54 +0200 Subject: [PATCH 01/22] [10.0][ADD] auth_keycloak --- auth_keycloak/README.rst | 152 ++ auth_keycloak/__init__.py | 3 + auth_keycloak/__manifest__.py | 23 + auth_keycloak/data/auth_oauth_provider.xml | 13 + auth_keycloak/examples/README.md | 207 +++ auth_keycloak/examples/__init__.py | 0 auth_keycloak/examples/cli.py | 142 ++ auth_keycloak/examples/common.py | 37 + auth_keycloak/examples/get_token.py | 61 + auth_keycloak/examples/keycloak-compose.yml | 23 + auth_keycloak/examples/realm-export.json | 1474 ++++++++++++++++++ auth_keycloak/examples/requirements.txt | 2 + auth_keycloak/exceptions.py | 5 + auth_keycloak/models/__init__.py | 2 + auth_keycloak/models/auth_oauth.py | 44 + auth_keycloak/models/res_users.py | 68 + auth_keycloak/readme/CONFIGURE.rst | 13 + auth_keycloak/readme/CONTRIBUTORS.rst | 1 + auth_keycloak/readme/CREDITS.rst | 1 + auth_keycloak/readme/DESCRIPTION.rst | 1 + auth_keycloak/readme/HISTORY.rst | 4 + auth_keycloak/readme/USAGE.rst | 45 + auth_keycloak/static/description/index.html | 496 ++++++ auth_keycloak/tests/__init__.py | 3 + auth_keycloak/tests/common.py | 131 ++ auth_keycloak/tests/test_auth.py | 124 ++ auth_keycloak/tests/test_wizard_create.py | 158 ++ auth_keycloak/tests/test_wizard_sync.py | 112 ++ auth_keycloak/views/auth_oauth_views.xml | 27 + auth_keycloak/views/res_users_views.xml | 22 + auth_keycloak/wizard/__init__.py | 1 + auth_keycloak/wizard/keycloak_create_wiz.xml | 63 + auth_keycloak/wizard/keycloak_sync_wiz.py | 311 ++++ auth_keycloak/wizard/keycloak_sync_wiz.xml | 45 + 34 files changed, 3814 insertions(+) create mode 100644 auth_keycloak/README.rst create mode 100644 auth_keycloak/__init__.py create mode 100644 auth_keycloak/__manifest__.py create mode 100644 auth_keycloak/data/auth_oauth_provider.xml create mode 100644 auth_keycloak/examples/README.md create mode 100644 auth_keycloak/examples/__init__.py create mode 100644 auth_keycloak/examples/cli.py create mode 100644 auth_keycloak/examples/common.py create mode 100644 auth_keycloak/examples/get_token.py create mode 100644 auth_keycloak/examples/keycloak-compose.yml create mode 100644 auth_keycloak/examples/realm-export.json create mode 100644 auth_keycloak/examples/requirements.txt create mode 100644 auth_keycloak/exceptions.py create mode 100644 auth_keycloak/models/__init__.py create mode 100644 auth_keycloak/models/auth_oauth.py create mode 100644 auth_keycloak/models/res_users.py create mode 100644 auth_keycloak/readme/CONFIGURE.rst create mode 100644 auth_keycloak/readme/CONTRIBUTORS.rst create mode 100644 auth_keycloak/readme/CREDITS.rst create mode 100644 auth_keycloak/readme/DESCRIPTION.rst create mode 100644 auth_keycloak/readme/HISTORY.rst create mode 100644 auth_keycloak/readme/USAGE.rst create mode 100644 auth_keycloak/static/description/index.html create mode 100644 auth_keycloak/tests/__init__.py create mode 100644 auth_keycloak/tests/common.py create mode 100644 auth_keycloak/tests/test_auth.py create mode 100644 auth_keycloak/tests/test_wizard_create.py create mode 100644 auth_keycloak/tests/test_wizard_sync.py create mode 100644 auth_keycloak/views/auth_oauth_views.xml create mode 100644 auth_keycloak/views/res_users_views.xml create mode 100644 auth_keycloak/wizard/__init__.py create mode 100644 auth_keycloak/wizard/keycloak_create_wiz.xml create mode 100644 auth_keycloak/wizard/keycloak_sync_wiz.py create mode 100644 auth_keycloak/wizard/keycloak_sync_wiz.xml diff --git a/auth_keycloak/README.rst b/auth_keycloak/README.rst new file mode 100644 index 0000000000..bbd028f498 --- /dev/null +++ b/auth_keycloak/README.rst @@ -0,0 +1,152 @@ +========================= +Keycloak auth integration +========================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github + :target: https://github.com/OCA/server-auth/tree/10.0/auth_keycloak + :alt: OCA/server-auth +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-auth-10-0/server-auth-10-0-auth_keycloak + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/251/10.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds support for SSO authentication via `Keycloak `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Settings -> Users -> OAuth Providers -> Keycloak + +Adjust endpoints according to your setup. + +Enable it: tick "Allowed". + +Official docs: https://www.keycloak.org/docs + + +.. note:: You must make sure your settings are correct. + Testing scripts are provided by this module in the folder `examples`. + + Please follow instructions contained in its README. + +Usage +===== + +Frontend +~~~~~~~~ + +When the provider is enabled you'll see an extra login button on login form. +Click on it to get redirected to Keycloak. + +Backend +~~~~~~~ + +**Link existing users from Keycloak** + +If you have existing users in Odoo and they are not linked to Keycloak yet +you can: + +1. get back to Settings -> Users -> OAuth Providers -> Keycloak +2. configure "Users management" box +3. click on "Sync users" button +4. select the matching key +5. submit + +Once the it's done all matching and updated users will be listed in a list view. +Now your users will be able to log in on Keycloak + + +**Push new users to Keycloak** + +Usually Keycloak is already populated w/ your users base. +Many times this will come via LDAP, AD, pick yours. + +Still, you might need to push some users to Keycloak on demand, +maybe just for testing. + +If you need this, either you + +1. go to a single user form +2. hit the button "Push to Keycloak" (in the header) +3. use the wizard to push it + +or + +1. go to the users list view +2. select some users +3. click on Actions -> Push to Keycloak +4. select "Keycloak" provider +5. push them all + +Changelog +========= + +10.0.1.0.0 2018-10-17 +~~~~~~~~~~~~~~~~~~~~~ + +* Initial implementation + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +Simone Orsi + +Other credits +~~~~~~~~~~~~~ + +Development sponsored by `Sensefly `_ and `UTB `_. + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-auth `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/auth_keycloak/__init__.py b/auth_keycloak/__init__.py new file mode 100644 index 0000000000..408a6001bd --- /dev/null +++ b/auth_keycloak/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import models +from . import wizard diff --git a/auth_keycloak/__manifest__.py b/auth_keycloak/__manifest__.py new file mode 100644 index 0000000000..f074ddd740 --- /dev/null +++ b/auth_keycloak/__manifest__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +{ + "name": "Keycloak auth integration", + "summary": "Integrate Keycloak into your SSO", + "version": "10.0.1.0.0", + 'category': 'Tools', + "website": "https://github.com/OCA/server-auth", + 'author': 'Camptocamp, Odoo Community Association (OCA)', + "license": "AGPL-3", + "depends": [ + "auth_oauth", + ], + "data": [ + 'data/auth_oauth_provider.xml', + 'wizard/keycloak_sync_wiz.xml', + 'wizard/keycloak_create_wiz.xml', + 'views/auth_oauth_views.xml', + 'views/res_users_views.xml', + ], +} diff --git a/auth_keycloak/data/auth_oauth_provider.xml b/auth_keycloak/data/auth_oauth_provider.xml new file mode 100644 index 0000000000..6c3e3039b9 --- /dev/null +++ b/auth_keycloak/data/auth_oauth_provider.xml @@ -0,0 +1,13 @@ + + + + Keycloak + + odoo + http://keycloak:8080/auth/realms/master/protocol/openid-connect/auth + http://keycloak:8080/auth/realms/master/protocol/openid-connect/token/introspect + http://keycloak:8080/auth/realms/master/protocol/openid-connect/userinfo + OAuth2 + Login with Keycloak + + diff --git a/auth_keycloak/examples/README.md b/auth_keycloak/examples/README.md new file mode 100644 index 0000000000..cffb95295e --- /dev/null +++ b/auth_keycloak/examples/README.md @@ -0,0 +1,207 @@ +# Keycloak dev and test + +In this folder you find some examples on how to test your Keycloak auth. + +**DISCLAIMER:** this is NOT a guide to help you set up your KC instance. + +You must refer to [official docs|https://www.keycloak.org/docs]. + +None of the settings or the scripts here are meant to work with your setup. + +If they do not work, feel free to modify them according to your needs +to be able to test your configuration easily. + + +## Setup Keycloak + +If you want to test on a dev instance and not on the customer's one +start from point 0 and 1. Otherwise, jump to point 2. + +0. create a DB and a user: + + ``` + odoodb=# create user keycloak with password 'keycloak'; + CREATE ROLE + odoodb=# create database keycloak OWNER keycloak; + CREATE DATABASE + ``` + +1. if you don't have a keycloak container already, see keycloak-compose.yml as an example). + +2. go to http://localhost:8080 (adapt URL to your setup) and log in as admin (admin/admin) + +3. create a new realm named `Odoo` + +4. go to "Clients" and click on "create" button + +5. configure KC client properly. See `real-export.json` file as an example. + + In particular, see that these flags are turned on: + + ```json + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": true, + "directAccessGrantsEnabled": true, + ``` + + (Note that these settings might differ from your final configuration + but they are required for the scripts and our current internal configuration to work.) + +6. go to "Manage / Users" and create a new user (default `c2c`) + +7. switch to "Credentials" tab, uncheck "temporary" flag and set a new pwd (default `c2c`) + +8. edit `/etc/hosts` and add a line `127.0.0.1 keycloak` + +Now your dev instance is ready to be tested. + + +**NOTE:** if the user already exists in Odoo, +it's keycloak's ID ("sub") should be added manually in the Oauth ID field in Odoo. +This is because it's automatically stored only on new signups. + +**WARNING:** if you have an LDAP configuration which is not working locally +go to company settings and delete it otherwise the auth will hang forever. + + +## Test calls + +You can use these scripts on real or dev/test keycloak instances +to test if they are properly configured to work smoothly w/ Odoo. + +Indeed, what they do, is to simulate Odoo calls. + + +### Requirements + +``` +cd path/to/auth_keycloak/examples +pip install -r requirements.txt +``` + +### Init + +1st of all you need a token. Run: + +``` +python get_token.py +``` + +If everything goes fine, you'll see something like: + +``` +$ python examples/get_token.py +Username [admin]: +Password [admin]: +Client ID [odoo]: +Client secret [6991c9e8-88bb-4cd8-bc94-7ffd914b0167]: +Calling http://localhost:8080/auth/realms/Odoo/protocol/openid-connect/token +Access token: +eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ5cDhwS29xVHVKUElGa1ZTMVdGWGxQZlpOdzRWZ042eXpocGFOZFBjWmQwIn0.eyJqdGkiOiIzMWVjODZiYS00OTcwLTRhNjEtODEyZi0xYTI2ZGYyOGY3OTIiLCJleHAiOjE1MjYzMDU3NzYsIm5iZiI6MCwiaWF0IjoxNTI2MzA1NDc2LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvT2RvbyIsImF1ZCI6Im9kb28iLCJzdWIiOiIyYmE1ZmQ0Ni00ZTNhLTRjYWYtOTNlNy0wODcwNDcyMGE5MzYiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJvZG9vIiwiYXV0aF90aW1lIjowLCJzZXNzaW9uX3N0YXRlIjoiNGQwZTM5MzgtOTRjNi00NmY4LWFlN2QtZjIwZjA5YjU2YjhjIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwOi8vb2Rvbzo4MDY5Il0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJ1bWFfYXV0aG9yaXphdGlvbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7Im9kb28iOnsicm9sZXMiOlsidGVzdCJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwicHJlZmVycmVkX3VzZXJuYW1lIjoiYzJjIn0.Ll5ZG6FdlQA_mlalixT8UFrhcgP3yx_qC-u-hl9TqnksrbN7x1ZOqy7_awkV5i_6TPlAKmoq5cHtWM0opJgRMrV7e5ZZ4ZEfEbw7PFSE1SdC7N961vhMx5Gts2l9xy0WQOJoeT3ImVdRgVtK8mIOYmBpiZXUJY7KfDTVUXt6HT9AVmNYArxBI1PZlbIRxwb_rSXMs1KAll3lcs506t5CSb2Utjvsx1kEcf5ZMfKiXkz0xAyT1ZMnWjpUu_iHKQQWLcefDvCDqmSofgR6WJ1hD9kJKsrY8BLn-BvLVsSzgj8jK8El6zXu0_rBscjyVaM4j-JeQ0qUqfe7Stzw8kbIUw +Saved to /tmp/keycloak.json +``` +Meaningful data as well as our brand new token are stored into "/tmp/keycloak.json". + +If want you can pass custom arguments when prompted. + +If you get an error, check Keycloak's logs. + +**NOTE:** by default we call `localhost` but you can pass extra arguments for domain and real like this: + +``` +$ python get_token.py --domain=https://keycloak.mycompany.io --realm=mycompany +[...] +Calling https://keycloak.mycompany.io/auth/realms/mycompany/protocol/openid-connect/token + +``` + +### Token validation + +Run: + +``` +python cli.py validate_token +``` +If everything goes fine, you'll see something like: +``` +$ python examples/cli.py validate_token +Calling http://localhost:8080/auth/realms/Odoo/protocol/openid-connect/token/introspect +{u'username': u'c2c', u'active': True, u'session_state': u'4d0e3938-94c6-46f8-ae7d-f20f09b56b8c', u'aud': u'odoo', u'realm_access': {u'roles': [u'uma_authorization']}, u'iss': u'http://localhost:8080/auth/realms/Odoo', u'resource_access': {u'account': {u'roles': [u'manage-account', u'manage-account-links', u'view-profile']}, u'odoo': {u'roles': [u'test']}}, u'allowed-origins': [u'http://odoo:8069'], u'preferred_username': u'c2c', u'acr': u'1', u'client_id': u'odoo', u'jti': u'31ec86ba-4970-4a61-812f-1a26df28f792', u'exp': 1526305776, u'auth_time': 0, u'azp': u'odoo', u'iat': 1526305476, u'typ': u'Bearer', u'nbf': 0, u'sub': u'2ba5fd46-4e3a-4caf-93e7-08704720a936'} +``` +If the token is expired meanwhile you'll see another call to `get_token`. + +If you get an error, check Keycloak's logs. + + +### User introspection + +Run: + +``` +python cli.py user_info +``` +If everything goes fine, you'll see something like: +``` +$ python examples/cli.py user_info +Calling http://localhost:8080/auth/realms/Odoo/protocol/openid-connect/userinfo +User info: +{u'preferred_username': u'c2c', u'sub': u'2ba5fd46-4e3a-4caf-93e7-08704720a936'} +``` +If you get an error, check Keycloak's logs. + + +### User creation + +Run: + +``` +$ python cli.py create_user --username=myuser --values=a:1;b:2 +``` + +If everything goes fine, you'll see something like: + +``` +$ python cli.py create_user --username=metesting --values=email:me@testing.com +Calling http://localhost:8080/auth/admin/realms/master/users +User created. +``` + +If you get an error, you'll see something like + +``` +$ python cli.py create_user --username=metest1 --values=email:me@test1.com +Calling http://localhost:8080/auth/admin/realms/master/users +Something went wrong. Quitting. +Status: 409 +Reason: Conflict +Result: {"errorMessage":"User exists with same username"} +``` + +### User search + +Run: + +``` +$ python cli.py search_users +``` + +If everything goes fine, you'll see something like: + +``` +$ python cli.py search_users +Calling http://localhost:8080/auth/admin/realms/master/users +User info: +[{u'username': u'pippo', u'access': {...}}, {u'username': u'johndoe', u'access': {...}}, ] +``` + +You can filter by user values (see KC docs) by passing `--search`: + +``` +$ python cli.py search_users --search=pluto +Calling http://localhost:8080/auth/admin/realms/master/users?search=pluto +User info: +[{u'username': u'pippo', u'access': {u'manage': True, u'manageGroupMembership': True, u'impersonate': True, u'mapRoles': True, u'view': True}, u'firstName': u'Pippo', u'notBefore': 0, u'emailVerified': False, u'requiredActions': [], u'enabled': True, u'email': u'pippo@pluto.com', u'createdTimestamp': 1539871348882, u'totp': False, u'disableableCredentialTypes': [], u'lastName': u'Pluto', u'id': u'1feb89e6-76bd-44a1-ab5d-df28b6477e19'}, {u'username': u'pluto', u'access': {u'manage': True, u'manageGroupMembership': True, u'impersonate': True, u'mapRoles': True, u'view': True}, u'notBefore': 0, u'emailVerified': True, u'enabled': True, u'email': u'pluto@test.com', u'createdTimestamp': 1540306774544, u'totp': False, u'disableableCredentialTypes': [], u'requiredActions': [], u'id': u'e06e1f36-7303-40b5-9a2e-a392ae1ec728'}] +``` + +If you get an error, check logs. diff --git a/auth_keycloak/examples/__init__.py b/auth_keycloak/examples/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/auth_keycloak/examples/cli.py b/auth_keycloak/examples/cli.py new file mode 100644 index 0000000000..e488580241 --- /dev/null +++ b/auth_keycloak/examples/cli.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# pylint: disable=W7935, W7936, W0403 + +import click +from urlparse import urljoin +import os +import sys +import json +from get_token import get_token +from common import ( + DATA_FILE, VALIDATE_PATH, + USERINFO_PATH, USERS_PATH, + do_request +) + + +def _read_data(): + if not os.path.isfile(DATA_FILE): + click.echo('You must run `get_token` before.') + sys.exit(0) + with open(DATA_FILE, 'r') as ff: + return json.loads(ff.read()) + + +@click.group() +@click.pass_context +def cli(ctx, **kw): + ctx.params.update(_read_data()) + + +@cli.command() +@click.pass_context +def user_info(ctx, **kw): + """Retrieve user info.""" + params = ctx.parent.params + url = urljoin( + params['domain'], USERINFO_PATH.format(realm=params['realm']) + ) + headers = { + 'Authorization': 'Bearer %s' % params['token'], + } + resp = do_request('get', url, headers=headers) + click.echo('User info:') + click.echo(resp.json()) + return resp.json() + + +@cli.command() +@click.option( + '--search', + help='Search string, see API.', +) +@click.pass_context +def search_users(ctx, search=None, **kw): + """Retrieve users info.""" + params = ctx.parent.params + url = urljoin( + params['domain'], + USERS_PATH.format(realm=params['realm']) + ) + if search: + url += '?search={}'.format(search) + headers = { + 'Authorization': 'Bearer %s' % params['token'], + } + resp = do_request('get', url, headers=headers) + click.echo('User info:') + click.echo(resp.json()) + return resp.json() + + +@cli.command() +@click.option( + '--username', + required=True +) +@click.option( + '--values', + help='Values mapping like "key:value;key1:value1", see API.', +) +@click.pass_context +def create_user(ctx, username, values=None, **kw): + """Create user.""" + params = ctx.parent.params + url = urljoin( + params['domain'], + USERS_PATH.format(realm=params['realm']) + ) + data = { + 'username': username, + 'enabled': True, + 'email': username + '@test.com', + 'emailVerified': True, + } + if values: + for pair in values.split(';'): + data[pair.split(':')[0]] = pair.split(':')[1] + + headers = { + 'Authorization': 'Bearer %s' % params['token'], + } + resp = do_request('post', url, headers=headers, json=data) + # crate user does not give back any value :( + click.echo('User created.') + return resp.ok + + +@cli.command() +@click.pass_context +def validate_token(ctx, **kw): + """Validate authentication token.""" + params = ctx.parent.params + if not params: + # invoked via context + params = _read_data() + headers = {'content-type': 'application/x-www-form-urlencoded'} + data = { + 'token': params['token'] + } + url = urljoin( + params['domain'], VALIDATE_PATH.format(realm=params['realm']) + ) + click.echo('Calling %s' % url) + resp = do_request( + 'post', + url, + data=data, + auth=(params['client_id'], params['client_secret']), + headers=headers, + ) + result = resp.json() + if not result.get('active'): + # token expired + click.echo('Token expired, running get token again...') + ctx.invoke(get_token) + ctx.forward(validate_token) + click.echo(result) + return resp.json() + + +if __name__ == '__main__': + cli() diff --git a/auth_keycloak/examples/common.py b/auth_keycloak/examples/common.py new file mode 100644 index 0000000000..aa785e92da --- /dev/null +++ b/auth_keycloak/examples/common.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# pylint: disable=W7935, W7936, W0403 + +import click +import requests +import sys + +UID = 'admin' +PWD = 'admin' +REALM = 'master' +DOMAIN = 'http://localhost:8080' +CLIENT_ID = 'odoo' +CLIENT_SECRET = '099d7d07-be3b-4bc6-b69e-b50ca5d0d864' +BASE_PATH = '/auth/realms/{realm}/protocol/openid-connect' +GET_TOKEN_PATH = BASE_PATH + '/token' +VALIDATE_PATH = GET_TOKEN_PATH + '/introspect' +USERINFO_PATH = BASE_PATH + '/userinfo' +# Watch out w/ official docs, they are wrong here +# https://issues.jboss.org/browse/KEYCLOAK-8615 +USERS_PATH = '/auth/admin/realms/{realm}/users' +DATA_FILE = '/tmp/keycloak.json' + + +def do_request(method, url, **kw): + """Unify requests handling their result.""" + handler = getattr(requests, method) + resp = handler(url, **kw) + click.echo('Calling %s' % url) + if not resp.ok: + click.echo('Something went wrong. Quitting. ') + click.echo('Status: %s' % resp.status_code) + if resp.reason: + click.echo('Reason: %s' % resp.reason) + if resp.content: + click.echo('Result: %s' % resp.content) + sys.exit(0) + return resp diff --git a/auth_keycloak/examples/get_token.py b/auth_keycloak/examples/get_token.py new file mode 100644 index 0000000000..e1789bc9af --- /dev/null +++ b/auth_keycloak/examples/get_token.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# pylint: disable=W7935, W7936, W0403 + +import click +from urlparse import urljoin +import json + +from common import ( + UID, PWD, + CLIENT_ID, CLIENT_SECRET, + DATA_FILE, DOMAIN, REALM, + GET_TOKEN_PATH, + do_request +) + + +@click.command() +@click.option('--domain', default=DOMAIN) +@click.option('--realm', default=REALM) +@click.option( + '--username', + prompt='Username', + help='Username to authenticate.', + default=UID) +@click.option( + '--password', + prompt='Password', + default=PWD) +@click.option( + '--client_id', + prompt='Client ID', + help='Keycloak client ID.', + default=CLIENT_ID) +@click.option( + '--client_secret', + prompt='Client secret', + help='Keycloak client secret.', + default=CLIENT_SECRET) +def get_token(**kw): + """Retrieve auth token.""" + data = kw.copy() + data['grant_type'] = 'password' + token = _get_token(data) + data['token'] = token + with open(DATA_FILE, 'w') as ff: + ff.write(json.dumps(data)) + click.echo('Saved to %s' % DATA_FILE) + return token + + +def _get_token(data): + headers = {'content-type': 'application/x-www-form-urlencoded'} + url = urljoin(data['domain'], GET_TOKEN_PATH.format(realm=data['realm'])) + resp = do_request('post', url, data=data, headers=headers) + click.echo('Access token:') + click.echo(resp.json()['access_token']) + return resp.json()['access_token'] + + +if __name__ == '__main__': + get_token() diff --git a/auth_keycloak/examples/keycloak-compose.yml b/auth_keycloak/examples/keycloak-compose.yml new file mode 100644 index 0000000000..584968ae79 --- /dev/null +++ b/auth_keycloak/examples/keycloak-compose.yml @@ -0,0 +1,23 @@ +# docker-compose example for keycloak container + +version: '2' +services: + keycloak: + image: jboss/keycloak + ports: + - 8080:8080 + # change this if your db container is named differently + depends_on: + - db + environment: + # odoodb=# create user keycloak with password 'keycloak'; + # CREATE ROLE + # odoodb=# create database keycloak OWNER keycloak; + # CREATE DATABASE + - KEYCLOAK_USER=admin + - KEYCLOAK_PASSWORD=admin + - DB_VENDOR=POSTGRES + - DB_ADDR=db + - DB_DATABASE=keycloak + - DB_USER=keycloak + - DB_PASSWORD=keycloak diff --git a/auth_keycloak/examples/realm-export.json b/auth_keycloak/examples/realm-export.json new file mode 100644 index 0000000000..00269c6579 --- /dev/null +++ b/auth_keycloak/examples/realm-export.json @@ -0,0 +1,1474 @@ +{ + "id": "master", + "realm": "master", + "displayName": "Keycloak", + "displayNameHtml": "
Keycloak
", + "notBefore": 0, + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 60, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "defaultRoles": [ + "offline_access", + "uma_authorization" + ], + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": [ + "FreeOTP", + "Google Authenticator" + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clients": [ + { + "id": "d79d6806-ed82-4194-bdfd-9fcd45b89307", + "clientId": "account", + "name": "${client_account}", + "baseUrl": "/auth/realms/master/account", + "surrogateAuthRequired": false, + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "defaultRoles": [ + "view-profile", + "manage-account" + ], + "redirectUris": [ + "/auth/realms/master/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "role_list", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access" + ] + }, + { + "id": "33c77f07-0bf9-4e71-9fe8-faac33c3594e", + "clientId": "master-realm", + "name": "master Realm", + "surrogateAuthRequired": false, + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "role_list", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access" + ], + "authorizationSettings": { + "allowRemoteResourceManagement": false, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "group.resource.6a248d9d-b133-4ebb-8a35-28162d9c270c", + "ownerManagedAccess": false, + "attributes": {}, + "_id": "7ff91ec4-3ea4-4cbc-912a-8dc8c9a8f47e", + "uris": [], + "scopes": [ + { + "name": "manage-members" + }, + { + "name": "view" + }, + { + "name": "manage-membership" + }, + { + "name": "view-members" + }, + { + "name": "manage" + } + ] + } + ], + "policies": [ + { + "id": "2ccdfe03-f65e-4b13-8aa0-3293163a9acf", + "name": "manage.membership.permission.group.6a248d9d-b133-4ebb-8a35-28162d9c270c", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"group.resource.6a248d9d-b133-4ebb-8a35-28162d9c270c\"]", + "scopes": "[\"manage-membership\"]" + } + }, + { + "id": "948bba59-d83f-44a8-aca9-b5423056e036", + "name": "view.members.permission.group.6a248d9d-b133-4ebb-8a35-28162d9c270c", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"group.resource.6a248d9d-b133-4ebb-8a35-28162d9c270c\"]", + "scopes": "[\"view-members\"]" + } + }, + { + "id": "3089d8f1-41a9-4edd-bf2e-b41adda19f61", + "name": "manage.members.permission.group.6a248d9d-b133-4ebb-8a35-28162d9c270c", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"group.resource.6a248d9d-b133-4ebb-8a35-28162d9c270c\"]", + "scopes": "[\"manage-members\"]" + } + }, + { + "id": "66c2aab3-62ca-4128-838d-96264e97a5fd", + "name": "view.permission.group.6a248d9d-b133-4ebb-8a35-28162d9c270c", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"group.resource.6a248d9d-b133-4ebb-8a35-28162d9c270c\"]", + "scopes": "[\"view\"]" + } + }, + { + "id": "dbf28d4d-13a9-410c-b9d8-339c50ee9600", + "name": "manage.permission.group.6a248d9d-b133-4ebb-8a35-28162d9c270c", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"group.resource.6a248d9d-b133-4ebb-8a35-28162d9c270c\"]", + "scopes": "[\"manage\"]" + } + } + ], + "scopes": [ + { + "id": "0e210696-58b4-4b6c-907f-fa571dfb870b", + "name": "token-exchange" + }, + { + "id": "f0a79f48-7c04-4ad9-aa9c-5da14fa3e9b1", + "name": "configure" + }, + { + "id": "1b2a9025-759d-4259-985b-dc69cfcdc3ce", + "name": "map-roles-composite" + }, + { + "id": "4b0ea686-fba2-46d5-b620-4262142d07b8", + "name": "map-roles-client-scope" + }, + { + "id": "901d3f08-a096-4cdc-9943-1bdb14ddc497", + "name": "map-roles" + }, + { + "id": "3c3cad68-c520-483d-9a3e-691b60007170", + "name": "manage-membership" + }, + { + "id": "89c31625-73f0-4265-a306-aed51d406099", + "name": "view-members" + }, + { + "id": "0b998450-cbbb-49c8-a4a5-8e327694ab7b", + "name": "manage-members" + }, + { + "id": "b6326f14-bdb6-478a-b90c-5521ce415f6b", + "name": "view" + }, + { + "id": "b184a8aa-0c09-4b56-a8ad-69cfa192fe10", + "name": "manage" + } + ] + } + }, + { + "id": "00f14900-6700-49ab-aff3-862e4bc422e1", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "role_list", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access" + ] + }, + { + "id": "1cfb9b85-017f-4080-a17d-f74d059fdbff", + "clientId": "odoo", + "baseUrl": "/auth/realms/master/odoo", + "surrogateAuthRequired": false, + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "localhost*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "true", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "role_list", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access" + ] + }, + { + "id": "fb7f4cf5-07ea-4061-94ff-32ebb573c87e", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "baseUrl": "/auth/admin/master/console/index.html", + "surrogateAuthRequired": false, + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "/auth/admin/master/console/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "d78c3ce0-99d2-4db4-b049-601b40cd7119", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "role_list", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access" + ] + }, + { + "id": "8811ae16-f535-48af-9278-719a2f26b789", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "role_list", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access" + ] + } + ], + "clientScopes": [ + { + "id": "d087c071-eb42-4580-aaa9-2e306757a028", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${phoneScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "7c9da94c-f9b5-4d3b-af19-a69913338337", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "0cecbf18-7d5c-45cf-93bb-2ff60745f420", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "4ad28d62-6354-4932-98e9-be73eeccb084", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "5526c3db-9878-4f8a-a6e8-cce9c35365e3", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "20e5a11f-fa55-4229-bc23-61a8cee89c8e", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "dbd23239-5a92-48b9-8670-2846be00bdc0", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "26dfbf76-5661-42cd-a318-b3a93234644a", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "3ac4b02f-ef37-4f48-be1d-14157b38eda6", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "4028abdd-1d66-4807-8202-82d6690423fd", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "f5e4cf65-a965-4313-aa0b-6a3925f1b54e", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "03cc9bad-4e6f-47c4-b5f5-84f7206d4557", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "8e5ae1de-db29-49fb-ad0b-e374f757a84b", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "e73736ce-99cb-4cc7-a977-13b82ab7b077", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "5e0e2bb7-d852-4bc8-ac4d-ef441ebdf2c6", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "75283db3-de51-483a-a906-0cae2f660c25", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "20125b95-0d56-4f20-8058-d10a04ad057c", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String" + } + }, + { + "id": "22a3416a-02ba-4949-9568-9e8b7677a216", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "a31ce9ee-1e72-4f92-8285-9174478e3b10", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "d9ca8a7d-bca0-40af-941d-3f9dad9e0a5d", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "e88c3b11-1de4-42d8-9e74-51a026db9fec", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "da80b7e2-b50e-40d3-9c13-b8b6ca9009c8", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "7058da3a-f8f1-4c40-9608-ddd699c4dfd5", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "6933669f-9243-4ec9-a318-09eb105011f5", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "aea0c3b9-a892-405f-af36-464b8a3c7a0b", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "07094917-d48f-4e9e-977a-c44a6f223cac", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "xXSSProtection": "1; mode=block", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "e351efc4-f289-42ce-874a-f07c8f1c3a8f", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "03bed773-4e24-4daa-9a15-1028d1cf8ea9", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "6b416737-a14c-4570-8790-df2db0af0d98", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "644692bd-d7c4-4582-9f0b-7a726a9563c0", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "19133e31-3b1c-4f6a-806c-5363325c9b6f", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-address-mapper", + "saml-user-attribute-mapper", + "saml-role-list-mapper", + "oidc-usermodel-property-mapper", + "saml-user-property-mapper" + ] + } + }, + { + "id": "ef3631f6-5780-49d0-b9a2-7ed52008f0e7", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-address-mapper", + "saml-user-attribute-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-property-mapper", + "oidc-usermodel-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-role-list-mapper" + ] + } + }, + { + "id": "8a11d9ce-f74c-4b50-867a-f912ddeb5bc5", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "496b68d8-a4e2-448c-9596-00a01e1c859f", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "d5daf67f-79ef-4797-8d68-9fc9e2c89a39", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "83ee3446-957e-4b96-9b86-b9c5e0247362", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] + } + }, + { + "id": "383e3b4f-0464-4d8f-92f6-ec4862e9b24a", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "8506d759-9543-4224-bb6a-3531830f3f4f", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "idp-email-verification", + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "1efde415-3e7f-4b55-89c0-2e2cd3232194", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "OPTIONAL", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "1092e3c9-6268-4510-bd97-c7df0bbba3d0", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "forms", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "b9c1054a-53cf-42c5-91b2-1952169d7a3d", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-jwt", + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-secret-jwt", + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-x509", + "requirement": "ALTERNATIVE", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "ef857189-8ec0-4756-a8c6-dccb6d9403b0", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-password", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-otp", + "requirement": "OPTIONAL", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "b9238431-3f54-4b41-9953-83daec4788aa", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "0afe07cf-8d1a-45d7-9cf3-fb2f187e90e3", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "e1479fce-0991-49d4-8505-508118d34cbb", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "OPTIONAL", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "abb10c16-e12d-4c16-a579-e63953a7b761", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth-otp", + "requirement": "DISABLED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "47f7f3c8-c9b7-4a36-b4d0-17bc287868db", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "requirement": "REQUIRED", + "priority": 10, + "flowAlias": "registration form", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "f6aeaac8-94ff-40af-be0f-dd4b4b01222c", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-profile-action", + "requirement": "REQUIRED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-password-action", + "requirement": "REQUIRED", + "priority": 50, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-recaptcha-action", + "requirement": "DISABLED", + "priority": 60, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "3e6457c9-e87a-40c2-bedb-14440ee7be7f", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-credential-email", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-password", + "requirement": "REQUIRED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-otp", + "requirement": "OPTIONAL", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "586446ce-5c7c-4b70-8b9e-b13ae37b75df", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "b3ea7d7e-83d9-441b-b8d8-d900cc7aa6c4", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "2b7b7de4-82bb-45ca-a177-c26f1bf82721", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "_browser_header.xXSSProtection": "1; mode=block", + "_browser_header.strictTransportSecurity": "max-age=31536000; includeSubDomains", + "_browser_header.xFrameOptions": "SAMEORIGIN", + "quickLoginCheckMilliSeconds": "1000", + "permanentLockout": "false", + "displayName": "Keycloak", + "_browser_header.xRobotsTag": "none", + "maxFailureWaitSeconds": "900", + "displayNameHtml": "
Keycloak
", + "minimumQuickLoginWaitSeconds": "60", + "failureFactor": "30", + "actionTokenGeneratedByUserLifespan": "300", + "maxDeltaTimeSeconds": "43200", + "_browser_header.xContentTypeOptions": "nosniff", + "actionTokenGeneratedByAdminLifespan": "43200", + "offlineSessionMaxLifespan": "5184000", + "_browser_header.contentSecurityPolicyReportOnly": "", + "bruteForceProtected": "false", + "_browser_header.contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "offlineSessionMaxLifespanEnabled": "false", + "waitIncrementSeconds": "60" + }, + "keycloakVersion": "4.5.0.Final", + "userManagedAccessAllowed": false +} \ No newline at end of file diff --git a/auth_keycloak/examples/requirements.txt b/auth_keycloak/examples/requirements.txt new file mode 100644 index 0000000000..23adf64051 --- /dev/null +++ b/auth_keycloak/examples/requirements.txt @@ -0,0 +1,2 @@ +requests +click \ No newline at end of file diff --git a/auth_keycloak/exceptions.py b/auth_keycloak/exceptions.py new file mode 100644 index 0000000000..f1ff494308 --- /dev/null +++ b/auth_keycloak/exceptions.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + + +class OAuthError(Exception): + pass diff --git a/auth_keycloak/models/__init__.py b/auth_keycloak/models/__init__.py new file mode 100644 index 0000000000..a005b45cfc --- /dev/null +++ b/auth_keycloak/models/__init__.py @@ -0,0 +1,2 @@ +from . import auth_oauth +from . import res_users diff --git a/auth_keycloak/models/auth_oauth.py b/auth_keycloak/models/auth_oauth.py new file mode 100644 index 0000000000..dfcdabd83b --- /dev/null +++ b/auth_keycloak/models/auth_oauth.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) +from odoo import fields, models, api + + +class OAuthProvider(models.Model): + _inherit = 'auth.oauth.provider' + + client_secret = fields.Char() + users_endpoint = fields.Char( + help='User endpoint', + placeholder='http://keycloak.mycompany.com' + '/auth/admin/realms/{realm}/users', + required=False, + ) + superuser = fields.Char( + help='A super power user that is able to CRUD users on KC.', + placeholder='admin', + required=False, + ) + superuser_pwd = fields.Char( + help='"Superuser" user password', + placeholder='I hope is not "admin"', + required=False, + ) + users_management_enabled = fields.Boolean( + compute='_compute_users_management_enabled' + ) + + @api.depends( + 'enabled', + 'users_endpoint', + 'superuser', + 'superuser_pwd', + ) + def _compute_users_management_enabled(self): + for item in self: + item.users_management_enabled = all([ + item.enabled, + item.users_endpoint, + item.superuser, + item.superuser_pwd, + ]) diff --git a/auth_keycloak/models/res_users.py b/auth_keycloak/models/res_users.py new file mode 100644 index 0000000000..9061994377 --- /dev/null +++ b/auth_keycloak/models/res_users.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) +from odoo import api, models, exceptions, _ +import logging +import requests +from ..exceptions import OAuthError + +logger = logging.getLogger(__name__) + + +# NOTE: `sub` is THE user id in keycloak +# https://www.keycloak.org/docs/latest/server_development/index.html#_action_token_anatomy # noqa +# and you cannot change it https://stackoverflow.com/questions/46529363 + + +class ResUsers(models.Model): + _inherit = 'res.users' + + def _keycloak_validate(self, provider, access_token): + """Validate token against Keycloak.""" + logger.debug('Calling: %s' % provider.validation_endpoint) + resp = requests.post( + provider.validation_endpoint, + data={'token': access_token}, + auth=(provider.client_id, provider.client_secret) + ) + if not resp.ok: + raise OAuthError(resp.reason) + validation = resp.json() + if validation.get("error"): + raise OAuthError(validation) + logger.debug('Validation: %s' % str(validation)) + return validation + + @api.model + def _auth_oauth_validate(self, provider, access_token): + """Override to use authentication headers. + + The method `_auth_oauth_rpc` is not pluggable + as you don't have the provider there. + """ + # `provider` is `provider_id` actually... I'm respecting orig signature + oauth_provider = self.env['auth.oauth.provider'].browse(provider) + validation = self._keycloak_validate(oauth_provider, access_token) + # clone keycloak ID expected by odoo into `user_id` + validation['user_id'] = validation['sub'] + return validation + + @api.multi + def button_push_to_keycloak(self, vals): + """Quick action to push current users to Keycloak.""" + provider = self.env.ref( + 'auth_keycloak.default_keycloak_provider', + raise_if_not_found=False + ) + enabled = provider and provider.users_management_enabled + if not enabled: + raise exceptions.UserError( + _('Keycloak provider not found or not configured properly.') + ) + wiz = self.env['auth.keycloak.create.wiz'].create({ + 'provider_id': provider.id, + 'user_ids': [(6, 0, self.ids)], + }) + action = self.env.ref('auth_keycloak.keycloak_create_users').read()[0] + action['res_id'] = wiz.id + return action diff --git a/auth_keycloak/readme/CONFIGURE.rst b/auth_keycloak/readme/CONFIGURE.rst new file mode 100644 index 0000000000..98a1e9fab8 --- /dev/null +++ b/auth_keycloak/readme/CONFIGURE.rst @@ -0,0 +1,13 @@ +Settings -> Users -> OAuth Providers -> Keycloak + +Adjust endpoints according to your setup. + +Enable it: tick "Allowed". + +Official docs: https://www.keycloak.org/docs + + +.. note:: You must make sure your settings are correct. + Testing scripts are provided by this module in the folder `examples`. + + Please follow instructions contained in its README. diff --git a/auth_keycloak/readme/CONTRIBUTORS.rst b/auth_keycloak/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..4637e40848 --- /dev/null +++ b/auth_keycloak/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +Simone Orsi diff --git a/auth_keycloak/readme/CREDITS.rst b/auth_keycloak/readme/CREDITS.rst new file mode 100644 index 0000000000..00c3e56539 --- /dev/null +++ b/auth_keycloak/readme/CREDITS.rst @@ -0,0 +1 @@ +Development sponsored by `Sensefly `_ and `UTB `_. diff --git a/auth_keycloak/readme/DESCRIPTION.rst b/auth_keycloak/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..aa752b65d7 --- /dev/null +++ b/auth_keycloak/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This module adds support for SSO authentication via `Keycloak `_ diff --git a/auth_keycloak/readme/HISTORY.rst b/auth_keycloak/readme/HISTORY.rst new file mode 100644 index 0000000000..6b2cd2cd55 --- /dev/null +++ b/auth_keycloak/readme/HISTORY.rst @@ -0,0 +1,4 @@ +10.0.1.0.0 2018-10-17 +~~~~~~~~~~~~~~~~~~~~~ + +* Initial implementation diff --git a/auth_keycloak/readme/USAGE.rst b/auth_keycloak/readme/USAGE.rst new file mode 100644 index 0000000000..954da21046 --- /dev/null +++ b/auth_keycloak/readme/USAGE.rst @@ -0,0 +1,45 @@ +Frontend +~~~~~~~~ + +When the provider is enabled you'll see an extra login button on login form. +Click on it to get redirected to Keycloak. + +Backend +~~~~~~~ + +**Link existing users from Keycloak** + +If you have existing users in Odoo and they are not linked to Keycloak yet +you can: + +1. get back to Settings -> Users -> OAuth Providers -> Keycloak +2. configure "Users management" box +3. click on "Sync users" button +4. select the matching key +5. submit + +Once the it's done all matching and updated users will be listed in a list view. +Now your users will be able to log in on Keycloak + + +**Push new users to Keycloak** + +Usually Keycloak is already populated w/ your users base. +Many times this will come via LDAP, AD, pick yours. + +Still, you might need to push some users to Keycloak on demand, +maybe just for testing. + +If you need this, either you + +1. go to a single user form +2. hit the button "Push to Keycloak" (in the header) +3. use the wizard to push it + +or + +1. go to the users list view +2. select some users +3. click on Actions -> Push to Keycloak +4. select "Keycloak" provider +5. push them all diff --git a/auth_keycloak/static/description/index.html b/auth_keycloak/static/description/index.html new file mode 100644 index 0000000000..22ac4f1c9d --- /dev/null +++ b/auth_keycloak/static/description/index.html @@ -0,0 +1,496 @@ + + + + + + +Keycloak auth integration + + + +
+

Keycloak auth integration

+ + +

Beta License: AGPL-3 OCA/server-auth Translate me on Weblate Try me on Runbot

+

This module adds support for SSO authentication via Keycloak

+

Table of contents

+ +
+

Configuration

+

Settings -> Users -> OAuth Providers -> Keycloak

+

Adjust endpoints according to your setup.

+

Enable it: tick “Allowed”.

+

Official docs: https://www.keycloak.org/docs

+
+

Note

+

You must make sure your settings are correct. +Testing scripts are provided by this module in the folder examples.

+

Please follow instructions contained in its README.

+
+
+
+

Usage

+
+

Frontend

+

When the provider is enabled you’ll see an extra login button on login form. +Click on it to get redirected to Keycloak.

+
+
+

Backend

+

Link existing users from Keycloak

+

If you have existing users in Odoo and they are not linked to Keycloak yet +you can:

+
    +
  1. get back to Settings -> Users -> OAuth Providers -> Keycloak
  2. +
  3. configure “Users management” box
  4. +
  5. click on “Sync users” button
  6. +
  7. select the matching key
  8. +
  9. submit
  10. +
+

Once the it’s done all matching and updated users will be listed in a list view. +Now your users will be able to log in on Keycloak

+

Push new users to Keycloak

+

Usually Keycloak is already populated w/ your users base. +Many times this will come via LDAP, AD, pick yours.

+

Still, you might need to push some users to Keycloak on demand, +maybe just for testing.

+

If you need this, either you

+
    +
  1. go to a single user form
  2. +
  3. hit the button “Push to Keycloak” (in the header)
  4. +
  5. use the wizard to push it
  6. +
+

or

+
    +
  1. go to the users list view
  2. +
  3. select some users
  4. +
  5. click on Actions -> Push to Keycloak
  6. +
  7. select “Keycloak” provider
  8. +
  9. push them all
  10. +
+
+
+
+

Changelog

+
+

10.0.1.0.0 2018-10-17

+
    +
  • Initial implementation
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+ +
+

Other credits

+

Development sponsored by Sensefly and UTB.

+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/server-auth project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/auth_keycloak/tests/__init__.py b/auth_keycloak/tests/__init__.py new file mode 100644 index 0000000000..b0569939f5 --- /dev/null +++ b/auth_keycloak/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_auth +from . import test_wizard_sync +from . import test_wizard_create diff --git a/auth_keycloak/tests/common.py b/auth_keycloak/tests/common.py new file mode 100644 index 0000000000..0e9d0af72e --- /dev/null +++ b/auth_keycloak/tests/common.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +import responses +import base64 +import odoo.tests.common as common + + +class TestKeycloakBase(common.SavepointCase): + + base_auth_url = 'https://keycloak/auth' + base_openid_url = base_auth_url + '/realms/Odoo/protocol/openid-connect' + + @classmethod + def setUpClass(cls): + super(TestKeycloakBase, cls).setUpClass() + cls.env = cls.env(context=dict( + cls.env.context, + tracking_disable=True, + no_reset_password=True + )) + cls.provider = cls.env['auth.oauth.provider'].create({ + 'name': 'Keycloak', + 'client_id': 'odoo', + 'client_secret': 'c35a795e-65ef-432d-97fb-6ef4bea84bb8', + 'auth_endpoint': cls.base_openid_url + '/token', + 'validation_endpoint': + cls.base_openid_url + '/token/introspect', + 'body': 'foo', + 'enabled': True, + }) + + def _assert_request_auth_header(self, request): + """Validate request has basic auth header.""" + auth = request.headers['Authorization'].replace('Basic ', '') + self.assertEqual( + base64.decodestring(auth), + '{}:{}'.format(self.provider.client_id, + self.provider.client_secret) + ) + + +FAKE_TOKEN_RESPONSE = { + u'session_state': u'623c9060-fd20-40e1-ad31-090bd77d521e', + u'not-before-policy': 0, + u'expires_in': 60, + u'token_type': u'bearer', + u'refresh_expires_in': 1800, + u'scope': u'profile email', + u'access_token': base64.b64encode(u'my nice token'), + u'refresh_token': base64.b64encode(u'my nice refresh token'), +} +FAKE_USERS_RESPONSE = [{ + u'username': u'jdoe', + u'access': { + u'manage': True, + u'manageGroupMembership': True, + u'impersonate': True, + u'mapRoles': True, + u'view': True + }, + u'notBefore': 0, + u'email': u'john@doe.com', + u'emailVerified': False, + u'enabled': True, + u'createdTimestamp': 1539857662328, + u'totp': False, + u'disableableCredentialTypes': [u'password'], + u'requiredActions': [], + u'id': u'ef1d2e5d-1aad-4daf-858e-f246168a10ef' +}, { + u'username': u'dduck', + u'access': { + u'manage': True, + u'manageGroupMembership': True, + u'impersonate': True, + u'mapRoles': True, + u'view': True + }, + u'firstName': u'Donald', + u'lastName': u'Duck', + u'notBefore': 0, + u'emailVerified': False, + u'requiredActions': [], + u'enabled': True, + u'email': u'donald@duck.com', + u'createdTimestamp': 1539871348882, + u'totp': False, + u'disableableCredentialTypes': [], + u'id': u'1feb89e6-76bd-44a1-ab5d-df28b6477e19', +}] + + +class TestKeycloakWizBase(TestKeycloakBase): + + wiz_model = '' + + @classmethod + def setUpClass(cls): + super(TestKeycloakWizBase, cls).setUpClass() + cls.users_endpoint = cls.base_auth_url + '/admin/realms/Odoo/users' + cls.provider.update({ + 'users_endpoint': cls.users_endpoint, + 'superuser': 'admin', + 'superuser_pwd': 'well, yes, is "admin"', + }) + cls.wiz = cls.env[cls.wiz_model].create({ + 'provider_id': cls.provider.id, + }) + # create users matching keycloak response + cls.user_john = cls.env['res.users'].create({ + 'name': 'John Doe', + 'login': 'jdoe', + 'email': 'john@doe.com', + }) + cls.user_donald = cls.env['res.users'].create({ + 'name': 'Donald Duck', + 'login': 'dduck', + 'email': 'donald@duck.com', + }) + + def setUp(self): + super(TestKeycloakWizBase, self).setUp() + responses.add( + responses.POST, + self.provider.auth_endpoint, + json=FAKE_TOKEN_RESPONSE, + status=200, + content_type='application/json', + ) diff --git a/auth_keycloak/tests/test_auth.py b/auth_keycloak/tests/test_auth.py new file mode 100644 index 0000000000..2ce75d076e --- /dev/null +++ b/auth_keycloak/tests/test_auth.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +import responses + +from .common import TestKeycloakBase +from ..exceptions import OAuthError + + +VALIDATE_RESP_BODY = { + "jti": "36acb399-31ed-4b0b-8f2e-4f645ab6d8c7", + "exp": 1525419762, + "nbf": 0, + "iat": 1525419462, + "iss": "https://keycloak/auth/realms/Odoo", + "aud": "odoo", + "sub": "df5ab747-2b80-4c18-bd03-5f3d3b2c0fd6", + "typ": "Bearer", + "azp": "odoo", + "auth_time": 0, + "session_state": "a82adc36-18ad-4a39-874d-abe9747205ba", + "name": "CampToCamp -", + "given_name": "CampToCamp", + "family_name": "-", + "preferred_username": "c2c", + "email": "foo@camptocamp.com", + "acr": "1", + "allowed-origins": [ + "*" + ], + "realm_access": { + "roles": [ + "uma_authorization" + ] + }, + "resource_access": { + "my-company": { + "roles": [ + "user" + ] + }, + "odoo": { + "roles": [ + "user" + ] + }, + "api-gateway": { + "roles": [ + "user" + ] + }, + "user-service": { + "roles": [ + "reader" + ] + }, + "account": { + "roles": [ + "manage-account", + "manage-account-links", + "view-profile" + ] + } + }, + "client_id": "odoo", + "username": "c2c", + "active": True +} + + +class TestAuth(TestKeycloakBase): + + @responses.activate + def test_validate_auth(self): + """Validate request has basic auth header.""" + responses.add( + responses.POST, + self.provider.validation_endpoint, + json=VALIDATE_RESP_BODY, + status=200, + content_type='application/json', + ) + access_token = 'XXXXXXX' + self.env['res.users']._auth_oauth_validate( + self.provider.id, access_token) + self.assertEqual(len(responses.calls), 1) + request = responses.calls[0].request + self._assert_request_auth_header(request) + + @responses.activate + def test_validate(self): + responses.add( + responses.POST, + self.provider.validation_endpoint, + json=VALIDATE_RESP_BODY, + status=200, + content_type='application/json', + ) + access_token = 'XXXXXXX' + result = self.env['res.users']._auth_oauth_validate( + self.provider.id, access_token) + self.assertEqual(len(responses.calls), 1) + self.assertEqual( + result['sub'], 'df5ab747-2b80-4c18-bd03-5f3d3b2c0fd6' + ) + self.assertEqual( + result['user_id'], 'df5ab747-2b80-4c18-bd03-5f3d3b2c0fd6' + ) + + @responses.activate + def test_validate_error(self): + responses.add( + responses.POST, + self.provider.validation_endpoint, + json={"error": "Something bad happened"}, + status=200, + content_type='application/json', + ) + access_token = 'XXXXXXX' + with self.assertRaises(OAuthError): + self.env['res.users']._auth_oauth_validate( + self.provider.id, access_token) + self.assertEqual(len(responses.calls), 1) diff --git a/auth_keycloak/tests/test_wizard_create.py b/auth_keycloak/tests/test_wizard_create.py new file mode 100644 index 0000000000..457b03f9c6 --- /dev/null +++ b/auth_keycloak/tests/test_wizard_create.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +import responses +import mock +from odoo import exceptions +from .common import ( + TestKeycloakWizBase, FAKE_USERS_RESPONSE +) + + +FAKE_NEW_USER = { + u'username': u'mmouse', + u'access': { + u'manage': True, + u'manageGroupMembership': True, + u'impersonate': True, + u'mapRoles': True, + u'view': True + }, + u'firstName': u'Micky', + u'lastName': u'Mouse', + u'notBefore': 0, + u'emailVerified': False, + u'requiredActions': [], + u'enabled': True, + u'email': u'mickey@mouse.com', + u'createdTimestamp': 1539871348883, + u'totp': False, + u'disableableCredentialTypes': [], + u'id': u'1feb89e6-76bd-44a1-ab5d-df28b2477e19', +} + + +class TestWizard(TestKeycloakWizBase): + + wiz_model = 'auth.keycloak.create.wiz' + + @classmethod + def setUpClass(cls): + super(TestWizard, cls).setUpClass() + cls.user_mickey = cls.env['res.users'].create({ + 'name': 'Mickey Mouse', + 'login': 'mmouse', + 'email': 'mickey@mouse.com', + }) + + def test_create_user_values(self): + values = self.wiz._create_user_values(self.user_donald) + self.assertEqual(values['username'], 'dduck') + self.assertEqual(values['email'], 'donald@duck.com') + # you could have partner_firstname installed + # or an override in fullname handling, + # hence we might have different results here. + # We just make sure that we have both names... + self.assertTrue(values['firstName']) + self.assertTrue(values['lastName']) + + values = self.wiz._create_user_values(self.user_john) + self.assertEqual(values['username'], 'jdoe') + self.assertEqual(values['email'], 'john@doe.com') + self.assertTrue(values['firstName']) + self.assertTrue(values['lastName']) + + @responses.activate + def test_get_or_create_user_exists(self): + # make users endpoint return one user less + responses.add( + responses.GET, + self.wiz.endpoint, + # return only one + json=FAKE_USERS_RESPONSE[1:2], + status=200, + content_type='application/json', + ) + with mock.patch.object( + type(self.wiz), '_create_user' + ) as mock_create_user: + kk_user = self.wiz._get_or_create_user('TOKEN', self.user_donald) + + self.assertEqual(kk_user['id'], FAKE_USERS_RESPONSE[1]['id']) + # user exists, no call to create user issued + self.assertFalse(mock_create_user.called) + # indeed we find only 1 calls + self.assertEqual(len(responses.calls), 1) + request = responses.calls[0].request + self.assertEqual( + request.url, + self.wiz.endpoint + '?search=%s' % self.user_donald.login + ) + auth = request.headers['Authorization'].replace('Bearer ', '') + self.assertEqual(auth, 'TOKEN') + + @responses.activate + def test_get_or_create_user_not_exists(self): + # mock 1st call to get all users: no users + responses.add( + responses.GET, + self.wiz.endpoint, + json=[], + status=200, + content_type='application/json', + ) + # mock 2nd call to create a new user: all good, nothing back + responses.add( + responses.POST, + self.wiz.endpoint, + body='', + status=200, + content_type='application/json', + ) + # mock 3rd call to retrieve new user's data + # yes, Keycloak sends back NOTHING :( + responses.add( + responses.GET, + self.wiz.endpoint, + json=[FAKE_NEW_USER, ], + status=200, + content_type='application/json', + ) + kk_user = self.wiz._get_or_create_user('TOKEN', self.user_mickey) + self.assertDictEqual(kk_user, FAKE_NEW_USER) + self.assertEqual(len(responses.calls), 3) + request = responses.calls[0].request + self.assertEqual( + request.url, + self.wiz.endpoint + '?search=%s' % self.user_mickey.login + ) + auth = request.headers['Authorization'].replace('Bearer ', '') + self.assertEqual(auth, 'TOKEN') + + @responses.activate + def test_create_user_conflict(self): + # simulate again we found no user + responses.add( + responses.GET, + self.wiz.endpoint, + json=[], + status=200, + content_type='application/json', + ) + # and we try to create a new one, but + # simulate HTTPError: 409 Client Error: + # Conflict for url: https://keycloak/auth/admin/realms/Odoo/users + responses.add( + responses.POST, + self.wiz.endpoint, + body='', + status=409, + content_type='application/json', + ) + with self.assertRaises(exceptions.UserError) as err: + self.wiz._get_or_create_user('TOKEN', self.user_mickey) + self.assertEqual(len(responses.calls), 2) + self.assertTrue( + err.exception.name.startswith('Conflict on user values.') + ) diff --git a/auth_keycloak/tests/test_wizard_sync.py b/auth_keycloak/tests/test_wizard_sync.py new file mode 100644 index 0000000000..ca86d8dd93 --- /dev/null +++ b/auth_keycloak/tests/test_wizard_sync.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +import responses +import base64 +import urlparse +from .common import ( + TestKeycloakWizBase, FAKE_TOKEN_RESPONSE, FAKE_USERS_RESPONSE +) + + +class TestWizard(TestKeycloakWizBase): + + wiz_model = 'auth.keycloak.sync.wiz' + + def setUp(self): + super(TestWizard, self).setUp() + responses.add( + responses.GET, + self.wiz.endpoint, + json=FAKE_USERS_RESPONSE, + status=200, + content_type='application/json', + ) + + @responses.activate + def test_get_token(self): + token = self.wiz._get_token() + self.assertEqual(len(responses.calls), 1) + self.assertDictEqual( + responses.calls[0].response.json(), FAKE_TOKEN_RESPONSE + ) + self.assertEqual(token, FAKE_TOKEN_RESPONSE['access_token']) + request = responses.calls[0].request + self.assertEqual(request.url, self.provider.auth_endpoint) + expected = [ + ('username', 'admin'), + ('client_secret', 'c35a795e-65ef-432d-97fb-6ef4bea84bb8'), + ('password', 'well, yes, is "admin"'), + ('client_id', 'odoo'), + ('grant_type', 'password') + ] + self.assertListEqual(urlparse.parse_qsl(request.body), expected) + + @responses.activate + def test_get_users(self): + token = self.wiz._get_token() + users = self.wiz._get_users(token) + self.assertEqual(len(responses.calls), 2) + self.assertListEqual( + responses.calls[1].response.json(), FAKE_USERS_RESPONSE + ) + self.assertEqual(len(users), 2) + request = responses.calls[1].request + self.assertEqual(request.url, self.wiz.endpoint) + auth = request.headers['Authorization'].replace('Bearer ', '') + self.assertEqual(base64.decodestring(auth), 'my nice token') + + @responses.activate + def test_sync_by_username(self): + self.assertFalse(self.user_donald.oauth_uid) + self.assertFalse(self.user_john.oauth_uid) + self.assertEqual(self.wiz.login_match_key, 'username:login') + action = self.wiz.button_sync() + self.assertEqual(len(responses.calls), 2) + self.assertEqual( + action['domain'], [ + ('id', 'in', (self.user_donald + self.user_john).ids) + ] + ) + self.assertEqual( + self.user_donald.oauth_uid, + u'1feb89e6-76bd-44a1-ab5d-df28b6477e19' + ) + self.assertEqual( + self.user_donald.oauth_provider_id, self.provider + ) + self.assertEqual( + self.user_john.oauth_uid, + u'ef1d2e5d-1aad-4daf-858e-f246168a10ef' + ) + self.assertEqual( + self.user_john.oauth_provider_id, self.provider + ) + + @responses.activate + def test_sync_by_email(self): + self.assertFalse(self.user_donald.oauth_uid) + self.assertFalse(self.user_john.oauth_uid) + self.wiz.login_match_key = 'email:partner_id.email' + action = self.wiz.button_sync() + self.assertEqual(len(responses.calls), 2) + self.assertEqual( + action['domain'], [ + ('id', 'in', (self.user_donald + self.user_john).ids) + ] + ) + self.assertEqual( + self.user_donald.oauth_uid, + u'1feb89e6-76bd-44a1-ab5d-df28b6477e19' + ) + self.assertEqual( + self.user_donald.oauth_provider_id, self.provider + ) + self.assertEqual( + self.user_john.oauth_uid, + u'ef1d2e5d-1aad-4daf-858e-f246168a10ef' + ) + self.assertEqual( + self.user_john.oauth_provider_id, self.provider + ) diff --git a/auth_keycloak/views/auth_oauth_views.xml b/auth_keycloak/views/auth_oauth_views.xml new file mode 100644 index 0000000000..60649c01e9 --- /dev/null +++ b/auth_keycloak/views/auth_oauth_views.xml @@ -0,0 +1,27 @@ + + + + auth.oauth.provider.form + auth.oauth.provider + + + + + + + + + + + + + +