DjangoでDigest認証をかける

django-digestというパッケージを使うとdjangoでdigest認証をかけることができます。
python-digestに依存してるので一緒にinstallします。

pip install django-digest python-digest


akoha / django-digest / wiki / Home ― Bitbucket
dimagi/python-digest · GitHub

設定

MIDDLEWARE_CLASSESにHttpDigestMiddlewareを追加

MIDDLEWARE_CLASSES = (
    'django.middleware.common.CommonMiddleware',
    'django_digest.middleware.HttpDigestMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
)


INSTALLED_APPSにdjango-digestを追加

INSTALLED_APPS = (
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.sites',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django_digest',
    'apps',
)


digest認証を有効にする

DIGEST_REQUIRE_AUTHENTICATION = True


syncdbしてユーザーも作っておきます。
このユーザーでダイジェスト認証します。

Creating tables ...
Creating table auth_permission
Creating table auth_group_permissions
Creating table auth_group
Creating table auth_user_groups
Creating table auth_user_user_permissions
Creating table auth_user
Creating table django_content_type
Creating table django_session
Creating table django_site
Creating table django_digest_usernonce
Creating table django_digest_partialdigest

You just installed Django's auth system, which means you don't have any superusers defined.
Would you like to create one now? (yes/no): 

digest認証の流れ


1. クライアントが認証が必要なページにアクセスする。

DIGEST_REQUIRE_AUTHENTICATION =Trueを使う場合は全部のページが認証の対象になります。
他にデコレータでview関数ごとに設定する方法がありますが、追加でライブラリのインストールが必要な模様。


2. サーバは401レスポンスコードを返し、認証領域 (realm) や認証方式(Digest)に関する情報をクライアントに返す。このとき、ランダムな文字列(nonce)も返される。


この辺が該当のコードですね。

    def build_challenge_response(self, stale=False):
        response = HttpResponse('Authorization Required',
                                content_type='text/plain', status=401)
        opaque =  ''.join([random.choice('0123456789ABCDEF') for x in range(32)])

        response["WWW-Authenticate"] = python_digest.build_digest_challenge(
            time.time(), self.secret_key, self.realm, opaque, stale)
        return response
def build_digest_challenge(timestamp, secret, realm, opaque, stale):
    '''
    Builds a Digest challenge that may be sent as the value of the 'WWW-Authenticate' header
    in a 401 or 403 response.
    
    'opaque' may be any value - it will be returned by the client.

    'timestamp' will be incorporated and signed in the nonce - it may be retrieved from the
    client's authentication request using get_nonce_timestamp()
    '''
    nonce = calculate_nonce(timestamp, secret)

    return 'Digest %s' % format_parts(realm=realm, qop='auth', nonce=nonce,
                                      opaque=opaque, algorithm='MD5',
                                      stale=stale and 'true' or 'false')
def calculate_nonce(timestamp, secret, salt=None):
    '''
    Generate a nonce using the provided timestamp, secret, and salt. If the salt is not provided,
    (and one should only be provided when validating a nonce) one will be generated randomly
    in order to ensure that two simultaneous requests do not generate identical nonces.
    '''
    if not salt:
        salt = ''.join([random.choice('0123456789ABCDEF') for x in range(4)])
    return "%s:%s:%s" % (timestamp, salt,
                         md5.md5("%s:%s:%s" % (timestamp, salt, secret)).hexdigest())


レスポンスのWWW-Authenticateヘッダはこんな感じになります。

WWW-Authenticate: Digest nonce="1364103971.44:B096:ac48ab9b1a210f433ea749432c7177a8", realm="DJANGO", algorithm="MD5", opaque="24B604C6BC4B38EA54C8E3E974B1E028", qop="auth", stale="false"

構成要素を見やすくするとこうなります。

nonce="1364103971.44:B096:ac48ab9b1a210f433ea749432c7177a8"
realm="DJANGO"
algorithm="MD5"
opaque="24B604C6BC4B38EA54C8E3E974B1E028"
qop="auth"
stale="false"


nonceは「number used once」の略で、リクエストごとに異なる値を返します。

opaqueは「不透明」という意味で、クライアントからは推測できない文字列ということです。

