diff options
| author | Jason A. Donenfeld | 2018-07-15 04:45:11 +0200 |
|---|---|---|
| committer | Jason A. Donenfeld | 2018-08-03 16:12:21 +0200 |
| commit | 77b6f833441dda1dd50f5a51a81036b1fde815d5 (patch) | |
| tree | e78d135809b3f3d2efa99946e405f2155e94e012 | |
| parent | 82856923bffaac3ac88a90a797ddb33dcee8635a (diff) | |
| download | cgit-77b6f833441dda1dd50f5a51a81036b1fde815d5.tar.gz cgit-77b6f833441dda1dd50f5a51a81036b1fde815d5.tar.bz2 cgit-77b6f833441dda1dd50f5a51a81036b1fde815d5.zip | |
auth-filters: add simple file-based authentication scheme
Signed-off-by: Jason A. Donenfeld <Jason@zx2c4.com>
| -rw-r--r-- | filters/file-authentication.lua | 352 |
1 files changed, 352 insertions, 0 deletions
diff --git a/filters/file-authentication.lua b/filters/file-authentication.lua new file mode 100644 index 0000000..6ee1e19 --- /dev/null +++ b/filters/file-authentication.lua | |||
| @@ -0,0 +1,352 @@ | |||
| 1 | -- This script may be used with the auth-filter. | ||
| 2 | -- | ||
| 3 | -- Requirements: | ||
| 4 | -- luacrypto >= 0.3 | ||
| 5 | -- <http://mkottman.github.io/luacrypto/> | ||
| 6 | -- luaposix | ||
| 7 | -- <https://github.com/luaposix/luaposix> | ||
| 8 | -- | ||
| 9 | local sysstat = require("posix.sys.stat") | ||
| 10 | local unistd = require("posix.unistd") | ||
| 11 | local crypto = require("crypto") | ||
| 12 | |||
| 13 | |||
| 14 | -- This file should contain a series of lines in the form of: | ||
| 15 | -- username1:hash1 | ||
| 16 | -- username2:hash2 | ||
| 17 | -- username3:hash3 | ||
| 18 | -- ... | ||
| 19 | -- Hashes can be generated using something like `mkpasswd -m sha-512 -R 300000`. | ||
| 20 | -- This file should not be world-readable. | ||
| 21 | local users_filename = "/etc/cgit-auth/users" | ||
| 22 | |||
| 23 | -- This file should contain a series of lines in the form of: | ||
| 24 | -- groupname1:username1,username2,username3,... | ||
| 25 | -- ... | ||
| 26 | local groups_filename = "/etc/cgit-auth/groups" | ||
| 27 | |||
| 28 | -- This file should contain a series of lines in the form of: | ||
| 29 | -- reponame1:groupname1,groupname2,groupname3,... | ||
| 30 | -- ... | ||
| 31 | local repos_filename = "/etc/cgit-auth/repos" | ||
| 32 | |||
| 33 | -- Set this to a path this script can write to for storing a persistent | ||
| 34 | -- cookie secret, which should not be world-readable. | ||
| 35 | local secret_filename = "/var/cache/cgit/auth-secret" | ||
| 36 | |||
| 37 | -- | ||
| 38 | -- | ||
| 39 | -- Authentication functions follow below. Swap these out if you want different authentication semantics. | ||
| 40 | -- | ||
| 41 | -- | ||
| 42 | |||
| 43 | -- Looks up a hash for a given user. | ||
| 44 | function lookup_hash(user) | ||
| 45 | local line | ||
| 46 | for line in io.lines(users_filename) do | ||
| 47 | local u, h = string.match(line, "(.-):(.+)") | ||
| 48 | if u:lower() == user:lower() then | ||
| 49 | return h | ||
| 50 | end | ||
| 51 | end | ||
| 52 | return nil | ||
| 53 | end | ||
| 54 | |||
| 55 | -- Looks up users for a given repo. | ||
| 56 | function lookup_users(repo) | ||
| 57 | local users = nil | ||
| 58 | local groups = nil | ||
| 59 | local line, group, user | ||
| 60 | for line in io.lines(repos_filename) do | ||
| 61 | local r, g = string.match(line, "(.-):(.+)") | ||
| 62 | if r == repo then | ||
| 63 | groups = { } | ||
| 64 | for group in string.gmatch(g, "([^,]+)") do | ||
| 65 | groups[group:lower()] = true | ||
| 66 | end | ||
| 67 | break | ||
| 68 | end | ||
| 69 | end | ||
| 70 | if groups == nil then | ||
| 71 | return nil | ||
| 72 | end | ||
| 73 | for line in io.lines(groups_filename) do | ||
| 74 | local g, u = string.match(line, "(.-):(.+)") | ||
| 75 | if groups[g:lower()] then | ||
| 76 | if users == nil then | ||
| 77 | users = { } | ||
| 78 | end | ||
| 79 | for user in string.gmatch(u, "([^,]+)") do | ||
| 80 | users[user:lower()] = true | ||
| 81 | end | ||
| 82 | end | ||
| 83 | end | ||
| 84 | return users | ||
| 85 | end | ||
| 86 | |||
| 87 | |||
| 88 | -- Sets HTTP cookie headers based on post and sets up redirection. | ||
| 89 | function authenticate_post() | ||
| 90 | local hash = lookup_hash(post["username"]) | ||
| 91 | local redirect = validate_value("redirect", post["redirect"]) | ||
| 92 | |||
| 93 | if redirect == nil then | ||
| 94 | not_found() | ||
| 95 | return 0 | ||
| 96 | end | ||
| 97 | |||
| 98 | redirect_to(redirect) | ||
| 99 | |||
| 100 | if hash == nil or hash ~= unistd.crypt(post["password"], hash) then | ||
| 101 | set_cookie("cgitauth", "") | ||
| 102 | else | ||
| 103 | -- One week expiration time | ||
| 104 | local username = secure_value("username", post["username"], os.time() + 604800) | ||
| 105 | set_cookie("cgitauth", username) | ||
| 106 | end | ||
| 107 | |||
| 108 | html("\n") | ||
| 109 | return 0 | ||
| 110 | end | ||
| 111 | |||
| 112 | |||
| 113 | -- Returns 1 if the cookie is valid and 0 if it is not. | ||
| 114 | function authenticate_cookie() | ||
| 115 | accepted_users = lookup_users(cgit["repo"]) | ||
| 116 | if accepted_users == nil then | ||
| 117 | -- We return as valid if the repo is not protected. | ||
| 118 | return 1 | ||
| 119 | end | ||
| 120 | |||
| 121 | local username = validate_value("username", get_cookie(http["cookie"], "cgitauth")) | ||
| 122 | if username == nil or not accepted_users[username:lower()] then | ||
| 123 | return 0 | ||
| 124 | else | ||
| 125 | return 1 | ||
| 126 | end | ||
| 127 | end | ||
| 128 | |||
| 129 | -- Prints the html for the login form. | ||
| 130 | function body() | ||
| 131 | html("<h2>Authentication Required</h2>") | ||
| 132 | html("<form method='post' action='") | ||
| 133 | html_attr(cgit["login"]) | ||
| 134 | html("'>") | ||
| 135 | html("<input type='hidden' name='redirect' value='") | ||
| 136 | html_attr(secure_value("redirect", cgit["url"], 0)) | ||
| 137 | html("' />") | ||
| 138 | html("<table>") | ||
| 139 | html("<tr><td><label for='username'>Username:</label></td><td><input id='username' name='username' autofocus /></td></tr>") | ||
| 140 | html("<tr><td><label for='password'>Password:</label></td><td><input id='password' name='password' type='password' /></td></tr>") | ||
| 141 | html("<tr><td colspan='2'><input value='Login' type='submit' /></td></tr>") | ||
| 142 | html("</table></form>") | ||
| 143 | |||
| 144 | return 0 | ||
| 145 | end | ||
| 146 | |||
| 147 | |||
| 148 | |||
| 149 | -- | ||
| 150 | -- | ||
| 151 | -- Wrapper around filter API, exposing the http table, the cgit table, and the post table to the above functions. | ||
| 152 | -- | ||
| 153 | -- | ||
| 154 | |||
| 155 | local actions = {} | ||
| 156 | actions["authenticate-post"] = authenticate_post | ||
| 157 | actions["authenticate-cookie"] = authenticate_cookie | ||
| 158 | actions["body"] = body | ||
| 159 | |||
| 160 | function filter_open(...) | ||
| 161 | action = actions[select(1, ...)] | ||
| 162 | |||
| 163 | http = {} | ||
| 164 | http["cookie"] = select(2, ...) | ||
| 165 | http["method"] = select(3, ...) | ||
| 166 | http["query"] = select(4, ...) | ||
| 167 | http["referer"] = select(5, ...) | ||
| 168 | http["path"] = select(6, ...) | ||
| 169 | http["host"] = select(7, ...) | ||
| 170 | http["https"] = select(8, ...) | ||
| 171 | |||
| 172 | cgit = {} | ||
| 173 | cgit["repo"] = select(9, ...) | ||
| 174 | cgit["page"] = select(10, ...) | ||
| 175 | cgit["url"] = select(11, ...) | ||
| 176 | cgit["login"] = select(12, ...) | ||
| 177 | |||
| 178 | end | ||
| 179 | |||
| 180 | function filter_close() | ||
| 181 | return action() | ||
| 182 | end | ||
| 183 | |||
| 184 | function filter_write(str) | ||
| 185 | post = parse_qs(str) | ||
| 186 | end | ||
| 187 | |||
| 188 | |||
| 189 | -- | ||
| 190 | -- | ||
| 191 | -- Utility functions based on keplerproject/wsapi. | ||
| 192 | -- | ||
| 193 | -- | ||
| 194 | |||
| 195 | function url_decode(str) | ||
| 196 | if not str then | ||
| 197 | return "" | ||
| 198 | end | ||
| 199 | str = string.gsub(str, "+", " ") | ||
| 200 | str = string.gsub(str, "%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end) | ||
| 201 | str = string.gsub(str, "\r\n", "\n") | ||
| 202 | return str | ||
| 203 | end | ||
| 204 | |||
| 205 | function url_encode(str) | ||
| 206 | if not str then | ||
| 207 | return "" | ||
| 208 | end | ||
| 209 | str = string.gsub(str, "\n", "\r\n") | ||
| 210 | str = string.gsub(str, "([^%w ])", function(c) return string.format("%%%02X", string.byte(c)) end) | ||
| 211 | str = string.gsub(str, " ", "+") | ||
| 212 | return str | ||
| 213 | end | ||
| 214 | |||
| 215 | function parse_qs(qs) | ||
| 216 | local tab = {} | ||
| 217 | for key, val in string.gmatch(qs, "([^&=]+)=([^&=]*)&?") do | ||
| 218 | tab[url_decode(key)] = url_decode(val) | ||
| 219 | end | ||
| 220 | return tab | ||
| 221 | end | ||
| 222 | |||
| 223 | function get_cookie(cookies, name) | ||
| 224 | cookies = string.gsub(";" .. cookies .. ";", "%s*;%s*", ";") | ||
| 225 | return url_decode(string.match(cookies, ";" .. name .. "=(.-);")) | ||
| 226 | end | ||
| 227 | |||
| 228 | |||
| 229 | -- | ||
| 230 | -- | ||
| 231 | -- Cookie construction and validation helpers. | ||
| 232 | -- | ||
| 233 | -- | ||
| 234 | |||
| 235 | local secret = nil | ||
| 236 | |||
| 237 | -- Loads a secret from a file, creates a secret, or returns one from memory. | ||
| 238 | function get_secret() | ||
| 239 | if secret ~= nil then | ||
| 240 | return secret | ||
| 241 | end | ||
| 242 | local secret_file = io.open(secret_filename, "r") | ||
| 243 | if secret_file == nil then | ||
| 244 | local old_umask = sysstat.umask(63) | ||
| 245 | local temporary_filename = secret_filename .. ".tmp." .. crypto.hex(crypto.rand.bytes(16)) | ||
| 246 | local temporary_file = io.open(temporary_filename, "w") | ||
| 247 | if temporary_file == nil then | ||
| 248 | os.exit(177) | ||
| 249 | end | ||
| 250 | temporary_file:write(crypto.hex(crypto.rand.bytes(32))) | ||
| 251 | temporary_file:close() | ||
| 252 | unistd.link(temporary_filename, secret_filename) -- Intentionally fails in the case that another process is doing the same. | ||
| 253 | unistd.unlink(temporary_filename) | ||
| 254 | sysstat.umask(old_umask) | ||
| 255 | secret_file = io.open(secret_filename, "r") | ||
| 256 | end | ||
| 257 | if secret_file == nil then | ||
| 258 | os.exit(177) | ||
| 259 | end | ||
| 260 | secret = secret_file:read() | ||
| 261 | secret_file:close() | ||
| 262 | if secret:len() ~= 64 then | ||
| 263 | os.exit(177) | ||
| 264 | end | ||
| 265 | return secret | ||
| 266 | end | ||
| 267 | |||
| 268 | -- Returns value of cookie if cookie is valid. Otherwise returns nil. | ||
| 269 | function validate_value(expected_field, cookie) | ||
| 270 | local i = 0 | ||
| 271 | local value = "" | ||
| 272 | local field = "" | ||
| 273 | local expiration = 0 | ||
| 274 | local salt = "" | ||
| 275 | local hmac = "" | ||
| 276 | |||
| 277 | if cookie == nil or cookie:len() < 3 or cookie:sub(1, 1) == "|" then | ||
| 278 | return nil | ||
| 279 | end | ||
| 280 | |||
| 281 | for component in string.gmatch(cookie, "[^|]+") do | ||
| 282 | if i == 0 then | ||
| 283 | field = component | ||
| 284 | elseif i == 1 then | ||
| 285 | value = component | ||
| 286 | elseif i == 2 then | ||
| 287 | expiration = tonumber(component) | ||
| 288 | if expiration == nil then | ||
| 289 | expiration = -1 | ||
| 290 | end | ||
| 291 | elseif i == 3 then | ||
| 292 | salt = component | ||
| 293 | elseif i == 4 then | ||
| 294 | hmac = component | ||
| 295 | else | ||
| 296 | break | ||
| 297 | end | ||
| 298 | i = i + 1 | ||
| 299 | end | ||
| 300 | |||
| 301 | if hmac == nil or hmac:len() == 0 then | ||
| 302 | return nil | ||
| 303 | end | ||
| 304 | |||
| 305 | -- Lua hashes strings, so these comparisons are time invariant. | ||
| 306 | if hmac ~= crypto.hmac.digest("sha256", field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt, get_secret()) then | ||
| 307 | return nil | ||
| 308 | end | ||
| 309 | |||
| 310 | if expiration == -1 or (expiration ~= 0 and expiration <= os.time()) then | ||
| 311 | return nil | ||
| 312 | end | ||
| 313 | |||
| 314 | if url_decode(field) ~= expected_field then | ||
| 315 | return nil | ||
| 316 | end | ||
| 317 | |||
| 318 | return url_decode(value) | ||
| 319 | end | ||
| 320 | |||
| 321 | function secure_value(field, value, expiration) | ||
| 322 | if value == nil or value:len() <= 0 then | ||
| 323 | return "" | ||
| 324 | end | ||
| 325 | |||
| 326 | local authstr = "" | ||
| 327 | local salt = crypto.hex(crypto.rand.bytes(16)) | ||
| 328 | value = url_encode(value) | ||
| 329 | field = url_encode(field) | ||
| 330 | authstr = field .. "|" .. value .. "|" .. tostring(expiration) .. "|" .. salt | ||
| 331 | authstr = authstr .. "|" .. crypto.hmac.digest("sha256", authstr, get_secret()) | ||
| 332 | return authstr | ||
| 333 | end | ||
| 334 | |||
| 335 | function set_cookie(cookie, value) | ||
| 336 | html("Set-Cookie: " .. cookie .. "=" .. value .. "; HttpOnly") | ||
| 337 | if http["https"] == "yes" or http["https"] == "on" or http["https"] == "1" then | ||
| 338 | html("; secure") | ||
| 339 | end | ||
| 340 | html("\n") | ||
| 341 | end | ||
| 342 | |||
| 343 | function redirect_to(url) | ||
| 344 | html("Status: 302 Redirect\n") | ||
| 345 | html("Cache-Control: no-cache, no-store\n") | ||
| 346 | html("Location: " .. url .. "\n") | ||
| 347 | end | ||
| 348 | |||
| 349 | function not_found() | ||
| 350 | html("Status: 404 Not Found\n") | ||
| 351 | html("Cache-Control: no-cache, no-store\n\n") | ||
| 352 | end | ||
