Skip to content

Commit

Permalink
[ADD] auth_jwt
Browse files Browse the repository at this point in the history
  • Loading branch information
sbidoul committed Jun 7, 2023
1 parent 302a3a8 commit e89d615
Show file tree
Hide file tree
Showing 16 changed files with 809 additions and 0 deletions.
129 changes: 129 additions & 0 deletions auth_jwt/README.rst
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.
1 change: 1 addition & 0 deletions auth_jwt/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
17 changes: 17 additions & 0 deletions auth_jwt/__manifest__.py
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": [],
}
32 changes: 32 additions & 0 deletions auth_jwt/exceptions.py
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
2 changes: 2 additions & 0 deletions auth_jwt/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import auth_jwt_validator
from . import ir_http
186 changes: 186 additions & 0 deletions auth_jwt/models/auth_jwt_validator.py
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()
Loading

0 comments on commit e89d615

Please sign in to comment.