From d31d02ccb6dd32057d3ca2bc609c7163f9205188 Mon Sep 17 00:00:00 2001 From: Hisham Muhammad Date: Tue, 5 May 2026 15:21:28 -0300 Subject: luarocks upload: implement two-factor authentication Same implementation as https://github.com/leafo/moonrocks/commit/2e9585862497e65284bfb728542e5686ebec1280 by @leafo --- src/luarocks/cmd/upload.lua | 37 +++++++- src/luarocks/cmd/upload.tl | 35 ++++++++ src/luarocks/core/types/args.d.tl | 183 +++++++++++++++++++------------------- src/luarocks/upload/api.lua | 109 ++++++++++++++++------- src/luarocks/upload/api.tl | 111 ++++++++++++++++------- 5 files changed, 318 insertions(+), 157 deletions(-) diff --git a/src/luarocks/cmd/upload.lua b/src/luarocks/cmd/upload.lua index e4319bb1..9cc98fd6 100644 --- a/src/luarocks/cmd/upload.lua +++ b/src/luarocks/cmd/upload.lua @@ -1,4 +1,4 @@ -local _tl_compat; if (tonumber((_VERSION or ''):match('[%d.]*$')) or 0) < 5.3 then local p, m = pcall(require, 'compat53.module'); if p then _tl_compat = m end end; local math = _tl_compat and _tl_compat.math or math; local string = _tl_compat and _tl_compat.string or string +local _tl_compat; if (tonumber((_VERSION or ''):match('[%d.]*$')) or 0) < 5.3 then local p, m = pcall(require, 'compat53.module'); if p then _tl_compat = m end end; local io = _tl_compat and _tl_compat.io or io; local math = _tl_compat and _tl_compat.math or math; local os = _tl_compat and _tl_compat.os or os; local string = _tl_compat and _tl_compat.string or string; local table = _tl_compat and _tl_compat.table or table local upload = { Response = { version = {} } } @@ -40,6 +40,8 @@ function upload.add_to_parser(parser) cmd:option("--temp-key", "Use the given a temporary API key in this " .. "invocation only. It will not be stored."): argname("") + cmd:option("--code", "Two-factor code (or set $LUAROCKS_TFA_CODE."): + argname("") cmd:flag("--force", "Replace existing rockspec if the same revision of a " .. "module already exists. This should be used only in case of upload " .. "mistakes: when updating a rockspec, increment the revision number " .. @@ -52,11 +54,44 @@ local function is_dev_version(version) return version:match("^dev") or version:match("^scm") end +local function prompt_tfa(api) + util.printout("Two-factor authentication required for this account.") + local initial = os.getenv("LUAROCKS_TFA_CODE") or api.code + local attempts = 0 + while true do + local code = initial + initial = nil + if not code then + util.printout("Enter 2FA code: ") + code = io.stdin:read("*l") + if not (code and code ~= "") then + return nil, "no code provided" + end + end + local res = api:raw_method("verify_tfa", nil, { + code = code, + }) + if res.success and res.tfa_token then + api.tfa_token = res.tfa_token + util.printout("Verified.") + return true + end + attempts = attempts + 1 + local err = res.errors and table.concat(res.errors, ", ") or "verification failed" + util.printout(tostring(err)) + if attempts >= 3 then + return nil, "two-factor verification failed after " .. tostring(attempts) .. " attempt(s)" + end + end +end + function upload.command(args) local api, err = Api.new(args) if not api then return nil, err end + api.code = args.code + api.on_tfa_required = prompt_tfa if cfg.verbose then api.debug = true end diff --git a/src/luarocks/cmd/upload.tl b/src/luarocks/cmd/upload.tl index edfe4f89..52957de1 100644 --- a/src/luarocks/cmd/upload.tl +++ b/src/luarocks/cmd/upload.tl @@ -40,6 +40,8 @@ function upload.add_to_parser(parser: Parser) cmd:option("--temp-key", "Use the given a temporary API key in this ".. "invocation only. It will not be stored.") :argname("") + cmd:option("--code", "Two-factor code (or set $LUAROCKS_TFA_CODE.") + :argname("") cmd:flag("--force", "Replace existing rockspec if the same revision of a ".. "module already exists. This should be used only in case of upload ".. "mistakes: when updating a rockspec, increment the revision number ".. @@ -52,11 +54,44 @@ local function is_dev_version(version: string): string return version:match("^dev") or version:match("^scm") end +local function prompt_tfa(api: Api.Api): boolean, string + util.printout("Two-factor authentication required for this account.") + local initial = os.getenv("LUAROCKS_TFA_CODE") or api.code + local attempts = 0 + while true do + local code = initial + initial = nil + if not code then + util.printout("Enter 2FA code: ") + code = io.stdin:read("*l") + if not (code and code ~= "") then + return nil, "no code provided" + end + end + local res = api:raw_method("verify_tfa", nil, { + code = code + }) + if res.success and res.tfa_token then + api.tfa_token = res.tfa_token as string + util.printout("Verified.") + return true + end + attempts = attempts + 1 + local err = res.errors and table.concat(res.errors as {string}, ", ") or "verification failed" + util.printout(tostring(err)) + if attempts >= 3 then + return nil, "two-factor verification failed after " .. tostring(attempts) .. " attempt(s)" + end + end +end + function upload.command(args: Args): boolean, string, string local api, err = Api.new(args) if not api then return nil, err end + api.code = args.code + api.on_tfa_required = prompt_tfa if cfg.verbose then api.debug = true end diff --git a/src/luarocks/core/types/args.d.tl b/src/luarocks/core/types/args.d.tl index 510855dd..5aac3f55 100644 --- a/src/luarocks/core/types/args.d.tl +++ b/src/luarocks/core/types/args.d.tl @@ -1,111 +1,112 @@ local record args record Args - tree: string - global: boolean - deps_mode: string - ["local"]: boolean - project_tree: string - server: string - dev: boolean - only_server: string - verbose: string - lua_version: string - lua_dir: string - no_project: boolean - input: {string} - nodeps: boolean - timeout: number - command: string - key: string - value: string - only_sources: string - no_manifest: boolean - force_lock: boolean - rockspec: string - namespace: string - pack_binary_rock: boolean - only_deps: boolean + add_server: string + all: boolean + api_key: string + append: boolean + arch: string + args: {string} + binary: boolean branch: string - verify: boolean + build_deps: boolean check_lua_versions: boolean - pin: boolean - no_install: boolean - sign: boolean - no_doc: boolean - keep: boolean + code: string + command: string + debug: boolean + deps: boolean + deps_mode: string + detailed: string + dev: boolean + dir: string + filter: string force: boolean force_fast: boolean - rock: string - version: string - scope: string - lua_incdir: string - lua_libdir: string - lua_ver: string - system_config: string - user_config: string - rock_trees: string - unset: boolean - json: boolean + force_lock: boolean + full: boolean + global: boolean home: boolean - porcelain: boolean - list: boolean - name: string - all: boolean - source: string - arch: string - location: string - tag: string - output: string homepage: string - rockspec_format: string - summary: string - detailed: string + index: boolean + input: {string} + issues: boolean + json: boolean + keep: boolean + key: string + labels: boolean + lib: string license: string + list: boolean + ["local"]: boolean + local_tree: string + location: string + lr_bin: string + lr_cpath: string + lr_path: string + lua_dir: string + lua_incdir: string + lua_libdir: string lua_versions: string - lib: string - no_gitignore: boolean - no_wrapper_scripts: boolean - wrapper_dir: string - reset: boolean - filter: string - outdated: boolean - new_version: string - dir: string + lua_version: string + lua_ver: string + modname: string + modules: boolean + mversion: boolean + namespace: string + name: string new_url: string - lr_path: string - lr_cpath: string - lr_bin: string - full: boolean - append: boolean + new_version: string no_bin: boolean + nodeps: boolean + no_doc: boolean + no_gitignore: boolean + no_install: boolean + no_manifest: boolean + no_project: boolean + no_refresh: boolean + no_wrapper_scripts: boolean old_versions: boolean - binary: boolean - rock_tree: boolean - rock_namespace: boolean + only_deps: boolean + only_server: string + only_sources: string + outdated: boolean + output: string + pack_binary_rock: boolean + pin: boolean + porcelain: boolean + prepare: boolean + project_tree: string + repository: string + reset: boolean rock_dir: boolean rock_license: boolean - issues: boolean - labels: boolean - modules: boolean - deps: boolean - build_deps: boolean - test_deps: boolean - mversion: boolean - test_type: string - args: {string} - prepare: boolean - src_rock: string + rock_namespace: boolean + rockspec_format: string + rockspec: string + rocks: {string} + rock: string + rock_tree: boolean + rock_trees: string + scope: string + server: string + sign: boolean skip_pack: boolean - modname: string - add_server: string - no_refresh: boolean - index: boolean - repository: string - local_tree: string + source: string + src_rock: string + summary: string + system_config: string + tag: string temp_key: string - api_key: string - debug: boolean - rocks: {string} + test_deps: boolean + test_type: string + timeout: number + tree: string + unset: boolean + user_config: string + value: string + verbose: string + verify: boolean + version: string + wrapper_dir: string end end diff --git a/src/luarocks/upload/api.lua b/src/luarocks/upload/api.lua index 3074965b..1251a9b9 100644 --- a/src/luarocks/upload/api.lua +++ b/src/luarocks/upload/api.lua @@ -1,4 +1,8 @@ -local _tl_compat; if (tonumber((_VERSION or ''):match('[%d.]*$')) or 0) < 5.3 then local p, m = pcall(require, 'compat53.module'); if p then _tl_compat = m end end; local io = _tl_compat and _tl_compat.io or io; local os = _tl_compat and _tl_compat.os or os; local package = _tl_compat and _tl_compat.package or package; local pairs = _tl_compat and _tl_compat.pairs or pairs; local pcall = _tl_compat and _tl_compat.pcall or pcall; local string = _tl_compat and _tl_compat.string or string; local table = _tl_compat and _tl_compat.table or table; local type = type; local api = { Configuration = {}, Api = {} } +local _tl_compat; if (tonumber((_VERSION or ''):match('[%d.]*$')) or 0) < 5.3 then local p, m = pcall(require, 'compat53.module'); if p then _tl_compat = m end end; local assert = _tl_compat and _tl_compat.assert or assert; local io = _tl_compat and _tl_compat.io or io; local os = _tl_compat and _tl_compat.os or os; local package = _tl_compat and _tl_compat.package or package; local pairs = _tl_compat and _tl_compat.pairs or pairs; local pcall = _tl_compat and _tl_compat.pcall or pcall; local string = _tl_compat and _tl_compat.string or string; local table = _tl_compat and _tl_compat.table or table; local type = type; local api = { Configuration = {}, Api = {} } + + + + @@ -55,9 +59,9 @@ function api.Api:save_config() if not res then return nil, errraw end - local reserrors = res.errors - if type(reserrors) == "table" then - return nil, ("Server error: " .. tostring(reserrors[1])) + local res_errors = res.errors + if type(res_errors) == "table" then + return nil, ("Server error: " .. tostring(res_errors[1])) end local upload_conf = upload_config_file() if not upload_conf then return nil end @@ -94,25 +98,39 @@ function api.Api:check_version() end function api.Api:method(path, ...) - local res, err = self:raw_method(path, ...) - if not res then - return nil, err + local res = self:raw_method(path, ...) + if res.two_factor_required then + assert(self.on_tfa_required, "Server requires two-factor verification but no handler is configured") + local ok, err = self:on_tfa_required() + if not ok then + return nil, err + end + res, err = self:raw_method(path, ...) + if err then + return nil, err + end end - local reserrors = res.errors - if type(reserrors) == "table" then - if reserrors[1] == "Invalid key" then - return nil, reserrors[1] .. " (use the --api-key flag to change)" + local res_errors = res.errors + if type(res_errors) == "table" then + if res_errors[1] == "Invalid key" then + res_errors[1] = res_errors[1] .. " (use the --api-key flag to change)" end - local msg = table.concat(reserrors, ", ") + local msg = table.concat(res_errors, ", ") return nil, "API Failed: " .. msg end return res end -function api.Api:raw_method(path, ...) +function api.Api:raw_method(path, params, post_params) self:check_version() - local url = tostring(self.config.server) .. "/api/" .. tostring(cfg.upload.api_version) .. "/" .. tostring(self.config.key) .. "/" .. path - return self:request(url, ...) + local extra_headers = nil + if self.tfa_token then + extra_headers = { + ["X-TFA-Token"] = self.tfa_token, + } + end + local url = tostring(self.config.server) .. "/api/" .. tostring(cfg.upload.api_version) .. "/" .. tostring(self.config.key) .. "/" .. tostring(path) + return self:request(url, params, post_params, extra_headers) end local function encode_query_string(t, sep) @@ -144,10 +162,25 @@ local function redact_api_url(url) return (urls:gsub(".*/api/[^/]+/[^/]+", "")) or "" end +local function request_result(url, response, status) + local pok, ret = pcall(json.decode, response) + if pok and ret then + if ret.two_factor_required then + return nil, "API Failed: two-factor verification required" + end + return ret + end + if status then + return nil, "API returned " .. tostring(status) .. " - " .. redact_api_url(url) + else + return nil, "API failed - " .. redact_api_url(url) + end +end + local ltn12_ok, ltn12 = pcall(require, "ltn12") if not ltn12_ok then - api.Api.request = function(self, url, params, post_params) + api.Api.request = function(self, url, params, post_params, extra_headers) local vars = cfg.variables if fs.which_tool("downloader") == "wget" then @@ -166,17 +199,26 @@ if not ltn12_ok then local method = "GET" local out local tmpfile = fs.tmpname() - if post_params then + if post_params or extra_headers then method = "POST" local curl_cmd = vars.CURL .. " " .. vars.CURLNOCERTFLAG .. " -f -L --silent --user-agent \"" .. cfg.user_agent .. " via curl\" " - for k, v in pairs(post_params) do - local var - if type(v) == "table" then - var = "@" .. v.fname - else - var = v + if post_params then + for k, v in pairs(post_params) do + local var + if type(v) == "table" then + var = "@" .. v.fname + else + var = v + end + curl_cmd = curl_cmd .. "--form \"" .. k .. "=" .. var .. "\" " + end + end + if extra_headers then + for k, v in pairs(extra_headers) do + if type(v) == "string" then + curl_cmd = curl_cmd .. "--header \"" .. k .. ": " .. v .. "\" " + end end - curl_cmd = curl_cmd .. "--form \"" .. k .. "=" .. var .. "\" " end if cfg.connection_timeout and cfg.connection_timeout > 0 then curl_cmd = curl_cmd .. "--connect-timeout " .. tonumber(cfg.connection_timeout) .. " " @@ -205,14 +247,14 @@ if not ltn12_ok then util.printout("[" .. tostring(method) .. " via curl] " .. redact_api_url(url) .. " ... ") end - return json.decode(out) + return request_result(url, out) end else local warned_luasec = false - api.Api.request = function(self, url, params, post_params) + api.Api.request = function(self, url, params, post_params, extra_headers) local server = tostring(self.config.server) local http_ok, http @@ -251,6 +293,13 @@ else headers["Content-length"] = tostring(#body) headers["Content-type"] = "multipart/form-data; boundary=" .. tostring(boundary) end + if extra_headers then + for k, v in pairs(extra_headers) do + if type(v) == "string" then + headers[k] = v + end + end + end local method = post_params and "POST" or "GET" if self.debug then util.printout("[" .. tostring(method) .. " via " .. via .. "] " .. redact_api_url(url) .. " ... ") @@ -266,13 +315,9 @@ else if self.debug then util.printout(tostring(status)) end - local pok, ret = pcall(json.decode, table.concat(out)) - if pok and ret then - return ret - end - return nil, "API returned " .. tostring(status) .. " - " .. redact_api_url(url) - end + return request_result(url, table.concat(out), status) + end end function api.new(args) diff --git a/src/luarocks/upload/api.tl b/src/luarocks/upload/api.tl index e89ecf6e..bcc200e3 100644 --- a/src/luarocks/upload/api.tl +++ b/src/luarocks/upload/api.tl @@ -6,12 +6,16 @@ local record api end record Api + code: string + tfa_token: string + on_tfa_required: function(Api): (boolean, string) + load_config: function(Api): Configuration save_config: function(Api): boolean, string check_version: function(Api): boolean, string method: function(Api, string, ...:Parameters): {string : any}, string - raw_method: function(Api, string, ...:Parameters): {string : any}, string - request: function(Api, string, ?Parameters, ?Parameters): {string : any}, string + raw_method: function(Api, string, ?Parameters, ?Parameters): {string : any}, string + request: function(Api, string, ?Parameters, ?Parameters, ?Parameters): {string : any}, string config: Configuration debug: boolean _server_tool_version: string @@ -55,9 +59,9 @@ function api.Api:save_config(): boolean, string if not res then return nil, errraw end - local reserrors = res.errors - if reserrors is {string} then - return nil, ("Server error: " .. tostring(reserrors[1])) + local res_errors = res.errors + if res_errors is {string} then + return nil, ("Server error: " .. tostring(res_errors[1])) end local upload_conf = upload_config_file() if not upload_conf then return nil end @@ -94,25 +98,39 @@ function api.Api:check_version(): boolean, string end function api.Api:method(path: string, ...: Parameters): {string : any}, string - local res, err = self:raw_method(path, ...) - if not res then - return nil, err + local res = self:raw_method(path, ...) + if res.two_factor_required then + assert(self.on_tfa_required, "Server requires two-factor verification but no handler is configured") + local ok, err = self:on_tfa_required() + if not ok then + return nil, err + end + res, err = self:raw_method(path, ...) + if err then + return nil, err + end end - local reserrors = res.errors - if reserrors is {string} then --! not checking the contents - if reserrors[1] == "Invalid key" then - return nil, reserrors[1] .. " (use the --api-key flag to change)" + local res_errors = res.errors + if res_errors is {string} then + if res_errors[1] == "Invalid key" then + res_errors[1] = res_errors[1] .. " (use the --api-key flag to change)" end - local msg = table.concat(reserrors, ", ") + local msg = table.concat(res_errors, ", ") return nil, "API Failed: " .. msg end return res end -function api.Api:raw_method(path: string, ...: Parameters): {string : any}, string +function api.Api:raw_method(path: string, params?: Parameters, post_params?: Parameters): {string : any}, string self:check_version() - local url = tostring(self.config.server) .. "/api/" .. tostring(cfg.upload.api_version) .. "/" .. tostring(self.config.key) .. "/" .. path - return self:request(url, ...) + local extra_headers: Parameters = nil + if self.tfa_token then + extra_headers = { + ["X-TFA-Token"] = self.tfa_token + } + end + local url = tostring(self.config.server) .. "/api/" .. tostring(cfg.upload.api_version) .. "/" .. tostring(self.config.key) .. "/" .. tostring(path) + return self:request(url, params, post_params, extra_headers) end local function encode_query_string(t: Parameters, sep?: string): string @@ -144,10 +162,25 @@ local function redact_api_url(url: any): string return (urls:gsub(".*/api/[^/]+/[^/]+", "")) or "" end +local function request_result(url: string, response: string, status?: integer|string): {string : any}, string + local pok, ret = pcall(json.decode, response) + if pok and ret then + if ret.two_factor_required then + return nil, "API Failed: two-factor verification required" + end + return ret + end + if status then + return nil, "API returned " .. tostring(status) .. " - " .. redact_api_url(url) + else + return nil, "API failed - " .. redact_api_url(url) + end +end + local ltn12_ok, ltn12 = pcall(require, "ltn12") if not ltn12_ok then -- If not using LuaSocket and/or LuaSec... - api.Api.request = function(self: Api, url: string, params?: Parameters, post_params?: Parameters): {string : any}, string + api.Api.request = function(self: Api, url: string, params?: Parameters, post_params?: Parameters, extra_headers?: Parameters): {string : any}, string local vars = cfg.variables if fs.which_tool("downloader") == "wget" then @@ -166,17 +199,26 @@ if not ltn12_ok then -- If not using LuaSocket and/or LuaSec... local method = "GET" local out: string local tmpfile = fs.tmpname() - if post_params then + if post_params or extra_headers then method = "POST" local curl_cmd = vars.CURL.." "..vars.CURLNOCERTFLAG.." -f -L --silent --user-agent \""..cfg.user_agent.." via curl\" " - for k,v in pairs(post_params) do - local var: string - if v is File then - var = "@"..v.fname - else - var = v + if post_params then + for k,v in pairs(post_params) do + local var: string + if v is File then + var = "@"..v.fname + else + var = v + end + curl_cmd = curl_cmd .. "--form \""..k.."="..var.."\" " + end + end + if extra_headers then + for k,v in pairs(extra_headers) do + if v is string then + curl_cmd = curl_cmd .. "--header \""..k..": "..v.."\" " + end end - curl_cmd = curl_cmd .. "--form \""..k.."="..var.."\" " end if cfg.connection_timeout and cfg.connection_timeout > 0 then curl_cmd = curl_cmd .. "--connect-timeout "..tonumber(cfg.connection_timeout).." " @@ -205,14 +247,14 @@ if not ltn12_ok then -- If not using LuaSocket and/or LuaSec... util.printout("[" .. tostring(method) .. " via curl] " .. redact_api_url(url) .. " ... ") end - return json.decode(out) + return request_result(url, out) end else -- use LuaSocket and LuaSec local warned_luasec = false - api.Api.request = function(self: Api, url: string, params?: Parameters, post_params?: Parameters): {string : any}, string + api.Api.request = function(self: Api, url: string, params?: Parameters, post_params?: Parameters, extra_headers?: Parameters): {string : any}, string local server = tostring(self.config.server) local type Http = require("socket.http") local http_ok, http: boolean, Http @@ -251,6 +293,13 @@ else -- use LuaSocket and LuaSec headers["Content-length"] = tostring(#body) headers["Content-type"] = "multipart/form-data; boundary=" .. tostring(boundary) end + if extra_headers then + for k, v in pairs(extra_headers) do + if v is string then + headers[k] = v + end + end + end local method = post_params and "POST" or "GET" if self.debug then util.printout("[" .. tostring(method) .. " via "..via.."] " .. redact_api_url(url) .. " ... ") @@ -266,13 +315,9 @@ else -- use LuaSocket and LuaSec if self.debug then util.printout(tostring(status)) end - local pok, ret = pcall(json.decode, table.concat(out)) - if pok and ret then - return ret - end - return nil, "API returned " .. tostring(status) .. " - " .. redact_api_url(url) - end + return request_result(url, table.concat(out), status) + end end function api.new(args: Args): Api, string -- cgit v1.2.3-55-g6feb