Browse Source

[openid_connect]

v12_initial_fix
Maciej Wawro 6 years ago
parent
commit
87cabaa178
  1. 5
      galicea_openid_connect/__init__.py
  2. 49
      galicea_openid_connect/__manifest__.py
  3. 82
      galicea_openid_connect/api.py
  4. 4
      galicea_openid_connect/controllers/__init__.py
  5. 17
      galicea_openid_connect/controllers/ext_web_login.py
  6. 355
      galicea_openid_connect/controllers/main.py
  7. 4
      galicea_openid_connect/models/__init__.py
  8. 64
      galicea_openid_connect/models/access_token.py
  9. 65
      galicea_openid_connect/models/client.py
  10. 14
      galicea_openid_connect/random_tokens.py
  11. 2
      galicea_openid_connect/requirements.txt
  12. 23
      galicea_openid_connect/security/__init__.py
  13. 4
      galicea_openid_connect/security/init.yml
  14. 5
      galicea_openid_connect/security/ir.model.access.csv
  15. 35
      galicea_openid_connect/security/security.xml
  16. BIN
      galicea_openid_connect/static/description/icon.png
  17. BIN
      galicea_openid_connect/static/description/images/client_screenshot.png
  18. BIN
      galicea_openid_connect/static/description/images/login_screenshot.png
  19. BIN
      galicea_openid_connect/static/description/images/master_screenshot.png
  20. 133
      galicea_openid_connect/static/description/index.html
  21. 24
      galicea_openid_connect/system_checks.py
  22. 24
      galicea_openid_connect/views/templates.xml
  23. 65
      galicea_openid_connect/views/views.xml

5
galicea_openid_connect/__init__.py

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models
from . import system_checks

49
galicea_openid_connect/__manifest__.py

@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
{
'name': "Galicea OpenID Connect Provider",
'summary': """OpenID Connect Provider and OAuth2 resource server""",
'author': "Maciej Wawro",
'maintainer': "Galicea",
'website': "http://galicea.pl",
'category': 'Technical Settings',
'version': '10.0.1.0',
'depends': ['web', 'galicea_environment_checkup'],
'external_dependencies': {
'python': ['jwcrypto', 'cryptography']
},
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'security/init.yml',
'views/views.xml',
'views/templates.xml'
],
'environment_checkup': {
'dependencies': {
'python': [
{
'name': 'jwcrypto',
'install': "pip install 'jwcrypto==0.5.0'"
},
{
'name': 'cryptography',
'version': '>=2.3',
'install': "pip install 'cryptography>=2.3'"
}
]
}
},
'images': [
'static/description/images/custom_screenshot.png',
'static/description/images/dependencies_screenshot.png'
]
}

82
galicea_openid_connect/api.py

@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
import json
import logging
from functools import wraps
from odoo import http
import werkzeug
_logger = logging.getLogger(__name__)
class ApiException(Exception):
INVALID_REQUEST = 'invalid_request'
def __init__(self, message, code=None):
super(Exception, self).__init__(message)
self.code = code if code else self.INVALID_REQUEST
def resource(path, method, auth='user'):
assert auth in ['user', 'client']
def endpoint_decorator(func):
@http.route(path, auth='public', type='http', methods=[method], csrf=False)
@wraps(func)
def func_wrapper(self, req, **query):
try:
access_token = None
if 'Authorization' in req.httprequest.headers:
authorization_header = req.httprequest.headers['Authorization']
if authorization_header[:7] == 'Bearer ':
access_token = authorization_header.split(' ', 1)[1]
if access_token is None:
access_token = query.get('access_token')
if not access_token:
raise ApiException(
'access_token param is missing',
'invalid_request',
)
if auth == 'user':
token = req.env['galicea_openid_connect.access_token'].sudo().search(
[('token', '=', access_token)]
)
if not token:
raise ApiException(
'access_token is invalid',
'invalid_request',
)
req.uid = token.user_id.id
elif auth == 'client':
token = req.env['galicea_openid_connect.client_access_token'].sudo().search(
[('token', '=', access_token)]
)
if not token:
raise ApiException(
'access_token is invalid',
'invalid_request',
)
req.uid = token.client_id.system_user_id.id
ctx = req.context.copy()
ctx.update({'client_id': token.client_id.id})
req.context = ctx
response = func(self, req, **query)
return json.dumps(response)
except ApiException as e:
return werkzeug.Response(
response=json.dumps({'error': e.code, 'error_message': e.message}),
status=400,
)
except:
_logger.exception('Unexpected exception while processing API request')
return werkzeug.Response(
response=json.dumps({
'error': 'server_error',
'error_message': 'Unexpected server error',
}),
status=500,
)
return func_wrapper
return endpoint_decorator

