2016.01.21
Jackal

basic認証

サイトのテストアップ時などちょっとした認証をかけたいときにbasic認証をよく使っているのですが、
去年の暮れに「basic認証のことを知ってるようで全然知らないな」と思う出来事があったので
basic認証のことを少し調べました。

basic認証の流れ

basic認証の処理の簡単にかくと以下の通りです

  1. ユーザはブラウザを使ってbasic認証のかかったページにアクセスする。
  2. サーバはhttpリクエストのヘッダを見て、 Authorizationというヘッダに正しい認証情報(IDとPASSWORD)が設定されているかを確認する。
  3. もし正しい値が設定されていれば、リクエストのあったページをブラウザに返す。 -> 終了
  4. もし正しい値が設定されてなければ、WWW-Authenticateヘッダに認証方式などに関する情報を設定して401レスポンスを返す。
  5. ブラウザはWWW-AuthenticateにBasic realm=”値”というヘッダがセットされた401レスポンスを受け取ったら、IDとPASSWORDを入力するダイアログを表示する。
  6. ユーザはIDとPASSWORDを入力する。
  7. ブラウザは入力された値をAuthrizationヘッダに設定したあと、ページに再アクセスする。 -> 2. に戻る。

basic認証で行われているwebサーバ側の処理をざっくりとまとめると

  • Authorizationヘッダを解析して正しいIDとパスワードが設定されているかを確認する
  • 正しいAuthrizeationが設定されていない場合、WWW-Authenticateヘッダを含むstatus code 401のレスポンスを返す

という2つになるのですが、これをwebサーバにやらせるためにwebサーバの設定ファイルに

AuthType Basic
AuthName "Secret Zone"
AuthUserFile /etc/httpd/.htpasswd
Require user secret

というような設定を書いているわけです。

実はこの処理は自分で実装することが可能で、python3 + django1.8で書いてみるとこんな感じになります。

とりあえず、作業用ディレクトリを作ります。

$ mkdir basicauth_test
$ cd basicauth_test

pyvenvで仮想環境を作ってdjango1.8をインストールします。

$ pyvenv virtualenv
$ . virtualenv/bin/activate
(virtualenv)$ pip install django==1.8

テスト用のプロジェクトとアプリケーションを作成します。

(virtualenv)$ django-admin startproject testproject
(virtualenv)$ cd testproject
(virtualenv)$ ./manage.py startapp basicauth

これで準備ができたので、後はコードを書いていきます。
まずは、https://サーバのIP:8000/にアクセスが来たときにhello worldというテキストを返すだけの処理を書きます。

testproject/urls.py

# -*- coding: utf-8 -*-
from django.conf.urls import url

# /にアクセスが来たらbasicauth/views.pyのindexを呼ぶ
urlpatterns = [
    url(r'^$', 'basicauth.views.index', name='index', ),
]

basicauth/views.py

# -*- coding: utf-8 -*-
from django.http import HttpResponse

def index(request):
    # hello worldというテキストをレスポンスとして返す
    return HttpResponse('hello world')

コードを入力したら

(virtualenv)$ ./manage.py runserver 0.0.0.0:8000

で開発サーバを起動し、ブラウザでhttps://サーバのIP:8000/にアクセスするとhello worldが返ってくると思います。

これにbasic認証の処理を追加し、ID:HELLO, PASSWORD: WORLD!!が入力されたときだけhello worldを返すようにします。

basicauth/views.py

# -*- coding: utf-8 -*

import base64
from django.http import HttpResponse


def index(request):
    # もしリクエストヘッダにAuthrizationがなければ401を返す。
    if 'HTTP_AUTHORIZATION' not in request.META:
        return _http401()
    """
        Basic認証の場合のAuthrizationのフォーマットは

            Basic ユーザID:パスワードをbase64エンコードした値

        なので、これを解析してIDとPASSWORDを抽出、
        ID: HELLO, PASSWORD: WORLD!!のときだけhello worldを返す
    """
    (authscheme, base64_idpass) = request.META['HTTP_AUTHORIZATION'].split(' ', 1)
    if authscheme.lower() != 'basic':
        return _http401()
    idpass = base64.decodestring(base64_idpass.strip().encode('ascii')).decode('ascii')
    (id_, password) = idpass.split(':', 1)
    if id_ == 'HELLO' and password == 'WORLD!!':
        return HttpResponse('hello world')
    else:
        return _http401()

