Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[14.0][MIG] backport auth_jwt and auth_jwt_demo improvements from 16.0 #531

Merged
merged 9 commits into from
Jul 17, 2023
2 changes: 1 addition & 1 deletion auth_jwt/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
"maintainers": ["sbidoul"],
"website": "https://github.com/OCA/server-auth",
"depends": [],
"depends": ["base_future_response"],
"external_dependencies": {"python": ["pyjwt", "cryptography"]},
"data": ["security/ir.model.access.csv", "views/auth_jwt_validator_views.xml"],
"demo": [],
Expand Down
10 changes: 9 additions & 1 deletion auth_jwt/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ class UnauthorizedMissingAuthorizationHeader(Unauthorized):
pass


class UnauthorizedMissingCookie(Unauthorized):
pass


class UnauthorizedMalformedAuthorizationHeader(Unauthorized):
pass

Expand All @@ -32,7 +36,7 @@ class UnauthorizedPartnerNotFound(Unauthorized):
pass


class CompositeJwtError(Unauthorized):
class UnauthorizedCompositeJwtError(Unauthorized):
"""Indicate that multiple errors occurred during JWT chain validation."""

def __init__(self, errors):
Expand All @@ -44,3 +48,7 @@ def __init__(self, errors):
for validator_name, error in self.errors.items()
)
)


class ConfigurationError(InternalServerError):
pass
89 changes: 84 additions & 5 deletions auth_jwt/models/auth_jwt_validator.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
# Copyright 2021 ACSONE SA/NV
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

import datetime
import logging
import re
from calendar import timegm
from functools import partial

import jwt # pylint: disable=missing-manifest-dependency
Expand All @@ -13,13 +16,18 @@

from ..exceptions import (
AmbiguousJwtValidator,
ConfigurationError,
JwtValidatorNotFound,
UnauthorizedInvalidToken,
UnauthorizedMalformedAuthorizationHeader,
UnauthorizedMissingAuthorizationHeader,
UnauthorizedPartnerNotFound,
)

_logger = logging.getLogger(__name__)

AUTHORIZATION_RE = re.compile(r"^Bearer ([^ ]+)$")


class AuthJwtValidator(models.Model):
_name = "auth.jwt.validator"
Expand Down Expand Up @@ -73,6 +81,23 @@ class AuthJwtValidator(models.Model):
help="Next validator to try if this one fails",
)

cookie_enabled = fields.Boolean(
help=(
"Convert the JWT token into an HttpOnly Secure cookie. "
"When both an Authorization header and the cookie are present "
"in the request, the cookie is ignored."
)
)
cookie_name = fields.Char(default="authorization")
cookie_path = fields.Char(default="/")
cookie_max_age = fields.Integer(
default=86400 * 365,
help="Number of seconds until the cookie expires (Max-Age).",
)
cookie_secure = fields.Boolean(
default=True, help="Set to false only for development without https."
)

_sql_constraints = [
("name_uniq", "unique(name)", "JWT validator names must be unique !"),
]
Expand Down Expand Up @@ -101,6 +126,18 @@ def _check_next_validator_id(self):
)
)

@api.constrains("cookie_enabled", "cookie_name")
def _check_cookie_name(self):
for rec in self:
if rec.cookie_enabled and not rec.cookie_name:
raise ValidationError(
_(
"A cookie name must be provided on JWT validator %s "
"because it has cookie mode enabled."
)
% (rec.name,)
)

@api.model
def _get_validator_by_name_domain(self, validator_name):
if validator_name:
Expand All @@ -126,17 +163,35 @@ def _get_key(self, kid):
jwks_client = PyJWKClient(self.public_key_jwk_uri, cache_keys=False)
return jwks_client.get_signing_key(kid).key

