diff --git a/.luacheckrc b/.luacheckrc index bb5143e9..4ebc09be 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -5,6 +5,7 @@ globals = { "_TEST", "ngx.config.is_console", "ngx.run_worker_thread", + "table.unpack", } not_globals = { diff --git a/doorbell-dev-1.rockspec b/doorbell-dev-1.rockspec index c7116898..f07b1679 100644 --- a/doorbell-dev-1.rockspec +++ b/doorbell-dev-1.rockspec @@ -24,6 +24,7 @@ build = { ["doorbell.api.access"] = "lib/doorbell/api/access.lua", ["doorbell.api.schema"] = "lib/doorbell/api/schema.lua", ["doorbell.api.nginx"] = "lib/doorbell/api/nginx.lua", + ["doorbell.api.auth-test"] = "lib/doorbell/api/auth-test.lua", ["doorbell.cache"] = "lib/doorbell/cache.lua", ["doorbell.cache.shared"] = "lib/doorbell/cache/shared.lua", diff --git a/lib/doorbell.lua b/lib/doorbell.lua index c6a7a888..c4c7837c 100644 --- a/lib/doorbell.lua +++ b/lib/doorbell.lua @@ -48,6 +48,7 @@ local SHM = require("doorbell.shm").doorbell local GLOBAL_REWRITE_MWARE = middleware.compile({ request.middleware.pre_handler, router.on_match, + http.CORS.middleware, }) local GLOBAL_AUTH_MWARE = middleware.compile({ @@ -55,10 +56,6 @@ local GLOBAL_AUTH_MWARE = middleware.compile({ }) -local GLOBAL_PRE_HANDLER_MWARE = middleware.compile({ - http.CORS.middleware, -}) - -- keeping these in a single table ensures that we run init() and init_worker() -- functions in a consistent order local submodules = { @@ -158,7 +155,6 @@ function _M.content() handler = cors_preflight end - GLOBAL_PRE_HANDLER_MWARE(ctx, route, match) exec_route_middleware(PRE_HANDLER, ctx, route, match) if not handler then diff --git a/lib/doorbell/api/auth-test.lua b/lib/doorbell/api/auth-test.lua new file mode 100644 index 00000000..13ff9fdb --- /dev/null +++ b/lib/doorbell/api/auth-test.lua @@ -0,0 +1,117 @@ +local routes = {} + +local http = require "doorbell.http" +local mw = require "doorbell.middleware" +local request = require "doorbell.request" +local auth = require "doorbell.auth" +local util = require "doorbell.util" + +local split = require("pl.stringx").split + +local send = http.send + +local MWARE = { + [mw.phase.REWRITE] = { + request.middleware.enable_logging, + }, +} + +local function parse_strategies(strategies) + local list = split(strategies, "+") + local strats = {} + + for _, item in ipairs(list) do + if item == "proxy-ip" then + table.insert(strats, auth.TRUSTED_PROXY_IP) + + elseif item == "downstream-ip" then + table.insert(strats, auth.TRUSTED_DOWNSTREAM_IP) + + elseif item == "openid" then + table.insert(strats, auth.OPENID) + + elseif item == "api-key" then + table.insert(strats, auth.API_KEY) + + else + error("oops") + end + end + + return strats, list +end + +local function auth_test(ctx, match, all) + local strats, list = parse_strategies(match.strategies) + + local handler = all and auth.require_all(util.unpack(strats)) + or auth.require_any(util.unpack(strats)) + + local passed = handler(ctx) + local status = passed and 200 or ctx.auth_http_status + send(status, { + passed = passed, + tried = list, + error = ctx.auth_client_message, + jwt = ctx.jwt, + user = ctx.user, + trusted_proxy = not not ctx.is_trusted_proxy, + trusted_downstream = not not ctx.is_trusted_downstream, + }) +end + +local function auth_test_none(ctx) + send(200, { + passed = auth.require_none()(ctx), + jwt = ctx.jwt, + user = ctx.user, + trusted_proxy = not not ctx.is_trusted_proxy, + trusted_downstream = not not ctx.is_trusted_downstream, + }) +end + +routes["/auth-test/none"] = { + id = "auth-test-none", + description = "test authentication (none required)", + metrics_enabled = false, + content_type = "application/json", + auth_strategy = auth.require_none(), + middleware = MWARE, + ---@param ctx doorbell.ctx + GET = function(ctx) + return auth_test_none(ctx) + end, + + ---@param ctx doorbell.ctx + OPTIONS = function(ctx) + return auth_test_none(ctx) + end, +} + +routes["~^/auth-test/any/(?.+)"] = { + id = "auth-test-any", + description = "test authentication (any required)", + metrics_enabled = false, + content_type = "application/json", + auth_strategy = auth.require_none(), + middleware = MWARE, + ---@param ctx doorbell.ctx + GET = function(ctx, match) + return auth_test(ctx, match, false) + end, +} + +routes["~^/auth-test/all/(?.+)"] = { + id = "auth-test-all", + description = "test authentication (all required)", + metrics_enabled = false, + content_type = "application/json", + auth_strategy = auth.require_none(), + middleware = MWARE, + ---@param ctx doorbell.ctx + GET = function(ctx, match) + return auth_test(ctx, match, true) + end, +} + +return routes diff --git a/lib/doorbell/auth.lua b/lib/doorbell/auth.lua index d6135047..25ad13f3 100644 --- a/lib/doorbell/auth.lua +++ b/lib/doorbell/auth.lua @@ -8,21 +8,15 @@ local access = require "doorbell.auth.access" local openid = require "doorbell.auth.openid" local apikey = require "doorbell.auth.api-key" -local bor = bit.bor -local band = bit.band -local lshift = bit.lshift +local new_tab = require "table.new" -local REQUIRE_ANY = lshift(1, 0) -local REQUIRE_ALL = lshift(1, 1) -local REQUIRE_NONE = lshift(1, 2) -local AUTH_TRUSTED_IP = lshift(1, 3) -local AUTH_OPENID = lshift(1, 4) -local AUTH_API_KEY = lshift(1, 5) +local AUTH_TRUSTED_PROXY = 1 +local AUTH_OPENID = 2 +local AUTH_API_KEY = 3 +local AUTH_TRUSTED_DOWNSTREAM = 4 local STRATEGIES = { - { - code = AUTH_TRUSTED_IP, - + [AUTH_TRUSTED_PROXY] = { ---@param ctx doorbell.ctx ---@param check_only boolean handler = function(ctx, check_only) @@ -37,9 +31,22 @@ local STRATEGIES = { end, }, - { - code = AUTH_API_KEY, + [AUTH_TRUSTED_DOWNSTREAM] = { + ---@param ctx doorbell.ctx + ---@param check_only boolean + handler = function(ctx, check_only) + if ctx.is_trusted_downstream then + return true + elseif not check_only then + ctx.auth_http_status = 403 + ctx.auth_client_message = "go away please" + end + return false + end, + }, + + [AUTH_API_KEY] = { ---@param ctx doorbell.ctx ---@param check_only boolean handler = function(ctx, check_only) @@ -56,12 +63,14 @@ local STRATEGIES = { end, }, - { - code = AUTH_OPENID, - + [AUTH_OPENID] = { ---@param ctx doorbell.ctx ---@param check_only boolean handler = function(ctx, check_only) + if check_only and not openid.enabled() then + return false + end + local user, err, status = openid.identify(ctx) if user then return true @@ -76,15 +85,10 @@ local STRATEGIES = { } } -local NUM_STRATEGIES = #STRATEGIES - -_M.TRUSTED_IP = AUTH_TRUSTED_IP -_M.OPENID = AUTH_OPENID -_M.API_KEY = AUTH_API_KEY - -local function is_set(input, flag) - return band(input, flag) == flag -end +_M.TRUSTED_PROXY_IP = AUTH_TRUSTED_PROXY +_M.TRUSTED_DOWNSTREAM_IP = AUTH_TRUSTED_DOWNSTREAM +_M.OPENID = AUTH_OPENID +_M.API_KEY = AUTH_API_KEY ---@param conf doorbell.config function _M.init(conf) @@ -110,44 +114,12 @@ function _M.middleware(ctx, route) return http.send(500) end - local passed = 0 - local required = 0 - - for i = 1, NUM_STRATEGIES do - local s = STRATEGIES[i] - local strat_required = is_set(strat, s.code) - local strat_ok = s.handler(ctx, not strat_required) - - if strat_required then - required = required + 1 - - if strat_ok then - passed = passed + 1 - end - end - end - - local ok = false - - if is_set(strat, REQUIRE_ALL) then - ok = passed >= required - - elseif is_set(strat, REQUIRE_ANY) then - ok = passed >= 1 - - elseif is_set(strat, REQUIRE_NONE) then - ok = true - - else - error("unreachable") - end - - + local passed = strat(ctx) if ctx.method == "OPTIONS" then - ok = true + passed = true end - if ok then + if passed then ctx.auth_http_status = nil ctx.auth_client_message = nil @@ -164,18 +136,90 @@ function _M.middleware(ctx, route) end +local function require_none(ctx) + STRATEGIES[AUTH_TRUSTED_PROXY].handler(ctx, true) + STRATEGIES[AUTH_TRUSTED_DOWNSTREAM].handler(ctx, true) + STRATEGIES[AUTH_API_KEY].handler(ctx, true) + STRATEGIES[AUTH_OPENID].handler(ctx, true) + + return true +end + + function _M.require_none() - return REQUIRE_NONE + return require_none +end + + +local function require_any(...) + local n = select("#", ...) + assert(n > 0) + + local items = new_tab(n, 0) + + for i = 1, n do + local idx = select(i, ...) + items[i] = assert(STRATEGIES[idx].handler) + end + + if n == 1 then + local handler = items[1] + return function(ctx) + return handler(ctx, false) + end + end + + return function(ctx) + local passed = false + + for i = 1, n do + if items[i](ctx, passed) then + passed = true + end + end + + return passed + end end + ---@param ... integer function _M.require_any(...) local n = select("#", ...) if n == 0 then - return _M.require_any(AUTH_TRUSTED_IP, AUTH_OPENID, AUTH_API_KEY) + return require_any(AUTH_TRUSTED_DOWNSTREAM, AUTH_OPENID, AUTH_API_KEY) end - return bor(REQUIRE_ANY, ...) + return require_any(...) +end + +local function require_all(...) + local n = select("#", ...) + assert(n > 0) + + local handlers = new_tab(n, 0) + + for i = 1, n do + local idx = select(i, ...) + handlers[i] = assert(STRATEGIES[idx].handler) + end + + if n == 1 then + local handler = handlers[1] + return function(ctx) + return handler(ctx, false) + end + end + + return function(ctx) + for i = 1, n do + if not handlers[i](ctx, false) then + return false + end + end + + return true + end end @@ -183,9 +227,34 @@ end function _M.require_all(...) local n = select("#", ...) if n == 0 then - return _M.require_all(AUTH_TRUSTED_IP, AUTH_OPENID, AUTH_API_KEY) + return require_all(AUTH_TRUSTED_DOWNSTREAM, AUTH_OPENID, AUTH_API_KEY) + end + return require_all(...) +end + + +function _M.chain(...) + local n = select("#", ...) + assert(n > 1) + + local handlers = new_tab(n, 0) + + for i = 1, n do + local handler = select(i, ...) + assert(type(handler) == "function") + handlers[i] = handler + end + + return function(ctx) + for i = 1, n do + if not handlers[i](ctx, false) then + return false + end + end + + return true end - return bor(REQUIRE_ALL, ...) end + return _M diff --git a/lib/doorbell/auth/openid.lua b/lib/doorbell/auth/openid.lua index 7c8ff61b..4f3ac881 100644 --- a/lib/doorbell/auth/openid.lua +++ b/lib/doorbell/auth/openid.lua @@ -338,66 +338,67 @@ end ---@param conf doorbell.config function _M.init(conf) - if not conf.auth then + if not (conf.auth and conf.auth.openid) then log.notice("OpenID auth is not configured") return - elseif conf.auth.disabled then + elseif conf.auth.openid.disabled then log.notice("OpenID auth is disabled") DISABLED = true return - end + elseif not conf.auth.openid.issuer then + log.notice("OpenID auth is not configured") + return + end - if conf.auth.openid_issuer then - log.notice("Enabling OpenID auth") + log.notice("Enabling OpenID auth") - local DEBUG = ngx.DEBUG - oidc.set_logging(function(lvl, ...) - -- resty.openid's logging is way too chatty, even for debugging - if lvl == DEBUG then - return - end - log[lvl](...) - end, {}) + local iss = conf.auth.openid.issuer + assert(http.parse_url(iss), "invalid issuer url") + iss = iss:gsub("/+$", "") .. "/" - local iss = conf.auth.openid_issuer + local DEBUG = ngx.DEBUG + oidc.set_logging(function(lvl, ...) + -- resty.openid's logging is way too chatty, even for debugging + if lvl == DEBUG then + return + end + log[lvl](...) + end, {}) - assert(http.parse_url(iss), "invalid issuer url") - iss = iss:gsub("/+$", "") .. "/" - DISCOVERY_URL = iss .. ".well-known/openid-configuration" - OIDC_OPTS.discovery = DISCOVERY_URL - VALIDATORS.iss = validators.equals(iss) + DISCOVERY_URL = iss .. ".well-known/openid-configuration" + OIDC_OPTS.discovery = DISCOVERY_URL + VALIDATORS.iss = validators.equals(iss) - USERS = {} - USERS_BY_SUB = {} - USERS_BY_EMAIL = {} + USERS = {} + USERS_BY_SUB = {} + USERS_BY_EMAIL = {} - for _, u in ipairs(conf.auth.users or {}) do - local user = { name = u.name } - assert(USERS[u.name] == nil, "duplicate username: " .. u.name) - USERS[u.name] = user + for _, u in ipairs(conf.auth.users or {}) do + local user = { name = u.name } + assert(USERS[u.name] == nil, "duplicate username: " .. u.name) + USERS[u.name] = user - for _, id in ipairs(u.identifiers or {}) do - if id.email then - assert(USERS_BY_EMAIL[id.email] == nil, - "duplicate user email: " .. id.email) + for _, id in ipairs(u.identifiers or {}) do + if id.email then + assert(USERS_BY_EMAIL[id.email] == nil, + "duplicate user email: " .. id.email) - USERS_BY_EMAIL[id.email] = user - end + USERS_BY_EMAIL[id.email] = user + end - if id.sub then - assert(USERS_BY_SUB[id.sub] == nil, - "duplicate user sub: " .. id.sub) + if id.sub then + assert(USERS_BY_SUB[id.sub] == nil, + "duplicate user sub: " .. id.sub) - USERS_BY_SUB[id.sub] = user - end + USERS_BY_SUB[id.sub] = user end end - - CONFIGURED = true end + + CONFIGURED = true end @@ -480,4 +481,8 @@ function _M.auth_middleware(ctx, route) end end +function _M.enabled() + return not DISABLED +end + return _M diff --git a/lib/doorbell/auth/ring.lua b/lib/doorbell/auth/ring.lua index a84f93ef..d98a2be3 100644 --- a/lib/doorbell/auth/ring.lua +++ b/lib/doorbell/auth/ring.lua @@ -18,7 +18,7 @@ local _M = { metrics_enabled = true, allow_untrusted = false, content_type = "text/plain", - auth_strategy = auth.require_all(auth.TRUSTED_IP), + auth_strategy = auth.require_all(auth.TRUSTED_PROXY_IP), } diff --git a/lib/doorbell/http.lua b/lib/doorbell/http.lua index 47759a04..5a574844 100644 --- a/lib/doorbell/http.lua +++ b/lib/doorbell/http.lua @@ -315,8 +315,13 @@ function _M.CORS.preflight(ctx) end +---@param ctx doorbell.ctx ---@param route doorbell.route -function _M.CORS.middleware(_, route) +function _M.CORS.middleware(ctx, route) + -- the preflight handler will take care of OPTIONS requests + if ctx.method == "OPTIONS" then + return + end add_cors_headers(route, false) end diff --git a/lib/doorbell/ip.lua b/lib/doorbell/ip.lua index 6d2894e8..1ebe4fa5 100644 --- a/lib/doorbell/ip.lua +++ b/lib/doorbell/ip.lua @@ -601,6 +601,7 @@ function _M.init_request_ctx(ctx) ctx.geoip_net_asn = client.asn ctx.geoip_net_org = client.org ctx.forwarded_network_tag = client.net_tag + ctx.is_trusted_downstream = ctx.is_trusted_proxy else local forwarded = get_basic_info(forwarded_addr) @@ -608,6 +609,7 @@ function _M.init_request_ctx(ctx) ctx.geoip_net_asn = forwarded.asn ctx.geoip_net_org = forwarded.org ctx.forwarded_network_tag = forwarded.net_tag + ctx.is_trusted_downstream = forwarded.trusted end end diff --git a/lib/doorbell/request.lua b/lib/doorbell/request.lua index fbe0cb7c..7144d7d1 100644 --- a/lib/doorbell/request.lua +++ b/lib/doorbell/request.lua @@ -146,6 +146,7 @@ function _M.log(ctx) request_uri = ctx.uri, request_normalized_uri = var.uri, request_total_bytes = tonumber(var.request_length), + is_trusted_downstream = ctx.is_trusted_downstream, -- routing route_path = ctx.route and ctx.route.path, diff --git a/lib/doorbell/request/context.lua b/lib/doorbell/request/context.lua index d0562da5..4d5d533b 100644 --- a/lib/doorbell/request/context.lua +++ b/lib/doorbell/request/context.lua @@ -29,6 +29,7 @@ local get_phase = ngx.get_phase ---@field forwarded_network_tag string ---@field forwarded_addr string ---@field is_trusted_proxy boolean +---@field is_trusted_downstream boolean ---@field forwarded_request doorbell.forwarded_request ---@field geoip_country_code? string ---@field geoip_net_asn? integer diff --git a/lib/doorbell/router.lua b/lib/doorbell/router.lua index b2040411..64789d84 100644 --- a/lib/doorbell/router.lua +++ b/lib/doorbell/router.lua @@ -4,7 +4,10 @@ local util = require "doorbell.util" local middleware = require "doorbell.middleware" local auth = require "doorbell.auth" -local DEFAULT_AUTH = auth.require_all(auth.TRUSTED_IP, auth.OPENID) +local DEFAULT_AUTH = auth.chain( + auth.require_any(auth.API_KEY, auth.OPENID), + auth.require_any(auth.TRUSTED_PROXY_IP, auth.TRUSTED_DOWNSTREAM_IP) +) local cache = require("doorbell.cache").new("routes", 1000) @@ -31,7 +34,7 @@ local set_response_header = require("doorbell.http").response.set_header ---@field middleware table ---@field _middleware table --- ----@field auth_required boolean +---@field auth_required function ---@field auth_strategy integer ---@class doorbell.route_list : table diff --git a/lib/doorbell/routes.lua b/lib/doorbell/routes.lua index 3ea2ea16..d0557d24 100644 --- a/lib/doorbell/routes.lua +++ b/lib/doorbell/routes.lua @@ -106,46 +106,12 @@ function _M.init() GET = function() return send(404) end, } - do - local strategies = { - ["token"] = auth.require_all(auth.OPENID), - ["trusted-ip"] = auth.require_all(auth.TRUSTED_IP), - ["api-key"] = auth.require_all(auth.API_KEY), - ["any"] = auth.require_any(), - ["ip-and-token"] = auth.require_all(auth.TRUSTED_IP, auth.OPENID), - ["none"] = auth.require_none(), - } - - for name, strategy in pairs(strategies) do - router["/auth-test/" .. name] = { - id = "auth-test-" .. name, - description = "test authentication (" .. name .. ")", - metrics_enabled = false, - content_type = "application/json", - auth_strategy = strategy, - middleware = { - [mw.phase.REWRITE] = { - request.middleware.enable_logging, - }, - }, - ---@param ctx doorbell.ctx - GET = function(ctx) - send(200, { - message = "OK", - jwt = ctx.jwt, - user = ctx.user, - trusted_ip = ctx.is_trusted_proxy, - }) - end, - } - end - end - add_submodule_routes("doorbell.api.access") add_submodule_routes("doorbell.api.schema") add_submodule_routes("doorbell.api.ip") add_submodule_routes("doorbell.api.rules") add_submodule_routes("doorbell.api.nginx") + add_submodule_routes("doorbell.api.auth-test") end return _M diff --git a/lib/doorbell/schema.lua b/lib/doorbell/schema.lua index d5e7b3fc..94c0c699 100644 --- a/lib/doorbell/schema.lua +++ b/lib/doorbell/schema.lua @@ -1167,9 +1167,13 @@ validator(config.fields.network_tags) ---@class doorbell.config.auth : table --- ----@field openid_issuer string ----@field disabled boolean ----@field users doorbell.config.auth.user[] +---@field openid doorbell.config.auth.openid +---@field users doorbell.config.auth.user[] + +---@class doorbell.config.auth.openid : table +--- +---@field issuer string +---@field disabled boolean ---@class doorbell.config.auth.user : table --- @@ -1188,12 +1192,12 @@ config.fields.auth = { type = "object", properties = { - openid_issuer = { - type = "string", - }, - - disabled = { - type = "boolean", + openid = { + type = "object", + properties = { + issuer = { type = "string" }, + disabled = { type = "boolean" }, + }, }, users = { diff --git a/lib/doorbell/util.lua b/lib/doorbell/util.lua index d4ae7b38..e4a927f3 100644 --- a/lib/doorbell/util.lua +++ b/lib/doorbell/util.lua @@ -504,4 +504,6 @@ do end end +_M.unpack = _G.table.unpack or _G.unpack + return _M diff --git a/spec/02-integration/01-ring_spec.lua b/spec/02-integration/01-ring_spec.lua index f81c4c1f..5a0c6d1d 100644 --- a/spec/02-integration/01-ring_spec.lua +++ b/spec/02-integration/01-ring_spec.lua @@ -49,6 +49,7 @@ describe("/ring", function() client.timeout = 1000 client.request.path = "/ring" client.request.host = "127.0.0.1" + client.api_key = nil nginx:add_client(client) end) diff --git a/spec/02-integration/12-api-auth_spec.lua b/spec/02-integration/12-api-auth_spec.lua index 79e5c1c6..20b51226 100644 --- a/spec/02-integration/12-api-auth_spec.lua +++ b/spec/02-integration/12-api-auth_spec.lua @@ -12,6 +12,9 @@ local SUB = "provider|my-test-user-sub" local EMAIL = "my-test-user@email.test" local API_KEY = test.random_string(36) +local TRUSTED_DOWNSTREAM = "1.2.3.4" +local UNTRUSTED_DOWNSTREAM = "5.6.7.8" + local function new_openid_conf() return { authorization_endpoint = BASE_URL .. "authorize", @@ -143,10 +146,10 @@ end describe("API auth", function() - for _, trusted_client_ip in ipairs({ true, false }) do - local label = trusted_client_ip - and "(trusted client IP)" - or "(untrusted client IP)" + for _, trusted_proxy_ip in ipairs({ true, false }) do + local label = trusted_proxy_ip + and "(trusted proxy IP)" + or "(untrusted proxy IP)" describe(label, function() @@ -162,7 +165,9 @@ describe("API auth", function() lazy_setup(function() conf = test.config() conf.auth = { - openid_issuer = BASE_URL, + openid = { + issuer = BASE_URL, + }, users = { { name = USER, @@ -175,8 +180,8 @@ describe("API auth", function() }, } - if trusted_client_ip then - conf.trusted = { "0.0.0.0/0" } + if trusted_proxy_ip then + conf.trusted = { "127.0.0.1", TRUSTED_DOWNSTREAM } else conf.trusted = { "4.3.2.1/32" } end @@ -188,6 +193,8 @@ describe("API auth", function() client = test.client() nginx:add_client(client) + client.api_key = nil + client.reset_request_on_send = true client.raise_on_connect_error = true client.reopen = true end) @@ -198,7 +205,6 @@ describe("API auth", function() end) before_each(function() - client:reset() mu.mock.reset() end) @@ -215,28 +221,20 @@ describe("API auth", function() assert.same(USER, entry.authenticated_user.name) end - - local function check_options(path) - it("allows OPTIONS requests", function() - client:options(path) - assert.same(200, client.response.status) - end) - end - local function check_allowed_ip_only(path) - it("allows requests from trusted IP addresses", function() + it("allows requests from trusted proxy IP addresses", function() client:get(path) - assert.same(200, client.response.status) - if trusted_client_ip then - assert.is_true(client.response.json.trusted_ip) + if trusted_proxy_ip then + assert.is_true(client.response.json.trusted_proxy) end end) end - local function check_denied_ip_only(path, status) - it("denies requests from trusted IP addresses (with no token)", function() + local function check_denied_ip_only(path) + it("denies requests from trusted proxy IP addresses (with no token)", function() client:get(path) - assert.same(status or 403, client.response.status) + assert.is_true(client.response.status == 401 + or client.response.status == 403) assert.is_string(client.response.json.error) end) end @@ -261,8 +259,8 @@ describe("API auth", function() }, }) - client.headers.Authorization = "Bearer " .. token - client:get(path) + local params = { headers = { Authorization = "Bearer " .. token } } + client:get(path, params) assert.is_true(client.response.status == 401 or client.response.status == 403) @@ -276,7 +274,7 @@ describe("API auth", function() email_verified = true, }) - client:get("/auth-test/token") + client:get("/auth-test/any/openid", params) assert.same(200, client.response.status) await_user_log_entry(client.response) @@ -447,8 +445,9 @@ describe("API auth", function() local function check_valid_api_key_allowed(path) it("allows requests with a proper API key", function() - client.headers[const.headers.api_key] = API_KEY - client:get(path) + client:get(path, { + headers = { [const.headers.api_key] = API_KEY }, + }) assert.same(200, client.response.status) await_user_log_entry(client.response) end) @@ -456,7 +455,6 @@ describe("API auth", function() local function check_no_api_key_allowed(path) it("allows requests without an API key", function() - client.headers[const.headers.api_key] = nil client:get(path) assert.same(200, client.response.status) end) @@ -464,7 +462,6 @@ describe("API auth", function() local function check_no_api_key_denied(path) it("rejects requests without an API key", function() - client.headers[const.headers.api_key] = nil client:get(path) assert.is_true(client.response.status == 401 or client.response.status == 403) @@ -495,64 +492,135 @@ describe("API auth", function() end - describe("strategy => IP", function() - local path = "/auth-test/trusted-ip" - check_options(path) + describe("strategy => proxy IP", function() + local path = "/auth-test/any/proxy-ip" + + if trusted_proxy_ip then + it("allows requests with no auth headers (unproxied)", function() + client:get(path) + assert.same(200, client.response.status) + assert.is_true(client.response.json.trusted_proxy) + end) + + it("allows requests with no auth headers (proxied, trusted downstream)", function() + client.headers["x-forwarded-for"] = TRUSTED_DOWNSTREAM + client:get(path) + assert.same(200, client.response.status) + assert.is_true(client.response.json.trusted_proxy) + assert.is_true(client.response.json.trusted_downstream) + end) + + it("allows requests with no auth headers (proxied, untrusted downstream)", function() + client.headers["x-forwarded-for"] = UNTRUSTED_DOWNSTREAM + client:get(path) + assert.same(200, client.response.status) + assert.is_true(client.response.json.trusted_proxy) + assert.is_false(client.response.json.trusted_downstream) + end) - if trusted_client_ip then - check_allowed_ip_only(path) - check_allowed_token(path) - check_valid_api_key_allowed(path) - check_no_api_key_allowed(path) else - check_denied_ip_only(path) - check_denied_valid_token(path) - check_no_api_key_denied(path) + it("denies requests with no auth headers", function() + client:get(path) + assert.same(403, client.response.status) + assert.is_false(client.response.json.trusted_proxy) + end) + + it("denies requests with no auth headers (proxied, trusted downstream)", function() + client.headers["x-forwarded-for"] = TRUSTED_DOWNSTREAM + client:get(path) + assert.same(403, client.response.status) + assert.is_false(client.response.json.trusted_proxy) + assert.is_false(client.response.json.trusted_downstream) + end) + + it("denies requests with no auth headers (proxied, untrusted downstream)", function() + client.headers["x-forwarded-for"] = UNTRUSTED_DOWNSTREAM + client:get(path) + assert.same(403, client.response.status) + assert.is_false(client.response.json.trusted_proxy) + assert.is_false(client.response.json.trusted_downstream) + end) + check_valid_api_key_denied(path) + check_denied_valid_token(path) end end) - describe("strategy => token", function() - local path = "/auth-test/token" - check_options(path) - check_denied_ip_only(path, 401) + describe("strategy => downstream IP", function() + local path = "/auth-test/any/downstream-ip" + + if trusted_proxy_ip then + it("allows requests with no auth headers (proxied, trusted downstream)", function() + client.headers["x-forwarded-for"] = TRUSTED_DOWNSTREAM + client:get(path) + assert.same(200, client.response.status) + assert.is_true(client.response.json.trusted_proxy) + assert.is_true(client.response.json.trusted_downstream) + end) + + else + it("denies requests with no auth headers (proxied, trusted downstream)", function() + client.headers["x-forwarded-for"] = TRUSTED_DOWNSTREAM + client:get(path) + assert.same(403, client.response.status) + assert.same(trusted_proxy_ip, client.response.json.trusted_proxy) + assert.is_false(client.response.json.trusted_downstream) + end) + end + + it("denies requests with no auth headers (proxied, untrusted downstream)", function() + client.headers["x-forwarded-for"] = UNTRUSTED_DOWNSTREAM + client:get(path) + assert.same(403, client.response.status) + assert.same(trusted_proxy_ip, client.response.json.trusted_proxy) + assert.is_false(client.response.json.trusted_downstream) + end) + end) + + + describe("strategy => openid", function() + local path = "/auth-test/any/openid" + + it("denies requests with no auth headers (proxied, trusted downstream)", function() + client.headers["x-forwarded-for"] = TRUSTED_DOWNSTREAM + client:get(path) + assert.same(401, client.response.status) + assert.same(trusted_proxy_ip, client.response.json.trusted_proxy) + assert.same(trusted_proxy_ip, client.response.json.trusted_downstream) + end) + check_allowed_token(path) check_bad_token(path) + check_valid_api_key_denied(path) end) - describe("strategy => any", function() - local path = "/auth-test/any" - check_options(path) - check_allowed_token(path) + describe("(any)", function() + describe("openid+api-key", function() + local path = "/auth-test/any/openid+api-key" - if trusted_client_ip then - check_allowed_ip_only(path) - check_valid_api_key_allowed(path) - check_no_api_key_allowed(path) - else check_denied_ip_only(path, 401) + check_allowed_token(path) check_valid_api_key_allowed(path) - check_no_api_key_denied(path) - check_invalid_api_key_denied(path, 401) - end + end) end) - describe("strategy => IP+token", function() - local path = "/auth-test/ip-and-token" - check_options(path) - check_denied_ip_only(path, 401) + describe("(all)", function() + describe("strategy => proxy IP+openid", function() + local path = "/auth-test/all/proxy-ip+openid" + check_denied_ip_only(path) + check_valid_api_key_denied(path) - if trusted_client_ip then - check_allowed_token(path) - check_bad_token(path) - else - check_denied_valid_token(path) - end + if trusted_proxy_ip then + check_allowed_token(path) + check_bad_token(path) + else + check_denied_valid_token(path) + end + end) end) - describe("strategy => none", function() + describe("(none)", function() local path = "/auth-test/none" - check_options(path) check_allowed_token(path) check_allowed_ip_only(path) check_valid_api_key_allowed(path) @@ -560,8 +628,7 @@ describe("API auth", function() end) describe("strategy => api key", function() - local path = "/auth-test/api-key" - check_options(path) + local path = "/auth-test/any/api-key" check_denied_ip_only(path, 401) check_valid_api_key_allowed(path) check_invalid_api_key_denied(path) diff --git a/spec/testing.lua b/spec/testing.lua index b9cfa1b3..248ae1db 100644 --- a/spec/testing.lua +++ b/spec/testing.lua @@ -30,6 +30,7 @@ end _M.config = config.new +_M.API_KEY = config.API_KEY _M.client = client.new diff --git a/spec/testing/client.lua b/spec/testing/client.lua index d15021b3..21656401 100644 --- a/spec/testing/client.lua +++ b/spec/testing/client.lua @@ -5,6 +5,7 @@ local cjson = require("cjson").new() local clone = require "table.clone" local const = require "doorbell.constants" local parse_url = require("doorbell.http").parse_url +local test_conf = require "spec.testing.config" cjson.decode_array_with_array_mt(true) @@ -26,6 +27,7 @@ local function is_conn_err(e) or e == "connection reset by peer" end +---@param self spec.testing.client ---@param req spec.testing.client.request local function prepare(self, req) if req.json then @@ -55,6 +57,11 @@ local function prepare(self, req) if self.unix then req.headers.host = req.headers.host or "doorbell" end + + if self.api_key then + req.headers[const.headers.api_key] = req.headers[const.headers.api_key] + or self.api_key + end end ---@param res resty.http.response @@ -180,17 +187,19 @@ end ---@class spec.testing.client : table --- ----@field httpc resty.http.client ----@field request spec.testing.client.request ----@field response spec.testing.client.response ----@field err string|nil ----@field host string ----@field port integer|nil ----@field scheme "http"|"https"|nil ----@field headers spec.testing.client.headers ----@field need_connect boolean ----@field timeout number ----@field unix boolean +---@field httpc resty.http.client +---@field request spec.testing.client.request +---@field response spec.testing.client.response +---@field err string|nil +---@field host string +---@field port integer|nil +---@field scheme "http"|"https"|nil +---@field headers spec.testing.client.headers +---@field need_connect boolean +---@field timeout number +---@field unix boolean +---@field api_key string|nil +---@field reset_request_on_send boolean --- ---@field raise_on_request_error boolean ---@field raise_on_connect_error boolean @@ -214,7 +223,9 @@ end function client:reset() self.headers = _M.headers() - self.request = {} + self.request = { + headers = _M.headers(), + } self.response = nil self.err = nil end @@ -263,6 +274,9 @@ function client:send() req.headers[k] = v end + if self.reset_request_on_send then + self:reset() + end self.httpc:set_timeout(self.timeout or 5000) @@ -284,6 +298,7 @@ function client:send() if is_conn_err(err) and self.reopen then self.reopen = false + self.request = req self:send() @@ -364,10 +379,16 @@ function client:add_x_forwarded_headers(addr, method, url) local headers = self.headers headers.x_forwarded_for = addr headers.x_forwarded_method = method - local parsed = assert(self:parse_uri(url, true)) - headers.x_forwarded_proto = parsed[1] - headers.x_forwarded_host = parsed[2] - headers.x_forwarded_uri = parsed[4] + + local parsed = assert(parse_url(url)) + local uri = parsed.path + if parsed.query then + uri = uri .. "?" .. parsed.query + end + + headers.x_forwarded_proto = parsed.scheme + headers.x_forwarded_host = parsed.host + headers.x_forwarded_uri = uri end for _, method in ipairs({"get", "put", "post", "delete", "patch", "options"}) do @@ -421,6 +442,8 @@ function _M.new(url) assert_status = {}, reopen = false, unix = unix, + api_key = test_conf.API_KEY, + reset_request_on_send = false, } return setmetatable(self, client) diff --git a/spec/testing/config.lua b/spec/testing/config.lua index 56deb9ff..ca858f4c 100644 --- a/spec/testing/config.lua +++ b/spec/testing/config.lua @@ -2,6 +2,9 @@ local config = {} local const = require "spec.testing.constants" local join = require("spec.testing.fs").join +local sha256 = require("doorbell.util").sha256 + +local API_KEY = "apikey" --- just a helper that returns a sensible default config for integration tests ---@param runtime_path? string @@ -26,9 +29,22 @@ function config.new(runtime_path) }, }, auth = { - disabled = true, + openid = { + disabled = true, + }, + + users = { + { + name = "system", + identifiers = { + { apikey = sha256(API_KEY) }, + }, + }, + }, }, } end +config.API_KEY = API_KEY + return config