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

# 注意

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

- [PHPによる簡単なログイン認証いろいろ](http://qiita.com/mpyw/items/bb8305ba196f5105be15)
- [password_verifyは本当にタイミングセーフなのか？](http://qiita.com/mpyw/items/f53e66b64cdab6f0aa54)

# 導入

「今どき[Basic認証](http://ja.wikipedia.org/wiki/Basic%E8%AA%8D%E8%A8%BC)みたいな危険なもの使うなんて…」 なんて心配はさておき、何故以下のような記述でダイアログを交えた認証フローが成り立つのか疑問に思っていました。結論からすれば大したことないんですけど、それがイメージしにくかったのでここにメモしておきます。

```html+php
<?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認証用のヘッダーは受け取っていないので、 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

```php
$_SERVER['PHP_AUTH_USER']
$_SERVER['PHP_AUTH_PW']
```

2回目のアクセスでは、

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

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

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

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

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

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

# FAQ

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

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

```php
header('HTTP/1.0 401 Unauthorized');
header('WWW-Authenticate: Basic realm="Enter username and password."');
```

```php
header('WWW-Authenticate: Basic realm="Enter username and password."', true, 401);
```

```php:PHP5.4以降のみ
header('WWW-Authenticate: Basic realm="Enter username and password."');
http_response_code(401);
```

しかし… [header関数のマニュアル](http://www.php.net/manual/ja/function.header.php) には記載されていないものの、実は `WWW-Authenticate` ヘッダも **「特殊な header コール」** に含まれているようです。著者がApacheで確認した限りでは、<ins>自動的にレスポンスコードが `401` に設定されました</ins>。

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

## 今回は [filter_input](http://www.php.net/manual/ja/function.filter-input.php) 関数は使わないの？

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

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

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

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

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

```html+php
<?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>
```