You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

463 lines
17 KiB

import logging
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
_logger = logging.getLogger(__name__)
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,
)
_logger.debug("#### openid client: %s" % client)
return client
def __validate_redirect_uri(self, client, req, **query):
_logger.debug("#### openid client: %s" % query)
if "redirect_uri" not in query:
raise OAuthException(
"redirect_uri param is missing", OAuthException.INVALID_GRANT,
)
redirect_uri = query["redirect_uri"]
_logger.debug(
"#### openid client: %s (%s)" % (client.auth_redirect_uri, 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:
_logger.debug(
"#### openid client: %s (%s)" % (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",
"password",
"client_credentials",
],
"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
_logger.debug("#### OPENID (3): %s" % values)
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.
_logger.debug("#### OPENID (auth)")
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
_logger.debug("#### OPENID (4): %s" % e)
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"]:
_logger.debug("#### OPENID (auth 1)")
response_mode = None
raise OAuthException(
"The only supported response_modes are 'query' and 'fragment'",
OAuthException.INVALID_REQUEST,
)
if "response_type" not in query:
_logger.debug("#### OPENID (auth 2)")
raise OAuthException(
"response_type param is missing", OAuthException.INVALID_REQUEST,
)
response_type = query["response_type"]
if response_type not in RESPONSE_TYPES_SUPPORTED:
_logger.debug("#### OPENID (auth 3)")
raise OAuthException(
"The only supported response_types are: {}".format(
", ".join(RESPONSE_TYPES_SUPPORTED)
),
OAuthException.UNSUPPORTED_RESPONSE_TYPE,
)
except OAuthException as e:
_logger.debug("#### OPENID (5): %s" % e)
response_params["error"] = e.type
response_params["error_description"] = e
return self.__redirect(
redirect_uri, response_params, response_mode or "query"
)
_logger.debug("#### OPENID (auth 4)")
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)
)
}
_logger.debug("#### OPENID (auth 4.2): %s" % params)
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
)
_logger.debug(
"#### OPENID (6): %s, %s, %s"
% (redirect_uri, response_params, response_mode)
)
_logger.debug(
"#### OPENID (6.1): %s"
% self.__redirect(redirect_uri, response_params, response_mode)
)
return self.__redirect(redirect_uri, response_params, response_mode)
@http.route(
"/oauth/token",
auth="public",
type="http",
methods=["POST", "OPTIONS"],
csrf=False,
)
def token(self, req, **query):
cors_headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept, X-Debug-Mode, Authorization",
"Access-Control-Max-Age": 60 * 60 * 24,
}
if req.httprequest.method == "OPTIONS":
return http.Response(status=200, headers=cors_headers)
try:
_logger.debug("#### OPENID (7): %s" % query)
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)
)
elif query["grant_type"] == "password":
return werkzeug.Response(
response=json.dumps(
self.__handle_grant_type_password(req, **query)
),
headers=cors_headers,
)
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})
return werkzeug.Response(response=body, status=400, headers=cors_headers)
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))
_logger.debug("#### OPENID (8): %s" % payload)
except jwt.JWTExpired:
_logger.debug("#### OPENID (9): %s" % OAuthException.INVALID_GRANT)
raise OAuthException(
"Code expired", OAuthException.INVALID_GRANT,
)
except ValueError:
_logger.debug("#### OPENID (10): %s" % OAuthException.INVALID_GRANT)
raise OAuthException(
"code malformed", OAuthException.INVALID_GRANT,
)
if payload["client_id"] != client.client_id:
_logger.debug("#### OPENID (11): %s" % OAuthException.INVALID_GRANT)
raise OAuthException(
"client_id doesn't match the authorization request",
OAuthException.INVALID_GRANT,
)
if payload["redirect_uri"] != redirect_uri:
_logger.debug("#### OPENID (12): %s" % OAuthException.INVALID_GRANT)
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
)
_logger.debug("#### OPENID (12): %s" % response)
return response
def __handle_grant_type_password(self, req, **query):
client = self.__validate_client(req, **query)
if not client.allow_password_grant:
raise OAuthException(
"This client is not allowed to perform password flow",
OAuthException.UNSUPPORTED_GRANT_TYPE,
)
for param in ["username", "password"]:
if param not in query:
raise OAuthException(
"{} is required".format(param), OAuthException.INVALID_REQUEST
)
user_id = req.env["res.users"].authenticate(
req.env.cr.dbname, query["username"], query["password"], None
)
if not user_id:
raise OAuthException(
"Invalid username or password", OAuthException.INVALID_REQUEST
)
scopes = query["scope"].split(" ") if query.get("scope") else []
# Retrieve/generate access token. We currently only store one per user/client
token = (
req.env["galicea_openid_connect.access_token"]
.sudo()
.retrieve_or_create(user_id, client.id)
)
response = {"access_token": token.token, "token_type": "bearer"}
if "openid" in scopes:
response["id_token"] = self.__create_id_token(req, user_id, client, {})
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,
)