diff mbox series

[v7,08/12] test-http-server: add simple authentication

Message ID b8d3e81b5534148359c7e92807cf1e2795480ddf.1674252531.git.gitgitgadget@gmail.com (mailing list archive)
State Superseded
Commit 7739ad6745bac369a69e25be1fbbf9a1518fce7d
Headers show
Series Enhance credential helper protocol to include auth headers | expand

Commit Message

Matthew John Cheetham Jan. 20, 2023, 10:08 p.m. UTC
From: Matthew John Cheetham <mjcheetham@outlook.com>

Add simple authentication to the test-http-server test helper.
Authentication schemes and sets of valid tokens can be specified via
a configuration file (in the normal gitconfig file format).
Incoming requests are compared against the set of valid schemes and
tokens and only approved if a matching token is found, or if no auth
was provided and anonymous auth is enabled.

Configuration for auth includes a simple set of three options:

[auth]
	challenge = <scheme>[:<challenge_params>]
	token = <scheme>:[<token>]*
	allowAnonymous = <bool>

`auth.challenge` allows you define what authentication schemes, and
optional challenge parameters the server should use. Scheme names are
unique and subsequently specified challenge parameters in the config
file will replace previously specified ones.

`auth.token` allows you to define the set of value token values for an
authentication scheme. This is a multi-var and each entry in the
config file will append to the set of valid tokens for that scheme.
Specifying an empty token value will clear the list of tokens so far for
that scheme, i.e. `token = <scheme>:`.

`auth.allowAnonymous` controls whether or not unauthenticated requests
(those without any `Authorization` headers) should succeed or not, and
trigger a 401 Unauthorized response.

Signed-off-by: Matthew John Cheetham <mjcheetham@outlook.com>
---
 t/helper/test-http-server.c | 232 +++++++++++++++++++++++++++++++++++-
 t/t5556-http-auth.sh        |  43 ++++++-
 2 files changed, 272 insertions(+), 3 deletions(-)

Comments

Jeff King Jan. 26, 2023, 10:02 a.m. UTC | #1
On Fri, Jan 20, 2023 at 10:08:46PM +0000, Matthew John Cheetham via GitGitGadget wrote:

> +struct auth_module {
> +	char *scheme;
> +	char *challenge_params;
> +	struct string_list *tokens;
> +};

This is a really minor nit, but: why is "tokens" a pointer? It's always
initialized, so you never need or want to test it for NULL.

That would make this:

> +	if (create) {
> +		struct auth_module *mod = xmalloc(sizeof(struct auth_module));
> +		mod->scheme = xstrdup(scheme);
> +		mod->challenge_params = NULL;
> +		ALLOC_ARRAY(mod->tokens, 1);
> +		string_list_init_dup(mod->tokens);

simplify to:

  string_list_init_dup(&mod->tokens);

and one does not have to wonder why we use ALLOC_ARRAY() there, but not
when allocating the module itself. :)

Likewise you could skip freeing it, but since the memory is held until
program end anyway, that doesn't happen either way.

Certainly what you have won't behave wrong; I'd consider this more like
a coding style thing.

> +	cat >auth.config <<-EOF &&
> +	[auth]
> +		challenge = no-params
> +		challenge = with-params:foo=\"bar\" p=1
> +		challenge = with-params:foo=\"replaced\" q=1
> +
> +		token = no-explicit-challenge:valid-token
> +		token = no-explicit-challenge:also-valid
> +		token = reset-tokens:these-tokens
> +		token = reset-tokens:will-be-reset
> +		token = reset-tokens:
> +		token = reset-tokens:the-only-valid-one
> +
> +		allowAnonymous = false
> +	EOF
> +
> +	cat >OUT.expected <<-EOF &&
> +	WWW-Authenticate: no-params
> +	WWW-Authenticate: with-params foo="replaced" q=1
> +	WWW-Authenticate: no-explicit-challenge
> +	WWW-Authenticate: reset-tokens
> +
> +	Error: 401 Unauthorized
> +	EOF

OK, so I think now we are getting to the interesting part of what your
custom http-server does compared to something like apache. And the
answer so far is: custom WWW-Authenticate lines.

