chiark / gitweb /
httpauth.py: Don't crash if Base-64 decoding of the CSRF token fails.
[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
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.)
a2916c06
MW
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.
101CONF.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.
44e94112
MW
107 SECRETFRESH = 5*60,
108
109 ## Hash function to use for crypto.
110 AUTHHASH = H.sha256)
a2916c06
MW
111
112def 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
118def 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
136def 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
154def hack_octets(s):
155 """Return the octet string S, in a vaguely pretty form."""
40c5485b 156 return BN.b64encode(s, '+$').rstrip('=')
a2916c06 157
3cf8e1b7
MW
158def unhack_octets(s):
159 """Reverse the operation done by `hack_octets'."""
160 pad = (len(s) + 3)&3 - len(s)
558d2d93
MW
161 try:
162 return BN.b64decode(s + '='*pad, '+$')
163 except TypeError:
164 raise AuthenticationFailed, 'BADNONCE'
3cf8e1b7
MW
165
166def auth_tag(sec, stamp, user):
167 """Compute a tag using secret SEC on `STAMP.USER'."""
44e94112 168 hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH)
3cf8e1b7 169 hmac.update('chpwd-token.%d.%s' % (stamp, user))
a2916c06
MW
170 return hack_octets(hmac.digest())
171
3cf8e1b7
MW
172def 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
178def 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
182def 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
a2916c06
MW
187def mint_token(user):
188 """Make and return a fresh token for USER."""
189 sec = freshsecret()
3cf8e1b7
MW
190 tag = auth_tag(sec, U.NOW, user)
191 return '%d.%s.%s' % (U.NOW, tag, user)
a2916c06
MW
192
193## Long messages for reasons why one might have been redirected back to the
194## login page.
195LOGIN_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',
170f1769 205 'LOGOUT': 'explicitly logged out',
a2916c06
MW
206 None: None
207}
208
209class 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
220def 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
3cf8e1b7
MW
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'.
a2916c06
MW
228 """
229
230 global NONCE
231
170f1769
MW
232 ## If the token has been explicitly clobbered, then we're logged out.
233 if token == 'logged-out': raise AuthenticationFailed, 'LOGOUT'
234
a2916c06
MW
235 ## Parse the token.
236 bits = token.split('.', 3)
3cf8e1b7
MW
237 if len(bits) != 3: raise AuthenticationFailed, 'BADTOKEN'
238 stamp, tag, user = bits
a2916c06
MW
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.
3cf8e1b7 247 t = auth_tag(sec, when, user)
a2916c06
MW
248 if t != tag: raise AuthenticationFailed, 'BADTAG'
249
3cf8e1b7
MW
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
a2916c06
MW
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
60b6f5b3
MW
274def 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
a2916c06
MW
284###--------------------------------------------------------------------------
285### Authentication commands.
286
287## A dummy string, for when we're invoked from the command-line.
288NONCE = '@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')])
296def cmd_login(why = None):
297 CGI.page('login.fhtml',
298 title = 'Chopwood: login',
7d41b86a 299 why = LOGIN_REASONS.get(why, '<unknown error %s>' % why))
a2916c06
MW
300
301@CGI.subcommand(
302 'auth', ['cgi-noauth'],
303 'Verify a user name and password',
304 params = [SC.Arg('u'), SC.Arg('pw')])
305def 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)
bb623e8f 314 CGI.redirect(CGI.action('list', u),
60b6f5b3 315 set_cookie = bake_cookie(t))
a2916c06
MW
316
317###----- That's all, folks --------------------------------------------------