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>
This commit is contained in:
Jason A. Donenfeld 2014-01-14 21:49:31 +01:00
parent 3741254a69
commit d6e9200cc3
6 changed files with 387 additions and 16 deletions

96
cgit.c
View File

@ -192,6 +192,8 @@ static void config_cb(const char *name, const char *value)
ctx.cfg.commit_filter = cgit_new_filter(value, COMMIT); ctx.cfg.commit_filter = cgit_new_filter(value, COMMIT);
else if (!strcmp(name, "email-filter")) else if (!strcmp(name, "email-filter"))
ctx.cfg.email_filter = cgit_new_filter(value, EMAIL); 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")) else if (!strcmp(name, "embedded"))
ctx.cfg.embedded = atoi(value); ctx.cfg.embedded = atoi(value);
else if (!strcmp(name, "max-atom-items")) 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.script_name = getenv("SCRIPT_NAME");
ctx->env.server_name = getenv("SERVER_NAME"); ctx->env.server_name = getenv("SERVER_NAME");
ctx->env.server_port = getenv("SERVER_PORT"); 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.mimetype = "text/html";
ctx->page.charset = PAGE_ENCODING; ctx->page.charset = PAGE_ENCODING;
ctx->page.filename = NULL; ctx->page.filename = NULL;
@ -593,11 +599,92 @@ static int prepare_repo_cmd(struct cgit_context *ctx)
return 0; 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) static void process_request(void *cbdata)
{ {
struct cgit_context *ctx = cbdata; struct cgit_context *ctx = cbdata;
struct cgit_cmd *cmd; 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); cmd = cgit_get_cmd(ctx);
if (!cmd) { if (!cmd) {
ctx->page.title = "cgit error"; ctx->page.title = "cgit error";
@ -911,6 +998,7 @@ int main(int argc, const char **argv)
int err, ttl; int err, ttl;
cgit_init_filters(); cgit_init_filters();
atexit(cgit_cleanup_filters);
prepare_context(&ctx); prepare_context(&ctx);
cgit_repolist.length = 0; cgit_repolist.length = 0;
@ -948,18 +1036,22 @@ int main(int argc, const char **argv)
cgit_parse_url(ctx.qry.url); 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(); ttl = calc_ttl();
if (ttl < 0) if (ttl < 0)
ctx.page.expires += 10 * 365 * 24 * 60 * 60; /* 10 years */ ctx.page.expires += 10 * 365 * 24 * 60 * 60; /* 10 years */
else else
ctx.page.expires += ttl * 60; 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; ctx.cfg.nocache = 1;
if (ctx.cfg.nocache) if (ctx.cfg.nocache)
ctx.cfg.cache_size = 0; ctx.cfg.cache_size = 0;
err = cache_process(ctx.cfg.cache_size, ctx.cfg.cache_root, err = cache_process(ctx.cfg.cache_size, ctx.cfg.cache_root,
ctx.qry.raw, ttl, process_request, &ctx); ctx.qry.raw, ttl, process_request, &ctx);
cgit_cleanup_filters();
if (err) if (err)
cgit_print_error("Error processing page: %s (%d)", cgit_print_error("Error processing page: %s (%d)",
strerror(err), err); strerror(err), err);

7
cgit.h
View File

@ -53,7 +53,7 @@ typedef void (*filepair_fn)(struct diff_filepair *pair);
typedef void (*linediff_fn)(char *line, int len); typedef void (*linediff_fn)(char *line, int len);
typedef enum { typedef enum {
ABOUT, COMMIT, SOURCE, EMAIL ABOUT, COMMIT, SOURCE, EMAIL, AUTH
} filter_type; } filter_type;
struct cgit_filter { struct cgit_filter {
@ -252,6 +252,7 @@ struct cgit_config {
struct cgit_filter *commit_filter; struct cgit_filter *commit_filter;
struct cgit_filter *source_filter; struct cgit_filter *source_filter;
struct cgit_filter *email_filter; struct cgit_filter *email_filter;
struct cgit_filter *auth_filter;
}; };
struct cgit_page { struct cgit_page {
@ -278,6 +279,10 @@ struct cgit_environment {
const char *script_name; const char *script_name;
const char *server_name; const char *server_name;
const char *server_port; const char *server_port;
const char *http_cookie;
const char *http_referer;
unsigned int content_length;
int authenticated;
}; };
struct cgit_context { struct cgit_context {

View File

@ -42,6 +42,13 @@ agefile::
hh:mm:ss". You may want to generate this file from a post-receive hh:mm:ss". You may want to generate this file from a post-receive
hook. Default value: "info/web/last-modified". 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:: branch-sort::
Flag which, when set to "age", enables date ordering in the branch ref 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 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. URL escapes for a path and writes 'str' to the webpage.
'html_url_arg(str)':: 'html_url_arg(str)'::
URL escapes for an argument and writes 'str' to the webpage. 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. 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 file that is to be filtered is available on standard input and the
filtered contents is expected on standard output. 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_URL (from repo.url)
- CGIT_REPO_NAME (from repo.name) - CGIT_REPO_NAME (from repo.name)

View File

@ -244,6 +244,11 @@ static int html_url_arg_lua_filter(lua_State *lua_state)
return hook_lua_filter(lua_state, html_url_arg); 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) static void cleanup_lua_filter(struct cgit_filter *base)
{ {
struct lua_filter *filter = (struct lua_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_setglobal(filter->lua_state, "html_url_path");
lua_pushcfunction(filter->lua_state, html_url_arg_lua_filter); lua_pushcfunction(filter->lua_state, html_url_arg_lua_filter);
lua_setglobal(filter->lua_state, "html_url_arg"); 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)) { if (luaL_dofile(filter->lua_state, filter->script_file)) {
error_lua_filter(filter); error_lua_filter(filter);
@ -409,6 +416,10 @@ struct cgit_filter *cgit_new_filter(const char *cmd, filter_type filtertype)
colon = NULL; colon = NULL;
switch (filtertype) { switch (filtertype) {
case AUTH:
argument_count = 11;
break;
case EMAIL: case EMAIL:
argument_count = 2; argument_count = 2;
break; break;

View File

@ -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

View File

@ -641,6 +641,8 @@ void cgit_print_http_headers(struct cgit_context *ctx)
if (ctx->page.filename) if (ctx->page.filename)
htmlf("Content-Disposition: inline; filename=\"%s\"\n", htmlf("Content-Disposition: inline; filename=\"%s\"\n",
ctx->page.filename); 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("Last-Modified: %s\n", http_date(ctx->page.modified));
htmlf("Expires: %s\n", http_date(ctx->page.expires)); htmlf("Expires: %s\n", http_date(ctx->page.expires));
if (ctx->page.etag) 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); cgit_index_link("index", NULL, NULL, NULL, NULL, 0);
html(" : "); html(" : ");
cgit_summary_link(ctx->repo->name, ctx->repo->name, NULL, NULL); cgit_summary_link(ctx->repo->name, ctx->repo->name, NULL, NULL);
html("</td><td class='form'>"); if (ctx->env.authenticated) {
html("<form method='get' action=''>\n"); html("</td><td class='form'>");
cgit_add_hidden_formfields(0, 1, ctx->qry.page); html("<form method='get' action=''>\n");
html("<select name='h' onchange='this.form.submit();'>\n"); cgit_add_hidden_formfields(0, 1, ctx->qry.page);
for_each_branch_ref(print_branch_option, ctx->qry.head); html("<select name='h' onchange='this.form.submit();'>\n");
html("</select> "); for_each_branch_ref(print_branch_option, ctx->qry.head);
html("<input type='submit' name='' value='switch'/>"); html("</select> ");
html("</form>"); html("<input type='submit' name='' value='switch'/>");
html("</form>");
}
} else } else
html_txt(ctx->cfg.root_title); html_txt(ctx->cfg.root_title);
html("</td></tr>\n"); html("</td></tr>\n");
@ -843,11 +847,11 @@ static void print_header(struct cgit_context *ctx)
void cgit_print_pageheader(struct cgit_context *ctx) void cgit_print_pageheader(struct cgit_context *ctx)
{ {
html("<div id='cgit'>"); html("<div id='cgit'>");
if (!ctx->cfg.noheader) if (!ctx->env.authenticated || !ctx->cfg.noheader)
print_header(ctx); print_header(ctx);
html("<table class='tabs'><tr><td>\n"); html("<table class='tabs'><tr><td>\n");
if (ctx->repo) { if (ctx->env.authenticated && ctx->repo) {
cgit_summary_link("summary", NULL, hc(ctx, "summary"), cgit_summary_link("summary", NULL, hc(ctx, "summary"),
ctx->qry.head); ctx->qry.head);
cgit_refs_link("refs", NULL, hc(ctx, "refs"), 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("'/>\n");
html("<input type='submit' value='search'/>\n"); html("<input type='submit' value='search'/>\n");
html("</form>\n"); html("</form>\n");
} else { } else if (ctx->env.authenticated) {
site_link(NULL, "index", NULL, hc(ctx, "repolist"), NULL, NULL, 0); site_link(NULL, "index", NULL, hc(ctx, "repolist"), NULL, NULL, 0);
if (ctx->cfg.root_readme) if (ctx->cfg.root_readme)
site_link("about", "about", NULL, hc(ctx, "about"), site_link("about", "about", NULL, hc(ctx, "about"),
@ -902,7 +906,7 @@ void cgit_print_pageheader(struct cgit_context *ctx)
html("</form>"); html("</form>");
} }
html("</td></tr></table>\n"); html("</td></tr></table>\n");
if (ctx->qry.vpath) { if (ctx->env.authenticated && ctx->qry.vpath) {
html("<div class='path'>"); html("<div class='path'>");
html("path: "); html("path: ");
cgit_print_path_crumbs(ctx, ctx->qry.vpath); cgit_print_path_crumbs(ctx, ctx->qry.vpath);