I think we could do that with mod_headers pretty easily. But presumably
we also want to check that we are getting the correct tokens, generate a
401, etc.

I suspect this could all be done as a CGI wrapping git-http-backend. You
can influence the HTTP response code by sending:

   Status: 401 Authorization Required
   WWW-Authenticate: whatever you want

And likewise you can see what the client sends by putting something like
this in apache.conf:

   SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1

and then reading $HTTP_AUTHORIZATION as you like. At that point, it
feels like a simple shell or perl script could then decide whether to
return a 401 or not (and if not, then just exec git-http-backend to do
the rest). And the scripts would be simple enough that you could have
individual scripts to implement various schemes, rather than
implementing this configuration scheme. You can control which script is
run based on the URL; see the way we match /broken_smart/, etc, in
t/lib-httpd/apache.conf.

-Peff
Jeff King Jan. 26, 2023, 8:33 p.m. UTC | #2
On Fri, Jan 20, 2023 at 10:08:46PM +0000, Matthew John Cheetham via GitGitGadget wrote:

> +static int split_auth_param(const char *str, char **scheme, char **val)
> +{
> +	struct strbuf **p = strbuf_split_str(str, ':', 2);
> +
> +	if (!p[0])
> +		return -1;
> +
> +	/* trim trailing ':' */
> +	if (p[0]->len && p[0]->buf[p[0]->len - 1] == ':')
> +		strbuf_setlen(p[0], p[0]->len - 1);
> +
> +	*scheme = strbuf_detach(p[0], NULL);
> +	*val = p[1] ? strbuf_detach(p[1], NULL) : NULL;
> +
> +	strbuf_list_free(p);
> +	return 0;
> +}

Oh, I forgot one more Coverity-detected problem here when reviewing last
night. The early "return -1" here leaks "p" (there are no strbufs in the
resulting array, but strbuf_split_str() will still have allocated the
array). It needs a call to strbuf_list_free(p) there.

-Peff
Jeff King Jan. 26, 2023, 9:22 p.m. UTC | #3
On Thu, Jan 26, 2023 at 05:02:27AM -0500, Jeff King wrote:

> I suspect this could all be done as a CGI wrapping git-http-backend. You
> can influence the HTTP response code by sending:
> 
>    Status: 401 Authorization Required
>    WWW-Authenticate: whatever you want
> 
> And likewise you can see what the client sends by putting something like
> this in apache.conf:
> 
>    SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
> 
> and then reading $HTTP_AUTHORIZATION as you like. At that point, it
> feels like a simple shell or perl script could then decide whether to
> return a 401 or not (and if not, then just exec git-http-backend to do
> the rest). And the scripts would be simple enough that you could have
> individual scripts to implement various schemes, rather than
> implementing this configuration scheme. You can control which script is
> run based on the URL; see the way we match /broken_smart/, etc, in
> t/lib-httpd/apache.conf.

And here's a minimally worked-out example of that approach. It's on top
of your patches so I could use your credential-helper infrastructure in
the test, but the intent is that it would replace all of the test-tool
server patches and be rolled into t5556 as appropriate.

---
 t/lib-httpd.sh              |  1 +
 t/lib-httpd/apache.conf     |  6 ++++++
 t/lib-httpd/custom-auth.sh  | 18 ++++++++++++++++
 t/t5563-simple-http-auth.sh | 42 +++++++++++++++++++++++++++++++++++++
 4 files changed, 67 insertions(+)
 create mode 100644 t/lib-httpd/custom-auth.sh
 create mode 100755 t/t5563-simple-http-auth.sh