4
galicea_openid_connect/controllers/__init__.py

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import ext_web_login
from . import main

17
galicea_openid_connect/controllers/ext_web_login.py

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
import time
from odoo import http
from odoo.addons import web
class Home(web.controllers.main.Home):
@http.route('/web/login', type='http', auth="none")
def web_login(self, redirect=None, **kw):
result = super(Home, self).web_login(redirect, **kw)
if result.is_qweb and 'force_auth_and_redirect' in kw:
result.qcontext['redirect'] = kw['force_auth_and_redirect']
if http.request.params.get('login_success'):
http.request.session['auth_time'] = int(time.time())
return result

355
galicea_openid_connect/controllers/main.py

@ -0,0 +1,355 @@
# -*- coding: utf-8 -*-
import json
import time
import os
import base64
from odoo import http
import werkzeug
from .. api import resource
try:
from jwcrypto import jwk, jwt
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
except ImportError:
pass
def jwk_from_json(json_key):
key = jwk.JWK()
key.import_key(**json.loads(json_key))
return key
def jwt_encode(claims, key):
token = jwt.JWT(
header={'alg': key._params['alg'], 'kid': key._params['kid']},
claims=claims
)
token.make_signed_token(key)
return token.serialize()
def jwt_decode(serialized, key):
token = jwt.JWT(jwt=serialized, key=key)
return json.loads(token.claims)
RESPONSE_TYPES_SUPPORTED = [
'code',
'token',
'id_token token',
'id_token'
]
class OAuthException(Exception):
INVALID_REQUEST = 'invalid_request'
INVALID_CLIENT = 'invalid_client'
UNSUPPORTED_RESPONSE_TYPE = 'unsupported_response_type'
INVALID_GRANT = 'invalid_grant'
UNSUPPORTED_GRANT_TYPE = 'unsupported_grant_type'
def __init__(self, message, type):
super(Exception, self).__init__(message)
self.type = type
class Main(http.Controller):
def __get_authorization_code_jwk(self, req):
return jwk_from_json(req.env['ir.config_parameter'].sudo().get_param(
'galicea_openid_connect.authorization_code_jwk'
))
def __get_id_token_jwk(self, req):
return jwk_from_json(req.env['ir.config_parameter'].sudo().get_param(
'galicea_openid_connect.id_token_jwk'
))
def __validate_client(self, req, **query):
if 'client_id' not in query:
raise OAuthException(
'client_id param is missing',
OAuthException.INVALID_CLIENT,
)
client_id = query['client_id']
client = req.env['galicea_openid_connect.client'].sudo().search(
[('client_id', '=', client_id)]
)
if not client:
raise OAuthException(
'client_id param is invalid',
OAuthException.INVALID_CLIENT,
)
return client
def __validate_redirect_uri(self, client, req, **query):
if 'redirect_uri' not in query:
raise OAuthException(
'redirect_uri param is missing',
OAuthException.INVALID_GRANT,
)
redirect_uri = query['redirect_uri']
if client.auth_redirect_uri != redirect_uri:
raise OAuthException(
'redirect_uri param doesn\'t match the pre-configured redirect URI',
OAuthException.INVALID_GRANT,
)
return redirect_uri
def __validate_client_secret(self, client, req, **query):
if 'client_secret' not in query or query['client_secret'] != client.secret:
raise OAuthException(
'client_secret param is not valid',
OAuthException.INVALID_CLIENT,
)
@http.route('/.well-known/openid-configuration', auth='public', type='http')
def metadata(self, req, **query):
base_url = http.request.httprequest.host_url
data = {
'issuer': base_url,
'authorization_endpoint': base_url + 'oauth/authorize',
'token_endpoint': base_url + 'oauth/token',
'userinfo_endpoint': base_url + 'oauth/userinfo',
'jwks_uri': base_url + 'oauth/jwks',
'scopes_supported': ['openid'],
'response_types_supported': RESPONSE_TYPES_SUPPORTED,
'grant_types_supported': ['authorization_code', 'implicit'],
'subject_types_supported': ['public'],
'id_token_signing_alg_values_supported': ['RS256'],
'token_endpoint_auth_methods_supported': ['client_secret_post']
}
return json.dumps(data)
@http.route('/oauth/jwks', auth='public', type='http')
def jwks(self, req, **query):
keyset = jwk.JWKSet()
keyset.add(self.__get_id_token_jwk(req))
return keyset.export(private_keys=False)
@resource('/oauth/userinfo', method='GET')
def userinfo(self, req, **query):
user = req.env.user
values = {
'sub': str(user.id),
# Needed in case the client is another Odoo instance
'user_id': str(user.id),
'name': user.name,
}
if user.email:
values['email'] = user.email
return values
@resource('/oauth/clientinfo', method='GET', auth='client')
def clientinfo(self, req, **query):
client = req.env['galicea_openid_connect.client'].browse(req.context['client_id'])
return {
'name': client.name
}
@http.route('/oauth/authorize', auth='public', type='http', csrf=False)
def authorize(self, req, **query):
# First, validate client_id and redirect_uri params.
try:
client = self.__validate_client(req, **query)
redirect_uri = self.__validate_redirect_uri(client, req, **query)
except OAuthException as e:
# If those are not valid, we must not redirect back to the client
# - instead, we display a message to the user
return req.render('galicea_openid_connect.error', {'exception': e})
scopes = query['scope'].split(' ') if query.get('scope') else []
is_openid_request = 'openid' in scopes
# state, if present, is just mirrored back to the client
response_params = {}
if 'state' in query:
response_params['state'] = query['state']
response_mode = query.get('response_mode')
try:
if response_mode and response_mode not in ['query', 'fragment']:
response_mode = None
raise OAuthException(
'The only supported response_modes are \'query\' and \'fragment\'',
OAuthException.INVALID_REQUEST
)
if 'response_type' not in query:
raise OAuthException(
'response_type param is missing',
OAuthException.INVALID_REQUEST,
)
response_type = query['response_type']
if response_type not in RESPONSE_TYPES_SUPPORTED:
raise OAuthException(
'The only supported response_types are: {}'.format(', '.join(RESPONSE_TYPES_SUPPORTED)),
OAuthException.UNSUPPORTED_RESPONSE_TYPE,
)
except OAuthException as e:
response_params['error'] = e.type
response_params['error_description'] = e.message
return self.__redirect(redirect_uri, response_params, response_mode or 'query')
if not response_mode:
response_mode = 'query' if response_type == 'code' else 'fragment'
user = req.env.user
# In case user is not logged in, we redirect to the login page and come back
needs_login = user.login == 'public'
# Also if they didn't authenticate recently enough
if 'max_age' in query and http.request.session.get('auth_time', 0) + int(query['max_age']) < time.time():
needs_login = True
if needs_login:
params = {
'force_auth_and_redirect': '/oauth/authorize?{}'.format(werkzeug.url_encode(query))
}
return self.__redirect('/web/login', params, 'query')
response_types = response_type.split()
extra_claims = {
'sid': http.request.httprequest.session.sid,
}
if 'nonce' in query:
extra_claims['nonce'] = query['nonce']
if 'code' in response_types:
# Generate code that can be used by the client server to retrieve
# the token. It's set to be valid for 60 seconds only.
# TODO: The spec says the code should be single-use. We're not enforcing
# that here.
payload = {
'redirect_uri': redirect_uri,
'client_id': client.client_id,
'user_id': user.id,
'scopes': scopes,
'exp': int(time.time()) + 60
}
payload.update(extra_claims)
key = self.__get_authorization_code_jwk(req)
response_params['code'] = jwt_encode(payload, key)
if 'token' in response_types:
access_token = req.env['galicea_openid_connect.access_token'].sudo().retrieve_or_create(
user.id,
client.id
).token
response_params['access_token'] = access_token
response_params['token_type'] = 'bearer'
digest = hashes.Hash(hashes.SHA256(), backend=default_backend())
digest.update(access_token.encode('ascii'))
at_hash = digest.finalize()
extra_claims['at_hash'] = base64.urlsafe_b64encode(at_hash[:16]).strip('=')
if 'id_token' in response_types:
response_params['id_token'] = self.__create_id_token(req, user.id, client, extra_claims)
return self.__redirect(redirect_uri, response_params, response_mode)
@http.route('/oauth/token', auth='public', type='http', methods=['POST'], csrf=False)
def token(self, req, **query):
try:
if 'grant_type' not in query:
raise OAuthException(
'grant_type param is missing',
OAuthException.INVALID_REQUEST,
)
if query['grant_type'] == 'authorization_code':
return json.dumps(self.__handle_grant_type_authorization_code(req, **query))
elif query['grant_type'] == 'client_credentials':
return json.dumps(self.__handle_grant_type_client_credentials(req, **query))
else:
raise OAuthException(
'Unsupported grant_type param: \'{}\''.format(query['grant_type']),
OAuthException.UNSUPPORTED_GRANT_TYPE,
)
except OAuthException as e:
body = json.dumps({'error': e.type, 'error_description': e.message})
return werkzeug.Response(response=body, status=400)
def __handle_grant_type_authorization_code(self, req, **query):
client = self.__validate_client(req, **query)
redirect_uri = self.__validate_redirect_uri(client, req, **query)
self.__validate_client_secret(client, req, **query)
if 'code' not in query:
raise OAuthException(
'code param is missing',
OAuthException.INVALID_GRANT,
)
try:
payload = jwt_decode(query['code'], self.__get_authorization_code_jwk(req))
except jwt.JWTExpired:
raise OAuthException(
'Code expired',
OAuthException.INVALID_GRANT,
)
except ValueError:
raise OAuthException(
'code malformed',
OAuthException.INVALID_GRANT,
)
if payload['client_id'] != client.client_id:
raise OAuthException(
'client_id doesn\'t match the authorization request',
OAuthException.INVALID_GRANT,
)
if payload['redirect_uri'] != redirect_uri:
raise OAuthException(
'redirect_uri doesn\'t match the authorization request',
OAuthException.INVALID_GRANT,
)
# Retrieve/generate access token. We currently only store one per user/client
token = req.env['galicea_openid_connect.access_token'].sudo().retrieve_or_create(
payload['user_id'],
client.id
)
response = {
'access_token': token.token,
'token_type': 'bearer'
}
if 'openid' in payload['scopes']:
extra_claims = { name: payload[name] for name in payload if name in ['sid', 'nonce'] }
response['id_token'] = self.__create_id_token(req, payload['user_id'], client, extra_claims)
return response
def __handle_grant_type_client_credentials(self, req, **query):
client = self.__validate_client(req, **query)
self.__validate_client_secret(client, req, **query)
token = req.env['galicea_openid_connect.client_access_token'].sudo().retrieve_or_create(client.id)
return {
'access_token': token.token,
'token_type': 'bearer'
}
def __create_id_token(self, req, user_id, client, extra_claims):
claims = {
'iss': http.request.httprequest.host_url,
'sub': str(user_id),
'aud': client.client_id,
'iat': int(time.time()),
'exp': int(time.time()) + 15 * 60
}
auth_time = extra_claims.get('sid') and http.root.session_store.get(extra_claims['sid']).get('auth_time')
if auth_time:
claims['auth_time'] = auth_time
if 'nonce' in extra_claims:
claims['nonce'] = extra_claims['nonce']
if 'at_hash' in extra_claims:
claims['at_hash'] = extra_claims['at_hash']
key = self.__get_id_token_jwk(req)
return jwt_encode(claims, key)
def __redirect(self, url, params, response_mode):
location = '{}{}{}'.format(
url,
'?' if response_mode == 'query' else '#',
werkzeug.url_encode(params)
)
return werkzeug.Response(
headers={'Location': location},
response=None,
status=302,
)

