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 /filters | |
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>
Diffstat (limited to 'filters')
-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 | ||