PHPを利用したBasic認証の仕組み

  • 124
    Like
  • 1
    Comment
More than 1 year has passed since last update.

注意

この記事はBasic認証フローの仕組みを解説することに重点を置いており,セキュリティに関してはあまり考慮しておりません.セキュリティを考慮した実用的な実装に関しては以下をあたってください.

導入

「今どきBasic認証みたいな危険なもの使うなんて…」 なんて心配はさておき、何故以下のような記述でダイアログを交えた認証フローが成り立つのか疑問に思っていました。結論からすれば大したことないんですけど、それがイメージしにくかったのでここにメモしておきます。

<?php

switch (true) {
    case !isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']):
    case $_SERVER['PHP_AUTH_USER'] !== 'admin':
    case $_SERVER['PHP_AUTH_PW']   !== 'test':
        header('WWW-Authenticate: Basic realm="Enter username and password."');
        header('Content-Type: text/plain; charset=utf-8');
        die('このページを見るにはログインが必要です');
}

header('Content-Type: text/html; charset=utf-8');

?>
<!DOCTYPE html>
<html>
...
</html>

認証フローの追跡

では実際に見ていきましょう。

1. 初回アクセス

最初アクセスしてきたときにはもちろん何もBasic認証用のヘッダーは受け取っていないので、

case !isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']):

True と判定されます。よって、(他のCASE文はフォールスルーして)その下にある

header('WWW-Authenticate: Basic realm="Enter username and password."');
header('Content-Type: text/plain; charset=utf-8');
die('このページを見るにはログインが必要です');

が実行され、サーバ側からクライアント側に以下のものが返されます。

名称 説明
WWW-Authenticate
ヘッダ
Webブラウザに対して認証用ダイアログの表示をさせるための指示。
ダイアログメッセージとなる realm にはマルチバイト文字を含めることは出来ない。
Content-Type
ヘッダ
返すデータの種類と、指定する必要があればその文字セット。
Apacheのデフォルトでは text/html となっている。
ステータスコード リクエストが成功したかどうかを表す数値。
Apacheのデフォルトでは「OK」を意味する 200 となっている。
コンテンツ 実際にブラウザ上に表示されるデータ。

ここで気づいてほしいのは、ダイアログが表示されるときにはもうこのHTTPリクエストは終了しているということです。Webブラウザはビジー状態のように見えますが、単にユーザの入力を待機しているだけで、通信しているわけではありません。

ユーザがキャンセルボタンを押した

コンテンツが表示され、それ以上ダイアログは表示されなくなります。

ユーザがユーザ名とパスワードを入力してOKボタンを押した

コンテンツは表示されないまま、ステップ2に進みます。

2. Authorization ヘッダを伴うアクセス

Webブラウザはダイアログ入力を受け取ると、Basic認証の仕様に従って Authorization ヘッダを作成し、他のリクエストヘッダと共に送信します。PHPコードで表現するとすれば以下のようなイメージです。

header('Authorization: Basic ' . base64_encode("{$username}:{$password}"));

サーバ側ではこのヘッダを検知すると、BASE64エンコードされた部分がデコードされ、以下の変数が自動的に生成されます。

$_SERVER['PHP_AUTH_USER']
$_SERVER['PHP_AUTH_PW']

2回目のアクセスでは、

case !isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']):

False と判定されます。続いて、

case $_SERVER['PHP_AUTH_USER'] !== 'admin':
case $_SERVER['PHP_AUTH_PW']   !== 'test':

によるチェックが行われます。もしここで正しいユーザ名とパスワードであれば、switch文を脱出し、目的のコンテンツ(HTML)が返されます。誤っていれば、ステップ1と同様に

header('WWW-Authenticate: Basic realm="Enter username and password."');
header('Content-Type: text/plain; charset=utf-8');
die('このページを見るにはログインが必要です');

が実行され、このステップを繰り返すことになります。

FAQ

レスポンスコードに 401 (Unauthorized) って指定しなくていいの?

Basic認証を要求するとき(認証に失敗したとき)に返されるレスポンスコードは「Unauthorized」を表す 401 でなければなりません。よって、多くの方は以下のいずれかの選択肢を採られると思います。

header('HTTP/1.0 401 Unauthorized');
header('WWW-Authenticate: Basic realm="Enter username and password."');
header('WWW-Authenticate: Basic realm="Enter username and password."', true, 401);
PHP5.4以降のみ
header('WWW-Authenticate: Basic realm="Enter username and password."');
http_response_code(401);

しかし… header関数のマニュアル には記載されていないものの、実は WWW-Authenticate ヘッダも 「特殊な header コール」 に含まれているようです。著者がApacheで確認した限りでは、自動的にレスポンスコードが 401 に設定されました

初めてソースコードを読んだ人が混乱しないためには明示的な指定があったほうがいいとは思いますが、必ずしも必要というわけでは無さそうです。

今回は filter_input 関数は使わないの?

使わないのではなく 使えない のが正解です。私が 「$_GET, $_POSTなどを受け取る際の処理」 で散々プッシュしたこの関数ですが、一部の要素は後から生成され、この関数で検出可能な対象とはならないようです。詳しくは @mugng さんの 「filter_input( INPUT_SERVER, $key ) で取得できない変数」 を参照してください。

CGI版使ってるから、この方法使えないみたいなんだけど?

その場合は直接 $_SERVER['HTTP_AUTHORIZATION'] を参照することになります。しかし、デフォルトでこのインデックスは作成されないので、以下のような環境変数追加のためのリライトルールの追加が必要です。

<IfModule mod_rewrite.c>
RewriteEngine on
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization},L]
</IfModule>

このようにリライトルールを設定した上で、以下のように記述すれば実現できます。

<?php

switch (true) {
    case !isset($_SERVER['HTTP_AUTHORIZATION']):
    case explode(':', base64_decode(substr($_SERVER['HTTP_AUTHORIZATION']), 6), 2) !== array('admin', 'test'):
        header('WWW-Authenticate: Basic realm="Enter username and password."');
        header('Content-Type: text/plain; charset=utf-8');
        die('このページを見るにはログインが必要です');
}

header('Content-Type: text/html; charset=utf-8');

?>
<!DOCTYPE html>
<html>
...
</html>