diff --git a/t/lib-httpd.sh b/t/lib-httpd.sh
index 608949ea80..ab255bdbc5 100644
--- a/t/lib-httpd.sh
+++ b/t/lib-httpd.sh
@@ -137,6 +137,7 @@ prepare_httpd() {
 	install_script error-smart-http.sh
 	install_script error.sh
 	install_script apply-one-time-perl.sh
+	install_script custom-auth.sh
 
 	ln -s "$LIB_HTTPD_MODULE_PATH" "$HTTPD_ROOT_PATH/modules"
 
diff --git a/t/lib-httpd/apache.conf b/t/lib-httpd/apache.conf
index 0294739a77..4b2256363f 100644
--- a/t/lib-httpd/apache.conf
+++ b/t/lib-httpd/apache.conf
@@ -135,6 +135,11 @@ Alias /auth/dumb/ www/auth/dumb/
 	SetEnv GIT_HTTP_EXPORT_ALL
 	SetEnv GIT_PROTOCOL
 </LocationMatch>
+<LocationMatch /custom_auth/>
+	SetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}
+	SetEnv GIT_HTTP_EXPORT_ALL
+	CGIPassAuth on
+</LocationMatch>
 ScriptAlias /smart/incomplete_length/git-upload-pack incomplete-length-upload-pack-v2-http.sh/
 ScriptAlias /smart/incomplete_body/git-upload-pack incomplete-body-upload-pack-v2-http.sh/
 ScriptAlias /smart/no_report/git-receive-pack error-no-report.sh/
@@ -144,6 +149,7 @@ ScriptAlias /broken_smart/ broken-smart-http.sh/
 ScriptAlias /error_smart/ error-smart-http.sh/
 ScriptAlias /error/ error.sh/
 ScriptAliasMatch /one_time_perl/(.*) apply-one-time-perl.sh/$1
+ScriptAliasMatch /custom_auth/(.*) custom-auth.sh/$1
 <Directory ${GIT_EXEC_PATH}>
 	Options FollowSymlinks
 </Directory>
diff --git a/t/lib-httpd/custom-auth.sh b/t/lib-httpd/custom-auth.sh
new file mode 100644
index 0000000000..686895ee8c
--- /dev/null
+++ b/t/lib-httpd/custom-auth.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+
+# Our acceptable auth here is hard-coded, but we could
+# read it from a file provided by individual tests, etc.
+#
+# base64("alice:secret-passwd")
+USERPASS64=YWxpY2U6c2VjcmV0LXBhc3N3ZA==
+
+case "$HTTP_AUTHORIZATION" in
+"Basic $USERPASS64")
+	exec "$GIT_EXEC_PATH"/git-http-backend
+	;;
+*)
+	echo 'Status: 401 Auth Required'
+	echo 'WWW-Authenticate: Basic realm="whatever"'
+	echo
+	;;
+esac
diff --git a/t/t5563-simple-http-auth.sh b/t/t5563-simple-http-auth.sh
new file mode 100755
index 0000000000..314f9217e6
--- /dev/null
+++ b/t/t5563-simple-http-auth.sh
@@ -0,0 +1,42 @@
+#!/bin/sh
+
+test_description='test http auth header and credential helper interop'
+
+. ./test-lib.sh
+. "$TEST_DIRECTORY"/lib-httpd.sh
+. "$TEST_DIRECTORY"/lib-credential-helper.sh
+
+start_httpd
+
+setup_credential_helper
+
+test_expect_success 'setup repository' '
+	test_commit foo &&
+	git init --bare "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" &&
+	git push --mirror "$HTTPD_DOCUMENT_ROOT_PATH/repo.git"
+'
+
+test_expect_success 'access using custom auth' '
+	set_credential_reply get <<-EOF &&
+	username=alice
+	password=secret-passwd
+	EOF
+
+	git -c "credential.helper=!\"$CREDENTIAL_HELPER\"" ls-remote \
+		"$HTTPD_URL/custom_auth/repo.git" &&
+
+	expect_credential_query get <<-EOF &&
+	protocol=http
+	host=$HTTPD_DEST
+	wwwauth[]=Basic realm="whatever"
+	EOF
+
+	expect_credential_query store <<-EOF
+	protocol=http
+	host=$HTTPD_DEST
+	username=alice
+	password=secret-passwd
+	EOF
+'
+
+test_done
Junio C Hamano Jan. 26, 2023, 10:27 p.m. UTC | #4
Jeff King <peff@peff.net> writes:

>> I suspect this could all be done as a CGI wrapping git-http-backend. You
>> can influence the HTTP response code by sending:
> ...
> And here's a minimally worked-out example of that approach. It's on top
> of your patches so I could use your credential-helper infrastructure in
> the test, but the intent is that it would replace all of the test-tool
> server patches and be rolled into t5556 as appropriate.

