3 ### HTTP authentication
5 ### (c) 2013 Mark Wooding
8 ###----- Licensing notice ---------------------------------------------------
10 ### This file is part of Chopwood: a password-changing service.
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.
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.
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/>.
26 from __future__ import with_statement
35 import config as CONF; CFG = CONF.CFG
37 import output as O; PRINT = O.PRINT
39 import subcommand as SC
42 ###--------------------------------------------------------------------------
43 ### About the authentication scheme.
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.
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.
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.)
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
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.)
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.
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
97 ###--------------------------------------------------------------------------
98 ### Generating and checking authentication tokens.
100 ## Secret lifetime parameters.
101 CONF.DEFAULTS.update(
103 ## The lifetime of a session cookie, in seconds.
106 ## Maximum age of an authentication key, in seconds.
109 ## Hash function to use for crypto.
113 """Remove dead secrets from the database."""
115 D.DB.execute("DELETE FROM secrets WHERE stamp < $stale",
116 stale = U.NOW - CFG.SECRETLIFE)
120 Return the newest and most shiny secret no older than WHEN.
122 If there is no such secret, or the only one available would have been stale
123 at WHEN, then return `None'.
127 D.DB.execute("""SELECT stamp, secret FROM secrets
129 ORDER BY stamp DESC""",
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')
137 """Return a fresh secret."""
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()
146 sec = row[0].decode('base64')
149 D.DB.execute("""INSERT INTO secrets(stamp, secret)
150 VALUES ($stamp, $secret)""",
151 stamp = U.NOW, secret = sec.encode('base64'))
155 """Return the octet string S, in a vaguely pretty form."""
156 return BN.b64encode(s, '+$').rstrip('=')
158 def unhack_octets(s):
159 """Reverse the operation done by `hack_octets'."""
160 pad = (len(s) + 3)&3 - len(s)
161 return BN.b64decode(s + '='*pad, '+$')
163 def auth_tag(sec, stamp, user):
164 """Compute a tag using secret SEC on `STAMP.USER'."""
165 hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH)
166 hmac.update('chpwd-token.%d.%s' % (stamp, user))
167 return hack_octets(hmac.digest())
169 def csrf_tag(sec, stamp, user):
170 """Compute a tag using secret SEC on `STAMP.USER'."""
171 hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH)
172 hmac.update('chpwd-nonce.%d.%s' % (stamp, user))
175 def xor_strings(x, y):
176 """Return the bitwise XOR of two octet strings."""
177 return ''.join(chr(ord(xc) ^ ord(yc)) for xc, yc in I.izip(x, y))
179 def mint_csrf_nonce(sec, ntag):
180 left = OS.urandom(len(ntag))
181 right = xor_strings(left, ntag)
182 return '%s.%s' % (hack_octets(left), hack_octets(right))
184 def mint_token(user):
185 """Make and return a fresh token for USER."""
187 tag = auth_tag(sec, U.NOW, user)
188 return '%d.%s.%s' % (U.NOW, tag, user)
190 ## Long messages for reasons why one might have been redirected back to the
193 'AUTHFAIL': 'incorrect user name or password',
194 'NOAUTH': 'not authenticated',
195 'NONONCE': 'missing nonce',
196 'BADTOKEN': 'malformed token',
197 'BADTIME': 'invalid timestamp',
198 'BADNONCE': 'nonce mismatch',
199 'EXPIRED': 'session timed out',
200 'BADTAG': 'incorrect tag',
201 'NOUSER': 'unknown user name',
202 'LOGOUT': 'explicitly logged out',
206 class AuthenticationFailed (U.ExpectedError):
208 An authentication error. The most interesting extra feature is an
209 attribute `why' carrying a reason code, which can be looked up in
212 def __init__(me, why):
213 msg = LOGIN_REASONS[why]
214 U.ExpectedError.__init__(me, 403, msg)
217 def check_auth(token, nonce = None):
219 Check that the TOKEN is valid, comparing it against the NONCE if this is
222 If the token is OK, then return the correct user name, and set `NONCE' to a
223 new nonce for the next request. Otherwise raise an `AuthenticationFailed'
224 exception with an appropriate `why'.
229 ## If the token has been explicitly clobbered, then we're logged out.
230 if token == 'logged-out': raise AuthenticationFailed, 'LOGOUT'
233 bits = token.split('.', 3)
234 if len(bits) != 3: raise AuthenticationFailed, 'BADTOKEN'
235 stamp, tag, user = bits
237 ## Check the stamp, and find the right secret.
238 if not stamp.isdigit(): raise AuthenticationFailed, 'BADTIME'
240 sec = getsecret(when)
241 if sec is None: raise AuthenticationFailed, 'EXPIRED'
244 t = auth_tag(sec, when, user)
245 if t != tag: raise AuthenticationFailed, 'BADTAG'
247 ## Determine the correct CSRF tag.
248 ntag = csrf_tag(sec, when, user)
250 ## Check that the nonce matches, if one was supplied.
251 if nonce is not None:
252 bits = nonce.split('.', 2)
253 if len(bits) != 2: raise AuthenticationFailed, 'BADNONCE'
254 try: left, right = map(unhack_octets, bits)
255 except TypeError: raise AuthenticationFailed, 'BADNONCE'
256 if len(left) != len(right) or len(left) != len(ntag):
257 raise AuthenticationFailed, 'BADNONCE'
258 gtag = xor_strings(left, right)
259 if gtag != ntag: raise AuthenticationFailed, 'BADNONCE'
261 ## Make a new nonce string for use in forms.
262 NONCE = mint_csrf_nonce(sec, ntag)
264 ## Make sure the user still exists.
265 try: acct = S.SERVICES['master'].find(user)
266 except S.UnknownUser: raise AuthenticationFailed, 'NOUSER'
271 def bake_cookie(value):
273 Return a properly baked authentication-token cookie with the given VALUE.
275 return CGI.cookie('chpwd-token', value,
278 path = CFG.SCRIPT_NAME,
279 max_age = (CFG.SECRETLIFE - CFG.SECRETFRESH))
281 ###--------------------------------------------------------------------------
282 ### Authentication commands.
284 ## A dummy string, for when we're invoked from the command-line.
285 NONCE = '@DUMMY-NONCE'
288 'login', ['cgi-noauth'],
289 'Authenticate to the CGI machinery',
290 opts = [SC.Opt('why', '-w', '--why',
291 'Reason for redirection back to the login page.',
293 def cmd_login(why = None):
294 CGI.page('login.fhtml',
295 title = 'Chopwood: login',
296 why = LOGIN_REASONS.get(why, '<unknown error %s>' % why))
299 'auth', ['cgi-noauth'],
300 'Verify a user name and password',
301 params = [SC.Arg('u'), SC.Arg('pw')])
303 svc = S.SERVICES['master']
307 except (S.UnknownUser, S.IncorrectPassword):
308 CGI.redirect(CGI.action('login', why = 'AUTHFAIL'))
311 CGI.redirect(CGI.action('list', u),
312 set_cookie = bake_cookie(t))
314 ###----- That's all, folks --------------------------------------------------