From 4c73a69cdb8d5c6413d7fc251380ce8b9a32f8a3 Mon Sep 17 00:00:00 2001 From: fit2cloud-chenyw Date: Thu, 11 Jul 2024 16:48:00 +0800 Subject: [PATCH] =?UTF-8?q?perf(X-Pack):=20OIDC=20=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=20Realm=E9=9D=9E=E5=BF=85=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- de-xpack | 2 +- .../apisix/plugins/openid-connect.lua | 419 ++++++++++++++++++ installer/dataease/docker-compose-apisix.yml | 1 + 3 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 installer/dataease/apisix/plugins/openid-connect.lua diff --git a/de-xpack b/de-xpack index d21daef323..60f2ec7866 160000 --- a/de-xpack +++ b/de-xpack @@ -1 +1 @@ -Subproject commit d21daef323ca920d8f2ca467b5a69f018d30384b +Subproject commit 60f2ec78667120b19816a4aa7570fc861870d209 diff --git a/installer/dataease/apisix/plugins/openid-connect.lua b/installer/dataease/apisix/plugins/openid-connect.lua new file mode 100644 index 0000000000..e96bec23ed --- /dev/null +++ b/installer/dataease/apisix/plugins/openid-connect.lua @@ -0,0 +1,419 @@ +-- +-- Licensed to the Apache Software Foundation (ASF) under one or more +-- contributor license agreements. See the NOTICE file distributed with +-- this work for additional information regarding copyright ownership. +-- The ASF licenses this file to You under the Apache License, Version 2.0 +-- (the "License"); you may not use this file except in compliance with +-- the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +local core = require("apisix.core") +local ngx_re = require("ngx.re") +local openidc = require("resty.openidc") +local random = require("resty.random") +local string = string +local ngx = ngx + +local ngx_encode_base64 = ngx.encode_base64 + +local plugin_name = "openid-connect" + + +local schema = { + type = "object", + properties = { + client_id = {type = "string"}, + client_secret = {type = "string"}, + discovery = {type = "string"}, + scope = { + type = "string", + default = "openid", + }, + ssl_verify = { + type = "boolean", + default = false, + }, + timeout = { + type = "integer", + minimum = 1, + default = 3, + description = "timeout in seconds", + }, + introspection_endpoint = { + type = "string" + }, + introspection_endpoint_auth_method = { + type = "string", + default = "client_secret_basic" + }, + bearer_only = { + type = "boolean", + default = false, + }, + session = { + type = "object", + properties = { + secret = { + type = "string", + description = "the key used for the encrypt and HMAC calculation", + minLength = 16, + }, + }, + required = {"secret"}, + additionalProperties = false, + }, + realm = { + type = "string", + default = "", + }, + logout_path = { + type = "string", + default = "/logout", + }, + redirect_uri = { + type = "string", + description = "use ngx.var.request_uri if not configured" + }, + post_logout_redirect_uri = { + type = "string", + description = "the URI will be redirect when request logout_path", + }, + unauth_action = { + type = "string", + default = "auth", + enum = {"auth", "deny", "pass"}, + description = "The action performed when client is not authorized. Use auth to " .. + "redirect user to identity provider, deny to respond with 401 Unauthorized, and " .. + "pass to allow the request regardless." + }, + public_key = {type = "string"}, + token_signing_alg_values_expected = {type = "string"}, + use_pkce = { + description = "when set to true the PKEC(Proof Key for Code Exchange) will be used.", + type = "boolean", + default = false + }, + set_access_token_header = { + description = "Whether the access token should be added as a header to the request " .. + "for downstream", + type = "boolean", + default = true + }, + access_token_in_authorization_header = { + description = "Whether the access token should be added in the Authorization " .. + "header as opposed to the X-Access-Token header.", + type = "boolean", + default = false + }, + set_id_token_header = { + description = "Whether the ID token should be added in the X-ID-Token header to " .. + "the request for downstream.", + type = "boolean", + default = true + }, + set_userinfo_header = { + description = "Whether the user info token should be added in the X-Userinfo " .. + "header to the request for downstream.", + type = "boolean", + default = true + }, + set_refresh_token_header = { + description = "Whether the refresh token should be added in the X-Refresh-Token " .. + "header to the request for downstream.", + type = "boolean", + default = false + }, + proxy_opts = { + description = "HTTP proxy server be used to access identity server.", + type = "object", + properties = { + http_proxy = { + type = "string", + description = "HTTP proxy like: http://proxy-server:80.", + }, + https_proxy = { + type = "string", + description = "HTTPS proxy like: http://proxy-server:80.", + }, + http_proxy_authorization = { + type = "string", + description = "Basic [base64 username:password].", + }, + https_proxy_authorization = { + type = "string", + description = "Basic [base64 username:password].", + }, + no_proxy = { + type = "string", + description = "Comma separated list of hosts that should not be proxied.", + } + }, + } + }, + encrypt_fields = {"client_secret"}, + required = {"client_id", "client_secret", "discovery"} +} + + +local _M = { + version = 0.2, + priority = 2599, + name = plugin_name, + schema = schema, +} + + +function _M.check_schema(conf) + if conf.ssl_verify == "no" then + -- we used to set 'ssl_verify' to "no" + conf.ssl_verify = false + end + + if not conf.bearer_only and not conf.session then + core.log.warn("when bearer_only = false, " .. + "you'd better complete the session configuration manually") + conf.session = { + -- generate a secret when bearer_only = false and no secret is configured + secret = ngx_encode_base64(random.bytes(32, true) or random.bytes(32)) + } + end + + local ok, err = core.schema.check(schema, conf) + if not ok then + return false, err + end + + return true +end + + +local function get_bearer_access_token(ctx) + -- Get Authorization header, maybe. + local auth_header = core.request.header(ctx, "Authorization") + if not auth_header then + -- No Authorization header, get X-Access-Token header, maybe. + local access_token_header = core.request.header(ctx, "X-Access-Token") + if not access_token_header then + -- No X-Access-Token header neither. + return false, nil, nil + end + + -- Return extracted header value. + return true, access_token_header, nil + end + + -- Check format of Authorization header. + local res, err = ngx_re.split(auth_header, " ", nil, nil, 2) + + if not res then + -- No result was returned. + return false, nil, err + elseif #res < 2 then + -- Header doesn't split into enough tokens. + return false, nil, "Invalid Authorization header format." + end + + if string.lower(res[1]) == "bearer" then + -- Return extracted token. + return true, res[2], nil + end + + return false, nil, nil +end + + +local function introspect(ctx, conf) + -- Extract token, maybe. + local has_token, token, err = get_bearer_access_token(ctx) + + if err then + return ngx.HTTP_BAD_REQUEST, err, nil, nil + end + + if not has_token then + -- Could not find token. + + if conf.bearer_only then + -- Token strictly required in request. + ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. conf.realm .. '"' + return ngx.HTTP_UNAUTHORIZED, "No bearer token found in request.", nil, nil + else + -- Return empty result. + return nil, nil, nil, nil + end + end + + -- If we get here, token was found in request. + + if conf.public_key or conf.use_jwks then + -- Validate token against public key or jwks document of the oidc provider. + -- TODO: In the called method, the openidc module will try to extract + -- the token by itself again -- from a request header or session cookie. + -- It is inefficient that we also need to extract it (just from headers) + -- so we can add it in the configured header. Find a way to use openidc + -- module's internal methods to extract the token. + local res, err = openidc.bearer_jwt_verify(conf) + + if err then + -- Error while validating or token invalid. + ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. conf.realm .. + '", error="invalid_token", error_description="' .. err .. '"' + return ngx.HTTP_UNAUTHORIZED, err, nil, nil + end + + -- Token successfully validated. + local method = (conf.public_key and "public_key") or (conf.use_jwks and "jwks") + core.log.debug("token validate successfully by ", method) + return res, err, token, res + else + -- Validate token against introspection endpoint. + -- TODO: Same as above for public key validation. + local res, err = openidc.introspect(conf) + + if err then + ngx.header["WWW-Authenticate"] = 'Bearer realm="' .. conf.realm .. + '", error="invalid_token", error_description="' .. err .. '"' + return ngx.HTTP_UNAUTHORIZED, err, nil, nil + end + + -- Token successfully validated and response from the introspection + -- endpoint contains the userinfo. + core.log.debug("token validate successfully by introspection") + return res, err, token, res + end +end + + +local function add_access_token_header(ctx, conf, token) + if token then + -- Add Authorization or X-Access-Token header, respectively, if not already set. + if conf.set_access_token_header then + if conf.access_token_in_authorization_header then + if not core.request.header(ctx, "Authorization") then + -- Add Authorization header. + core.request.set_header(ctx, "Authorization", "Bearer " .. token) + end + else + if not core.request.header(ctx, "X-Access-Token") then + -- Add X-Access-Token header. + core.request.set_header(ctx, "X-Access-Token", token) + end + end + end + end +end + + +function _M.rewrite(plugin_conf, ctx) + local conf = core.table.clone(plugin_conf) + + -- Previously, we multiply conf.timeout before storing it in etcd. + -- If the timeout is too large, we should not multiply it again. + if not (conf.timeout >= 1000 and conf.timeout % 1000 == 0) then + conf.timeout = conf.timeout * 1000 + end + + if not conf.redirect_uri then + conf.redirect_uri = ctx.var.request_uri + end + + if not conf.ssl_verify then + -- openidc use "no" to disable ssl verification + conf.ssl_verify = "no" + end + + local response, err, session, _ + + if conf.bearer_only or conf.introspection_endpoint or conf.public_key then + -- An introspection endpoint or a public key has been configured. Try to + -- validate the access token from the request, if it is present in a + -- request header. Otherwise, return a nil response. See below for + -- handling of the case where the access token is stored in a session cookie. + local access_token, userinfo + response, err, access_token, userinfo = introspect(ctx, conf) + + if err then + -- Error while validating token or invalid token. + core.log.error("OIDC introspection failed: ", err) + return response + end + + if response then + -- Add configured access token header, maybe. + add_access_token_header(ctx, conf, access_token) + + if userinfo and conf.set_userinfo_header then + -- Set X-Userinfo header to introspection endpoint response. + core.request.set_header(ctx, "X-Userinfo", + ngx_encode_base64(core.json.encode(userinfo))) + end + end + end + + if not response then + -- Either token validation via introspection endpoint or public key is + -- not configured, and/or token could not be extracted from the request. + + local unauth_action = conf.unauth_action + if unauth_action ~= "auth" then + unauth_action = "deny" + end + + -- Authenticate the request. This will validate the access token if it + -- is stored in a session cookie, and also renew the token if required. + -- If no token can be extracted, the response will redirect to the ID + -- provider's authorization endpoint to initiate the Relying Party flow. + -- This code path also handles when the ID provider then redirects to + -- the configured redirect URI after successful authentication. + response, err, _, session = openidc.authenticate(conf, nil, unauth_action, conf.session) + + if err then + if err == "unauthorized request" then + if conf.unauth_action == "pass" then + return nil + end + return 401 + end + core.log.error("OIDC authentication failed: ", err) + return 500 + end + + if response then + -- If the openidc module has returned a response, it may contain, + -- respectively, the access token, the ID token, the refresh token, + -- and the userinfo. + -- Add respective headers to the request, if so configured. + + -- Add configured access token header, maybe. + add_access_token_header(ctx, conf, response.access_token) + + -- Add X-ID-Token header, maybe. + if response.id_token and conf.set_id_token_header then + local token = core.json.encode(response.id_token) + core.request.set_header(ctx, "X-ID-Token", ngx.encode_base64(token)) + end + + -- Add X-Userinfo header, maybe. + if response.user and conf.set_userinfo_header then + core.request.set_header(ctx, "X-Userinfo", + ngx_encode_base64(core.json.encode(response.user))) + end + + -- Add X-Refresh-Token header, maybe. + if session.data.refresh_token and conf.set_refresh_token_header then + core.request.set_header(ctx, "X-Refresh-Token", session.data.refresh_token) + end + end + end +end + + +return _M diff --git a/installer/dataease/docker-compose-apisix.yml b/installer/dataease/docker-compose-apisix.yml index 6fc32ea4d2..870814bbd3 100644 --- a/installer/dataease/docker-compose-apisix.yml +++ b/installer/dataease/docker-compose-apisix.yml @@ -22,6 +22,7 @@ services: - ${DE_BASE}/dataease2.0/apisix/apisix_conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro - ${DE_BASE}/dataease2.0/apisix/logs:/usr/local/apisix/logs - ${DE_BASE}/dataease2.0/apisix/plugins/cas-auth.lua:/usr/local/apisix/apisix/plugins/cas-auth.lua + - ${DE_BASE}/dataease2.0/apisix/plugins/openid-connect.lua:/usr/local/apisix/apisix/plugins/openid-connect.lua depends_on: - etcd ports: