chiark / gitweb /
httpauth.py: Allow configuration of the hash function.
[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
31import os as OS
32
33import cgi as CGI
34import config as CONF; CFG = CONF.CFG
35import dbmaint as D
36import output as O; PRINT = O.PRINT
37import service as S
38import subcommand as SC
39import 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.
97CONF.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.
44e94112
MW
103 SECRETFRESH = 5*60,
104
105 ## Hash function to use for crypto.
106 AUTHHASH = H.sha256)
a2916c06
MW
107
108def 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
114def 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
132def 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
150def hack_octets(s):
151 """Return the octet string S, in a vaguely pretty form."""
152 return BN.b64encode(s) \
153 .rstrip('=') \
154 .replace('/', '$')
155
156def auth_tag(sec, stamp, nonce, user):
157 """Compute a tag using secret SEC on `STAMP.NONCE.USER'."""
44e94112 158 hmac = HM.HMAC(sec, digestmod = CFG.AUTHHASH)
a2916c06
MW
159 hmac.update('%d.%s.%s' % (stamp, nonce, user))
160 return hack_octets(hmac.digest())
161
162def mint_token(user):
163 """Make and return a fresh token for USER."""
164 sec = freshsecret()
165 nonce = hack_octets(OS.urandom(16))
166 tag = auth_tag(sec, U.NOW, nonce, user)
167 return '%d.%s.%s.%s' % (U.NOW, nonce, tag, user)
168
169## Long messages for reasons why one might have been redirected back to the
170## login page.
171LOGIN_REASONS = {
172 'AUTHFAIL': 'incorrect user name or password',
173 'NOAUTH': 'not authenticated',
174 'NONONCE': 'missing nonce',
175 'BADTOKEN': 'malformed token',
176 'BADTIME': 'invalid timestamp',
177 'BADNONCE': 'nonce mismatch',
178 'EXPIRED': 'session timed out',
179 'BADTAG': 'incorrect tag',
180 'NOUSER': 'unknown user name',
170f1769 181 'LOGOUT': 'explicitly logged out',
a2916c06
MW
182 None: None
183}
184
185class AuthenticationFailed (U.ExpectedError):
186 """
187 An authentication error. The most interesting extra feature is an
188 attribute `why' carrying a reason code, which can be looked up in
189 `LOGIN_REASONS'.
190 """
191 def __init__(me, why):
192 msg = LOGIN_REASONS[why]
193 U.ExpectedError.__init__(me, 403, msg)
194 me.why = why
195
196def check_auth(token, nonce = None):
197 """
198 Check that the TOKEN is valid, comparing it against the NONCE if this is
199 not `None'.
200
201 If the token is OK, then return the correct user name, and set `NONCE' set
202 to the appropriate portion of the token. Otherwise raise an
203 `AuthenticationFailed' exception with an appropriate `why'.
204 """
205
206 global NONCE
207
170f1769
MW
208 ## If the token has been explicitly clobbered, then we're logged out.
209 if token == 'logged-out': raise AuthenticationFailed, 'LOGOUT'
210
a2916c06
MW
211 ## Parse the token.
212 bits = token.split('.', 3)
213 if len(bits) != 4: raise AuthenticationFailed, 'BADTOKEN'
214 stamp, NONCE, tag, user = bits
215
216 ## Check that the nonce matches, if one was supplied.
217 if nonce is not None and nonce != NONCE:
218 raise AuthenticationFailed, 'BADNONCE'
219
220 ## Check the stamp, and find the right secret.
221 if not stamp.isdigit(): raise AuthenticationFailed, 'BADTIME'
222 when = int(stamp)
223 sec = getsecret(when)
224 if sec is None: raise AuthenticationFailed, 'EXPIRED'
225
226 ## Check the tag.
227 t = auth_tag(sec, when, NONCE, user)
228 if t != tag: raise AuthenticationFailed, 'BADTAG'
229
230 ## Make sure the user still exists.
231 try: acct = S.SERVICES['master'].find(user)
232 except S.UnknownUser: raise AuthenticationFailed, 'NOUSER'
233
234 ## Done.
235 return user
236
60b6f5b3
MW
237def bake_cookie(value):
238 """
239 Return a properly baked authentication-token cookie with the given VALUE.
240 """
241 return CGI.cookie('chpwd-token', value,
242 httponly = True,
243 secure = CGI.SSLP,
244 path = CFG.SCRIPT_NAME,
245 max_age = (CFG.SECRETLIFE - CFG.SECRETFRESH))
246
a2916c06
MW
247###--------------------------------------------------------------------------
248### Authentication commands.
249
250## A dummy string, for when we're invoked from the command-line.
251NONCE = '@DUMMY-NONCE'
252
253@CGI.subcommand(
254 'login', ['cgi-noauth'],
255 'Authenticate to the CGI machinery',
256 opts = [SC.Opt('why', '-w', '--why',
257 'Reason for redirection back to the login page.',
258 argname = 'WHY')])
259def cmd_login(why = None):
260 CGI.page('login.fhtml',
261 title = 'Chopwood: login',
262 why =LOGIN_REASONS.get(why, '<unknown error %s>' % why))
263
264@CGI.subcommand(
265 'auth', ['cgi-noauth'],
266 'Verify a user name and password',
267 params = [SC.Arg('u'), SC.Arg('pw')])
268def cmd_auth(u, pw):
269 svc = S.SERVICES['master']
270 try:
271 acct = svc.find(u)
272 acct.check(pw)
273 except (S.UnknownUser, S.IncorrectPassword):
274 CGI.redirect(CGI.action('login', why = 'AUTHFAIL'))
275 else:
276 t = mint_token(u)
bb623e8f 277 CGI.redirect(CGI.action('list', u),
60b6f5b3 278 set_cookie = bake_cookie(t))
a2916c06
MW
279
280###----- That's all, folks --------------------------------------------------