Thanks for helping Matthew's topic move forward.  I very much like
seeing apache used for tests in this sample approach, like all the
other http tests we do with apache, instead of a custom server that
we need to ensure that it mimics the real-world use cases and we
have to maintain.
diff mbox series

Patch

diff --git a/t/helper/test-http-server.c b/t/helper/test-http-server.c
index 4191daf3c64..72c6cca7e5c 100644
--- a/t/helper/test-http-server.c
+++ b/t/helper/test-http-server.c
@@ -7,6 +7,7 @@ 
 #include "version.h"
 #include "dir.h"
 #include "date.h"
+#include "config.h"
 
 #define TR2_CAT "test-http-server"
 
@@ -19,6 +20,7 @@  static const char test_http_auth_usage[] =
 "           [--timeout=<n>] [--max-connections=<n>]\n"
 "           [--reuseaddr] [--pid-file=<file>]\n"
 "           [--listen=<host_or_ipaddr>]* [--port=<n>]\n"
+"           [--auth-config=<file>]\n"
 ;
 
 static unsigned int timeout;
@@ -349,7 +351,7 @@  static int is_git_request(struct req *req)
 		!regexec(smart_http_regex, req->uri_path.buf, 0, NULL, 0);
 }
 
-static enum worker_result do__git(struct req *req)
+static enum worker_result do__git(struct req *req, const char *user)
 {
 	const char *ok = "HTTP/1.1 200 OK\r\n";
 	struct child_process cp = CHILD_PROCESS_INIT;
@@ -366,10 +368,16 @@  static enum worker_result do__git(struct req *req)
 	 * exit status of the process, then write the HTTP status line followed
 	 * by the http-backend output. This is outside of the scope of this test
 	 * helper's use at time of writing.
+	 *
+	 * The important auth responses (401) we are handling prior to getting
+	 * to this point.
 	 */
 	if (write(STDOUT_FILENO, ok, strlen(ok)) < 0)
 		return error(_("could not send '%s'"), ok);
 
+	if (user)
+		strvec_pushf(&cp.env, "REMOTE_USER=%s", user);
+
 	strvec_pushf(&cp.env, "REQUEST_METHOD=%s", req->method);
 	strvec_pushf(&cp.env, "PATH_TRANSLATED=%s", req->uri_path.buf);
 	strvec_push(&cp.env, "SERVER_PROTOCOL=HTTP/1.1");
@@ -388,10 +396,217 @@  static enum worker_result do__git(struct req *req)
 	return !!res;
 }
 
