chiark / gitweb /
6bb3ec83c14701433eeb6a83801ecf8a2000895f
[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 os as OS
32
33 import cgi as CGI
34 import config as CONF; CFG = CONF.CFG
35 import dbmaint as D
36 import output as O; PRINT = O.PRINT
37 import service as S
38 import subcommand as SC
39 import util as U
40
41 ###--------------------------------------------------------------------------
42 ### About the authentication scheme.
43 ###
44 ### We mustn't allow a CGI user to make changes (or even learn about a user's
45 ### accounts) without authenticating first.  Curently, that means a username
46 ### and password, though I really dislike this; maybe I'll add a feature for
47 ### handling TLS client certificates some time.
48 ###
49 ### We're particularly worried about cross-site request forgery: a forged
50 ### request to change a password to some known value lets a bad guy straight
51 ### into a restricted service -- and a change to the `master' account lets
52 ### him into all of them.
53 ###
54 ### Once we've satisfied ourselves of the user's credentials, we issue a
55 ### short-lived session token, stored in a cookie namde `chpwd-token'.  This
56 ### token has the form `DATE.NONCE.TAG.USER': here, DATE is the POSIX time of
57 ### issue, as a decimal number; NONCE is a randomly chosen string, encoded in
58 ### base64, USER is the user's login name, and TAG is a cryptographic MAC tag
59 ### on the string `DATE.NONCE.USER'.  (The USER name is on the end so that it
60 ### can contain `.' characters without introducing parsing difficulties.)
61 ###
62 ### Secrets for these MAC tags are stored in the database: secrets expire
63 ### after 30 minutes (invalidating all tokens issued with them); we only
64 ### issue a token with a secret that's at most five minutes old.  A session's
65 ### lifetime, then, is somewhere between 25 and 30 minutes.  We choose the
66 ### lower bound as the cookie lifetime, just so that error messages end up
67 ### consistent.
68 ###
69 ### A cookie with a valid token is sufficient to grant read-only access to a
70 ### user's account details.  However, this authority is ambient: during the
71 ### validity period of the token, a cross-site request forgery can easily
72 ### succeed, since there's nothing about the rest of a request which is hard
73 ### to forge, and the cookie will be supplied automatically by the user
74 ### agent.  Showing the user some information we were quite happy to release
75 ### anyway isn't an interesting attack, but we must certainly require
76 ### something stronger for state-change requests.  Here, we also check that a
77 ### special request parameter `%nonce' matches the token's NONCE field: forms
78 ### setting up a `POST' action must include an appropriate hidden input
79 ### element.
80 ###
81 ### Messing about with cookies is a bit annoying, but it's hard to come up
82 ### with alternatives.  I'm trying to keep the URLs fairly pretty, and anyway
83 ### putting secrets into them is asking for trouble, since user agents have
84 ### an awful tendecy to store URLs in a history database, send them to
85 ### motherships, leak them in `Referer' headers, and other awful things.  Our
86 ### cookie is marked `HttpOnly' so, in particular, user agents must keep them
87 ### out of the grubby mitts of Javascript programs.
88 ###
89 ### I promise that I'm only using these cookies for the purposes of
90 ### maintaining security: I don't log them or do anything else at all with
91 ### them.
92
93 ###--------------------------------------------------------------------------
94 ### Generating and checking authentication tokens.
95
96 ## Secret lifetime parameters.
97 CONF.DEFAULTS.update(
98
99   ## The lifetime of a session cookie, in seconds.
100   SECRETLIFE = 30*60,
101
102   ## Maximum age of an authentication key, in seconds.
103   SECRETFRESH = 5*60,
104
105   ## Hash function to use for crypto.
106   AUTHHASH = H.sha256)
107
108 def cleansecrets():
109   """Remove dead secrets from the database."""
110   with D.DB:
111     D.DB.execute("DELETE FROM secrets WHERE stamp < $stale",
112                  stale = U.NOW - CFG.SECRETLIFE)
113
114 def getsecret(when):
115   """
116   Return the newest and most shiny secret no older than WHEN.
117
118   If there is no such secret, or the only one available would have been stale
119   at WHEN, then return `None'.
120   """
121   cleansecrets()
122   with D.DB:
123     D.DB.execute("""SELECT stamp, secret FROM secrets
124                     WHERE stamp <= $when
125                     ORDER BY stamp DESC""",
126                when = when)
127     row = D.DB.fetchone()
128     if row is None: return None
129     if row[0] < when - CFG.SECRETFRESH: return None
130     return row[1].decode('base64')
131
132 def freshsecret():
133   """Return a fresh secret."""
134   cleansecrets()
135   with D.DB:
136     D.DB.execute("""SELECT secret FROM secrets
137                     WHERE stamp >= $fresh
138                     ORDER BY stamp DESC""",
139                  fresh = U.NOW - CFG.SECRETFRESH)
140     row = D.DB.fetchone()
141     if row is not None:
142       sec = row[0].decode('base64')
143     else:
144       sec = OS.urandom(16)
145       D.DB.execute("""INSERT INTO secrets(stamp, secret)
146                       VALUES ($stamp, $secret)""",
147                    stamp = U.NOW, secret = sec.encode('base64'))
148     return sec
149
150 def hack_octets(s):
151   """Return the octet string S, in a vaguely pretty form."""
152   return BN.b64encode(s, '+$').rstrip('=')
153
154 def auth_tag(sec, stamp, nonce, user):
155   """Compute a tag using secret SEC on `STAMP.NONCE.USER'."""
156   hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH)
157   hmac.update('%d.%s.%s' % (stamp, nonce, user))
158   return hack_octets(hmac.digest())
159
160 def mint_token(user):
161   """Make and return a fresh token for USER."""
162   sec = freshsecret()
163   nonce = hack_octets(OS.urandom(16))
164   tag = auth_tag(sec, U.NOW, nonce, user)
165   return '%d.%s.%s.%s' % (U.NOW, nonce, tag, user)
166
167 ## Long messages for reasons why one might have been redirected back to the
168 ## login page.
169 LOGIN_REASONS = {
170   'AUTHFAIL': 'incorrect user name or password',
171   'NOAUTH': 'not authenticated',
172   'NONONCE': 'missing nonce',
173   'BADTOKEN': 'malformed token',
174   'BADTIME': 'invalid timestamp',
175   'BADNONCE': 'nonce mismatch',
176   'EXPIRED': 'session timed out',
177   'BADTAG': 'incorrect tag',
178   'NOUSER': 'unknown user name',
179   'LOGOUT': 'explicitly logged out',
180   None: None
181 }
182
183 class AuthenticationFailed (U.ExpectedError):
184   """
185   An authentication error.  The most interesting extra feature is an
186   attribute `why' carrying a reason code, which can be looked up in
187   `LOGIN_REASONS'.
188   """
189   def __init__(me, why):
190     msg = LOGIN_REASONS[why]
191     U.ExpectedError.__init__(me, 403, msg)
192     me.why = why
193
194 def check_auth(token, nonce = None):
195   """
196   Check that the TOKEN is valid, comparing it against the NONCE if this is
197   not `None'.
198
199   If the token is OK, then return the correct user name, and set `NONCE' set
200   to the appropriate portion of the token.  Otherwise raise an
201   `AuthenticationFailed' exception with an appropriate `why'.
202   """
203
204   global NONCE
205
206   ## If the token has been explicitly clobbered, then we're logged out.
207   if token == 'logged-out': raise AuthenticationFailed, 'LOGOUT'
208
209   ## Parse the token.
210   bits = token.split('.', 3)
211   if len(bits) != 4: raise AuthenticationFailed, 'BADTOKEN'
212   stamp, NONCE, tag, user = bits
213
214   ## Check that the nonce matches, if one was supplied.
215   if nonce is not None and nonce != NONCE:
216     raise AuthenticationFailed, 'BADNONCE'
217
218   ## Check the stamp, and find the right secret.
219   if not stamp.isdigit(): raise AuthenticationFailed, 'BADTIME'
220   when = int(stamp)
221   sec = getsecret(when)
222   if sec is None: raise AuthenticationFailed, 'EXPIRED'
223
224   ## Check the tag.
225   t = auth_tag(sec, when, NONCE, user)
226   if t != tag: raise AuthenticationFailed, 'BADTAG'
227
228   ## Make sure the user still exists.
229   try: acct = S.SERVICES['master'].find(user)
230   except S.UnknownUser: raise AuthenticationFailed, 'NOUSER'
231
232   ## Done.
233   return user
234
235 def bake_cookie(value):
236   """
237   Return a properly baked authentication-token cookie with the given VALUE.
238   """
239   return CGI.cookie('chpwd-token', value,
240                     httponly = True,
241                     secure = CGI.SSLP,
242                     path = CFG.SCRIPT_NAME,
243                     max_age = (CFG.SECRETLIFE - CFG.SECRETFRESH))
244
245 ###--------------------------------------------------------------------------
246 ### Authentication commands.
247
248 ## A dummy string, for when we're invoked from the command-line.
249 NONCE = '@DUMMY-NONCE'
250
251 @CGI.subcommand(
252   'login', ['cgi-noauth'],
253   'Authenticate to the CGI machinery',
254   opts = [SC.Opt('why', '-w', '--why',
255                  'Reason for redirection back to the login page.',
256                  argname = 'WHY')])
257 def cmd_login(why = None):
258   CGI.page('login.fhtml',
259            title = 'Chopwood: login',
260            why =LOGIN_REASONS.get(why, '<unknown error %s>' % why))
261
262 @CGI.subcommand(
263   'auth', ['cgi-noauth'],
264   'Verify a user name and password',
265   params = [SC.Arg('u'), SC.Arg('pw')])
266 def cmd_auth(u, pw):
267   svc = S.SERVICES['master']
268   try:
269     acct = svc.find(u)
270     acct.check(pw)
271   except (S.UnknownUser, S.IncorrectPassword):
272     CGI.redirect(CGI.action('login', why = 'AUTHFAIL'))
273   else:
274     t = mint_token(u)
275     CGI.redirect(CGI.action('list', u),
276                  set_cookie = bake_cookie(t))
277
278 ###----- That's all, folks --------------------------------------------------