def _decode(self, token):
def _encode(self, payload, secret, expire):
"""Encode and sign a JWT payload so it can be decoded and validated with
_decode().

The aud and iss claims are set to this validator's values.
The exp claim is set according to the expire parameter.
"""
payload = dict(
payload,
exp=timegm(datetime.datetime.utcnow().utctimetuple()) + expire,
aud=self.audience,
iss=self.issuer,
)
return jwt.encode(payload, key=secret, algorithm="HS256")

def _decode(self, token, secret=None):
"""Validate and decode a JWT token, return the payload."""
if self.signature_type == "secret":
if secret:
key = secret
algorithm = "HS256"
elif self.signature_type == "secret":
key = self.secret_key
algorithm = self.secret_algorithm
else:
try:
header = jwt.get_unverified_header(token)
except Exception as e:
_logger.info("Invalid token: %s", e)
raise UnauthorizedInvalidToken()
raise UnauthorizedInvalidToken() from e
key = self._get_key(header.get("kid"))
algorithm = self.public_key_algorithm
try:
Expand All @@ -155,7 +210,7 @@ def _decode(self, token):
)
except Exception as e:
_logger.info("Invalid token: %s", e)
raise UnauthorizedInvalidToken()
raise UnauthorizedInvalidToken() from e
return payload

def _get_uid(self, payload):
Expand Down Expand Up @@ -216,7 +271,7 @@ def _unregister_auth_method(self):
try:
delattr(IrHttp.__class__, f"_auth_method_jwt_{rec.name}")
delattr(IrHttp.__class__, f"_auth_method_public_or_jwt_{rec.name}")
except AttributeError:
except AttributeError: # pylint: disable=except-pass
pass

@api.model_create_multi
Expand All @@ -235,3 +290,27 @@ def write(self, vals):
def unlink(self):
self._unregister_auth_method()
return super().unlink()

def _get_jwt_cookie_secret(self):
secret = self.env["ir.config_parameter"].sudo().get_param("database.secret")
if not secret:
_logger.error("database.secret system parameter is not set.")
raise ConfigurationError()
return secret

@api.model
def _parse_bearer_authorization(self, authorization):
"""Parse a Bearer token authorization header and return the token.

Raises UnauthorizedMissingAuthorizationHeader if authorization is falsy.
Raises UnauthorizedMalformedAuthorizationHeader if invalid.
"""
if not authorization:
_logger.info("Missing Authorization header.")
raise UnauthorizedMissingAuthorizationHeader()
# https://tools.ietf.org/html/rfc6750#section-2.1
mo = AUTHORIZATION_RE.match(authorization)
if not mo:
_logger.info("Malformed Authorization header.")
raise UnauthorizedMalformedAuthorizationHeader()
return mo.group(1)
78 changes: 57 additions & 21 deletions auth_jwt/models/ir_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,22 @@
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).

import logging
import re

from odoo import SUPERUSER_ID, api, models
from odoo.http import request

from ..exceptions import (
CompositeJwtError,
UnauthorizedMalformedAuthorizationHeader,
ConfigurationError,
Unauthorized,
UnauthorizedCompositeJwtError,
UnauthorizedMissingAuthorizationHeader,
UnauthorizedMissingCookie,
UnauthorizedSessionMismatch,
)

_logger = logging.getLogger(__name__)


AUTHORIZATION_RE = re.compile(r"^Bearer ([^ ]+)$")


class IrHttpJwt(models.AbstractModel):

_inherit = "ir.http"
Expand Down Expand Up @@ -54,12 +52,25 @@ def _authenticate(cls, endpoint):
raise UnauthorizedSessionMismatch()
return super()._authenticate(endpoint)

@classmethod
def _get_jwt_payload(cls, validator):
"""Obtain and validate the JWT payload from the request authorization header or
cookie."""
try:
token = cls._get_bearer_token()
assert token
return validator._decode(token)
except UnauthorizedMissingAuthorizationHeader:
if not validator.cookie_enabled:
raise
token = cls._get_cookie_token(validator.cookie_name)
assert token
return validator._decode(token, secret=validator._get_jwt_cookie_secret())