+enum auth_result {
+	/* No auth module matches the request. */
+	AUTH_UNKNOWN = 0,
+
+	/* Auth module denied the request. */
+	AUTH_DENY = 1,
+
+	/* Auth module successfully validated the request. */
+	AUTH_ALLOW = 2,
+};
+
+struct auth_module {
+	char *scheme;
+	char *challenge_params;
+	struct string_list *tokens;
+};
+
+static int allow_anonymous;
+static struct auth_module **auth_modules = NULL;
+static size_t auth_modules_nr = 0;
+static size_t auth_modules_alloc = 0;
+
+static struct auth_module *get_auth_module(const char *scheme, int create)
+{
+	struct auth_module *mod;
+	for (size_t i = 0; i < auth_modules_nr; i++) {
+		mod = auth_modules[i];
+		if (!strcasecmp(mod->scheme, scheme))
+			return mod;
+	}
+
+	if (create) {
+		struct auth_module *mod = xmalloc(sizeof(struct auth_module));
+		mod->scheme = xstrdup(scheme);
+		mod->challenge_params = NULL;
+		ALLOC_ARRAY(mod->tokens, 1);
+		string_list_init_dup(mod->tokens);
+
+		ALLOC_GROW(auth_modules, auth_modules_nr + 1, auth_modules_alloc);
+		auth_modules[auth_modules_nr++] = mod;
+
+		return mod;
+	}
+
+	return NULL;
+}
+
+static int is_authed(struct req *req, const char **user, enum worker_result *wr)
+{
+	enum auth_result result = AUTH_UNKNOWN;
+	struct string_list hdrs = STRING_LIST_INIT_NODUP;
+	struct auth_module *mod;
+
+	struct string_list_item *hdr;
+	struct string_list_item *token;
+	const char *v;
+	struct strbuf **split = NULL;
+	int i;
+	char *challenge;
+
+	/*
+	 * Check all auth modules and try to validate the request.
+	 * The first Authorization header that matches a known auth module
+	 * scheme will be consulted to either approve or deny the request.
+	 * If no module is found, or if there is no valid token, then 401 error.
+	 * Otherwise, only permit the request if anonymous auth is enabled.
+	 * It's atypical for user agents/clients to send multiple Authorization
+	 * headers, but not explicitly forbidden or defined.
+	 */
+	for_each_string_list_item(hdr, &req->header_list) {
+		if (skip_iprefix(hdr->string, "Authorization: ", &v)) {
+			split = strbuf_split_str(v, ' ', 2);
+			if (split[0] && split[1]) {
+				/* trim trailing space ' ' */
+				strbuf_rtrim(split[0]);
+
+				mod = get_auth_module(split[0]->buf, 0);
+				if (mod) {
+					result = AUTH_DENY;
+
+					for_each_string_list_item(token, mod->tokens) {
+						if (!strcmp(split[1]->buf, token->string)) {
+							result = AUTH_ALLOW;
+							break;
+						}
+					}
+
+					strbuf_list_free(split);
+					goto done;
+				}
+			}
+
+			strbuf_list_free(split);
+		}
+	}
+
+done:
+	switch (result) {
+	case AUTH_ALLOW:
+		trace2_printf("%s: auth '%s' ALLOW", TR2_CAT, mod->scheme);
+		*user = "VALID_TEST_USER";
+		*wr = WR_OK;
+		break;
+
+	case AUTH_DENY:
+		trace2_printf("%s: auth '%s' DENY", TR2_CAT, mod->scheme);
+		/* fall-through */
+
+	case AUTH_UNKNOWN:
+		if (result != AUTH_DENY && allow_anonymous)
+			break;
+
+		for (i = 0; i < auth_modules_nr; i++) {
+			mod = auth_modules[i];
+			if (mod->challenge_params)
+				challenge = xstrfmt("WWW-Authenticate: %s %s",
+						    mod->scheme,
+						    mod->challenge_params);
+			else
+				challenge = xstrfmt("WWW-Authenticate: %s",
+						    mod->scheme);
+			string_list_append(&hdrs, challenge);
+		}
+
+		*wr = send_http_error(STDOUT_FILENO, 401, "Unauthorized", -1,
+				      &hdrs, *wr);
+	}
+
+	string_list_clear(&hdrs, 0);
+
+	return result == AUTH_ALLOW ||
+	      (result == AUTH_UNKNOWN && allow_anonymous);
+}
+
+static int split_auth_param(const char *str, char **scheme, char **val)
+{
+	struct strbuf **p = strbuf_split_str(str, ':', 2);
+
+	if (!p[0])
+		return -1;
+
+	/* trim trailing ':' */
+	if (p[0]->len && p[0]->buf[p[0]->len - 1] == ':')
+		strbuf_setlen(p[0], p[0]->len - 1);
+
+	*scheme = strbuf_detach(p[0], NULL);
+	*val = p[1] ? strbuf_detach(p[1], NULL) : NULL;
+
+	strbuf_list_free(p);
+	return 0;
+}
+
+static int read_auth_config(const char *name, const char *val, void *data)
+{
+	int ret = 0;
+	char *scheme = NULL;
+	char *token = NULL;
+	char *challenge = NULL;
+	struct auth_module *mod;
+
+	if (!strcmp(name, "auth.challenge")) {
+		if (split_auth_param(val, &scheme, &challenge)) {
+			ret = error("invalid auth challenge '%s'", val);
+			goto cleanup;
+		}
+
+		mod = get_auth_module(scheme, 1);
+
+		/* Replace any existing challenge parameters */
+		free(mod->challenge_params);
+		mod->challenge_params = challenge ? xstrdup(challenge) : NULL;
+	} else if (!strcmp(name, "auth.token")) {
+		if (split_auth_param(val, &scheme, &token)) {
+			ret = error("invalid auth token '%s'", val);
+			goto cleanup;
+		}
+
+		mod = get_auth_module(scheme, 1);
+
+		/*
+		 * Append to set of valid tokens unless an empty token value
+		 * is provided, then clear the existing list.
+		 */
+		if (token)
+			string_list_append(mod->tokens, token);
+		else
+			string_list_clear(mod->tokens, 1);
+	} else if (!strcmp(name, "auth.allowanonymous")) {
+		allow_anonymous = git_config_bool(name, val);
+	} else {
+		warning("unknown auth config '%s'", name);
+	}
+
+cleanup:
+	free(scheme);
+	free(token);
+	free(challenge);
+
+	return ret;
+}
+
 static enum worker_result dispatch(struct req *req)
 {
+	enum worker_result wr = WR_OK;
+	const char *user = NULL;
+
+	if (!is_authed(req, &user, &wr))
+		return wr;
+
 	if (is_git_request(req))
-		return do__git(req);
+		return do__git(req, user);
 
 	return send_http_error(STDOUT_FILENO, 501, "Not Implemented", -1, NULL,
 			       WR_HANGUP);
@@ -655,6 +870,19 @@  int cmd_main(int argc, const char **argv)
 			pid_file = v;
 			continue;
 		}
