# Copyright (c) 2007, Kundan Singh. All rights reserved. See LICENSING for details.
''' The HTTP basic and digest access authentication as per RFC 2617. ''' from random import randint from hashlib import md5 from base64 import b64encode import time
HTTP provides a simple challenge-response authentication mechanism
that MAY be used by a server to challenge a client request and by a
client to provide authentication information. It uses an extensible,
case-insensitive token to identify the authentication scheme,
followed by a comma-separated list of attribute-value pairs which
carry the parameters necessary for achieving authentication via that
scheme.
auth-scheme = token
auth-param = token "=" ( token | quoted-string )_quote = lambda s: '"' + s + '"' if not s or s[0] != '"' != s[-1] else s _unquote = lambda s: s[1:-1] if s and s[0] == '"' == s[-1] else s def createAuthenticate(authMethod='Digest', **kwargs): '''Build the WWW-Authenticate header's value. >>> print createAuthenticate('Basic', realm='iptel.org') Basic realm="iptel.org" >>> print createAuthenticate('Digest', realm='iptel.org', domain='sip:iptel.org', nonce='somenonce') Digest realm="iptel.org", domain="sip:iptel.org", qop="auth", nonce="somenonce", opaque="", stale=FALSE, algorithm=MD5 ''' if authMethod.lower() == 'basic': return 'Basic realm=%s'%(_quote(kwargs.get('realm', ''))) elif authMethod.lower() == 'digest': predef = ('realm', 'domain', 'qop', 'nonce', 'opaque', 'stale', 'algorithm') unquoted = ('stale', 'algorithm') now = time.time(); nonce = kwargs.get('nonce', b64encode('%d %s'%(now, md5('%d:%d'%(now, id(createAuthenticate)))))) default = dict(realm='', domain='', opaque='', stale='FALSE', algorithm='MD5', qop='auth', nonce=nonce) kv = map(lambda x: (x, kwargs.get(x, default[x])), predef) + filter(lambda x: x[0] not in predef, kwargs.items()) # put predef attributes in order before non predef attributes return 'Digest ' + ', '.join(map(lambda y: '%s=%s'%(y[0], _quote(y[1]) if y[0] not in unquoted else y[1]), kv)) else: raise ValueError, 'invalid authMethod%s'%(authMethod)
The 401 (Unauthorized) response message is used by an origin server
to challenge the authorization of a user agent. This response MUST
include a WWW-Authenticate header field containing at least one
challenge applicable to the requested resource. The 407 (Proxy
Authentication Required) response message is used by a proxy to
challenge the authorization of a client and MUST include a Proxy-
Authenticate header field containing at least one challenge
applicable to the proxy for the requested resource.
challenge = auth-scheme 1*SP 1#auth-paramA user agent that wishes to authenticate itself with an origin server--usually, but not necessarily, after receiving a 401 (Unauthorized)--MAY do so by including an Authorization header field with the request. A client that wishes to authenticate itself with a proxy--usually, but not necessarily, after receiving a 407 (Proxy Authentication Required)--MAY do so by including a Proxy- Authorization header field with the request. Both the Authorization field value and the Proxy-Authorization field value consist of credentials containing the authentication information of the client for the realm of the resource being requested. The user agent MUST choose to use one of the challenges with the strongest auth-scheme it understands and request credentials from the user based upon that challenge. credentials = auth-scheme #auth-param
def createAuthorization(challenge, username, password, uri=None, method=None, entityBody=None, context=None): '''Build the Authorization header for this challenge. The challenge represents the WWW-Authenticate header's value and the function returns the Authorization header's value. The context (dict) is used to save cnonce and nonceCount if available. The uri represents the request URI str, and method the request method. The result contains the properties in alphabetical order of property name. >>> context = {'cnonce':'0a4f113b', 'nc': 0} >>> print createAuthorization('Digest realm="testrealm@host.com", qop="auth", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"', 'Mufasa', 'Circle Of Life', '/dir/index.html', 'GET', None, context) Digest cnonce="0a4f113b",nc=00000001,nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",opaque="5ccc069c403ebaf9f0171e9517f40e41",qop=auth,realm="testrealm@host.com",response="6629fae49393a05397450978507c4ef1",uri="/dir/index.html",username="Mufasa" >>> print createAuthorization('Basic realm="WallyWorld"', 'Aladdin', 'open sesame') Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== ''' authMethod, sep, rest = challenge.strip().partition(' ') ch, cr = dict(), dict() # challenge and credentials cr['password'] = password cr['username'] = username
The "basic" authentication scheme is based on the model that the
client must authenticate itself with a user-ID and a password for
each realm. The realm value should be considered an opaque string
which can only be compared for equality with other realms on that
server. The server will service the request only if it can validate
the user-ID and password for the protection space of the Request-URI.
There are no optional authentication parameters.
For Basic, the framework above is utilized as follows:
challenge = "Basic" realm
credentials = "Basic" basic-credentials
Upon receipt of an unauthorized request for a URI within the
protection space, the origin server MAY respond with a challenge like
the following:
WWW-Authenticate: Basic realm="WallyWorld"
where "WallyWorld" is the string assigned by the server to identify
the protection space of the Request-URI. A proxy may respond with the
same challenge using the Proxy-Authenticate header field.
if authMethod.lower() == 'basic':
return authMethod + ' ' + basic(cr)
Like Basic Access Authentication, the Digest scheme is based on a simple challenge-response paradigm. The Digest scheme challenges using a nonce value. A valid response contains a checksum (by default, the MD5 checksum) of the username, the password, the given nonce value, the HTTP method, and the requested URI. In this way, the password is never sent in the clear. Just as with the Basic scheme, the username and password must be prearranged in some fashion not addressed by this document.
elif authMethod.lower() == 'digest':
for n,v in map(lambda x: x.strip().split('='), rest.split(',') if rest else []):
ch[n.lower().strip()] = _unquote(v.strip())
# TODO: doesn't work if embedded ',' in value, e.g., qop="auth,auth-int"
If a server receives a request for an access-protected object, and an
acceptable Authorization header is not sent, the server responds with
a "401 Unauthorized" status code, and a WWW-Authenticate header as
per the framework defined above, which for the digest scheme is
utilized as follows:
challenge = "Digest" digest-challenge
digest-challenge = 1#( realm | [ domain ] | nonce |
[ opaque ] |[ stale ] | [ algorithm ] |
[ qop-options ] | [auth-param] )
domain = "domain" "=" <"> URI ( 1*SP URI ) <">
URI = absoluteURI | abs_path
nonce = "nonce" "=" nonce-value
nonce-value = quoted-string
opaque = "opaque" "=" quoted-string
stale = "stale" "=" ( "true" | "false" )
algorithm = "algorithm" "=" ( "MD5" | "MD5-sess" |
token )
qop-options = "qop" "=" <"> 1#qop-value <">
qop-value = "auth" | "auth-int" | token
for y in filter(lambda x: x in ch, ['username', 'realm', 'nonce', 'opaque', 'algorithm']):
cr[y] = ch[y]
cr['uri'] = uri
cr['httpMethod'] = method
if 'qop' in ch:
if context and 'cnonce' in context:
cnonce, nc = context['cnonce'], context['nc'] + 1
else:
cnonce, nc = H(str(randint(0, 2**31))), 1
if context:
context['cnonce'], context['nc'] = cnonce, nc
cr['qop'], cr['cnonce'], cr['nc'] = 'auth', cnonce, '%08x'% nc
The client is expected to retry the request, passing an Authorization
header line, which is defined according to the framework above,
utilized as follows.
credentials = "Digest" digest-response
digest-response = 1#( username | realm | nonce | digest-uri
| response | [ algorithm ] | [cnonce] |
[opaque] | [message-qop] |
[nonce-count] | [auth-param] )
username = "username" "=" username-value
username-value = quoted-string
digest-uri = "uri" "=" digest-uri-value
digest-uri-value = request-uri ; As specified by HTTP/1.1
message-qop = "qop" "=" qop-value
cnonce = "cnonce" "=" cnonce-value
cnonce-value = nonce-value
nonce-count = "nc" "=" nc-value
nc-value = 8LHEX
response = "response" "=" request-digest
cr['response'] = digest(cr)
items = sorted(filter(lambda x: x not in ['name', 'authMethod', 'value', 'httpMethod', 'entityBody', 'password'], cr))
return authMethod + ' ' + ','.join(map(lambda y: '%s=%s'%(y, (cr[y] if y == 'qop' or y == 'nc' else _quote(cr[y]))), items))
else:
raise ValueError, 'Invalid auth method -- ' + authMethod
In this document the string obtained by applying the digest
algorithm to the data "data" with secret "secret" will be denoted
by KD(secret, data), and the string obtained by applying the
checksum algorithm to the data "data" will be denoted H(data). The
notation unq(X) means the value of the quoted-string X without the
surrounding quotes.
For the "MD5" and "MD5-sess" algorithms
H(data) = MD5(data)
and
KD(secret, data) = H(concat(secret, ":", data))
H = lambda d: md5(d).hexdigest() KD = lambda s, d: H(s + ':' + d)
The first time the client requests the document, no Authorization
header is sent, so the server responds with:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Digest
realm="testrealm@host.com",
qop="auth,auth-int",
nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
opaque="5ccc069c403ebaf9f0171e9517f40e41"
The client may prompt the user for the username and password, after
which it will respond with a new request, including the following
Authorization header:
Authorization: Digest username="Mufasa",
realm="testrealm@host.com",
nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
uri="/dir/index.html",
qop=auth,
nc=00000001,
cnonce="0a4f113b",
response="6629fae49393a05397450978507c4ef1",
opaque="5ccc069c403ebaf9f0171e9517f40e41"def digest(cr): '''Create a digest response for the credentials. >>> input = {'httpMethod':'GET', 'username':'Mufasa', 'password': 'Circle Of Life', 'realm':'testrealm@host.com', 'algorithm':'md5', 'nonce':'dcd98b7102dd2f0e8b11d0f600bfb0c093', 'uri':'/dir/index.html', 'qop':'auth', 'nc': '00000001', 'cnonce':'0a4f113b', 'opaque':'5ccc069c403ebaf9f0171e9517f40e41'} >>> print digest(input) "6629fae49393a05397450978507c4ef1" ''' algorithm, username, realm, password, nonce, cnonce, nc, qop, httpMethod, uri, entityBody \ = map(lambda x: cr[x] if x in cr else None, ['algorithm', 'username', 'realm', 'password', 'nonce', 'cnonce', 'nc', 'qop', 'httpMethod', 'uri', 'entityBody'])
If the "algorithm" directive's value is "MD5" or is unspecified, then
A1 is:
A1 = unq(username-value) ":" unq(realm-value) ":" passwd
where
passwd = < user's password >
If the "algorithm" directive's value is "MD5-sess", then A1 is
calculated only once - on the first request by the client following
receipt of a WWW-Authenticate challenge from the server. It uses the
server nonce from that challenge, and the first client nonce value to
construct A1 as follows:
A1 = H( unq(username-value) ":" unq(realm-value)
":" passwd )
":" unq(nonce-value) ":" unq(cnonce-value)
This creates a 'session key' for the authentication of subsequent
if algorithm and algorithm.lower() == 'md5-sess':
A1 = H(username + ':' + realm + ':' + password) + ':' + nonce + ':' + cnonce
else:
A1 = username + ':' + realm + ':' + password
If the "qop" directive's value is "auth" or is unspecified, then A2
is:
A2 = Method ":" digest-uri-value
If the "qop" value is "auth-int", then A2 is:
A2 = Method ":" digest-uri-value ":" H(entity-body)
if not qop or qop == 'auth':
A2 = httpMethod + ':' + str(uri)
else:
A2 = httpMethod + ':' + str(uri) + ':' + H(str(entityBody))
If the "qop" value is "auth" or "auth-int":
request-digest = <"> < KD ( H(A1), unq(nonce-value)
":" nc-value
":" unq(cnonce-value)
":" unq(qop-value)
":" H(A2)
) <">
If the "qop" directive is not present (this construction is for
compatibility with RFC 2069):
request-digest =
<"> < KD ( H(A1), unq(nonce-value) ":" H(A2) ) >
<">
if qop and (qop == 'auth' or qop == 'auth-int'):
a = nonce + ':' + str(nc) + ':' + cnonce + ':' + qop + ':' + A2
return _quote(KD(H(A1), nonce + ':' + str(nc) + ':' + cnonce + ':' + qop + ':' + H(A2)))
else:
return _quote(KD(H(A1), nonce + ':' + H(A2)))
If the user agent wishes to send the userid "Aladdin" and password
"open sesame", it would use the following header field:
Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==def basic(cr): '''Create a basic response for the credentials. >>> print basic({'username':'Aladdin', 'password':'open sesame'}) QWxhZGRpbjpvcGVuIHNlc2FtZQ== '''
To receive authorization, the client sends the userid and password,
separated by a single colon (":") character, within a base64 [7]
encoded string in the credentials.
basic-credentials = base64-user-pass
base64-user-pass = <base64 [4] encoding of user-pass,
except not limited to 76 char/line>
user-pass = userid ":" password
userid = *<TEXT excluding ":">
password = *TEXT
Userids might be case sensitive.
return b64encode(cr['username'] + ':' + cr['password'])
if __name__ == '__main__':
import doctest
doctest.testmod()