PHPによる簡単なログイン認証いろいろ

GitHubにダウンロードしてすぐ試せるサンプル置きました↓
https://github.com/mpyw-yattemita/php-auth-examples

# TLS暗号化を使用できる場合 (`https://` の場合)

この場合は生のパスワードをそのままクライアント側から送信してもらって構いません．

なおパスワードをそのまま平文でスクリプト中に書くことはあまり望ましくないので，下準備としてあらかじめ以下のコマンドを実行してパスワードハッシュを作成しておいてください．

```shell-session
mpyw@localhost:~$ php -r 'echo password_hash("パスワード", PASSWORD_BCRYPT), PHP_EOL;'
$2y$10$TThG3fsMJegLJHzVQbz8IeHhvpgBg7P5j6gjQWEUOrKKCtsA9L87G
```

## Basic認証

危ないと言われるBasic認証ですが，実はTLS暗号化をかければ普通のセッション認証と変わらず安全です．認証処理が非常に簡単に書けるのは嬉しいところです．

- [PHPを利用したBasic認証の仕組み](http://qiita.com/mpyw/items/dc2cb3632370389d700e)
- [password_verifyは本当にタイミングセーフなのか？](http://qiita.com/mpyw/items/f53e66b64cdab6f0aa54)

上記のコードをベースにします．

```php:/functions.php
<?php

/**
 * Basic認証を要求するページの先頭で使う関数
 * 初回時または失敗時にはヘッダを送信してexitする
 *
 * @return string ログインしたユーザ名
 */
function require_basic_auth()
{
    // 事前に生成したユーザごとのパスワードハッシュの配列
    $hashes = [
        'ユーザ名' => '$2y$10$TThG3fsMJegLJHzVQbz8IeHhvpgBg7P5j6gjQWEUOrKKCtsA9L87G',
    ];

    if (
        !isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) ||
        !password_verify(
            $_SERVER['PHP_AUTH_PW'],
            isset($hashes[$_SERVER['PHP_AUTH_USER']])
                ? $hashes[$_SERVER['PHP_AUTH_USER']]
                : '$2y$10$abcdefghijklmnopqrstuv' // ユーザ名が存在しないときだけ極端に速くなるのを防ぐ
        )
    ) {
        // 初回時または認証が失敗したとき
        header('WWW-Authenticate: Basic realm="Enter username and password."');
        header('Content-Type: text/plain; charset=utf-8');
        exit('このページを見るにはログインが必要です');
    }

    // 認証が成功したときはユーザ名を返す
    return $_SERVER['PHP_AUTH_USER'];
}

/**
 * htmlspecialcharsのラッパー関数
 *
 * @param string $str
 * @return string
 */
function h($str)
{
    return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
```

```php:/index.php
<?php

require_once __DIR__ . '/functions.php';
$username = require_basic_auth();

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

?>
<!DOCTYPE html>
<title>会員限定ページ</title>
<h1>ようこそ,<?=h($username)?>さん</h1>
<a href="http://dummy@localhost:8080/">ログアウト</a>
```

- ダミーのユーザ名で再認証を促すとログアウトさせることが出来ます．

## セッション認証

PHPの `$_SESSION` を用いる認証方式です．任意のHTMLでログイン用ページを作れる，一度ログインした後は負荷が軽くなるなどのメリットがあります．

一応ログインとログアウトの両方でCSRF対策しておきます．後者に関してはしなくても問題無いケースがほとんどだと思いますが，一応．

```php:/functions.php
<?php

/**
 * ログイン状態によってリダイレクトを行うsession_startのラッパー関数
 * 初回時または失敗時にはヘッダを送信してexitする
 */
function require_unlogined_session()
{
    // セッション開始
    @session_start();
    // ログインしていれば / に遷移
    if (isset($_SESSION['username'])) {
        header('Location: /');
        exit;
    }
}
function require_logined_session()
{
    // セッション開始
    @session_start();
    // ログインしていなければ /login.php に遷移
    if (!isset($_SESSION['username'])) {
        header('Location: /login.php');
        exit;
    }
}

/**
 * CSRFトークンの生成
 *
 * @return string トークン
 */
function generate_token()
{
    // セッションIDからハッシュを生成
    return hash('sha256', session_id());
}

/**
 * CSRFトークンの検証
 *
 * @param string $token
 * @return bool 検証結果
 */
function validate_token($token)
{
    // 送信されてきた$tokenがこちらで生成したハッシュと一致するか検証
    return $token === generate_token();
}

/**
 * htmlspecialcharsのラッパー関数
 *
 * @param string $str
 * @return string
 */
function h($str)
{
    return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
```

```php:/login.php
<?php

require_once __DIR__ . '/functions.php';
require_unlogined_session();

// 事前に生成したユーザごとのパスワードハッシュの配列
$hashes = [
    'ユーザ名' => '$2y$10$TThG3fsMJegLJHzVQbz8IeHhvpgBg7P5j6gjQWEUOrKKCtsA9L87G',
]; 

// ユーザから受け取ったユーザ名とパスワード
$username = filter_input(INPUT_POST, 'username');
$password = filter_input(INPUT_POST, 'password');

// POSTメソッドのときのみ実行
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (
        validate_token(filter_input(INPUT_POST, 'token')) &&
        password_verify(
            $password,
            isset($hashes[$username])
                ? $hashes[$username]
                : '$2y$10$abcdefghijklmnopqrstuv' // ユーザ名が存在しないときだけ極端に速くなるのを防ぐ
        )
    ) {
        // 認証が成功したとき
        // セッションIDの追跡を防ぐ
        session_regenerate_id(true);
        // ユーザ名をセット
        $_SESSION['username'] = $username;
        // ログイン完了後に / に遷移
        header('Location: /');
        exit;
    }
    // 認証が失敗したとき
    // 「403 Forbidden」
    http_response_code(403);
}

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

?>
<!DOCTYPE html>
<title>ログインページ</title>
<h1>ログインしてください</h1>
<form method="post" action="">
    ユーザ名: <input type="text" name="username" value="">
    パスワード: <input type="password" name="password" value="">
    <input type="hidden" name="token" value="<?=h(generate_token())?>">
    <input type="submit" value="ログイン">
</form>
<?php if (http_response_code() === 403): ?>
<p style="color: red;">ユーザ名またはパスワードが違います</p>
<?php endif; ?>
```

```php:/index.php
<?php

require_once __DIR__ . '/functions.php';
require_logined_session();

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

?>
<!DOCTYPE html>
<title>会員限定ページ</title>
<h1>ようこそ,<?=h($_SESSION['username'])?>さん</h1>
<a href="/logout.php?token=<?=h(generate_token())?>">ログアウト</a>
```

```php:/logout.php
<?php

require_once __DIR__ . '/functions.php';
require_logined_session();

// CSRFトークンを検証
if (!validate_token(filter_input(INPUT_GET, 'token'))) {
    // 「400 Bad Request」
    header('Content-Type: text/plain; charset=UTF-8', true, 400);
    exit('トークンが無効です');
}

// セッション用Cookieの破棄
setcookie(session_name(), '', 1);
// セッションファイルの破棄
session_destroy();
// ログアウト完了後に /login.php に遷移
header('Location: /login.php');
```

# TLS暗号化を使用できない場合 (`http://` の場合)

この場合はパスワードを元に生成されたダイジェストを送信させなければなりません．これによってある程度安全性が確保されます．前回同様に，下準備としてあらかじめ以下のコマンドを実行してダイジェストを作成しておいてください．

```shell-session
mpyw@localhost:~$ php -r 'echo md5("ユーザ名:Enter username and password.:パスワード"), PHP_EOL;'
8c0f09ca470f4cad5a9db679598230b4
```

## Digest認証

Basic認証の派生です．ハッシュアルゴリズムにSHA-256を採用しようという動きが起こってきているようですが，現状多くのブラウザではMD5しかサポートされていないため，渋々ですがこれを採用します．

```php:/functions.php
<?php

/**
 * Digest認証を要求するページの先頭で使う関数
 * 初回時または失敗時にはヘッダを送信してexitする
 *
 * @return string ログインしたユーザ名
 */
function require_digest_auth()
{
    // 事前に生成したユーザごとのダイジェストの配列
    $digests = [
        'ユーザ名' => '8c0f09ca470f4cad5a9db679598230b4',
    ];
    
    // Authorizationヘッダの認証を行うクロージャ
    $verify = function ($header) use ($digests) {
        // 利用するパラメータ
        $keys = ['response', 'nonce', 'nc', 'cnonce', 'qop', 'uri', 'username'];
        // あらかじめ空欄で埋めておく
        $p = array_fill_keys($keys, '');
        // 正規表現を生成してパラメータをパース
        $regex = '/(' . implode('|', $keys) . ')=(?:\'([^\']++)\'|"([^"]++)"|([^\s,]++))/';
        preg_match_all($regex, $header, $matches, PREG_SET_ORDER);
        foreach ($matches as $m) {
            // 見つかったところは空欄を上書き
            $p[$m[1]] = $m[3] ?: $m[4];
        }
        // ユーザ名に対応するダイジェストを取り出し，期待されるレスポンスを生成する
        $expected = md5(implode(':', [
            isset($digests[$p['username']]) ? $digests[$p['username']] : '',
            $p['nonce'],
            $p['nc'],
            $p['cnonce'],
            $p['qop'],
            md5("$_SERVER[REQUEST_METHOD]:$p[uri]")
        ]));
        // 検証結果が正しければユーザ名を返す
        return hash_equals($expected, $p['response']) ? $p['username'] : false;
    };

    if (
        !isset($_SERVER['PHP_AUTH_DIGEST']) ||
        !is_string($username = $verify($_SERVER['PHP_AUTH_DIGEST']))
    ) {
        // 初回時または認証が失敗したとき
        $nonce = md5(openssl_random_pseudo_bytes(30));
        header('WWW-Authenticate: Digest realm="Enter username and password.", qop=auth, nonce="' . $nonce . '"');
        header('Content-Type: text/plain; charset=utf-8');
        exit('このページを見るにはログインが必要です');
    }

    // 認証が成功したときはユーザ名を返す
    return $username;
}

/**
 * htmlspecialcharsのラッパー関数
 *
 * @param string $str
 * @return string
 */
function h($str)
{
    return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}
```

```php:/index.php
<?php

require_once __DIR__ . '/functions.php';
$username = require_digest_auth();

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

?>
<!DOCTYPE html>
<title>会員限定ページ</title>
<h1>ようこそ，<?=h($username)?>さん</h1>
<a href="http://dummy@localhost:8080/">ログアウト</a>
```

- ダミーのユーザ名で再認証を促すとログアウトさせることが出来ます．
- **【2016/04/17 追記】<br>
Digest認証に盗聴されたとき<font color="red">なりすましを防げない</font>脆弱性があったので，完全な実装を追加しました．**かなり複雑なのでここでの紹介は割愛します，[GitHub](https://github.com/mpyw/php-auth-examples/blob/master/digest/DigestAuthenticator.php)のほうでご覧ください．
  - `nonce`がサーバによって発行されたものかどうかを検証
  - `nc`が以前のリクエストに1加算されたものかどうかを検証

# 各方式の比較

<table>
<tr>
<th width="90"></th>
<th width="270">Basic認証</th>
<th width="270">Digest認証</th>
<th width="270">セッション認証</th>
</tr>
<tr>
<th>TLSあり</th>
<td>小規模ならおすすめ✨<br />大規模だと認証がボトルネック😨</td>
<td>Basic認証でおｋ💢</td>
<td><font color="fuchsia">オールマイティ</font>💓</td>
</tr>
<tr>
<th>TLSなし</th>
<td><font color="red">超絶危険</font>💣</td>
<td>妥協手段としては優秀✨<br />大規模だと認証がボトルネック😨</td>
<td><font color="red">パスワード平文送信は超絶危険</font>💣<br />JavaScriptで頑張ってダイジェスト作れば多少は安全になるけどセッションIDは丸見えなのでメリットあるかどうか微妙💧</td>
</tr>
</table>

# バックポート

## PHP5.6より古いバージョン向け

- [indigophp/hash-compat](https://github.com/indigophp/hash-compat)

## PHP5.5より古いバージョン向け

- [ircmaxell/password_compat](https://github.com/ircmaxell/password_compat)