def _http401():
    # WWW-Authenticate付きのレスポンスを返す処理
    response = HttpResponse("Unauthorized", status=401)
    # WWW-Authenticateヘッダを追加し認証スキームにbasic, realmに"basic auth test"を設定
    response['WWW-Authenticate'] = 'Basic realm="basic auth test"'

    return response

また、

(virtualenv)$ ./manage.py runserver 0.0.0.0:8000

で開発サーバを起動し、ブラウザでアクセスするとID/PASSWORDが求められるようになったのではないかと思います。

2回目以降のアクセスでID/PASSWORDを入力しなくてもよい理由

2回目以降はブラウザが勝手にAuthrizationを送ってくれているからです。
ブラウザのデバッグツールなどでリクエストヘッダの内容を見るとそれを確認できます。

realmについて

WWW-Authenticateで指定したrealmは認証領域を表すもので、同一ドメインで同じrealmの場合は
同じ認証情報であると判断するために使用していると考えられます。
(はっきりとそうだと書いた資料を見つけられなかったので推測です)

先ほどのプログラムを少し変更して、

/zone1/にアクセスが来た場合ID: zone1, PASSWORD: pass1で認証
/zone2/にアクセスが来た場合ID: zone2, PASSWORD: pass2で認証

するように変更してみます。

testproject/urls.py

# -*- coding: utf-8 -*

from django.conf.urls import url


urlpatterns = [
    url(r'^zone1/$', 'basicauth.views.zone1', name='zone1', ),
    url(r'^zone2/$', 'basicauth.views.zone2', name='zone2', ),
]

basicauth/views.py

# -*- coding: utf-8 -*

import base64
from django.http import HttpResponse

def zone1(request):
    # zone1はrealm="hello world", id=zone1, password=pass1で認証
    return _get_response(request, 'hello world', 'zone1', 'pass1')


def zone2(request):
    # zone2はrealm="hello world", id=zone2, password=pass2で認証
    return _get_response(request, 'hello world', 'zone2', 'pass2')


def _get_response(request, realm, realm_id, realm_password):
    """ 引数で与えられたrealm, id, passwordを元に認証を行いそれに合わせたレスポンスを返す。"""
    # もしリクエストヘッダにAuthrizationがなければ401を返す。
    if 'HTTP_AUTHORIZATION' not in request.META:
        return _http401(realm)
    (authscheme, base64_idpass) = request.META['HTTP_AUTHORIZATION'].split(' ', 1)
    if authscheme.lower() != 'basic':
        return _http401(realm)
    idpass = base64.decodestring(base64_idpass.strip().encode('ascii')).decode('ascii')
    (id_, password) = idpass.split(':', 1)
    if id_ == realm_id and password == realm_password:
        return HttpResponse('hello world')
    else:
        return _http401(realm)


def _http401(realm):
    # ステータスコード401のレスポンスを用意
    response = HttpResponse("Unauthorized", status=401)
    # WWW-Authenticateヘッダを追加し認証スキームにbasic, realmにauthtestを設定
    response['WWW-Authenticate'] = 'Basic realm="%s"' % (realm, )

    return response

この状態で/zone1/, /zone2/に交互にアクセスすると、アクセスするたびにID/PASSWORDの入力を求められるはずです。
なぜなら、/zone1/と/zone2/で同じrealmを設定しているため、/zone2/にアクセスしたときにブラウザが勝手に/zone1/で入力した
ID/PASSWORDを送り付けているからです。(/zone2/ -> /zone1/というアクセスでも同様の現象が起こります。)

そこでzone1とzone2のrealmに別の値を設定し、zone1とzone2は別々のID/PASSWORDを使っている
認証領域だとブラウザに理解させます。

basicauth/views.py

~~ 略 ~~
def zone1(request):
    # zone1はrealm="zone1", id=zone1, password=pass1で認証
    return _get_response(request, 'zone1', 'zone1', 'pass1')

def zone2(request):
    # zone2はrealm="zone2", id=zone2, password=pass2で認証
    return _get_response(request, 'zone2', 'zone2', 'pass2')
~~ 略 ~~

今度は/zone1/,/zone2/でそれぞれ一回だけID/PASSWORDを入力すれば、あとは交互にアクセスできるようになると思います。
realmはダイアログにメッセージを表示するためだけにあったわけではないということですね。

参考RFC 7235 日本語訳

YI

一覧に戻る