chiark / gitweb /
httpauth.py: Don't crash if Base-64 decoding of the CSRF token fails.
[chopwood] / httpauth.py
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
26 from __future__ import with_statement
27
28 import base64 as BN
29 import hashlib as H
30 import hmac as HM
31 import itertools as I
32 import os as OS
33
34 import cgi as CGI
35 import config as CONF; CFG = CONF.CFG
36 import dbmaint as D
37 import output as O; PRINT = O.PRINT
38 import service as S
39 import subcommand as SC
40 import 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
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.)
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
77 ### something stronger for state-change requests.  Here, we also check a
78 ### special request parameter `%nonce': forms setting up a `POST' action must
79 ### include an appropriate hidden input element.  The `%nonce' parameter has
80 ### the form `LEFT.RIGHT', where LEFT and RIGHT are two base-64 strings such
81 ### that their XOR is the (deterministic) MAC tag on `chpwd-nonce.DATE.USER'.
82 ### (The LEFT string is chosen at random, and the RIGHT string is set to the
83 ### appropriate TAG XOR LEFT.)
84 ###
85 ### Messing about with cookies is a bit annoying, but it's hard to come up
86 ### with alternatives.  I'm trying to keep the URLs fairly pretty, and anyway
87 ### putting secrets into them is asking for trouble, since user agents have
88 ### an awful tendecy to store URLs in a history database, send them to
89 ### motherships, leak them in `Referer' headers, and other awful things.  Our
90 ### cookie is marked `HttpOnly' so, in particular, user agents must keep them
91 ### out of the grubby mitts of Javascript programs.
92 ###
93 ### I promise that I'm only using these cookies for the purposes of
94 ### maintaining security: I don't log them or do anything else at all with
95 ### them.
96
97 ###--------------------------------------------------------------------------
98 ### Generating and checking authentication tokens.
99
100 ## Secret lifetime parameters.
101 CONF.DEFAULTS.update(
102
103   ## The lifetime of a session cookie, in seconds.
104   SECRETLIFE = 30*60,
105
106   ## Maximum age of an authentication key, in seconds.
107   SECRETFRESH = 5*60,
108
109   ## Hash function to use for crypto.
110   AUTHHASH = H.sha256)
111
112 def cleansecrets():
113   """Remove dead secrets from the database."""
114   with D.DB:
115     D.DB.execute("DELETE FROM secrets WHERE stamp < $stale",
116                  stale = U.NOW - CFG.SECRETLIFE)
117
118 def getsecret(when):
119   """
120   Return the newest and most shiny secret no older than WHEN.
121
122   If there is no such secret, or the only one available would have been stale
123   at WHEN, then return `None'.
124   """
125   cleansecrets()
126   with D.DB:
127     D.DB.execute("""SELECT stamp, secret FROM secrets
128                     WHERE stamp <= $when
129                     ORDER BY stamp DESC""",
130                when = when)
131     row = D.DB.fetchone()
132     if row is None: return None
133     if row[0] < when - CFG.SECRETFRESH: return None
134     return row[1].decode('base64')
135
136 def freshsecret():
137   """Return a fresh secret."""
138   cleansecrets()
139   with D.DB:
140     D.DB.execute("""SELECT secret FROM secrets
141                     WHERE stamp >= $fresh
142                     ORDER BY stamp DESC""",
143                  fresh = U.NOW - CFG.SECRETFRESH)
144     row = D.DB.fetchone()
145     if row is not None:
146       sec = row[0].decode('base64')
147     else:
148       sec = OS.urandom(16)
149       D.DB.execute("""INSERT INTO secrets(stamp, secret)
150                       VALUES ($stamp, $secret)""",
151                    stamp = U.NOW, secret = sec.encode('base64'))
152     return sec
153
154 def hack_octets(s):
155   """Return the octet string S, in a vaguely pretty form."""
156   return BN.b64encode(s, '+$').rstrip('=')
157
158 def unhack_octets(s):
159   """Reverse the operation done by `hack_octets'."""
160   pad = (len(s) + 3)&3 - len(s)
161   try:
162     return BN.b64decode(s + '='*pad, '+$')
163   except TypeError:
164     raise AuthenticationFailed, 'BADNONCE'
165
166 def auth_tag(sec, stamp, user):
167   """Compute a tag using secret SEC on `STAMP.USER'."""
168   hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH)
169   hmac.update('chpwd-token.%d.%s' % (stamp, user))
170   return hack_octets(hmac.digest())
171
172 def csrf_tag(sec, stamp, user):
173   """Compute a tag using secret SEC on `STAMP.USER'."""
174   hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH)
175   hmac.update('chpwd-nonce.%d.%s' % (stamp, user))
176   return hmac.digest()
177
178 def xor_strings(x, y):
179   """Return the bitwise XOR of two octet strings."""
180   return ''.join(chr(ord(xc) ^ ord(yc)) for xc, yc in I.izip(x, y))
181
182 def mint_csrf_nonce(sec, ntag):
183   left = OS.urandom(len(ntag))
184   right = xor_strings(left, ntag)
185   return '%s.%s' % (hack_octets(left), hack_octets(right))
186
187 def mint_token(user):
188   """Make and return a fresh token for USER."""
189   sec = freshsecret()
190   tag = auth_tag(sec, U.NOW, user)
191   return '%d.%s.%s' % (U.NOW, tag, user)
192
193 ## Long messages for reasons why one might have been redirected back to the
194 ## login page.
195 LOGIN_REASONS = {
196   'AUTHFAIL': 'incorrect user name or password',
197   'NOAUTH': 'not authenticated',
198   'NONONCE': 'missing nonce',
199   'BADTOKEN': 'malformed token',
200   'BADTIME': 'invalid timestamp',
201   'BADNONCE': 'nonce mismatch',
202   'EXPIRED': 'session timed out',
203   'BADTAG': 'incorrect tag',
204   'NOUSER': 'unknown user name',
205   'LOGOUT': 'explicitly logged out',
206   None: None
207 }
208
209 class AuthenticationFailed (U.ExpectedError):
210   """
211   An authentication error.  The most interesting extra feature is an
212   attribute `why' carrying a reason code, which can be looked up in
213   `LOGIN_REASONS'.
214   """
215   def __init__(me, why):
216     msg = LOGIN_REASONS[why]
217     U.ExpectedError.__init__(me, 403, msg)
218     me.why = why
219
220 def check_auth(token, nonce = None):
221   """
222   Check that the TOKEN is valid, comparing it against the NONCE if this is
223   not `None'.
224
225   If the token is OK, then return the correct user name, and set `NONCE' to a
226   new nonce for the next request.  Otherwise raise an `AuthenticationFailed'
227   exception with an appropriate `why'.
228   """
229
230   global NONCE
231
232   ## If the token has been explicitly clobbered, then we're logged out.
233   if token == 'logged-out': raise AuthenticationFailed, 'LOGOUT'
234
235   ## Parse the token.
236   bits = token.split('.', 3)
237   if len(bits) != 3: raise AuthenticationFailed, 'BADTOKEN'
238   stamp, tag, user = bits
239
240   ## Check the stamp, and find the right secret.
241   if not stamp.isdigit(): raise AuthenticationFailed, 'BADTIME'
242   when = int(stamp)
243   sec = getsecret(when)
244   if sec is None: raise AuthenticationFailed, 'EXPIRED'
245
246   ## Check the tag.
247   t = auth_tag(sec, when, user)
248   if t != tag: raise AuthenticationFailed, 'BADTAG'
249
250   ## Determine the correct CSRF tag.
251   ntag = csrf_tag(sec, when, user)
252
253   ## Check that the nonce matches, if one was supplied.
254   if nonce is not None:
255     bits = nonce.split('.', 2)
256     if len(bits) != 2: raise AuthenticationFailed, 'BADNONCE'
257     try: left, right = map(unhack_octets, bits)
258     except TypeError: raise AuthenticationFailed, 'BADNONCE'
259     if len(left) != len(right) or len(left) != len(ntag):
260       raise AuthenticationFailed, 'BADNONCE'
261     gtag = xor_strings(left, right)
262     if gtag != ntag: raise AuthenticationFailed, 'BADNONCE'
263
264   ## Make a new nonce string for use in forms.
265   NONCE = mint_csrf_nonce(sec, ntag)
266
267   ## Make sure the user still exists.
268   try: acct = S.SERVICES['master'].find(user)
269   except S.UnknownUser: raise AuthenticationFailed, 'NOUSER'
270
271   ## Done.
272   return user
273
274 def bake_cookie(value):
275   """
276   Return a properly baked authentication-token cookie with the given VALUE.
277   """
278   return CGI.cookie('chpwd-token', value,
279                     httponly = True,
280                     secure = CGI.SSLP,
281                     path = CFG.SCRIPT_NAME,
282                     max_age = (CFG.SECRETLIFE - CFG.SECRETFRESH))
283
284 ###--------------------------------------------------------------------------
285 ### Authentication commands.
286
287 ## A dummy string, for when we're invoked from the command-line.
288 NONCE = '@DUMMY-NONCE'
289
290 @CGI.subcommand(
291   'login', ['cgi-noauth'],
292   'Authenticate to the CGI machinery',
293   opts = [SC.Opt('why', '-w', '--why',
294                  'Reason for redirection back to the login page.',
295                  argname = 'WHY')])
296 def cmd_login(why = None):
297   CGI.page('login.fhtml',
298            title = 'Chopwood: login',
299            why = LOGIN_REASONS.get(why, '<unknown error %s>' % why))
300
301 @CGI.subcommand(
302   'auth', ['cgi-noauth'],
303   'Verify a user name and password',
304   params = [SC.Arg('u'), SC.Arg('pw')])
305 def cmd_auth(u, pw):
306   svc = S.SERVICES['master']
307   try:
308     acct = svc.find(u)
309     acct.check(pw)
310   except (S.UnknownUser, S.IncorrectPassword):
311     CGI.redirect(CGI.action('login', why = 'AUTHFAIL'))
312   else:
313     t = mint_token(u)
314     CGI.redirect(CGI.action('list', u),
315                  set_cookie = bake_cookie(t))
316
317 ###----- That's all, folks --------------------------------------------------