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
34 import config
as CONF
; CFG
= CONF
.CFG
36 import output
as O
; PRINT
= O
.PRINT
38 import subcommand
as SC
41 ###--------------------------------------------------------------------------
42 ### About the authentication scheme.
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.
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.
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.)
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
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
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.
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
93 ###--------------------------------------------------------------------------
94 ### Generating and checking authentication tokens.
96 ## Secret lifetime parameters.
99 ## The lifetime of a session cookie, in seconds.
102 ## Maximum age of an authentication key, in seconds.
106 """Remove dead secrets from the database."""
108 D
.DB
.execute("DELETE FROM secrets WHERE stamp < $stale",
109 stale
= U
.NOW
- CFG
.SECRETLIFE
)
113 Return the newest and most shiny secret no older than WHEN.
115 If there is no such secret, or the only one available would have been stale
116 at WHEN, then return `None'.
120 D
.DB
.execute("""SELECT stamp, secret FROM secrets
122 ORDER BY stamp DESC""",
124 row
= D
.DB
.fetchone()
125 if row
is None: return None
126 if row
[0] < when
- CFG
.SECRETFRESH
: return None
127 return row
[1].decode('base64')
130 """Return a fresh secret."""
133 D
.DB
.execute("""SELECT secret FROM secrets
134 WHERE stamp >= $fresh
135 ORDER BY stamp DESC""",
136 fresh
= U
.NOW
- CFG
.SECRETFRESH
)
137 row
= D
.DB
.fetchone()
139 sec
= row
[0].decode('base64')
142 D
.DB
.execute("""INSERT INTO secrets(stamp, secret)
143 VALUES ($stamp, $secret)""",
144 stamp
= U
.NOW
, secret
= sec
.encode('base64'))
148 """Return the octet string S, in a vaguely pretty form."""
149 return BN
.b64encode(s
) \
153 def auth_tag(sec
, stamp
, nonce
, user
):
154 """Compute a tag using secret SEC on `STAMP.NONCE.USER'."""
155 hmac
= HM
.HMAC(sec
, digestmod
= H
.sha256
)
156 hmac
.update('%d.%s.%s' %
(stamp
, nonce
, user
))
157 return hack_octets(hmac
.digest())
159 def mint_token(user
):
160 """Make and return a fresh token for USER."""
162 nonce
= hack_octets(OS
.urandom(16))
163 tag
= auth_tag(sec
, U
.NOW
, nonce
, user
)
164 return '%d.%s.%s.%s' %
(U
.NOW
, nonce
, tag
, user
)
166 ## Long messages for reasons why one might have been redirected back to the
169 'AUTHFAIL': 'incorrect user name or password',
170 'NOAUTH': 'not authenticated',
171 'NONONCE': 'missing nonce',
172 'BADTOKEN': 'malformed token',
173 'BADTIME': 'invalid timestamp',
174 'BADNONCE': 'nonce mismatch',
175 'EXPIRED': 'session timed out',
176 'BADTAG': 'incorrect tag',
177 'NOUSER': 'unknown user name',
178 'LOGOUT': 'explicitly logged out',
182 class AuthenticationFailed (U
.ExpectedError
):
184 An authentication error. The most interesting extra feature is an
185 attribute `why' carrying a reason code, which can be looked up in
188 def __init__(me
, why
):
189 msg
= LOGIN_REASONS
[why
]
190 U
.ExpectedError
.__init__(me
, 403, msg
)
193 def check_auth(token
, nonce
= None):
195 Check that the TOKEN is valid, comparing it against the NONCE if this is
198 If the token is OK, then return the correct user name, and set `NONCE' set
199 to the appropriate portion of the token. Otherwise raise an
200 `AuthenticationFailed' exception with an appropriate `why'.
205 ## If the token has been explicitly clobbered, then we're logged out.
206 if token
== 'logged-out': raise AuthenticationFailed
, 'LOGOUT'
209 bits
= token
.split('.', 3)
210 if len(bits
) != 4: raise AuthenticationFailed
, 'BADTOKEN'
211 stamp
, NONCE
, tag
, user
= bits
213 ## Check that the nonce matches, if one was supplied.
214 if nonce
is not None and nonce
!= NONCE
:
215 raise AuthenticationFailed
, 'BADNONCE'
217 ## Check the stamp, and find the right secret.
218 if not stamp
.isdigit(): raise AuthenticationFailed
, 'BADTIME'
220 sec
= getsecret(when
)
221 if sec
is None: raise AuthenticationFailed
, 'EXPIRED'
224 t
= auth_tag(sec
, when
, NONCE
, user
)
225 if t
!= tag
: raise AuthenticationFailed
, 'BADTAG'
227 ## Make sure the user still exists.
228 try: acct
= S
.SERVICES
['master'].find(user
)
229 except S
.UnknownUser
: raise AuthenticationFailed
, 'NOUSER'
234 def bake_cookie(value
):
236 Return a properly baked authentication-token cookie with the given VALUE.
238 return CGI
.cookie('chpwd-token', value
,
241 path
= CFG
.SCRIPT_NAME
,
242 max_age
= (CFG
.SECRETLIFE
- CFG
.SECRETFRESH
))
244 ###--------------------------------------------------------------------------
245 ### Authentication commands.
247 ## A dummy string, for when we're invoked from the command-line.
248 NONCE
= '@DUMMY-NONCE'
251 'login', ['cgi-noauth'],
252 'Authenticate to the CGI machinery',
253 opts
= [SC
.Opt('why', '-w', '--why',
254 'Reason for redirection back to the login page.',
256 def cmd_login(why
= None):
257 CGI
.page('login.fhtml',
258 title
= 'Chopwood: login',
259 why
=LOGIN_REASONS
.get(why
, '<unknown error %s>' % why
))
262 'auth', ['cgi-noauth'],
263 'Verify a user name and password',
264 params
= [SC
.Arg('u'), SC
.Arg('pw')])
266 svc
= S
.SERVICES
['master']
270 except (S
.UnknownUser
, S
.IncorrectPassword
):
271 CGI
.redirect(CGI
.action('login', why
= 'AUTHFAIL'))
274 CGI
.redirect(CGI
.action('list', u
),
275 set_cookie
= bake_cookie(t
))
277 ###----- That's all, folks --------------------------------------------------