qopは「quality of protection」の略で、authかauth-initが入ります。
authの場合、メソッッドとURIからダイジェストを作成するのに対し、auth-initの場合はメッセージボディも利用します。
python-digestではauth固定になってます。

staleは「新鮮でない」とか、そういう意味ですね。
前回のリクエストでnonceが「新鮮でない」ために拒否された場合、trueになるようです。


参考 : RFC2617

Webを支える技術 -HTTP、URI、HTML、そしてREST (WEB+DB PRESS plus)

Webを支える技術 -HTTP、URI、HTML、そしてREST (WEB+DB PRESS plus)


3. 401を受けたクライアントは、ユーザ名とパスワードの入力を求めます。


4. ユーザによりユーザ名とパスワードが入力されると、クライアントはnonceとは別のランダムな文字列(cnonce)を生成する。そして、ユーザ名とパスワードとこれら2つのランダムな文字列などを使ってハッシュ文字列(response)を生成する。

5. クライアントはサーバから送られた認証に関する情報とともに、ユーザ名とresponseをサーバに送信する。

以下のような感じのAuthorizationヘッダが送信されます。
responseというのが、サーバーから送られたnonceとクライアントで生成されたcnonce、そしてuser/passなどから生成されたハッシュ値です。

Authorization: Digest username="user", realm="DJANGO", nonce="1364103998.63:0FA7:6414a5481e6a50ff169d59ef76601d2d", uri="/", algorithm=MD5, response="2a8e8b1423011f2b4317ab565d13c928", opaque="D2F4DE11BA31AC3BEAA9806C53D0F9F7", qop=auth, nc=00000001, cnonce="37db66bd20c031f3"

構成要素

username="user"
realm="DJANGO"
nonce="1364103998.63:0FA7:6414a5481e6a50ff169d59ef76601d2d"
uri="/"
algorithm=MD5
response="2a8e8b1423011f2b4317ab565d13c928"
opaque="D2F4DE11BA31AC3BEAA9806C53D0F9F7"
qop=auth
nc=00000001
cnonce="37db66bd20c031f3"

6. サーバ側では、クライアントから送られてきたランダムな文字列(nonce、cnonce)などとサーバに格納されているハッシュ化されたパスワードから、正解のハッシュを計算する。

「ハッシュ可されたパスワード」はdjango-digestでは、Userのpost_saveで作成されるようになります。
django_digest_partialdigestテーブルのpartial_digestカラムに保存されます。

以下のロジックで生成されます。

md5.md5("%s:%s:%s" % (username.encode('utf-8'), realm, password.encode('utf-8'))).hexdigest()


7. この計算値とクライアントから送られてきたresponseとが一致する場合は、認証が成功し、サーバはコンテンツを返す。不一致の場合は再び401レスポンスコードが返され、それによりクライアントは再びユーザにユーザ名とパスワードの入力を求める。


5で送信されたパラメータとpartial_digestから、response(2a8e8b1423011f2b4317ab565d13c928)の値が生成できれば認証OKってことですね。

python_digestのコードから、その辺だけ抜粋して試してみます。

# coding=utf-8
import hashlib as md5

# request.methodで取得
method = "GET"

# HTTPヘッダで渡される値
username = "user"
realm = "DJANGO"
nonce = "1364103998.63:0FA7:6414a5481e6a50ff169d59ef76601d2d"
uri = "/"
algorithm = "MD5"
opaque = "D2F4DE11BA31AC3BEAA9806C53D0F9F7"
qop = "auth"
nc = 00000001
cnonce = "37db66bd20c031f3"
response = "2a8e8b1423011f2b4317ab565d13c928"

# dbから取得
partial_digest = "5acaf5417a098526606b13519e21d775"

# 計算
ha2 = md5.md5("%s:%s" % (method, uri)).hexdigest()
data = "%s:%s:%s:%s:%s" % (nonce, "%08x" % nc, cnonce, 'auth', ha2)
kd = md5.md5("%s:%s" % (partial_digest, data)).hexdigest()

print kd
# => 2a8e8b1423011f2b4317ab565d13c928
print kd == response
# => True

一致しました。
一致すれば認証OKになります。


参考: Digest認証 - Wikipedia