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
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,
|
|
)
|
|
|