diff options
author | Richard Ipsum <richard.ipsum@codethink.co.uk> | 2014-02-18 17:48:22 +0000 |
---|---|---|
committer | Richard Ipsum <richard.ipsum@codethink.co.uk> | 2014-02-18 17:48:22 +0000 |
commit | ad9c49d2969332638f3e5b37ff6720c574bc4fbb (patch) | |
tree | dd798ac31fca707c4f1199e8da393261947b8eb4 | |
parent | 1df10596bc55e92cb2d8a73ed3c8e2ec8d246c40 (diff) | |
parent | 95ba6fb18fedf7d0b9ff66a8f344c94dfe4a0632 (diff) | |
download | gitano-ad9c49d2969332638f3e5b37ff6720c574bc4fbb.tar.gz |
Merge branch 'baserock/richardipsum/http_v3' into baserock/masterbaserock/master
Reviewed-by:
Daniel Silverstone
Richard Maw
-rw-r--r-- | Makefile | 5 | ||||
-rwxr-xr-x | bin/gitano-command.cgi.in | 91 | ||||
-rw-r--r-- | bin/gitano-setup.in | 2 | ||||
-rwxr-xr-x | bin/gitano-smart-http.cgi.in | 76 | ||||
-rw-r--r-- | gitano.morph | 6 | ||||
-rw-r--r-- | lib/gitano.lua | 2 | ||||
-rw-r--r-- | lib/gitano/auth.lua | 140 | ||||
-rw-r--r-- | lib/gitano/config.lua | 139 | ||||
-rw-r--r-- | lib/gitano/log.lua | 4 | ||||
-rw-r--r-- | lib/gitano/repository.lua | 9 | ||||
-rw-r--r-- | lib/gitano/usercommand.lua | 168 | ||||
-rw-r--r-- | lib/gitano/util.lua | 14 | ||||
-rwxr-xr-x | scripts/htpasswd | 151 | ||||
-rw-r--r-- | skel/gitano-admin/rules/defines.lace | 3 | ||||
-rw-r--r-- | skel/gitano-admin/rules/selfchecks.lace | 2 |
15 files changed, 712 insertions, 100 deletions
@@ -21,7 +21,8 @@ MAN_PATH := $(INST_ROOT)/share/man MAN_INST_PATH := $(DESTDIR)$(MAN_PATH) LIB_BINS := gitano-auth gitano-post-receive-hook gitano-update-hook \ - gitano-update-ssh gitano-pre-receive-hook + gitano-update-ssh gitano-pre-receive-hook gitano-smart-http.cgi \ + gitano-command.cgi BINS := gitano-setup @@ -42,7 +43,7 @@ MODS := gitano \ gitano.actions gitano.config gitano.lace gitano.log \ gitano.markdown gitano.repository gitano.supple \ gitano.command gitano.admincommand gitano.usercommand \ - gitano.repocommand gitano.copycommand + gitano.repocommand gitano.copycommand gitano.auth SKEL_FILES := gitano-admin/rules/selfchecks.lace \ gitano-admin/rules/aschecks.lace \ diff --git a/bin/gitano-command.cgi.in b/bin/gitano-command.cgi.in new file mode 100755 index 0000000..bc280b2 --- /dev/null +++ b/bin/gitano-command.cgi.in @@ -0,0 +1,91 @@ +-- @@SHEBANG +-- -*- Lua -*- +-- command cgi +-- +-- Git (with) Augmented network operations -- User authentication wrapper +-- +-- Copyright 2014 Codethink Ltd +-- +-- + +-- @@GITANO_LUA_PATH + +local gitano = require "gitano" +local gall = require "gall" +local luxio = require "luxio" +local sio = require "luxio.simple" + +-- @@GITANO_BIN_PATH +-- @@GITANO_SHARE_PATH + +local stdout = sio.stdout + +function url_decode(str) + str = string.gsub (str, "+", " ") + str = string.gsub (str, "%%(%x%x)", + function(h) return string.char(tonumber(h,16)) end) + str = string.gsub (str, "\r\n", "\n") + return str +end + +function run_command(cmd, cmdline, parsed_cmdline, user, config, env) + gitano.log.debug("Welcome to " .. config.global.site_name) + gitano.log.debug("Running:") + for i = 1, #parsed_cmdline do + gitano.log.debug(" => " .. parsed_cmdline[i]) + end + gitano.log.debug("") + gitano.log.debug("On behalf of " .. user .. " using key " .. env["GITANO_KEYTAG"]) + + local how, why = cmd.run(config, repo, parsed_cmdline, env) + + if how ~= "exit" or why ~= 0 then + gitano.log.critical("Error running " .. parsed_cmdline[1] .. ": " .. how) + return why + else + gitano.log.syslog.info(cmdline, "completed successfully") + return 0 + end +end + +if os.getenv("QUERY_STRING") then + local query_string = url_decode(os.getenv("QUERY_STRING")) + local cmdline = query_string + + local _, e = string.find(query_string, "cmd=") + + if not e then + stdout:write("Status: 400 Bad request\r\n\r\n") + stdout:write("Malformed command line, format: ?cmd=arg0 arg1 ... argn\n") + return + end + + cmdline = string.sub(query_string, e + 1, #query_string) + + if cmdline == '' then + stdout:write("Status: 400 Bad request\r\n\r\n") + stdout:write("Malformed command line, format: ?cmd=arg0 arg1 ... argn\n") + return + end + + local user = os.getenv("REMOTE_USER") or "gitano/anonymous" + + gitano.log.buffer_output() + + local authorized, cmd, parsed_cmdline, config, env = + gitano.auth.is_authorized(user, "http", cmdline) + + if authorized then + local exit = run_command(cmd, cmdline, parsed_cmdline, user, config, env) + + stdout:write("Status: " .. (exit == 0 and "200 OK" or "400 Bad request") + .. "\r\n\r\n") + stdout:write(gitano.log.get_buffered_output() or "") + else + stdout:write("Status: 403 Forbidden\r\n\r\n") + stdout:write(gitano.log.get_buffered_output() or "") + end +else + stdout:write("Status: 400 Bad request\r\n\r\n") + stdout:write("Malformed command line, format: ?cmd=arg0 arg1 ... argn\n") +end diff --git a/bin/gitano-setup.in b/bin/gitano-setup.in index fbfa58a..61a3246 100644 --- a/bin/gitano-setup.in +++ b/bin/gitano-setup.in @@ -161,6 +161,7 @@ validate_name(ask_for("admin.keyname", "Key name for administrator", ask_for("site.name", "Site name", "a random Gitano instance") ask_for("log.prefix", "Site log prefix", "gitano") +ask_for("use.htpasswd", "Store passwords with htpasswd? (needed for http authentication)", "no") gitano.log.chat("Step 2: Gather required content") @@ -169,6 +170,7 @@ local completely_flat = {} local site_conf = clod.parse("") site_conf.settings["site_name"] = get "site.name" site_conf.settings["log.prefix"] = get "log.prefix" +site_conf.settings["use_htpasswd"] = get "use.htpasswd" completely_flat["site.conf"] = site_conf:serialise() -- Acquire the contents of the skeleton gitano-admin repository diff --git a/bin/gitano-smart-http.cgi.in b/bin/gitano-smart-http.cgi.in new file mode 100755 index 0000000..8fb0240 --- /dev/null +++ b/bin/gitano-smart-http.cgi.in @@ -0,0 +1,76 @@ +-- @@SHEBANG +-- -*- Lua -*- +-- gitano-smart-http +-- +-- Git (with) Augmented network operations -- User authentication wrapper +-- +-- Copyright 2014 Codethink Ltd +-- +-- + +-- @@GITANO_LUA_PATH + +local gitano = require "gitano" +local gall = require "gall" +local luxio = require "luxio" +local subprocess = require "luxio.subprocess" +local sio = require "luxio.simple" + +-- @@GITANO_BIN_PATH +-- @@GITANO_SHARE_PATH + +local stdout = sio.stdout + +function parse_get_request() + query_string = os.getenv("QUERY_STRING") + + if query_string then + command = string.gsub(query_string, "^service=", "") + repo = string.match(os.getenv("PATH_INFO"), '/(.+)/info/refs') + return command .. " '" .. repo .. "'" + end + + return nil +end + +function parse_post_request() + path_info = os.getenv("PATH_INFO") + + if path_info then + repo, command = string.match(path_info, "/(.+)/(.+)") + return command .. " '" .. repo .. "'" + end + + return nil +end + +function parse_request(request_method) + if request_method == "GET" then + return parse_get_request() + elseif request_method == "POST" then + return parse_post_request() + end +end + +request_method = os.getenv("REQUEST_METHOD") + +if request_method == "GET" or request_method == "POST" then + local user = os.getenv("REMOTE_USER") or "gitano/anonymous" + local cmdline = parse_request(request_method) + + if cmdline and gitano.auth.is_authorized(user, "http", cmdline) then + local proc = subprocess.spawn_simple({"git", "http-backend"}) + local exit_code + + _, exit_code = proc:wait() + + if exit_code ~= 0 then + stdout:write("Status: 500 Internal Server Error\r\n\r\n") + end + else + stdout:write("Status: 403 Forbidden\r\n\r\n") + end +else + stdout:write("Status: 405 Method Not Allowed\r\n") + stdout:write("Allow: GET, POST\r\n\r\n") +end diff --git a/gitano.morph b/gitano.morph new file mode 100644 index 0000000..1b46d99 --- /dev/null +++ b/gitano.morph @@ -0,0 +1,6 @@ +name: gitano +kind: chunk +build-system: manual +install-commands: +- make install INST_ROOT="$PREFIX" DESTDIR="$DESTDIR" +- cp scripts/htpasswd "$DESTDIR/$PREFIX/bin" diff --git a/lib/gitano.lua b/lib/gitano.lua index ad3cd7c..b57bd71 100644 --- a/lib/gitano.lua +++ b/lib/gitano.lua @@ -14,6 +14,7 @@ local actions = require 'gitano.actions' local lace = require 'gitano.lace' local markdown = require 'gitano.markdown' local supple = require 'gitano.supple' +local auth = require 'gitano.auth' return { util = util, @@ -25,4 +26,5 @@ return { lace = lace, markdown = markdown, supple = supple, + auth = auth } diff --git a/lib/gitano/auth.lua b/lib/gitano/auth.lua new file mode 100644 index 0000000..8cdd8ec --- /dev/null +++ b/lib/gitano/auth.lua @@ -0,0 +1,140 @@ +local config = require 'gitano.config' +local command = require 'gitano.command' +local log = require 'gitano.log' +local repository = require 'gitano.repository' +local util = require 'gitano.util' +local gall = require 'gall' + +local function load_admin_conf(repo_root) + local admin_repo = gall.repository.new((repo_root or "") .. + "/gitano-admin.git") + + if not admin_repo then + log.critical("Unable to locate administration repository.") + return nil + end + + local admin_head = admin_repo:get(admin_repo.HEAD) + + if not admin_head then + log.critical("Unable to find the HEAD of the administration repository.") + return nil + end + + local admin_conf, msg = config.parse(admin_head) + + if not admin_conf then + log.critical("Unable to parse administration repository.") + log.critical(" * " .. (msg or "No error?")) + return nil + end + + return admin_conf +end + +local function set_environment(repo_root, repo, context, transactionid) + local env = { + ["GITANO_ROOT"] = repo_root, + ["GITANO_USER"] = context.user, + ["GITANO_KEYTAG"] = context.keytag, + ["GITANO_PROJECT"] = (repo or {}).name or "", + ["GITANO_SOURCE"] = context.source, + ["GITANO_TRANSACTION_ID"] = transactionid, + } + + for k, v in pairs(env) do + luxio.setenv(k, v) + end + + return env +end + +local function is_authorized(user, source, cmdline) + local repo_root = os.getenv("GITANO_ROOT") + local keytag = "" + local authorized = false + + local start_log_level = log.get_level() + log.cap_level(log.level.INFO) + local transactionid = log.syslog.open() + + config.repo_path(repo_root) + + if not user or not cmdline then + return nil + end + + local parsed_cmdline, warnings = util.parse_cmdline(cmdline) + + if (#warnings > 0) then + log.error("Error parsing command"); + return nil + end + + local admin_conf = load_admin_conf(repo_root) + if admin_conf == nil then + log.fatal("Couldn't load a config from the admin repository") + end + + if admin_conf.groups["gitano-admin"].filtered_members[user] then + log.set_level(start_log_level) + end + + if not admin_conf.global.silent then + log.bump_level(log.level.CHAT) + end + + ip = os.getenv("REMOTE_ADDR") or "unknown ip" + log.syslog.info("Client connected from", ip, "as", user, + "(" .. keytag .. ")", "Executing command:", cmdline) + + local cmd = command.get(parsed_cmdline[1]) + + if not cmd then + log.critical("Unknown command: " .. parsed_cmdline[1]) + return nil + end + + local repo + if cmd.takes_repo and #parsed_cmdline > 1 then + -- Acquire the repository object for the target repo + local msg + repo, msg = repository.find(admin_conf, parsed_cmdline[2]) + + if not repo then + log.critical("Unable to locate repository.") + log.critical(" * " .. (tostring(msg) or "No error")) + return nil + end + end + + if not cmd.validate(admin_conf, repo, parsed_cmdline) then + log.critical("Validation of command line failed") + return nil + end + + local context = {source = source, user = user, keytag = keytag} + local action, reason = cmd.prep(admin_conf, repo, parsed_cmdline, context) + + if not action then + log.critical(reason) + log.critical("Ruleset did not complete cleanly") + return nil + end + + local env + if action == "allow" then + log.info(reason or "Ruleset permitted action") + authorized = true + env = set_environment(repo_root, repo, context, transactionid) + else + log.critical(reason) + log.critical("Ruleset denied action. Sorry.") + end + + return authorized, cmd, parsed_cmdline, admin_conf, env +end + +return { + is_authorized = is_authorized +} diff --git a/lib/gitano/config.lua b/lib/gitano/config.lua index 865e222..afa9072 100644 --- a/lib/gitano/config.lua +++ b/lib/gitano/config.lua @@ -82,32 +82,31 @@ local function parse_admin_config(commit) for filename, obj in pairs(flat_tree) do local prefix, username = filename:match("^(users/.-)([a-z][a-z0-9_-]+)/user%.conf$") if prefix and username then - if not is_blob(obj) then - return nil, prefix .. username .. "/user.conf is not a blob?" - end - if users[username] then - return nil, "Duplicate user name: " .. username - end - -- Found a user, fill them out - local user_clod, err = - clod.parse(obj.obj.content, - commit.sha .. ":" .. prefix .. username .. "/user.conf") - - if not user_clod then - return nil, err - end - - if type(user_clod.settings.real_name) ~= "string" then - return nil, "gitano-admin:" .. commit.sha .. ":" .. prefix .. username .. "/user.conf missing real_name" - end - if (user_clod.settings.email_address and - type(user_clod.settings.email_address) ~= "string") then - return nil, "gitano-admin:" .. commit.sha .. ":" .. prefix .. username .. "/user.conf email_address is bad" - end - users[username] = setmetatable({ clod = user_clod, - keys = {}, - meta = { prefix = prefix }, - }, user_mt) + if not is_blob(obj) then + return nil, prefix .. username .. "/user.conf is not a blob?" + end + if users[username] then + return nil, "Duplicate user name: " .. username + end + + -- Found a user, fill them out + local user_clod, err = clod.parse(obj.obj.content, + commit.sha .. ":" .. prefix .. username .. "/user.conf") + + if not user_clod then + return nil, err + end + + if type(user_clod.settings.real_name) ~= "string" then + return nil, "gitano-admin:" .. commit.sha .. ":" .. prefix .. username .. "/user.conf missing real_name" + end + if (user_clod.settings.email_address and + type(user_clod.settings.email_address) ~= "string") then + return nil, "gitano-admin:" .. commit.sha .. ":" .. prefix .. username .. "/user.conf email_address is bad" + end + + users[username] = setmetatable({ clod = user_clod, keys = {}, + meta = { prefix = prefix }, }, user_mt) end end @@ -116,41 +115,41 @@ local function parse_admin_config(commit) for filename, obj in pairs(flat_tree) do local prefix, username, keyname = filename:match("^(users/.-)([a-z][a-z0-9_-]+)/([a-z][a-z0-9_-]+)%.key$") if prefix and username and keyname then - if not users[username] then - return nil, "Found a key (" .. keyname .. ") for " .. username .. " which lacks a user.conf" - end - local this_key = obj.obj.content - - this_key = this_key:gsub("\n*$", "") - - if this_key:match("\n") then - return nil, "Key " .. filename .. " has newlines in it -- is it in the wrong format?" - end - - local keytype, keydata, keytag = this_key:match("^([^ ]+) ([^ ]+) ([^ ].*)$") - if not (keytype and keydata and keytag) then - return nil, "Unable to parse key, " .. filename .. " did not smell like an OpenSSH v2 key" - end - if (keytype ~= "ssh-rsa") and (keytype ~= "ssh-dss") and - (keytype ~= "ecdsa-sha2-nistp256") and - (keytype ~= "ecdsa-sha2-nistp384") and - (keytype ~= "ecdsa-sha2-nistp521") then - return nil, "Unknown key type " .. keytype .. " in " .. filename - end - - if all_keys[this_key] then - return nil, ("Duplicate key found at (" .. keyname .. - ") for " .. username .. ". Previously found as (" .. - all_keys[this_key].keyname .. ") for " .. - all_keys[this_key].username) - end - all_keys[this_key] = { keyname = keyname, username = username } - users[username].keys[keyname] = { - data = this_key, - keyname = keyname, - username = username, - keytag = keytag, - } + if not users[username] then + return nil, "Found a key (" .. keyname .. ") for " .. username .. " which lacks a user.conf" + end + local this_key = obj.obj.content + + this_key = this_key:gsub("\n*$", "") + + if this_key:match("\n") then + return nil, "Key " .. filename .. " has newlines in it -- is it in the wrong format?" + end + + local keytype, keydata, keytag = this_key:match("^([^ ]+) ([^ ]+) ([^ ].*)$") + if not (keytype and keydata and keytag) then + return nil, "Unable to parse key, " .. filename .. " did not smell like an OpenSSH v2 key" + end + if (keytype ~= "ssh-rsa") and (keytype ~= "ssh-dss") and + (keytype ~= "ecdsa-sha2-nistp256") and + (keytype ~= "ecdsa-sha2-nistp384") and + (keytype ~= "ecdsa-sha2-nistp521") then + return nil, "Unknown key type " .. keytype .. " in " .. filename + end + + if all_keys[this_key] then + return nil, ("Duplicate key found at (" .. keyname .. ") for " .. + username .. ". Previously found as (" .. + all_keys[this_key].keyname .. ") for " .. + all_keys[this_key].username) + end + all_keys[this_key] = { keyname = keyname, username = username } + users[username].keys[keyname] = { + data = this_key, + keyname = keyname, + username = username, + keytag = keytag, + } end end @@ -369,30 +368,32 @@ local function commit_config_changes(conf, desc, username) -- write out everything we have here, and then prepare -- and write out a commit. local newtree = {} + -- Shallow copy the tree ready for mods, skipping users and groups for k,v in pairs(conf.content) do if not (k:match("^users/") or - k:match("^groups/")) then - newtree[k] = v + k:match("^groups/")) then + newtree[k] = v end end + -- Write out the site.conf - local obj = conf.repo.git:hash_object("blob", - conf.clod:serialise(), - true) + local obj = conf.repo.git:hash_object("blob", conf.clod:serialise(), true) newtree["site.conf"] = conf.repo.git:get(obj) + -- Construct all the users and write them out. for u, utab in pairs(conf.users) do local str = utab.clod:serialise() local obj = conf.repo.git:hash_object("blob", str, true) newtree[utab.meta.prefix .. u .. "/user.conf"] = conf.repo.git:get(obj) + -- Now the keys for k, ktab in pairs(utab.keys) do - obj = conf.repo.git:hash_object("blob", ktab.data .. "\n", true) - newtree[utab.meta.prefix .. u .. "/" .. k .. ".key"] = - conf.repo.git:get(obj) + obj = conf.repo.git:hash_object("blob", ktab.data .. "\n", true) + newtree[utab.meta.prefix .. u .. "/" .. k .. ".key"] = conf.repo.git:get(obj) end end + -- Do the same for the groups for g, gtab in pairs(conf.groups) do obj = conf.repo.git:hash_object("blob", gtab.clod:serialise(), true) diff --git a/lib/gitano/log.lua b/lib/gitano/log.lua index 34a7dd1..e1df00b 100644 --- a/lib/gitano/log.lua +++ b/lib/gitano/log.lua @@ -147,7 +147,9 @@ local function fatal(...) syslog_write(luxio.LOG_CRIT, ...) AT(ERRS, "FATAL:", ...) - if not is_buffered_output() then + if is_buffered_output() then + sio.stderr:write(get_buffered_output()) + else stream:close() end diff --git a/lib/gitano/repository.lua b/lib/gitano/repository.lua index 5d4a194..a15fcbe 100644 --- a/lib/gitano/repository.lua +++ b/lib/gitano/repository.lua @@ -183,8 +183,13 @@ function repo_method:check_local_git_files() end if anonexport then log.ddebug("<" .. self.name .. ">: Anonymous read allowed") - local fh = sio.open(self:fs_path() .. "/git-daemon-export-ok", "wc") - fh:close() + local fh, errmsg = sio.open(self:fs_path() .. "/git-daemon-export-ok", "wc") + + if fh then + fh:close() + else + log.warn("Can't create git-daemon-export file:", errmsg) + end else log.ddebug("<" .. self.name .. ">: Anonymous read not allowed") luxio.unlink(self:fs_path() .. "/git-daemon-export-ok") diff --git a/lib/gitano/usercommand.lua b/lib/gitano/usercommand.lua index d28b203..3c8b467 100644 --- a/lib/gitano/usercommand.lua +++ b/lib/gitano/usercommand.lua @@ -10,6 +10,7 @@ local repository = require 'gitano.repository' local config = require 'gitano.config' local sio = require 'luxio.simple' +local subprocess = require 'luxio.subprocess' local builtin_whoami_short = "Find out how Gitano identifies you" @@ -20,15 +21,19 @@ Tells you who you are, what your email address is set to, what keys you have registered etc. ]] -local function builtin_whoami_validate(config, repo, cmdline) - -- whoami +local function validate_single_argcmd(cmdline, msg) if #cmdline > 1 then - log.error("usage: whoami") + log.error(msg) return false end + return true end +local function builtin_whoami_validate(_, _, cmdline) + return validate_single_argcmd(cmdline, "usage: whoami") +end + local function builtin_whoami_prep(config, repo, cmdline, context) context.operation = "whoami" return config.repo:run_lace(context) @@ -37,6 +42,11 @@ end local function builtin_whoami_run(config, repo, cmdline, env) local username = env["GITANO_USER"] local userdata = config.users[username] + + if not userdata then + return "I don't know who you are", 1 + end + log.stdout(" User name:", username) log.stdout(" Real name:", userdata.real_name or "Unknown") log.stdout("Email address:", userdata.email_address or "unknown@example.com") @@ -159,53 +169,158 @@ local function builtin_sshkey_run(conf, _, cmdline, env) local utab = conf.users[env.GITANO_USER] if cmdline[2] == "list" then if not next(utab.keys) then - log.warn("There are no SSH keys registered for", env.GITANO_USER .. ", sorry") + log.warn("There are no SSH keys registered for", env.GITANO_USER + .. ", sorry") else - local pfx = " SSH key:" - for tagname, keydata in pairs(utab.keys) do - local suffix = (env.GITANO_KEYTAG == tagname) and " [*]" or "" - log.state(pfx, tagname, "=>", keydata.keytag .. suffix) - pfx = " " - end + local pfx = " SSH key:" + for tagname, keydata in pairs(utab.keys) do + local suffix = (env.GITANO_KEYTAG == tagname) and " [*]" or "" + log.state(pfx, tagname, "=>", keydata.keytag .. suffix) + pfx = " " + end end elseif cmdline[2] == "add" then local sshkey = sio.stdin:read("*l") local keytype, keydata, keytag = sshkey:match("^([^ ]+) ([^ ]+) ([^ ].*)$") if not (keytype and keydata and keytag) then - log.error("Unable to parse key,", filename, - "did not smell like an OpenSSH v2 key") - return "exit", 1 + log.error("Unable to parse key,", filename, + "did not smell like an OpenSSH v2 key") + return "exit", 1 end + if (keytype ~= "ssh-rsa") and (keytype ~= "ssh-dss") and - (keytype ~= "ecdsa-sha2-nistp256") and - (keytype ~= "ecdsa-sha2-nistp384") and - (keytype ~= "ecdsa-sha2-nistp521") then - log.error("Unknown key type", keytype) - return "exit", 1 + (keytype ~= "ecdsa-sha2-nistp256") and + (keytype ~= "ecdsa-sha2-nistp384") and + (keytype ~= "ecdsa-sha2-nistp521") then + log.error("Unknown key type", keytype) + return "exit", 1 end + local keytab = { - data = sshkey, - keyname = cmdline[3], - username = env.GITANO_USER, - keytag = keytag, + data = sshkey, + keyname = cmdline[3], + username = env.GITANO_USER, + keytag = keytag, } + utab.keys[cmdline[3]] = keytab - elseif cmdline[2] == "del" then utab.keys[cmdline[3]] = nil end if cmdline[2] ~= "list" then -- Store the config back. + local action = (cmdline[2] == "add") and "Added" or "Deleted" action = action .. " " .. cmdline[3] .. " for " .. env.GITANO_USER local ok, msg = config.commit(conf, action, env.GITANO_USER) + + if not ok then + log.error(msg) + return "exit", 1 + end + end + + return "exit", 0 +end + +local builtin_passwd_short = "Set your password" + +local builtin_passwd_helptext = [[ +usage: passwd + +Sets your password, the password is read from stdin. + +If no password is provided your password is removed (if you have one). +]] + +local function builtin_passwd_validate(_, _, cmdline) + return validate_single_argcmd(cmdline, "usage: passwd") +end + +local function builtin_passwd_prep(conf, repo, cmdline, context) + context.operation = "passwd" + + local action, reason = conf.repo:run_lace(context) + if action == "deny" then + return reason + end + + return action, reason +end + +local function update_htpasswd(user, passwd) + local htpasswd_path = os.getenv("HOME") .. "/htpasswd" + local flags = io.open(htpasswd_path, "r") and "" or "-c" + local exit_code + + if passwd ~= '' then + local proc = subprocess.spawn_simple({ + "htpasswd", flags, htpasswd_path, user, + stdin = passwd .. '\n' .. passwd .. '\n', + stdout = subprocess.PIPE, + stderr = subprocess.PIPE + }) + + _, exit_code = proc:wait() + else + local proc = subprocess.spawn_simple({ + "htpasswd", "-D", htpasswd_path, user, + stdout = subprocess.PIPE, + stderr = subprocess.PIPE + }) + + _, exit_code = proc:wait() + end + + return exit_code == 0 +end + +local function builtin_passwd_run(conf, _, cmdline, env) + local user = env.GITANO_USER + + local password = sio.stdin:read("*l") + local method, hash = util.hash_password(password) + + if conf.users[user].hash == nil and password == "" then + log.chat(string.format("Password for %s is not set and no password was" + .. " provided, no action taken.", user)) + return "exit", 0 + end + + if password ~= "" then + conf.users[user].method = method + conf.users[user].hash = hash + else + -- user's password will be removed + conf.users[user].method = nil + conf.users[user].hash = nil + end + + local ok, msg + + if conf.clod.settings["use_htpasswd"] == "yes" then + ok = update_htpasswd(user, password) + if not ok then - log.error(msg) - return "exit", 1 + log.error("Failed to update htpasswd file") + return "exit", 1 end end + local action = string.format("%s password for %s", + password ~= '' and "Update" or "Remove", user) + + ok, msg = config.commit(conf, action, user) + + if not ok then + log.error(msg) + return "exit", 1 + end + + log.chat(string.format("%s password for %s", + password ~= '' and "Updated" or "Removed", user)) + return "exit", 0 end @@ -216,6 +331,9 @@ local function register_commands(reg) assert(reg("sshkey", builtin_sshkey_short, builtin_sshkey_helptext, builtin_sshkey_validate, builtin_sshkey_prep, builtin_sshkey_run, false, false)) + assert(reg("passwd", builtin_passwd_short, builtin_passwd_helptext, + builtin_passwd_validate, builtin_passwd_prep, + builtin_passwd_run, false, false)) end return { diff --git a/lib/gitano/util.lua b/lib/gitano/util.lua index 51c5bc2..c2a53a7 100644 --- a/lib/gitano/util.lua +++ b/lib/gitano/util.lua @@ -9,9 +9,20 @@ local luxio = require 'luxio' local sio = require 'luxio.simple' local log = require 'gitano.log' +local scrypt = require 'scrypt' local tconcat = table.concat +local check_password = scrypt.verify_password + +local function hash_password(password) + -- For the moment we are using scrypt, + -- we may decide to use other hash functions in the future + -- so it's useful to provide some way to identify which hash + -- function was used + return 'scrypt', scrypt.hash_password(password, 2^14, 8, 1) +end + local function _deep_copy(t, memo) if not memo then memo = {} end if memo[t] then return memo[t] end @@ -480,4 +491,7 @@ return { process_expansion = process_expansion, set = set, + + hash_password = hash_password, + check_password = check_password, } diff --git a/scripts/htpasswd b/scripts/htpasswd new file mode 100755 index 0000000..a28ba2a --- /dev/null +++ b/scripts/htpasswd @@ -0,0 +1,151 @@ +#!/usr/bin/env python +# Based on FreeBSD src/lib/libcrypt/crypt.c 1.2 +# http://www.freebsd.org/cgi/cvsweb.cgi/~checkout~/src/lib/libcrypt/crypt.c?rev=1.2&content-type=text/plain +# +# Original license: +# * "THE BEER-WARE LICENSE" (Revision 42): +# * <phk@login.dknet.dk> wrote this file. As long as you retain this notice you +# * can do whatever you want with this stuff. If we meet some day, and you think +# * this stuff is worth it, you can buy me a beer in return. Poul-Henning Kamp +# +# This port adds no further stipulations. I forfeit any copyright interest. + +from __future__ import print_function +import md5 +import random +import string +import sys +import getpass + +def hash(password, salt, magic='$apr1$'): + # /* The password first, since that is what is most unknown */ /* Then our magic string */ /* Then the raw salt */ + m = md5.new() + m.update(password + magic + salt) + + # /* Then just as many characters of the MD5(pw,salt,pw) */ + mixin = md5.md5(password + salt + password).digest() + for i in range(0, len(password)): + m.update(mixin[i % 16]) + + # /* Then something really weird... */ + # Also really broken, as far as I can tell. -m + i = len(password) + while i: + if i & 1: + m.update('\x00') + else: + m.update(password[0]) + i >>= 1 + + final = m.digest() + + # /* and now, just to make sure things don't run too fast */ + for i in range(1000): + m2 = md5.md5() + if i & 1: + m2.update(password) + else: + m2.update(final) + + if i % 3: + m2.update(salt) + + if i % 7: + m2.update(password) + + if i & 1: + m2.update(final) + else: + m2.update(password) + + final = m2.digest() + + # This is the bit that uses to64() in the original code. + + itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + + rearranged = '' + for a, b, c in ((0, 6, 12), (1, 7, 13), (2, 8, 14), (3, 9, 15), (4, 10, 5)): + v = ord(final[a]) << 16 | ord(final[b]) << 8 | ord(final[c]) + for i in range(4): + rearranged += itoa64[v & 0x3f]; v >>= 6 + + v = ord(final[11]) + for i in range(2): + rearranged += itoa64[v & 0x3f]; v >>= 6 + + return magic + salt + '$' + rearranged + +def usage(): + print('%s: usage: %s [-cD] passwdfile username' % + (sys.argv[0], sys.argv[0]), file=sys.stderr) + +def salt(len): + return ''.join([random.choice(string.ascii_letters + string.digits) + for x in range(len)]) + +def write_passwords(passwords, path): + with open(path, 'w') as f: + for (username, pwhash) in passwords: + f.write('%s:%s\n' % (username, pwhash)) + +def ask_password(): + x = getpass.getpass('New password: ') + y = getpass.getpass('Re-type password: ') + + return x if x == y else None + +if len(sys.argv) not in [3, 4]: + if len(sys.argv) == 4 and sys.argv[1] not in ['-c', '-D', '-cD', '-Dc']: + usage() + sys.exit(2) + +flags = len(sys.argv) == 4 +create_flag = flags and 'c' in sys.argv[1] +delete_flag = flags and 'D' in sys.argv[1] + +if create_flag and delete_flag: + print('%s: -c and -D options conflict' % sys.argv[0], file=sys.stderr) + sys.exit(2) + +file_path = sys.argv[flags + 1] +username = sys.argv[flags + 2] + +if not delete_flag: + password = ask_password() + + if password == None: + exit("%s: passwords weren't the same" % sys.argv[0]) + +contents = [] +found = False + +if not create_flag: + with open(file_path, 'r') as f: + # read in the existing passwd file + # replace entry for 'username' with entry containing new hash + # unless -D is used, in which case we remove the entry + # + # example entry: username:$apr1$gdehCd2T$ppFjRXlf1alPKSHqcBrjk0 + + for line in f: + (u, ph) = string.split(line.strip('\n'), ':') + + if u == username: + if not delete_flag: + ph = hash(password, salt(8)) + print('Updating password for user %s' % username) + contents.append((u, ph)) + + found = True + else: + contents.append((u, ph)) + +if not found: + if delete_flag: + print('User %s not found' % username) + else: + print('Adding password for user %s' % username) + contents.append((username, hash(password, salt(8)))) + +write_passwords(contents, file_path) diff --git a/skel/gitano-admin/rules/defines.lace b/skel/gitano-admin/rules/defines.lace index e72f598..95e729e 100644 --- a/skel/gitano-admin/rules/defines.lace +++ b/skel/gitano-admin/rules/defines.lace @@ -9,7 +9,8 @@ define if_asanother as_user ~. # Self-related operations define op_whoami operation whoami define op_sshkey operation sshkey -define op_self anyof op_whoami op_sshkey +define op_passwd operation passwd +define op_self anyof op_whoami op_sshkey op_passwd # Admin-related operations diff --git a/skel/gitano-admin/rules/selfchecks.lace b/skel/gitano-admin/rules/selfchecks.lace index 300bb91..e30e557 100644 --- a/skel/gitano-admin/rules/selfchecks.lace +++ b/skel/gitano-admin/rules/selfchecks.lace @@ -3,3 +3,5 @@ allow "You may ask who you are" op_whoami allow "You may manage your own ssh keys" op_sshkey + +allow "You may change your own password" op_passwd |