+		if (skip_prefix(arg, "--auth-config=", &v)) {
+			if (!strlen(v)) {
+				error("invalid argument - missing file path");
+				usage(test_http_auth_usage);
+			}
+
+			if (git_config_from_file(read_auth_config, v, NULL)) {
+				error("failed to read auth config file '%s'", v);
+				usage(test_http_auth_usage);
+			}
+
+			continue;
+		}
 
 		fprintf(stderr, "error: unknown argument '%s'\n", arg);
 		usage(test_http_auth_usage);
diff --git a/t/t5556-http-auth.sh b/t/t5556-http-auth.sh
index c0a47ce342b..20fd9b09aef 100755
--- a/t/t5556-http-auth.sh
+++ b/t/t5556-http-auth.sh
@@ -101,6 +101,7 @@  per_test_cleanup () {
 	stop_http_server &&
 	rm -f OUT.* &&
 	rm -f IN.* &&
+	rm -f auth.config
 }
 
 test_expect_success 'http auth server request parsing' '
@@ -160,11 +161,51 @@  test_expect_success 'http auth server request parsing' '
 	test_cmp OUT.http400 OUT.actual
 '
 
+test_expect_success CURL 'http auth server auth config' '
+	test_when_finished "per_test_cleanup" &&
+
+	cat >auth.config <<-EOF &&
+	[auth]
+		challenge = no-params
+		challenge = with-params:foo=\"bar\" p=1
+		challenge = with-params:foo=\"replaced\" q=1
+
+		token = no-explicit-challenge:valid-token
+		token = no-explicit-challenge:also-valid
+		token = reset-tokens:these-tokens
+		token = reset-tokens:will-be-reset
+		token = reset-tokens:
+		token = reset-tokens:the-only-valid-one
+
+		allowAnonymous = false
+	EOF
+
+	cat >OUT.expected <<-EOF &&
+	WWW-Authenticate: no-params
+	WWW-Authenticate: with-params foo="replaced" q=1
+	WWW-Authenticate: no-explicit-challenge
+	WWW-Authenticate: reset-tokens
+
+	Error: 401 Unauthorized
+	EOF
+
+	start_http_server --auth-config="$TRASH_DIRECTORY/auth.config" &&
+
+	curl --include $ORIGIN_URL >OUT.curl &&
+	tr -d "\r" <OUT.curl | sed -n "/WWW-Authenticate/,\$p" >OUT.actual &&
+
+	test_cmp OUT.expected OUT.actual
+'
 
 test_expect_success 'http auth anonymous no challenge' '
 	test_when_finished "per_test_cleanup" &&
 
-	start_http_server &&
+	cat >auth.config <<-EOF &&
+	[auth]
+		allowAnonymous = true
+	EOF
+
+	start_http_server --auth-config="$TRASH_DIRECTORY/auth.config" &&
 
 	# Attempt to read from a protected repository
 	git ls-remote $ORIGIN_URL