4
galicea_openid_connect/models/__init__.py

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import client
from . import access_token

64
galicea_openid_connect/models/access_token.py

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from .. import random_tokens
class AccessTokenBase(models.AbstractModel):
_name = 'galicea_openid_connect.access_token_base'
token = fields.Char(
readonly=True,
required=True,
default=lambda _: random_tokens.alpha_numeric(64),
index=True,
)
client_id = fields.Many2one(
'galicea_openid_connect.client',
readonly=True,
index=True,
required=True,
ondelete='cascade'
)
class AccessToken(models.Model):
_inherit = 'galicea_openid_connect.access_token_base'
_name = 'galicea_openid_connect.access_token'
_description = 'Acccess token representing user-client pair'
user_id = fields.Many2one(
'res.users',
required=True,
readonly=True,
index=True,
ondelete='cascade'
)
@api.model
def retrieve_or_create(self, user_id, client_id):
existing_tokens = self.search(
[
('user_id', '=', user_id),
('client_id', '=', client_id),
]
)
if existing_tokens:
return existing_tokens[0]
else:
return self.create({'user_id': user_id, 'client_id': client_id})
class ClientAccessToken(models.Model):
_inherit = 'galicea_openid_connect.access_token_base'
_name = 'galicea_openid_connect.client_access_token'
_description = 'Access token representing client credentials'
@api.model
def retrieve_or_create(self, client_id):
existing_tokens = self.search(
[
('client_id', '=', client_id),
]
)
if existing_tokens:
return existing_tokens[0]
else:
return self.create({'client_id': client_id})

65
galicea_openid_connect/models/client.py

@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from .. import random_tokens
class Client(models.Model):
_name = 'galicea_openid_connect.client'
_description = 'OpenID Connect client'
name = fields.Char(required=True)
auth_redirect_uri = fields.Char('Redirect URI for user login')
client_id = fields.Char(
string='Client ID',
required=True,
readonly=True,
index=True,
default=lambda _: random_tokens.lower_case(16),
)
secret = fields.Char(
string='Client secret',
required=True,
readonly=True,
default=lambda _: random_tokens.alpha_numeric(32),
groups='galicea_openid_connect.group_admin'
)
system_user_id = fields.Many2one(
'res.users',
'Artificial user representing the client in client credentials requests',
readonly=True,
required=True,
ondelete='restrict'
)
@api.model
def __system_user_name(self, client_name):
return '{} - API system user'.format(client_name)
@api.model
def create(self, values):
if 'name' in values:
system_user = self.env['res.users'].create({
'name': self.__system_user_name(values['name']),
'login': random_tokens.lower_case(8),
'groups_id': [(4, self.env.ref('galicea_openid_connect.group_system_user').id)]
})
# Do not include in the "Pending invitations" list
system_user.sudo(system_user.id)._update_last_login()
values['system_user_id'] = system_user.id
return super(Client, self).create(values)
@api.multi
def write(selfs, values):
super(Client, selfs).write(values)
if 'name' in values:
selfs.mapped(lambda client: client.system_user_id).write({
'name': selfs.__system_user_name(values['name'])
})
return True
@api.multi
def unlink(selfs):
users_to_unlink = selfs.mapped(lambda client: client.system_user_id)
ret = super(Client, selfs).unlink()
users_to_unlink.unlink()
return ret

