diff options
| author | Jason A. Donenfeld <Jason@zx2c4.com> | 2014-01-14 21:49:31 +0100 | 
|---|---|---|
| committer | Jason A. Donenfeld <Jason@zx2c4.com> | 2014-01-16 02:28:12 +0100 | 
| commit | d6e9200cc35411f3f27426b608bcfdef9348e6d3 (patch) | |
| tree | 9cdd921b03465458d10b99ff4357f79a810501c0 | |
| parent | 3741254a6989b2837cd8d20480f152f0096bcb9a (diff) | |
auth: add basic authentication filter framework
This leverages the new lua support. See
filters/simple-authentication.lua for explaination of how this works.
There is also additional documentation in cgitrc.5.txt.
Though this is a cookie-based approach, cgit's caching mechanism is
preserved for authenticated pages.
Very plugable and extendable depending on user needs.
The sample script uses an HMAC-SHA1 based cookie to store the
currently logged in user, with an expiration date.
Signed-off-by: Jason A. Donenfeld <Jason@zx2c4.com>
| -rw-r--r-- | cgit.c | 96 | ||||
| -rw-r--r-- | cgit.h | 7 | ||||
| -rw-r--r-- | cgitrc.5.txt | 36 | ||||
| -rw-r--r-- | filter.c | 11 | ||||
| -rw-r--r-- | filters/simple-authentication.lua | 225 | ||||
| -rw-r--r-- | ui-shared.c | 28 | 
6 files changed, 387 insertions, 16 deletions
| @@ -192,6 +192,8 @@ static void config_cb(const char *name, const char *value)  		ctx.cfg.commit_filter = cgit_new_filter(value, COMMIT);  	else if (!strcmp(name, "email-filter"))  		ctx.cfg.email_filter = cgit_new_filter(value, EMAIL); +	else if (!strcmp(name, "auth-filter")) +		ctx.cfg.auth_filter = cgit_new_filter(value, AUTH);  	else if (!strcmp(name, "embedded"))  		ctx.cfg.embedded = atoi(value);  	else if (!strcmp(name, "max-atom-items")) @@ -378,6 +380,10 @@ static void prepare_context(struct cgit_context *ctx)  	ctx->env.script_name = getenv("SCRIPT_NAME");  	ctx->env.server_name = getenv("SERVER_NAME");  	ctx->env.server_port = getenv("SERVER_PORT"); +	ctx->env.http_cookie = getenv("HTTP_COOKIE"); +	ctx->env.http_referer = getenv("HTTP_REFERER"); +	ctx->env.content_length = getenv("CONTENT_LENGTH") ? strtoul(getenv("CONTENT_LENGTH"), NULL, 10) : 0; +	ctx->env.authenticated = 0;  	ctx->page.mimetype = "text/html";  	ctx->page.charset = PAGE_ENCODING;  	ctx->page.filename = NULL; @@ -593,11 +599,92 @@ static int prepare_repo_cmd(struct cgit_context *ctx)  	return 0;  } +static inline void open_auth_filter(struct cgit_context *ctx, const char *function) +{ +	cgit_open_filter(ctx->cfg.auth_filter, function, +		ctx->env.http_cookie ? ctx->env.http_cookie : "", +		ctx->env.request_method ? ctx->env.request_method : "", +		ctx->env.query_string ? ctx->env.query_string : "", +		ctx->env.http_referer ? ctx->env.http_referer : "", +		ctx->env.path_info ? ctx->env.path_info : "", +		ctx->env.http_host ? ctx->env.http_host : "", +		ctx->env.https ? ctx->env.https : "", +		ctx->qry.repo ? ctx->qry.repo : "", +		ctx->qry.page ? ctx->qry.page : "", +		ctx->qry.url ? ctx->qry.url : ""); +} + +#define MAX_AUTHENTICATION_POST_BYTES 4096 +static inline void authenticate_post(struct cgit_context *ctx) +{ +	if (ctx->env.http_referer && strlen(ctx->env.http_referer) > 0) { +		html("Status: 302 Redirect\n"); +		html("Cache-Control: no-cache, no-store\n"); +		htmlf("Location: %s\n", ctx->env.http_referer); +	} else { +		html("Status: 501 Missing Referer\n"); +		html("Cache-Control: no-cache, no-store\n\n"); +		exit(0); +	} + +	open_auth_filter(ctx, "authenticate-post"); +	char buffer[MAX_AUTHENTICATION_POST_BYTES]; +	int len; +	len = ctx->env.content_length; +	if (len > MAX_AUTHENTICATION_POST_BYTES) +		len = MAX_AUTHENTICATION_POST_BYTES; +	if (read(STDIN_FILENO, buffer, len) < 0) +		die_errno("Could not read POST from stdin"); +	if (write(STDOUT_FILENO, buffer, len) < 0) +		die_errno("Could not write POST to stdout"); +	/* The filter may now spit out a Set-Cookie: ... */ +	cgit_close_filter(ctx->cfg.auth_filter); + +	html("\n"); +	exit(0); +} + +static inline void authenticate_cookie(struct cgit_context *ctx) +{ +	/* If we don't have an auth_filter, consider all cookies valid, and thus return early. */ +	if (!ctx->cfg.auth_filter) { +		ctx->env.authenticated = 1; +		return; +	} + +	/* If we're having something POST'd to /login, we're authenticating POST, +	 * instead of the cookie, so call authenticate_post and bail out early. +	 * This pattern here should match /?p=login with POST. */ +	if (ctx->env.request_method && ctx->qry.page && !ctx->repo && \ +	    !strcmp(ctx->env.request_method, "POST") && !strcmp(ctx->qry.page, "login")) { +		authenticate_post(ctx); +		return; +	} + +	/* If we've made it this far, we're authenticating the cookie for real, so do that. */ +	open_auth_filter(ctx, "authenticate-cookie"); +	ctx->env.authenticated = cgit_close_filter(ctx->cfg.auth_filter); +} +  static void process_request(void *cbdata)  {  	struct cgit_context *ctx = cbdata;  	struct cgit_cmd *cmd; +	/* If we're not yet authenticated, no matter what page we're on, +	 * display the authentication body from the auth_filter. This should +	 * never be cached. */ +	if (!ctx->env.authenticated) { +		ctx->page.title = "Authentication Required"; +		cgit_print_http_headers(ctx); +		cgit_print_docstart(ctx); +		cgit_print_pageheader(ctx); +		open_auth_filter(ctx, "body"); +		cgit_close_filter(ctx->cfg.auth_filter); +		cgit_print_docend(); +		return; +	} +  	cmd = cgit_get_cmd(ctx);  	if (!cmd) {  		ctx->page.title = "cgit error"; @@ -911,6 +998,7 @@ int main(int argc, const char **argv)  	int err, ttl;  	cgit_init_filters(); +	atexit(cgit_cleanup_filters);  	prepare_context(&ctx);  	cgit_repolist.length = 0; @@ -948,18 +1036,22 @@ int main(int argc, const char **argv)  		cgit_parse_url(ctx.qry.url);  	} +	/* Before we go any further, we set ctx.env.authenticated by checking to see +	 * if the supplied cookie is valid. All cookies are valid if there is no +	 * auth_filter. If there is an auth_filter, the filter decides. */ +	authenticate_cookie(&ctx); +  	ttl = calc_ttl();  	if (ttl < 0)  		ctx.page.expires += 10 * 365 * 24 * 60 * 60; /* 10 years */  	else  		ctx.page.expires += ttl * 60; -	if (ctx.env.request_method && !strcmp(ctx.env.request_method, "HEAD")) +	if (!ctx.env.authenticated || (ctx.env.request_method && !strcmp(ctx.env.request_method, "HEAD")))  		ctx.cfg.nocache = 1;  	if (ctx.cfg.nocache)  		ctx.cfg.cache_size = 0;  	err = cache_process(ctx.cfg.cache_size, ctx.cfg.cache_root,  			    ctx.qry.raw, ttl, process_request, &ctx); -	cgit_cleanup_filters();  	if (err)  		cgit_print_error("Error processing page: %s (%d)",  				 strerror(err), err); @@ -53,7 +53,7 @@ typedef void (*filepair_fn)(struct diff_filepair *pair);  typedef void (*linediff_fn)(char *line, int len);  typedef enum { -	ABOUT, COMMIT, SOURCE, EMAIL +	ABOUT, COMMIT, SOURCE, EMAIL, AUTH  } filter_type;  struct cgit_filter { @@ -252,6 +252,7 @@ struct cgit_config {  	struct cgit_filter *commit_filter;  	struct cgit_filter *source_filter;  	struct cgit_filter *email_filter; +	struct cgit_filter *auth_filter;  };  struct cgit_page { @@ -278,6 +279,10 @@ struct cgit_environment {  	const char *script_name;  	const char *server_name;  	const char *server_port; +	const char *http_cookie; +	const char *http_referer; +	unsigned int content_length; +	int authenticated;  };  struct cgit_context { diff --git a/cgitrc.5.txt b/cgitrc.5.txt index 170e825..c45dbd3 100644 --- a/cgitrc.5.txt +++ b/cgitrc.5.txt @@ -42,6 +42,13 @@ agefile::  	hh:mm:ss". You may want to generate this file from a post-receive  	hook. Default value: "info/web/last-modified". +auth-filter:: +	Specifies a command that will be invoked for authenticating repository +	access. Receives quite a few arguments, and data on both stdin and +	stdout for authentication processing. Details follow later in this +	document. If no auth-filter is specified, no authentication is +	performed. Default value: none. See also: "FILTER API". +  branch-sort::  	Flag which, when set to "age", enables date ordering in the branch ref  	list, and when set to "name" enables ordering by branch name. Default @@ -605,6 +612,8 @@ specification with the relevant string; available values are:  		URL escapes for a path and writes 'str' to the webpage.  	'html_url_arg(str)'::  		URL escapes for an argument and writes 'str' to the webpage. +	'html_include(file)':: +		Includes 'file' in webpage.  Parameters are provided to filters as follows. @@ -635,7 +644,32 @@ source filter::  	file that is to be filtered is available on standard input and the  	filtered contents is expected on standard output. -Also, all filters are handed the following environment variables: +auth filter:: +	The authentication filter receives 11 parameters: +	  - filter action, explained below, which specifies which action the +	    filter is called for +	  - http cookie +	  - http method +	  - http referer +	  - http path +	  - http https flag +	  - cgit repo +	  - cgit page +	  - cgit url +	When the filter action is "body", this filter must write to output the +	HTML for displaying the login form, which POSTs to "/?p=login". When +	the filter action is "authenticate-cookie", this filter must validate +	the http cookie and return a 0 if it is invalid or 1 if it is invalid, +	in the exit code / close function. If the filter action is +	"authenticate-post", this filter receives POST'd parameters on +	standard input, and should write to output one or more "Set-Cookie" +	HTTP headers, each followed by a newline. + +	Please see `filters/simple-authentication.lua` for a clear example +	script that may be modified. + + +All filters are handed the following environment variables:  - CGIT_REPO_URL (from repo.url)  - CGIT_REPO_NAME (from repo.name) @@ -244,6 +244,11 @@ static int html_url_arg_lua_filter(lua_State *lua_state)  	return hook_lua_filter(lua_state, html_url_arg);  } +static int html_include_lua_filter(lua_State *lua_state) +{ +	return hook_lua_filter(lua_state, (void (*)(const char *))html_include); +} +  static void cleanup_lua_filter(struct cgit_filter *base)  {  	struct lua_filter *filter = (struct lua_filter *)base; @@ -279,6 +284,8 @@ static int init_lua_filter(struct lua_filter *filter)  	lua_setglobal(filter->lua_state, "html_url_path");  	lua_pushcfunction(filter->lua_state, html_url_arg_lua_filter);  	lua_setglobal(filter->lua_state, "html_url_arg"); +	lua_pushcfunction(filter->lua_state, html_include_lua_filter); +	lua_setglobal(filter->lua_state, "html_include");  	if (luaL_dofile(filter->lua_state, filter->script_file)) {  		error_lua_filter(filter); @@ -409,6 +416,10 @@ struct cgit_filter *cgit_new_filter(const char *cmd, filter_type filtertype)  		colon = NULL;  	switch (filtertype) { +		case AUTH: +			argument_count = 11; +			break; +  		case EMAIL:  			argument_count = 2;  			break; diff --git a/filters/simple-authentication.lua b/filters/simple-authentication.lua new file mode 100644 index 0000000..4cd4983 --- /dev/null +++ b/filters/simple-authentication.lua @@ -0,0 +1,225 @@ +-- This script may be used with the auth-filter. Be sure to configure it as you wish. +-- +-- Requirements: +-- 	luacrypto >= 0.3 +-- 	<http://mkottman.github.io/luacrypto/> +-- + + +-- +-- +-- Configure these variables for your settings. +-- +-- + +local protected_repos = { +	glouglou	= { laurent = true, jason = true }, +	qt		= { jason = true, bob = true } +} + +local users = { +	jason		= "secretpassword", +	laurent		= "s3cr3t", +	bob		= "ilikelua" +} + +local secret = "BE SURE TO CUSTOMIZE THIS STRING TO SOMETHING BIG AND RANDOM" + + + +-- +-- +-- Authentication functions follow below. Swap these out if you want different authentication semantics. +-- +-- + +-- Sets HTTP cookie headers based on post +function authenticate_post() +	local password = users[post["username"]] +	-- TODO: Implement time invariant string comparison function to mitigate against timing attack. +	if password == nil or password ~= post["password"] then +		construct_cookie("", "cgitauth") +	else +		construct_cookie(post["username"], "cgitauth") +	end +	return 0 +end + + +-- Returns 1 if the cookie is valid and 0 if it is not. +function authenticate_cookie() +	accepted_users = protected_repos[cgit["repo"]] +	if accepted_users == nil then +		-- We return as valid if the repo is not protected. +		return 1 +	end + +	local username = validate_cookie(get_cookie(http["cookie"], "cgitauth")) +	if username == nil or not accepted_users[username] then +		return 0 +	else +		return 1 +	end +end + +-- Prints the html for the login form. +function body() +	html("<h2>Authentication Required</h2>") +	html("<form method='post' action='") +	html_attr(cgit["login"]) +	html("'>") +	html("<table>") +	html("<tr><td><label for='username'>Username:</label></td><td><input id='username' name='username' autofocus /></td></tr>") +	html("<tr><td><label for='password'>Password:</label></td><td><input id='password' name='password' type='password' /></td></tr>") +	html("<tr><td colspan='2'><input value='Login' type='submit' /></td></tr>") +	html("</table></form>") + +	return 0 +end + + +-- +-- +-- Cookie construction and validation helpers. +-- +-- + +local crypto = require("crypto") + +-- Returns username of cookie if cookie is valid. Otherwise returns nil. +function validate_cookie(cookie) +	local i = 0 +	local username = "" +	local expiration = 0 +	local salt = "" +	local hmac = "" + +	if cookie:len() < 3 or cookie:sub(1, 1) == "|" then +		return nil +	end + +	for component in string.gmatch(cookie, "[^|]+") do +		if i == 0 then +			username = component +		elseif i == 1 then +			expiration = tonumber(component) +			if expiration == nil then +				expiration = 0 +			end +		elseif i == 2 then +			salt = component +		elseif i == 3 then +			hmac = component +		else +			break +		end +		i = i + 1 +	end + +	if hmac == nil or hmac:len() == 0 then +		return nil +	end + +	-- TODO: implement time invariant comparison to prevent against timing attack. +	if hmac ~= crypto.hmac.digest("sha1", username .. "|" .. tostring(expiration) .. "|" .. salt, secret) then +		return nil +	end + +	if expiration <= os.time() then +		return nil +	end + +	return username:lower() +end + +function construct_cookie(username, cookie) +	local authstr = "" +	if username:len() > 0 then +		-- One week expiration time +		local expiration = os.time() + 604800 +		local salt = crypto.hex(crypto.rand.bytes(16)) + +		authstr = username .. "|" .. tostring(expiration) .. "|" .. salt +		authstr = authstr .. "|" .. crypto.hmac.digest("sha1", authstr, secret) +	end + +	html("Set-Cookie: " .. cookie .. "=" .. authstr .. "; HttpOnly") +	if http["https"] == "yes" or http["https"] == "on" or http["https"] == "1" then +		html("; secure") +	end +	html("\n") +end + +-- +-- +-- Wrapper around filter API follows below, exposing the http table, the cgit table, and the post table to the above functions. +-- +-- + +local actions = {} +actions["authenticate-post"] = authenticate_post +actions["authenticate-cookie"] = authenticate_cookie +actions["body"] = body + +function filter_open(...) +	action = actions[select(1, ...)] + +	http = {} +	http["cookie"] = select(2, ...) +	http["method"] = select(3, ...) +	http["query"] = select(4, ...) +	http["referer"] = select(5, ...) +	http["path"] = select(6, ...) +	http["host"] = select(7, ...) +	http["https"] = select(8, ...) + +	cgit = {} +	cgit["repo"] = select(9, ...) +	cgit["page"] = select(10, ...) +	cgit["url"] = select(11, ...) + +	cgit["login"] = "" +	for _ in cgit["url"]:gfind("/") do +		cgit["login"] = cgit["login"] .. "../" +	end +	cgit["login"] = cgit["login"] .. "?p=login" + +end + +function filter_close() +	return action() +end + +function filter_write(str) +	post = parse_qs(str) +end + + +-- +-- +-- Utility functions follow below, based on keplerproject/wsapi. +-- +-- + +function url_decode(str) +	if not str then +		return "" +	end +	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 parse_qs(qs) +	local tab = {} +	for key, val in string.gmatch(qs, "([^&=]+)=([^&=]*)&?") do +		tab[url_decode(key)] = url_decode(val) +	end +	return tab +end + +function get_cookie(cookies, name) +	cookies = string.gsub(";" .. cookies .. ";", "%s*;%s*", ";") +	return url_decode(string.match(cookies, ";" .. name .. "=(.-);")) +end diff --git a/ui-shared.c b/ui-shared.c index abe15cd..4f47c50 100644 --- a/ui-shared.c +++ b/ui-shared.c @@ -641,6 +641,8 @@ void cgit_print_http_headers(struct cgit_context *ctx)  	if (ctx->page.filename)  		htmlf("Content-Disposition: inline; filename=\"%s\"\n",  		      ctx->page.filename); +	if (!ctx->env.authenticated) +		html("Cache-Control: no-cache, no-store\n");  	htmlf("Last-Modified: %s\n", http_date(ctx->page.modified));  	htmlf("Expires: %s\n", http_date(ctx->page.expires));  	if (ctx->page.etag) @@ -814,14 +816,16 @@ static void print_header(struct cgit_context *ctx)  		cgit_index_link("index", NULL, NULL, NULL, NULL, 0);  		html(" : ");  		cgit_summary_link(ctx->repo->name, ctx->repo->name, NULL, NULL); -		html("</td><td class='form'>"); -		html("<form method='get' action=''>\n"); -		cgit_add_hidden_formfields(0, 1, ctx->qry.page); -		html("<select name='h' onchange='this.form.submit();'>\n"); -		for_each_branch_ref(print_branch_option, ctx->qry.head); -		html("</select> "); -		html("<input type='submit' name='' value='switch'/>"); -		html("</form>"); +		if (ctx->env.authenticated) { +			html("</td><td class='form'>"); +			html("<form method='get' action=''>\n"); +			cgit_add_hidden_formfields(0, 1, ctx->qry.page); +			html("<select name='h' onchange='this.form.submit();'>\n"); +			for_each_branch_ref(print_branch_option, ctx->qry.head); +			html("</select> "); +			html("<input type='submit' name='' value='switch'/>"); +			html("</form>"); +		}  	} else  		html_txt(ctx->cfg.root_title);  	html("</td></tr>\n"); @@ -843,11 +847,11 @@ static void print_header(struct cgit_context *ctx)  void cgit_print_pageheader(struct cgit_context *ctx)  {  	html("<div id='cgit'>"); -	if (!ctx->cfg.noheader) +	if (!ctx->env.authenticated || !ctx->cfg.noheader)  		print_header(ctx);  	html("<table class='tabs'><tr><td>\n"); -	if (ctx->repo) { +	if (ctx->env.authenticated && ctx->repo) {  		cgit_summary_link("summary", NULL, hc(ctx, "summary"),  				  ctx->qry.head);  		cgit_refs_link("refs", NULL, hc(ctx, "refs"), ctx->qry.head, @@ -886,7 +890,7 @@ void cgit_print_pageheader(struct cgit_context *ctx)  		html("'/>\n");  		html("<input type='submit' value='search'/>\n");  		html("</form>\n"); -	} else { +	} else if (ctx->env.authenticated) {  		site_link(NULL, "index", NULL, hc(ctx, "repolist"), NULL, NULL, 0);  		if (ctx->cfg.root_readme)  			site_link("about", "about", NULL, hc(ctx, "about"), @@ -902,7 +906,7 @@ void cgit_print_pageheader(struct cgit_context *ctx)  		html("</form>");  	}  	html("</td></tr></table>\n"); -	if (ctx->qry.vpath) { +	if (ctx->env.authenticated && ctx->qry.vpath) {  		html("<div class='path'>");  		html("path: ");  		cgit_print_path_crumbs(ctx, ctx->qry.vpath); | 
