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