Maciej Wawro
6 years ago
23 changed files with 974 additions and 0 deletions
-
5galicea_openid_connect/__init__.py
-
49galicea_openid_connect/__manifest__.py
-
82galicea_openid_connect/api.py
-
4galicea_openid_connect/controllers/__init__.py
-
17galicea_openid_connect/controllers/ext_web_login.py
-
355galicea_openid_connect/controllers/main.py
-
4galicea_openid_connect/models/__init__.py
-
64galicea_openid_connect/models/access_token.py
-
65galicea_openid_connect/models/client.py
-
14galicea_openid_connect/random_tokens.py
-
2galicea_openid_connect/requirements.txt
-
23galicea_openid_connect/security/__init__.py
-
4galicea_openid_connect/security/init.yml
-
5galicea_openid_connect/security/ir.model.access.csv
-
35galicea_openid_connect/security/security.xml
-
BINgalicea_openid_connect/static/description/icon.png
-
BINgalicea_openid_connect/static/description/images/client_screenshot.png
-
BINgalicea_openid_connect/static/description/images/login_screenshot.png
-
BINgalicea_openid_connect/static/description/images/master_screenshot.png
-
133galicea_openid_connect/static/description/index.html
-
24galicea_openid_connect/system_checks.py
-
24galicea_openid_connect/views/templates.xml
-
65galicea_openid_connect/views/views.xml
@ -0,0 +1,5 @@ |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
from . import controllers |
|||
from . import models |
|||
from . import system_checks |
@ -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' |
|||
] |
|||
} |
@ -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 |
@ -0,0 +1,4 @@ |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
from . import ext_web_login |
|||
from . import main |
@ -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 |
@ -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, |
|||
) |
@ -0,0 +1,4 @@ |
|||
# -*- coding: utf-8 -*- |
|||
|
|||
from . import client |
|||
from . import access_token |
@ -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}) |
@ -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 |
@ -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) |
@ -0,0 +1,2 @@ |
|||
cryptography>=2.3 |
|||
jwcrypto==0.5.0 |
@ -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)] |
|||
}) |
@ -0,0 +1,4 @@ |
|||
- |
|||
!python {model: ir.config_parameter}: | |
|||
from odoo.addons.galicea_openid_connect.security import init_keys |
|||
init_keys(self) |
@ -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 |
@ -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> |
After Width: 80 | Height: 80 | Size: 3.6 KiB |
After Width: 739 | Height: 368 | Size: 47 KiB |
After Width: 600 | Height: 330 | Size: 22 KiB |
After Width: 499 | Height: 267 | Size: 17 KiB |
@ -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 & 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><odoo-base-url>/.well-known/openid-configuration</tt> and it looks like this: <pre> |
|||
{ |
|||
"authorization_endpoint": "<odoo-base-url>/oauth/authorize", |
|||
"grant_types_supported": [ |
|||
"authorization_code", |
|||
"implicit" |
|||
], |
|||
"id_token_signing_alg_values_supported": [ |
|||
"RS256" |
|||
], |
|||
"issuer": "<odoo-base-url>/", |
|||
"jwks_uri": "<odoo-base-url>/oauth/jwks", |
|||
"response_types_supported": [ |
|||
"code", |
|||
"token", |
|||
"id_token token", |
|||
"id_token" |
|||
], |
|||
"scopes_supported": [ |
|||
"openid" |
|||
], |
|||
"subject_types_supported": [ |
|||
"public" |
|||
], |
|||
"token_endpoint": "<odoo-base-url>/oauth/token", |
|||
"token_endpoint_auth_methods_supported": [ |
|||
"client_secret_post" |
|||
], |
|||
"userinfo_endpoint": "<odoo-base-url>/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 <token></tt> or <tt>&access_token=<token></tt> query parameter. |
|||
<pre> |
|||
$ curl --header 'Authorization: Bearer 9Dkv2W...gzpz' '<odoo-base-url>/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>&grant_type=client_credentials</tt> parameter: |
|||
<pre> |
|||
$ curl -X POST '<odoo-base-url>/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' '<odoo-base-url>/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> |
@ -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 |
|||
) |
@ -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> |
@ -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> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue