chiark / gitweb /
backend.py: Use configured delimiter for joining fields.
[chopwood] / httpauth.py
CommitLineData
a2916c06
MW
1### -*-python-*-
2###
3### HTTP authentication
4###
5### (c) 2013 Mark Wooding
6###
7
8###----- Licensing notice ---------------------------------------------------
9###
10### This file is part of Chopwood: a password-changing service.
11###
12### Chopwood is free software; you can redistribute it and/or modify
13### it under the terms of the GNU Affero General Public License as
14### published by the Free Software Foundation; either version 3 of the
15### License, or (at your option) any later version.
16###
17### Chopwood is distributed in the hope that it will be useful,
18### but WITHOUT ANY WARRANTY; without even the implied warranty of
19### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20### GNU Affero General Public License for more details.
21###
22### You should have received a copy of the GNU Affero General Public
23### License along with Chopwood; if not, see
24### <http://www.gnu.org/licenses/>.
25
26from __future__ import with_statement
27
28import base64 as BN
29import hashlib as H
30import hmac as HM
3cf8e1b7 31import itertools as I
a2916c06
MW
32import os as OS
33
34import cgi as CGI
35import config as CONF; CFG = CONF.CFG
36import dbmaint as D
37import output as O; PRINT = O.PRINT
38import service as S
39import subcommand as SC
40import util as U
41
42###--------------------------------------------------------------------------
43### About the authentication scheme.
44###
45### We mustn't allow a CGI user to make changes (or even learn about a user's
46### accounts) without authenticating first. Curently, that means a username
47### and password, though I really dislike this; maybe I'll add a feature for
48### handling TLS client certificates some time.
49###
50### We're particularly worried about cross-site request forgery: a forged
51### request to change a password to some known value lets a bad guy straight
52### into a restricted service -- and a change to the `master' account lets
53### him into all of them.
54###
55### Once we've satisfied ourselves of the user's credentials, we issue a
56### short-lived session token, stored in a cookie namde `chpwd-token'. This
3cf8e1b7
MW
57### token has the form `DATE.TAG.USER': here, DATE is the POSIX time of
58### issue, as a decimal number; USER is the user's login name; and TAG is a
59### cryptographic MAC tag on the string `chpwd-token.DATE.USER'. (The USER
60### name is on the end so that it can contain `.' characters without
61### introducing parsing difficulties.)
a2916c06
MW
62###
63### Secrets for these MAC tags are stored in the database: secrets expire
64### after 30 minutes (invalidating all tokens issued with them); we only
65### issue a token with a secret that's at most five minutes old. A session's
66### lifetime, then, is somewhere between 25 and 30 minutes. We choose the
67### lower bound as the cookie lifetime, just so that error messages end up
68### consistent.
69###
70### A cookie with a valid token is sufficient to grant read-only access to a
71### user's account details. However, this authority is ambient: during the
72### validity period of the token, a cross-site request forgery can easily
73### succeed, since there's nothing about the rest of a request which is hard
74### to forge, and the cookie will be supplied automatically by the user
75### agent. Showing the user some information we were quite happy to release
76### anyway isn't an interesting attack, but we must certainly require
3cf8e1b7
MW
77### something stronger for state-change requests. Here, we also check a
78### special request parameter `%nonce': forms setting up a `POST' action must
7405b0d4
MW
79### include an appropriate hidden input element.
80###
81### The `%nonce' parameter encodes a randomized `all-or-nothing transform' of
82### the (deterministic) MAC tag on `chpwd-nonce.DATE.USER'. The standard
83### advice for defeating the BREACH attack (which uses differential
84### compression of HTTP payloads which include attacker-provided data to
85### recover CSRF tokens) is to transmit an XOR-split of the token; but that
86### allows an adversary to recover the token two bytes at a time; this makes
87### the attack take 256 times longer, which doesn't really seem enough. A
88### proper AONT, on the other hand, means that the adversary gets nothing if
89### he can't guess the entire transformed token -- and if he could do that,
90### he might as well just carry out the CSRF attack without messing with
91### BREACH in the first place.
a2916c06
MW
92###
93### Messing about with cookies is a bit annoying, but it's hard to come up
94### with alternatives. I'm trying to keep the URLs fairly pretty, and anyway
95### putting secrets into them is asking for trouble, since user agents have
96### an awful tendecy to store URLs in a history database, send them to
97### motherships, leak them in `Referer' headers, and other awful things. Our
98### cookie is marked `HttpOnly' so, in particular, user agents must keep them
99### out of the grubby mitts of Javascript programs.
100###
101### I promise that I'm only using these cookies for the purposes of
102### maintaining security: I don't log them or do anything else at all with
103### them.
104
105###--------------------------------------------------------------------------
106### Generating and checking authentication tokens.
107
108## Secret lifetime parameters.
109CONF.DEFAULTS.update(
110
111 ## The lifetime of a session cookie, in seconds.
112 SECRETLIFE = 30*60,
113
114 ## Maximum age of an authentication key, in seconds.
44e94112
MW
115 SECRETFRESH = 5*60,
116
117 ## Hash function to use for crypto.
118 AUTHHASH = H.sha256)
a2916c06
MW
119
120def cleansecrets():
121 """Remove dead secrets from the database."""
122 with D.DB:
123 D.DB.execute("DELETE FROM secrets WHERE stamp < $stale",
124 stale = U.NOW - CFG.SECRETLIFE)
125
126def getsecret(when):
127 """
128 Return the newest and most shiny secret no older than WHEN.
129
130 If there is no such secret, or the only one available would have been stale
131 at WHEN, then return `None'.
132 """
133 cleansecrets()
134 with D.DB:
135 D.DB.execute("""SELECT stamp, secret FROM secrets
136 WHERE stamp <= $when
137 ORDER BY stamp DESC""",
138 when = when)
139 row = D.DB.fetchone()
140 if row is None: return None
141 if row[0] < when - CFG.SECRETFRESH: return None
142 return row[1].decode('base64')
143
144def freshsecret():
145 """Return a fresh secret."""
146 cleansecrets()
147 with D.DB:
148 D.DB.execute("""SELECT secret FROM secrets
149 WHERE stamp >= $fresh
150 ORDER BY stamp DESC""",
151 fresh = U.NOW - CFG.SECRETFRESH)
152 row = D.DB.fetchone()
153 if row is not None:
154 sec = row[0].decode('base64')
155 else:
156 sec = OS.urandom(16)
157 D.DB.execute("""INSERT INTO secrets(stamp, secret)
158 VALUES ($stamp, $secret)""",
159 stamp = U.NOW, secret = sec.encode('base64'))
160 return sec
161
162def hack_octets(s):
163 """Return the octet string S, in a vaguely pretty form."""
40c5485b 164 return BN.b64encode(s, '+$').rstrip('=')
a2916c06 165
3cf8e1b7
MW
166def unhack_octets(s):
167 """Reverse the operation done by `hack_octets'."""
168 pad = (len(s) + 3)&3 - len(s)
558d2d93
MW
169 try:
170 return BN.b64decode(s + '='*pad, '+$')
171 except TypeError:
172 raise AuthenticationFailed, 'BADNONCE'
3cf8e1b7
MW
173
174def auth_tag(sec, stamp, user):
175 """Compute a tag using secret SEC on `STAMP.USER'."""
44e94112 176 hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH)
3cf8e1b7 177 hmac.update('chpwd-token.%d.%s' % (stamp, user))
a2916c06
MW
178 return hack_octets(hmac.digest())
179
3cf8e1b7
MW
180def csrf_tag(sec, stamp, user):
181 """Compute a tag using secret SEC on `STAMP.USER'."""
182 hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH)
183 hmac.update('chpwd-nonce.%d.%s' % (stamp, user))
184 return hmac.digest()
185
186def xor_strings(x, y):
187 """Return the bitwise XOR of two octet strings."""
188 return ''.join(chr(ord(xc) ^ ord(yc)) for xc, yc in I.izip(x, y))
189
7405b0d4
MW
190def aont_step(x, y):
191 """Perform a step of the OAEP-based all-or-nothing transform."""
192 return xor_strings(y, CFG.AUTHHASH(x).digest())
193
194def aont_transform(m):
195 """
196 Apply an all-or-nothing transform to a (short, binary) message M.
197
198 The result is returned as a binary string.
199 """
200
201 ## The current all-or-nothing transform is basically OAEP: a two-round
202 ## Feistel network applied to a (possibly lopsided) block consisting of the
203 ## message and a random nonce. Showing that this is an AONT (in the
204 ## random-oracle model) is pretty easy.
205 hashsz = CFG.AUTHHASH().digest_size
206 assert len(m) <= hashsz
207 r = OS.urandom(hashsz)
208 m = aont_step(r, m)
209 r = aont_step(m, r)
210 return r + m
211
212def aont_recover(c):
213 """
214 Recover a message from an all-or-nothing transform C (as a binary string).
215 """
216 hashsz = CFG.AUTHHASH().digest_size
217 if not (hashsz <= len(c) <= 2*hashsz):
218 raise AuthenticationFailed, 'BADNONCE'
219 r, m = c[:hashsz], c[hashsz:]
220 r = aont_step(m, r)
221 m = aont_step(r, m)
222 return m
3cf8e1b7 223
a2916c06
MW
224def mint_token(user):
225 """Make and return a fresh token for USER."""
226 sec = freshsecret()
3cf8e1b7
MW
227 tag = auth_tag(sec, U.NOW, user)
228 return '%d.%s.%s' % (U.NOW, tag, user)
a2916c06
MW
229
230## Long messages for reasons why one might have been redirected back to the
231## login page.
232LOGIN_REASONS = {
cced9f67
MW
233 'AUTHFAIL': 'Incorrect user name or password',
234 'NOAUTH': 'Not authenticated',
235 'NONONCE': 'Missing nonce',
236 'BADTOKEN': 'Malformed token',
237 'BADTIME': 'Invalid timestamp',
238 'BADNONCE': 'Nonce mismatch',
239 'EXPIRED': 'Session timed out',
240 'BADTAG': 'Incorrect tag',
241 'NOUSER': 'Unknown user name',
242 'LOGOUT': 'Explicitly logged out',
a2916c06
MW
243 None: None
244}
245
246class AuthenticationFailed (U.ExpectedError):
247 """
248 An authentication error. The most interesting extra feature is an
249 attribute `why' carrying a reason code, which can be looked up in
250 `LOGIN_REASONS'.
251 """
252 def __init__(me, why):
253 msg = LOGIN_REASONS[why]
254 U.ExpectedError.__init__(me, 403, msg)
255 me.why = why
256
257def check_auth(token, nonce = None):
258 """
259 Check that the TOKEN is valid, comparing it against the NONCE if this is
260 not `None'.
261
3cf8e1b7
MW
262 If the token is OK, then return the correct user name, and set `NONCE' to a
263 new nonce for the next request. Otherwise raise an `AuthenticationFailed'
264 exception with an appropriate `why'.
a2916c06
MW
265 """
266
267 global NONCE
268
170f1769
MW
269 ## If the token has been explicitly clobbered, then we're logged out.
270 if token == 'logged-out': raise AuthenticationFailed, 'LOGOUT'
271
a2916c06
MW
272 ## Parse the token.
273 bits = token.split('.', 3)
3cf8e1b7
MW
274 if len(bits) != 3: raise AuthenticationFailed, 'BADTOKEN'
275 stamp, tag, user = bits
a2916c06
MW
276
277 ## Check the stamp, and find the right secret.
278 if not stamp.isdigit(): raise AuthenticationFailed, 'BADTIME'
279 when = int(stamp)
280 sec = getsecret(when)
281 if sec is None: raise AuthenticationFailed, 'EXPIRED'
282
283 ## Check the tag.
3cf8e1b7 284 t = auth_tag(sec, when, user)
a2916c06
MW
285 if t != tag: raise AuthenticationFailed, 'BADTAG'
286
3cf8e1b7
MW
287 ## Determine the correct CSRF tag.
288 ntag = csrf_tag(sec, when, user)
289
290 ## Check that the nonce matches, if one was supplied.
291 if nonce is not None:
7405b0d4 292 gtag = aont_recover(unhack_octets(nonce))
3cf8e1b7
MW
293 if gtag != ntag: raise AuthenticationFailed, 'BADNONCE'
294
295 ## Make a new nonce string for use in forms.
7405b0d4 296 NONCE = hack_octets(aont_transform(ntag))
3cf8e1b7 297
a2916c06
MW
298 ## Make sure the user still exists.
299 try: acct = S.SERVICES['master'].find(user)
300 except S.UnknownUser: raise AuthenticationFailed, 'NOUSER'
301
302 ## Done.
303 return user
304
60b6f5b3
MW
305def bake_cookie(value):
306 """
307 Return a properly baked authentication-token cookie with the given VALUE.
308 """
309 return CGI.cookie('chpwd-token', value,
310 httponly = True,
311 secure = CGI.SSLP,
312 path = CFG.SCRIPT_NAME,
313 max_age = (CFG.SECRETLIFE - CFG.SECRETFRESH))
314
a2916c06
MW
315###--------------------------------------------------------------------------
316### Authentication commands.
317
318## A dummy string, for when we're invoked from the command-line.
319NONCE = '@DUMMY-NONCE'
320
321@CGI.subcommand(
322 'login', ['cgi-noauth'],
323 'Authenticate to the CGI machinery',
324 opts = [SC.Opt('why', '-w', '--why',
325 'Reason for redirection back to the login page.',
326 argname = 'WHY')])
327def cmd_login(why = None):
328 CGI.page('login.fhtml',
329 title = 'Chopwood: login',
7d41b86a 330 why = LOGIN_REASONS.get(why, '<unknown error %s>' % why))
a2916c06
MW
331
332@CGI.subcommand(
333 'auth', ['cgi-noauth'],
334 'Verify a user name and password',
9e574017 335 methods = ['POST'],
a2916c06
MW
336 params = [SC.Arg('u'), SC.Arg('pw')])
337def cmd_auth(u, pw):
338 svc = S.SERVICES['master']
339 try:
340 acct = svc.find(u)
341 acct.check(pw)
342 except (S.UnknownUser, S.IncorrectPassword):
343 CGI.redirect(CGI.action('login', why = 'AUTHFAIL'))
344 else:
345 t = mint_token(u)
bb623e8f 346 CGI.redirect(CGI.action('list', u),
60b6f5b3 347 set_cookie = bake_cookie(t))
a2916c06
MW
348
349###----- That's all, folks --------------------------------------------------