14
galicea_openid_connect/random_tokens.py

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from random import SystemRandom
def random_token(length, byte_filter):
allowed_bytes = ''.join(c for c in map(chr, range(256)) if byte_filter(c))
random = SystemRandom()
return ''.join([random.choice(allowed_bytes) for _ in range(length)])
def alpha_numeric(length):
return random_token(length, str.isalnum)
def lower_case(length):
return random_token(length, str.islower)

2
galicea_openid_connect/requirements.txt

@ -0,0 +1,2 @@
cryptography>=2.3
jwcrypto==0.5.0

23
galicea_openid_connect/security/__init__.py

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from .. import random_tokens
try:
from jwcrypto import jwk
except ImportError:
pass
def init_keys(IrConfigParameter):
keys = {
'galicea_openid_connect.authorization_code_jwk': lambda: \
jwk.JWK.generate(kty='oct', size=256, kid=random_tokens.alpha_numeric(16), use='sig', alg='HS256').export(),
'galicea_openid_connect.id_token_jwk': lambda: \
jwk.JWK.generate(kty='RSA', size=2054, kid=random_tokens.alpha_numeric(16), use='sig', alg='RS256').export()
}
for key, gen in keys.iteritems():
if not IrConfigParameter.search([('key', '=', key)]):
IrConfigParameter.create({
'key': key,
'value': gen(),
'group_ids': [(4, IrConfigParameter.env.ref('base.group_erp_manager').id)]
})

4
galicea_openid_connect/security/init.yml