@classmethod
def _auth_method_jwt(cls, validator_name=None):
assert not request.uid
assert not request.session.uid
token = cls._get_bearer_token()
assert token
# # Use request cursor to allow partner creation strategy in validator
env = api.Environment(request.cr, SUPERUSER_ID, {})
validator = env["auth.jwt.validator"]._get_validator_by_name(validator_name)
Expand All @@ -69,16 +80,33 @@ def _auth_method_jwt(cls, validator_name=None):
exceptions = {}
while validator:
try:
payload = validator._decode(token)
payload = cls._get_jwt_payload(validator)
break
except Exception as e:
except Unauthorized as e:
exceptions[validator.name] = e
validator = validator.next_validator_id

if not payload:
if len(exceptions) == 1:
raise list(exceptions.values())[0]
raise CompositeJwtError(exceptions)
raise UnauthorizedCompositeJwtError(exceptions)

if validator.cookie_enabled:
if not validator.cookie_name:
_logger.info("Cookie name not set for validator %s", validator.name)
raise ConfigurationError()
request.future_response.set_cookie(
key=validator.cookie_name,
value=validator._encode(
payload,
secret=validator._get_jwt_cookie_secret(),
expire=validator.cookie_max_age,
),
max_age=validator.cookie_max_age,
path=validator.cookie_path or "/",
secure=validator.cookie_secure,
httponly=True,
)

uid = validator._get_and_check_uid(payload)
assert uid
Expand All @@ -90,19 +118,27 @@ def _auth_method_jwt(cls, validator_name=None):
@classmethod
def _auth_method_public_or_jwt(cls, validator_name=None):
if "HTTP_AUTHORIZATION" not in request.httprequest.environ:
return cls._auth_method_public()
env = api.Environment(request.cr, SUPERUSER_ID, {})
validator = env["auth.jwt.validator"]._get_validator_by_name(validator_name)
assert len(validator) == 1
if not validator.cookie_enabled or not request.httprequest.cookies.get(
validator.cookie_name
):
return cls._auth_method_public()
return cls._auth_method_jwt(validator_name)

@classmethod
def _get_bearer_token(cls):
# https://tools.ietf.org/html/rfc2617#section-3.2.2
authorization = request.httprequest.environ.get("HTTP_AUTHORIZATION")
if not authorization:
_logger.info("Missing Authorization header.")
raise UnauthorizedMissingAuthorizationHeader()
# https://tools.ietf.org/html/rfc6750#section-2.1
mo = AUTHORIZATION_RE.match(authorization)
if not mo:
_logger.info("Malformed Authorization header.")
raise UnauthorizedMalformedAuthorizationHeader()
return mo.group(1)
return request.env["auth.jwt.validator"]._parse_bearer_authorization(
authorization
)

@classmethod
def _get_cookie_token(cls, cookie_name):
token = request.httprequest.cookies.get(cookie_name)
if not token:
_logger.info("Missing cookie %s.", cookie_name)
raise UnauthorizedMissingCookie()
return token
11 changes: 10 additions & 1 deletion auth_jwt/readme/USAGE.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ The JWT validator can be configured with the following properties:
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.
an invalid token, the request is rejected with a 401 (Unauthorized) code,
unless the cookie mode is enabled (see below).

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
Expand Down Expand Up @@ -53,3 +54,11 @@ endpoints that need to work for anonymous users, but can be enhanced when an
authenticated user is know. A typical use case is a "add to cart" endpoint that can work
for anonymous users, but can be enhanced by binding the cart to a known customer when
the authenticated user is known.

You can enable a cookie mode on JWT validators. In this case, the JWT payload obtained
from the ``Authorization`` header is returned as a Http-Only cookie. This mode is
sometimes simpler for front-end applications which do not then need to store and protect
the JWT token across requests and can simply rely on the cookie management mechanisms of
browsers. When both the ``Authorization`` header and a cookie are provided, the cookie
is ignored in order to let clients authenticate with a different user by providing a new
JWT token.
Loading