root / trunk / twisted / cred / credentials.py

Revision 25902, 14.7 kB (checked in by washort, 6 months ago)

Merge common-digest-3575.
Move digest-auth code out of twisted.web and into twisted.cred.
Author: washort
Reviewer: exarkun
Closes #3575.

Line 
1 # -*- test-case-name: twisted.test.test_newcred-*-
2
3 # Copyright (c) 2001-2009 Twisted Matrix Laboratories.
4 # See LICENSE for details.
5
6
7 from zope.interface import implements, Interface
8
9 import hmac, time, random
10 from twisted.python.hashlib import md5
11 from twisted.python.randbytes import secureRandom
12 from twisted.cred._digest import calcResponse, calcHA1, calcHA2
13 from twisted.cred import error
14
15 class ICredentials(Interface):
16     """
17     I check credentials.
18
19     Implementors _must_ specify which sub-interfaces of ICredentials
20     to which it conforms, using zope.interface.implements().
21     """
22
23
24
25 class IUsernameDigestHash(ICredentials):
26     """
27     This credential is used when a CredentialChecker has access to the hash
28     of the username:realm:password as in an Apache .htdigest file.
29     """
30     def checkHash(digestHash):
31         """
32         @param digestHash: The hashed username:realm:password to check against.
33
34         @return: C{True} if the credentials represented by this object match
35             the given hash, C{False} if they do not, or a L{Deferred} which
36             will be called back with one of these values.
37         """
38
39
40
41 class IUsernameHashedPassword(ICredentials):
42     """
43     I encapsulate a username and a hashed password.
44
45     This credential is used when a hashed password is received from the
46     party requesting authentication.  CredentialCheckers which check this
47     kind of credential must store the passwords in plaintext (or as
48     password-equivalent hashes) form so that they can be hashed in a manner
49     appropriate for the particular credentials class.
50
51     @type username: C{str}
52     @ivar username: The username associated with these credentials.
53     """
54
55     def checkPassword(password):
56         """Validate these credentials against the correct password.
57
58         @param password: The correct, plaintext password against which to
59         check.
60
61         @return: a deferred which becomes, or a boolean indicating if the
62         password matches.
63         """
64
65
66
67 class IUsernamePassword(ICredentials):
68     """
69     I encapsulate a username and a plaintext password.
70
71     This encapsulates the case where the password received over the network
72     has been hashed with the identity function (That is, not at all).  The
73     CredentialsChecker may store the password in whatever format it desires,
74     it need only transform the stored password in a similar way before
75     performing the comparison.
76
77     @type username: C{str}
78     @ivar username: The username associated with these credentials.
79
80     @type password: C{str}
81     @ivar password: The password associated with these credentials.
82     """
83
84     def checkPassword(password):
85         """Validate these credentials against the correct password.
86
87         @param password: The correct, plaintext password against which to
88         check.
89
90         @return: a deferred which becomes, or a boolean indicating if the
91         password matches.
92         """
93
94
95
96 class IAnonymous(ICredentials):
97     """
98     I am an explicitly anonymous request for access.
99     """
100
101
102
103 class DigestedCredentials(object):
104     """
105     Yet Another Simple HTTP Digest authentication scheme.
106     """
107     implements(IUsernameHashedPassword, IUsernameDigestHash)
108
109     def __init__(self, username, method, realm, fields):
110         self.username = username
111         self.method = method
112         self.realm = realm
113         self.fields = fields
114
115
116     def checkPassword(self, password):
117         """
118         Verify that the credentials represented by this object agree with the
119         given plaintext C{password} by hashing C{password} in the same way the
120         response hash represented by this object was generated and comparing
121         the results.
122         """
123         response = self.fields.get('response')
124         uri = self.fields.get('uri')
125         nonce = self.fields.get('nonce')
126         cnonce = self.fields.get('cnonce')
127         nc = self.fields.get('nc')
128         algo = self.fields.get('algorithm', 'md5').lower()
129         qop = self.fields.get('qop', 'auth')
130
131         expected = calcResponse(
132             calcHA1(algo, self.username, self.realm, password, nonce, cnonce),
133             calcHA2(algo, self.method, uri, qop, None),
134             algo, nonce, nc, cnonce, qop)
135
136         return expected == response
137
138
139     def checkHash(self, digestHash):
140         """
141         Verify that the credentials represented by this object agree with the
142         credentials represented by the I{H(A1)} given in C{digestHash}.
143
144         @param digestHash: A precomputed H(A1) value based on the username,
145             realm, and password associate with this credentials object.
146         """
147         response = self.fields.get('response')
148         uri = self.fields.get('uri')
149         nonce = self.fields.get('nonce')
150         cnonce = self.fields.get('cnonce')
151         nc = self.fields.get('nc')
152         algo = self.fields.get('algorithm', 'md5').lower()
153         qop = self.fields.get('qop', 'auth')
154
155         expected = calcResponse(
156             calcHA1(algo, None, None, None, nonce, cnonce, preHA1=digestHash),
157             calcHA2(algo, self.method, uri, qop, None),
158             algo, nonce, nc, cnonce, qop)
159
160         return expected == response
161
162
163
164 class DigestCredentialFactory(object):
165     """
166     Support for RFC2617 HTTP Digest Authentication
167
168     @cvar CHALLENGE_LIFETIME_SECS: The number of seconds for which an
169         opaque should be valid.
170
171     @type privateKey: C{str}
172     @ivar privateKey: A random string used for generating the secure opaque.
173
174     @type algorithm: C{str}
175     @param algorithm: Case insensitive string specifying the hash algorithm to
176         use.  Must be either C{'md5'} or C{'sha'}.  C{'md5-sess'} is B{not}
177         supported.
178
179     @type authenticationRealm: C{str}
180     @param authenticationRealm: case sensitive string that specifies the realm
181         portion of the challenge
182     """
183
184     CHALLENGE_LIFETIME_SECS = 15 * 60    # 15 minutes
185
186     scheme = "digest"
187
188     def __init__(self, algorithm, authenticationRealm):
189         self.algorithm = algorithm
190         self.authenticationRealm = authenticationRealm
191         self.privateKey = secureRandom(12)
192
193
194     def getChallenge(self, address):
195         """
196         Generate the challenge for use in the WWW-Authenticate header.
197
198         @param address: The client address to which this challenge is being
199         sent.
200
201         @return: The C{dict} that can be used to generate a WWW-Authenticate
202             header.
203         """
204         c = self._generateNonce()
205         o = self._generateOpaque(c, address)
206
207         return {'nonce': c,
208                 'opaque': o,
209                 'qop': 'auth',
210                 'algorithm': self.algorithm,
211                 'realm': self.authenticationRealm}
212
213
214     def _generateNonce(self):
215         """
216         Create a random value suitable for use as the nonce parameter of a
217         WWW-Authenticate challenge.
218
219         @rtype: C{str}
220         """
221         return secureRandom(12).encode('hex')
222
223
224     def _getTime(self):
225         """
226         Parameterize the time based seed used in C{_generateOpaque}
227         so we can deterministically unittest it's behavior.
228         """
229         return time.time()
230
231
232     def _generateOpaque(self, nonce, clientip):
233         """
234         Generate an opaque to be returned to the client.  This is a unique
235         string that can be returned to us and verified.
236         """
237         # Now, what we do is encode the nonce, client ip and a timestamp in the
238         # opaque value with a suitable digest.
239         now = str(int(self._getTime()))
240         if clientip is None:
241             clientip = ''
242         key = "%s,%s,%s" % (nonce, clientip, now)
243         digest = md5(key + self.privateKey).hexdigest()
244         ekey = key.encode('base64')
245         return "%s-%s" % (digest, ekey.strip('\n'))
246
247
248     def _verifyOpaque(self, opaque, nonce, clientip):
249         """
250         Given the opaque and nonce from the request, as well as the client IP
251         that made the request, verify that the opaque was generated by us.
252         And that it's not too old.
253
254         @param opaque: The opaque value from the Digest response
255         @param nonce: The nonce value from the Digest response
256         @param clientip: The remote IP address of the client making the request
257             or C{None} if the request was submitted over a channel where this
258             does not make sense.
259
260         @return: C{True} if the opaque was successfully verified.
261
262         @raise error.LoginFailed: if C{opaque} could not be parsed or
263             contained the wrong values.
264         """
265         # First split the digest from the key
266         opaqueParts = opaque.split('-')
267         if len(opaqueParts) != 2:
268             raise error.LoginFailed('Invalid response, invalid opaque value')
269
270         if clientip is None:
271             clientip = ''
272
273         # Verify the key
274         key = opaqueParts[1].decode('base64')
275         keyParts = key.split(',')
276
277         if len(keyParts) != 3:
278             raise error.LoginFailed('Invalid response, invalid opaque value')
279
280         if keyParts[0] != nonce:
281             raise error.LoginFailed(
282                 'Invalid response, incompatible opaque/nonce values')
283
284         if keyParts[1] != clientip:
285             raise error.LoginFailed(
286                 'Invalid response, incompatible opaque/client values')
287
288         try:
289             when = int(keyParts[2])
290         except ValueError:
291             raise error.LoginFailed(
292                 'Invalid response, invalid opaque/time values')
293
294         if (int(self._getTime()) - when >
295             DigestCredentialFactory.CHALLENGE_LIFETIME_SECS):
296
297             raise error.LoginFailed(
298                 'Invalid response, incompatible opaque/nonce too old')
299
300         # Verify the digest
301         digest = md5(key + self.privateKey).hexdigest()
302         if digest != opaqueParts[0]:
303             raise error.LoginFailed('Invalid response, invalid opaque value')
304
305         return True
306
307
308     def decode(self, response, method, host):
309         """
310         Decode the given response and attempt to generate a
311         L{DigestedCredentials} from it.
312
313         @type response: C{str}
314         @param response: A string of comma seperated key=value pairs
315
316         @type method: C{str}
317         @param method: The action requested to which this response is addressed
318         (GET, POST, INVITE, OPTIONS, etc).
319
320         @type host: C{str}
321         @param host: The address the request was sent from.
322
323         @raise error.LoginFailed: If the response does not contain a username,
324             a nonce, an opaque, or if the opaque is invalid.
325
326         @return: L{DigestedCredentials}
327         """
328         def unq(s):
329             if s[0] == s[-1] == '"':
330                 return s[1:-1]
331             return s
332         response = ' '.join(response.splitlines())
333         parts = response.split(',')
334
335         auth = {}
336
337         for (k, v) in [p.split('=', 1) for p in parts]:
338             auth[k.strip()] = unq(v.strip())
339
340         username = auth.get('username')
341         if not username:
342             raise error.LoginFailed('Invalid response, no username given.')
343
344         if 'opaque' not in auth:
345             raise error.LoginFailed('Invalid response, no opaque given.')
346
347         if 'nonce' not in auth:
348             raise error.LoginFailed('Invalid response, no nonce given.')
349
350         # Now verify the nonce/opaque values for this client
351         if self._verifyOpaque(auth.get('opaque'), auth.get('nonce'), host):
352             return DigestedCredentials(username,
353                                        method,
354                                        self.authenticationRealm,
355                                        auth)
356
357
358
359 class CramMD5Credentials:
360     implements(IUsernameHashedPassword)
361
362     challenge = ''
363     response = ''
364
365     def __init__(self, host=None):
366         self.host = host
367
368     def getChallenge(self):
369         if self.challenge:
370             return self.challenge
371         # The data encoded in the first ready response contains an
372         # presumptively arbitrary string of random digits, a timestamp, and
373         # the fully-qualified primary host name of the server.  The syntax of
374         # the unencoded form must correspond to that of an RFC 822 'msg-id'
375         # [RFC822] as described in [POP3].
376         #   -- RFC 2195
377         r = random.randrange(0x7fffffff)
378         t = time.time()
379         self.challenge = '<%d.%d@%s>' % (r, t, self.host)
380         return self.challenge
381
382     def setResponse(self, response):
383         self.username, self.response = response.split(None, 1)
384
385     def moreChallenges(self):
386         return False
387
388     def checkPassword(self, password):
389         verify = hmac.HMAC(password, self.challenge).hexdigest()
390         return verify == self.response
391
392
393 class UsernameHashedPassword:
394     implements(IUsernameHashedPassword)
395
396     def __init__(self, username, hashed):
397         self.username = username
398         self.hashed = hashed
399
400     def checkPassword(self, password):
401         return self.hashed == password
402
403
404 class UsernamePassword:
405     implements(IUsernamePassword)
406
407     def __init__(self, username, password):
408         self.username = username
409         self.password = password
410
411     def checkPassword(self, password):
412         return self.password == password
413
414
415 class Anonymous:
416     implements(IAnonymous)
417
418
419
420 class ISSHPrivateKey(ICredentials):
421     """
422     L{ISSHPrivateKey} credentials encapsulate an SSH public key to be checked
423     against a user's private key.
424
425     @ivar username: The username associated with these credentials.
426     @type username: C{str}
427
428     @ivar algName: The algorithm name for the blob.
429     @type algName: C{str}
430
431     @ivar blob: The public key blob as sent by the client.
432     @type blob: C{str}
433
434     @ivar sigData: The data the signature was made from.
435     @type sigData: C{str}
436
437     @ivar signature: The signed data.  This is checked to verify that the user
438         owns the private key.
439     @type signature: C{str} or C{NoneType}
440     """
441
442
443
444 class SSHPrivateKey:
445     implements(ISSHPrivateKey)
446     def __init__(self, username, algName, blob, sigData, signature):
447         self.username = username
448         self.algName = algName
449         self.blob = blob
450         self.sigData = sigData
451         self.signature = signature
452
453
454 class IPluggableAuthenticationModules(ICredentials):
455     """I encapsulate the authentication of a user via PAM (Pluggable
456     Authentication Modules.  I use PyPAM (available from
457     http://www.tummy.com/Software/PyPam/index.html).
458
459     @ivar username: The username for the user being logged in.
460
461     @ivar pamConversion: A function that is called with a list of tuples
462     (message, messageType).  See the PAM documentation
463     for the meaning of messageType.  The function
464     returns a Deferred which will fire with a list
465     of (response, 0), one for each message.  The 0 is
466     currently unused, but is required by the PAM library.
467     """
468
469 class PluggableAuthenticationModules:
470     implements(IPluggableAuthenticationModules)
471
472     def __init__(self, username, pamConversion):
473         self.username = username
474         self.pamConversion = pamConversion
475
Note: See TracBrowser for help on using the browser.