@ -0,0 +1,4 @@
-
!python {model: ir.config_parameter}: |
from odoo.addons.galicea_openid_connect.security import init_keys
init_keys(self)

5
galicea_openid_connect/security/ir.model.access.csv

@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_client,client,model_galicea_openid_connect_client,galicea_openid_connect.group_admin,1,1,1,1
access_client_system_user,client_system_user,model_galicea_openid_connect_client,galicea_openid_connect.group_system_user,1,0,0,0
access_access_token,access_token,model_galicea_openid_connect_access_token,galicea_openid_connect.group_admin,1,0,0,1
access_client_access_token,client_access_token,model_galicea_openid_connect_client_access_token,galicea_openid_connect.group_admin,1,0,0,1

35
galicea_openid_connect/security/security.xml

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_openid_connect" model="ir.module.category">
<field name="name">OpenID Connect Provider</field>
</record>
<record id="group_system_user" model="res.groups">
<field name="name">OpenID Client's system user</field>
<field name="category_id" ref="module_category_openid_connect" />
<field name="implied_ids" eval="[(4,ref('base.group_public'))]" />
</record>
<record id="group_admin" model="res.groups">
<field name="name">OpenID Connect Provider Administrator</field>
<field name="category_id" ref="module_category_openid_connect" />
</record>
<record id="base.group_erp_manager" model="res.groups">
<field name="implied_ids" eval="[(4,ref('group_admin'))]" />
</record>
<record id="client_system_user_access_rule" model="ir.rule">
<field name="name">OpenID system users can only see corresponding clients</field>
<field name="model_id" ref="model_galicea_openid_connect_client"/>
<field name="groups" eval="[(4, ref('group_system_user'))]"/>
<field name="domain_force">
[('system_user_id', '=', user.id)]
</field>
<field eval="1" name="perm_read" />
<field eval="0" name="perm_write" />
<field eval="0" name="perm_create" />
<field eval="0" name="perm_unlink" />
</record>
</odoo>

BIN
galicea_openid_connect/static/description/icon.png

After

Width: 80  |  Height: 80  |  Size: 3.6 KiB

BIN
galicea_openid_connect/static/description/images/client_screenshot.png

After

Width: 739  |  Height: 368  |  Size: 47 KiB

BIN
galicea_openid_connect/static/description/images/login_screenshot.png

After

Width: 600  |  Height: 330  |  Size: 22 KiB

BIN
galicea_openid_connect/static/description/images/master_screenshot.png

After

Width: 499  |  Height: 267  |  Size: 17 KiB

133
galicea_openid_connect/static/description/index.html

