forked from OCA/server-auth
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
16 changed files
with
809 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
======== | ||
Auth JWT | ||
======== | ||
|
||
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! | ||
!! This file is generated by oca-gen-addon-readme !! | ||
!! changes will be overwritten. !! | ||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! | ||
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png | ||
:target: https://odoo-community.org/page/development-status | ||
:alt: Beta | ||
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png | ||
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html | ||
:alt: License: AGPL-3 | ||
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github | ||
:target: https://github.com/OCA/server-auth/tree/13.0/auth_jwt | ||
:alt: OCA/server-auth | ||
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png | ||
:target: https://translation.odoo-community.org/projects/server-auth-13-0/server-auth-13-0-auth_jwt | ||
:alt: Translate me on Weblate | ||
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png | ||
:target: https://runbot.odoo-community.org/runbot/251/13.0 | ||
:alt: Try me on Runbot | ||
|
||
|badge1| |badge2| |badge3| |badge4| |badge5| | ||
|
||
JWT bearer token authentication. | ||
|
||
**Table of contents** | ||
|
||
.. contents:: | ||
:local: | ||
|
||
Installation | ||
============ | ||
|
||
This module requires the ``python-jose`` library to be installed. | ||
|
||
Usage | ||
===== | ||
|
||
This module lets developpers add a new ``jwt`` authentication method on Odoo | ||
controller routes. | ||
|
||
To use it, you must: | ||
|
||
* Create an ``auth.jwt.validator`` record to configure how the JWT token will | ||
be validated. | ||
* Add an ``auth="jwt_{validator-name}"`` attribute to the routes | ||
you want to protect where ``{validator-name}`` corresponds to the name | ||
attribute of the JWT validator record. | ||
|
||
The ``auth_jwt_test`` module provides examples. | ||
|
||
The JWT validator can be configured with the following properties: | ||
|
||
* ``name``: the validator name, to match the ``auth="jwt_{validator-name}"`` | ||
route property. | ||
* ``audience``: used to validate the ``aud`` claim. | ||
* ``issuer``: used to validate the ``iss`` claim. | ||
* Signature type (secret or public key), algorithm, secret and JWK URI | ||
are used to validate the token signature. | ||
|
||
In addition, the ``exp`` claim is validated to reject expired tokens. | ||
|
||
If the ``Authorization`` HTTP header is missing, malformed, or contains | ||
an invalid token, the request is rejected with a 401 (Unauthorized) code. | ||
|
||
If the token is valid, the request executes with the configured user id. By | ||
default the user id selection strategy is ``static`` (i.e. the same for all | ||
requests) and the selected user is configured on the JWT validator. Additional | ||
strategies can be provided by overriding the ``_get_uid()`` method and | ||
extending the ``user_id_strategy`` selection field.. | ||
|
||
Additionally, if a ``partner_id_strategy`` is configured, a partner is searched | ||
and if found, its id is stored in the ``request.partner_id`` attribute. If | ||
``partner_id_required`` is set, a 401 (Unauthorized) is returned if no partner | ||
was found. Otherwise ``request.partner_id`` is left falsy. Additional | ||
strategies can be provided by overriding the ``_get_partner_id()`` method | ||
and extending the ``partner_id_strategy`` selection field. | ||
|
||
Bug Tracker | ||
=========== | ||
|
||
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-auth/issues>`_. | ||
In case of trouble, please check there if your issue has already been reported. | ||
If you spotted it first, help us smashing it by providing a detailed and welcomed | ||
`feedback <https://github.com/OCA/server-auth/issues/new?body=module:%20auth_jwt%0Aversion:%2013.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_. | ||
|
||
Do not contact contributors directly about support or help with technical issues. | ||
|
||
Credits | ||
======= | ||
|
||
Authors | ||
~~~~~~~ | ||
|
||
* ACSONE SA/NV | ||
|
||
Contributors | ||
~~~~~~~~~~~~ | ||
|
||
* Stéphane Bidoul <[email protected]> | ||
|
||
Maintainers | ||
~~~~~~~~~~~ | ||
|
||
This module is maintained by the OCA. | ||
|
||
.. image:: https://odoo-community.org/logo.png | ||
:alt: Odoo Community Association | ||
:target: https://odoo-community.org | ||
|
||
OCA, or the Odoo Community Association, is a nonprofit organization whose | ||
mission is to support the collaborative development of Odoo features and | ||
promote its widespread use. | ||
|
||
.. |maintainer-sbidoul| image:: https://github.com/sbidoul.png?size=40px | ||
:target: https://github.com/sbidoul | ||
:alt: sbidoul | ||
|
||
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__: | ||
|
||
|maintainer-sbidoul| | ||
|
||
This module is part of the `OCA/server-auth <https://github.com/OCA/server-auth/tree/13.0/auth_jwt>`_ project on GitHub. | ||
|
||
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from . import models |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
# Copyright 2021 ACSONE SA/NV | ||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). | ||
|
||
{ | ||
"name": "Auth JWT", | ||
"summary": """ | ||
JWT bearer token authentication.""", | ||
"version": "13.0.1.0.0", | ||
"license": "AGPL-3", | ||
"author": "ACSONE SA/NV,Odoo Community Association (OCA)", | ||
"maintainers": ["sbidoul"], | ||
"website": "https://github.com/OCA/server-auth", | ||
"depends": [], | ||
"external_dependencies": {"python": ["python-jose", "cryptography"]}, | ||
"data": ["security/ir.model.access.csv", "views/auth_jwt_validator_views.xml"], | ||
"demo": [], | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
# Copyright 2021 ACSONE SA/NV | ||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). | ||
|
||
from werkzeug.exceptions import InternalServerError, Unauthorized | ||
|
||
|
||
class UnauthorizedMissingAuthorizationHeader(Unauthorized): | ||
pass | ||
|
||
|
||
class UnauthorizedMalformedAuthorizationHeader(Unauthorized): | ||
pass | ||
|
||
|
||
class UnauthorizedSessionMismatch(Unauthorized): | ||
pass | ||
|
||
|
||
class AmbiguousJwtValidator(InternalServerError): | ||
pass | ||
|
||
|
||
class JwtValidatorNotFound(InternalServerError): | ||
pass | ||
|
||
|
||
class UnauthorizedInvalidToken(Unauthorized): | ||
pass | ||
|
||
|
||
class UnauthorizedPartnerNotFound(Unauthorized): | ||
pass |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
from . import auth_jwt_validator | ||
from . import ir_http |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,186 @@ | ||
# Copyright 2021 ACSONE SA/NV | ||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). | ||
|
||
import logging | ||
from functools import partial | ||
|
||
import requests | ||
from jose import jwt # pylint: disable=missing-manifest-dependency | ||
from werkzeug.exceptions import InternalServerError | ||
|
||
from odoo import _, api, fields, models, tools | ||
from odoo.exceptions import ValidationError | ||
|
||
from ..exceptions import ( | ||
AmbiguousJwtValidator, | ||
JwtValidatorNotFound, | ||
UnauthorizedInvalidToken, | ||
UnauthorizedPartnerNotFound, | ||
) | ||
|
||
_logger = logging.getLogger(__name__) | ||
|
||
|
||
class AuthJwtValidator(models.Model): | ||
_name = "auth.jwt.validator" | ||
_description = "JWT Validator Configuration" | ||
|
||
name = fields.Char(required=True) | ||
signature_type = fields.Selection( | ||
[("secret", "Secret"), ("public_key", "Public key")], required=True | ||
) | ||
secret_key = fields.Char() | ||
secret_algorithm = fields.Selection([("HS256", "HS256")], default="HS256") # TODO | ||
public_key_jwk_uri = fields.Char() | ||
public_key_algorithm = fields.Selection( | ||
[("RS256", "RS256")], default="RS256" | ||
) # TODO | ||
audience = fields.Char(required=True, help="To validate aud.") | ||
issuer = fields.Char(required=True, help="To validate iss.") | ||
user_id_strategy = fields.Selection( | ||
[("static", "Static")], required=True, default="static" | ||
) | ||
static_user_id = fields.Many2one("res.users", default=1) | ||
partner_id_strategy = fields.Selection([("email", "From email claim")]) | ||
partner_id_required = fields.Boolean() | ||
|
||
_sql_constraints = [ | ||
("name_uniq", "unique(name)", "JWT validator names must be unique !"), | ||
] | ||
|
||
@api.constrains("name") | ||
def _check_name(self): | ||
for rec in self: | ||
if not rec.name.isidentifier(): | ||
raise ValidationError( | ||
_("Name %r is not a valid python identifier.") % (rec.name,) | ||
) | ||
|
||
@api.model | ||
def _get_validator_by_name_domain(self, validator_name): | ||
if validator_name: | ||
return [("name", "=", validator_name)] | ||
return [] | ||
|
||
@api.model | ||
def _get_validator_by_name(self, validator_name): | ||
domain = self._get_validator_by_name_domain(validator_name) | ||
validator = self.search(domain) | ||
if not validator: | ||
_logger.error("JWT validator not found for name %r", validator_name) | ||
raise JwtValidatorNotFound() | ||
if len(validator) != 1: | ||
_logger.error( | ||
"More than one JWT validator found for name %r", validator_name | ||
) | ||
raise AmbiguousJwtValidator() | ||
return validator | ||
|
||
@tools.ormcache("self.public_key_jwk_uri", "kid") | ||
def _get_key(self, kid): | ||
r = requests.get(self.public_key_jwk_uri) | ||
r.raise_for_status() | ||
response = r.json() | ||
for key in response["keys"]: | ||
if key["kid"] == kid: | ||
return key | ||
return {} | ||
|
||
def _decode(self, token): | ||
"""Validate and decode a JWT token, return the payload.""" | ||
if self.signature_type == "secret": | ||
key = self.secret_key | ||
algorithm = self.secret_algorithm | ||
else: | ||
try: | ||
header = jwt.get_unverified_header(token) | ||
except jwt.JWTError as e: | ||
_logger.info("Invalid token: %s", e) | ||
raise UnauthorizedInvalidToken() | ||
key = self._get_key(header.get("kid")) | ||
algorithm = self.public_key_algorithm | ||
try: | ||
payload = jwt.decode( | ||
token, | ||
key=key, | ||
algorithms=[algorithm], | ||
options=dict( | ||
require_exp=True, verify_exp=True, verify_aud=True, verify_iss=True | ||
), | ||
audience=self.audience, | ||
issuer=self.issuer, | ||
) | ||
except jwt.JWTError as e: | ||
_logger.info("Invalid token: %s", e) | ||
raise UnauthorizedInvalidToken() | ||
return payload | ||
|
||
def _get_uid(self, payload): | ||
# override for additional strategies | ||
if self.user_id_strategy == "static": | ||
return self.static_user_id.id | ||
|
||
def _get_and_check_uid(self, payload): | ||
uid = self._get_uid(payload) | ||
if not uid: | ||
_logger.error("_get_uid did not return a user id") | ||
raise InternalServerError() | ||
return uid | ||
|
||
def _get_partner_id(self, payload): | ||
# override for additional strategies | ||
if self.partner_id_strategy == "email": | ||
email = payload.get("email") | ||
if not email: | ||
_logger.debug("JWT payload does not have an email claim") | ||
return | ||
partner = self.env["res.partner"].search([("email", "=", email)]) | ||
if len(partner) != 1: | ||
_logger.debug("%d partners found for email %s", len(partner), email) | ||
return | ||
return partner.id | ||
|
||
def _get_and_check_partner_id(self, payload): | ||
partner_id = self._get_partner_id(payload) | ||
if not partner_id and self.partner_id_required: | ||
raise UnauthorizedPartnerNotFound() | ||
return partner_id | ||
|
||
def _register_hook(self): | ||
res = super()._register_hook() | ||
self.search([])._register_auth_method() | ||
return res | ||
|
||
def _register_auth_method(self): | ||
IrHttp = self.env["ir.http"] | ||
for rec in self: | ||
setattr( | ||
IrHttp.__class__, | ||
f"_auth_method_jwt_{rec.name}", | ||
partial(IrHttp.__class__._auth_method_jwt, validator_name=rec.name), | ||
) | ||
|
||
def _unregister_auth_method(self): | ||
IrHttp = self.env["ir.http"] | ||
for rec in self: | ||
try: | ||
delattr(IrHttp.__class__, f"_auth_method_jwt_{rec.name}") | ||
except AttributeError: | ||
pass | ||
|
||
@api.model_create_multi | ||
def create(self, vals): | ||
rec = super().create(vals) | ||
rec._register_auth_method() | ||
return rec | ||
|
||
def write(self, vals): | ||
if "name" in vals: | ||
self._unregister_auth_method() | ||
res = super().write(vals) | ||
self._register_auth_method() | ||
return res | ||
|
||
def unlink(self): | ||
self._unregister_auth_method() | ||
return super().unlink() |
Oops, something went wrong.