@ -0,0 +1,133 @@
<section class="oe_container">
<div class="oe_row oe_spaced">
<div class="oe_span12">
<h2 class="oe_slogan">Galicea OpenID Connect Provider</h2>
<h3 class="oe_slogan">
OpenID Connect Provider for Odoo &amp; OAuth2 resource server
</h3>
<p>
This add-on allows Odoo to become an OpenID Connect Identity Provider (or just OAuth2 authorization server). The supported use-case is to allow several company-owned applications (possibly other Odoo instances) to reuse identities provided by Odoo, by becoming its OpenID Connect Clients. <i>There is no technical reason not to allow third-party clients, but keep in mind that as is, there is no support for custom scopes (other than <tt>openid</tt>) and no permission is required from the user to share their identity with the client.</i></p>
<p>The add-on also provides OAuth2 token validation for use in custom API endpoints. This allows the clients to securely fetch data from Odoo.</p>
<h2>Prerequisites</h2>
<pre>
pip install -r galicea_openid_connect/requirements.txt
</pre>
<h2>Client configuration</h2>
<p>
Simply go to <tt>OpenID Connect Provider</tt> menu to register a new client. Make sure that the <tt>Redirect URI</tt> exactly matches <tt>redirect_uri</tt> parameter your client is going to send. Copy generated <tt>Client ID</tt> and <tt>Client secret</tt> to configure your client.
</p>
<p>
You can use <a href="https://openid.net/specs/openid-connect-discovery-1_0.html">OpenID Connect Discovery</a> to set up your client. The discovery document URL will be located at <tt>&lt;odoo-base-url&gt;/.well-known/openid-configuration</tt> and it looks like this: <pre>
{
"authorization_endpoint": "&lt;odoo-base-url&gt;/oauth/authorize",
"grant_types_supported": [
"authorization_code",
"implicit"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"issuer": "&lt;odoo-base-url&gt;/",
"jwks_uri": "&lt;odoo-base-url&gt;/oauth/jwks",
"response_types_supported": [
"code",
"token",
"id_token token",
"id_token"
],
"scopes_supported": [
"openid"
],
"subject_types_supported": [
"public"
],
"token_endpoint": "&lt;odoo-base-url&gt;/oauth/token",
"token_endpoint_auth_methods_supported": [
"client_secret_post"
],
"userinfo_endpoint": "&lt;odoo-base-url&gt;/oauth/userinfo"
}
</pre>
<h3>Configuring other Odoo instance/DB to be the client</h3>
<p>Let's say that you want to allow users registered in your <tt>master.odoo.com</tt> Odoo instance to be able to log into <tt>client.odoo.com</tt> instance, without having to create a separate account.</p>
<p>To do that, simply install this module on <tt>master.odoo.com</tt> and add the client, using <tt>/auth_oauth/signin</tt> as a redirect_uri:</p>
<img class="oe_picture oe_screenshot" src="images/master_screenshot.png" />
<p>Now, in <tt>client.odoo.com</tt>:
<ul>
<li>install the <tt>auth_oauth</tt> add-on,</li>
<li>enable developer mode,</li>
<li>make sure that <tt>Allow external users to sign up</tt> option is enabled in <tt>General settings</tt></li>
<li>add the following OAuth Provider data in the settings:</li>
<img class="oe_picture oe_screenshot" src="images/client_screenshot.png" />
</p>
Now, the users of <tt>client.odoo.com</tt> will be able to login using new <tt>Login with Master</tt> link.
<img class="oe_picture oe_screenshot" src="images/login_screenshot.png" />
In case they are already logged into <tt>master.odoo.com</tt>, all they need to do is to click it. Otherwise, they will be redirected to <tt>master.odoo.com</tt> to provide their credentials.
<h2>Creating JSON APIs with OAuth2 authorization</h2>
<p>Along with the ID token, it's possible for the OpenID Connect Client to request access token, that can be used to authorize access to a custom JSON API.</p>
<p>You can create such API in a way that is similar to creating regular Odoo controllers:</p>
<pre>
# -*- coding: utf-8 -*-
from odoo import http
from odoo.addons.galicea_openid_connect.api import resource
class ExternalAPI(http.Controller):
@resource('/oauth/userinfo', method='GET')
def userinfo(self, req, **query):
user = req.env.user
return {
'sub': str(user.id),
'name': user.name,
'email': user.email
}
</pre>
(note that this particular endpoint is bundled into <tt>galicea_openid_connect</tt> add-on). The client can then call this endpoint with either a header that looks like <tt>Authorization: Bearer &lt;token&gt;</tt> or <tt>&amp;access_token=&lt;token&gt;</tt> query parameter.
<pre>
$ curl --header 'Authorization: Bearer 9Dkv2W...gzpz' '&lt;odoo-base-url&gt;/oauth/userinfo'
{"email": false, "sub": "1", "name": "Administrator"}
</pre>
<h3>API authorized with client credentials tokens</h3>
It's also possible to create APIs for server-to-server requests from the Client.
<pre>
# -*- coding: utf-8 -*-
from odoo import http
from odoo.addons.galicea_openid_connect.api import resource
class ExternalAPI(http.Controller):
@resource('/oauth/clientinfo', method='GET'<b>, auth='client'</b>)
def clientinfo(self, req, **query):
client = req.env['galicea_openid_connect.client'].browse(req.context['client_id'])
return {
'name': client.name
}
</pre>
(note that this particular endpoint is bundled into <tt>galicea_openid_connect</tt> add-on as well). In order to receive the access token, the client needs to call the <tt>/oauth/token</tt> endpoint with <tt>&amp;grant_type=client_credentials</tt> parameter:
<pre>
$ curl -X POST '&lt;odoo-base-url&gt;/oauth/token?grant_type=client_credentials&client_id=dr...ds&client_secret=DL...gO'
{"access_token": "WWy74uJIIRA4bonJHdVUeY3N8Jn2vuMecIfQntLf5FvCj3C3nNJY9tRER0qcoHRw", "token_type": "bearer"}
</pre>
Such token can then be used to access the resource:
<pre>
$ curl --header 'Authorization: Bearer WWy...coHRw' '&lt;odoo-base-url&gt;/oauth/clientinfo'
{"name": "Test Client"}
</pre>
<h2>Additional notes</h2>
<ul>
<li>In order to support OpenID Connect features related to authentication time, this also adds time of the user log-in to Odoo session.</li>
<li>For each client, a special kind of public user ("system user") is created to be impersonated during the server-server API requests.</li>
</ul>
</div>
</div>
</section>

24
galicea_openid_connect/system_checks.py

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from odoo.addons.galicea_environment_checkup import \
custom_check, CheckWarning, CheckSuccess, CheckFail
from odoo import http
@custom_check
def check_single_db(env):
if not http.request:
raise CheckWarning('Could not detect DB settings.')
dbs = http.db_list(True, http.request.httprequest)
if len(dbs) == 1:
return CheckSuccess('Odoo runs in a single-DB mode.')
details = (
'<p>Odoo runs in a multi-DB mode, which will cause API request routing to fail.</p>'
'<p>Run Odoo with <tt>--dbfilter</tt> or <tt>--database</tt> flag.</p>'
)
return CheckFail(
'Odoo runs in a multi-DB mode.',
details=details
)

24
galicea_openid_connect/views/templates.xml

@ -0,0 +1,24 @@
<odoo>
<data>
<template id="error" name="OpenID/OAuth user-visible error">
<t t-call="web.layout">
<t t-set="head">
<t t-call-assets="web.assets_common" t-js="false"/>
<t t-call-assets="web.assets_frontend" t-js="false"/>
<t t-call-assets="web.assets_common" t-css="false"/>
<t t-call-assets="web.assets_frontend" t-css="false"/>
</t>
<t t-set="body_classname" t-value="'container'"/>
<div class="row">
<div class="panel panel-danger">
<div class="panel-heading">OpenID Client is misconfigured</div>
<div class="panel-body">
<b><t t-esc="exception.type"/>: </b><t t-esc="exception.message"/>
</div>
</div>
</div>
</t>
</template>
</data>
</odoo>

65
galicea_openid_connect/views/views.xml

@ -0,0 +1,65 @@
<odoo>
<data>
<record id="client_view_form" model="ir.ui.view">
<field name="model">galicea_openid_connect.client</field>
<field name="priority">10</field>
<field name="arch" type="xml">
<form>
<group>
<field name="name" />
<field name="create_date" invisible="1" />
<field name="client_id"
attrs="{'invisible':[('create_date', '==', False)]}" />
<label for="secret" class="oe_read_only" string="Client Secret" />
<button class="oe_read_only" string="Show" type="action" name="%(client_action_secret)d" />
<field name="auth_redirect_uri" />
</group>
</form>
</field>
</record>
<record model="ir.actions.server" id="client_action_secret">
<field name="name">Show Client Secret</field>
<field name="model_id" ref="model_galicea_openid_connect_client"/>
<field name="code">
action = {
"type": "ir.actions.act_window",
"view_mode": "form",
"view_id": obj.env.ref('galicea_openid_connect.client_view_form_secret').id,
"res_model": "galicea_openid_connect.client",
"res_id": obj.id
}
</field>
</record>
<record id="client_view_form_secret" model="ir.ui.view">
<field name="inherit_id" ref="galicea_openid_connect.client_view_form" />
<field name="priority">99</field>
<field name="model">galicea_openid_connect.client</field>
<field name="mode">primary</field>
<field name="arch" type="xml">
<button name="%(client_action_secret)d" position="replace">
<field class="oe_read_only" name="secret" nolabel="1" />
</button>
</field>
</record>
<record id="client_view_tree" model="ir.ui.view">
<field name="model">galicea_openid_connect.client</field>
<field name="arch" type="xml">
<tree>
<field name="name" />
<field name="client_id" />
<field name="auth_redirect_uri" />
</tree>
</field>
</record>
<act_window id="client_action"
name="OpenID Clients"
res_model="galicea_openid_connect.client" />
<menuitem name="OpenID Connect Provider" id="root_menu" sequence="19" />
<menuitem name="Clients" id="client_menu" parent="galicea_openid_connect.root_menu" action="client_action" />
</data>
</odoo>
Loading…
Cancel
Save