PHPでCookieを使ったステートレスなdigest認証の簡易版みたいなのを実装してみる

RESTのアーキテクチャで「クライアント／サーバはステートレスじゃなきゃダメだよね」っていう思想がありますが、セッションとか使ってサーバ側にアプリの状態持たせてたらステートレスじゃないですよね。
ってことで、digest認証までいかないけど、Cookieに認証状態を持たせてステートレスに認証させてみました。

CookieにはユーザーIDと色々混ぜてハッシュ化したパスワードダイジェストに加え、
認証に必要ないくつかの情報を持たせてみました。
ハッシュ化するときpassword_hash()を使おうと思ったら手元のPHPが5.4.24という・・。
バージョンアップが面倒だったのでcrypt()を使用しました。

```php:stateless_auth_test.php
<?php

/**
 * benchmark
 */
$before_time = microtime() * 1000000;

/**
 * ハッシュ用のsalt
 */
define('SERVER_SALT', 'serversalt');
define('CLIENT_SALT', 'clientsalt');
define('NONCE_SALT',  'noncesalt');

class StatelessAuth
{
    /**
     * constants
     */
    const EXPIRE = 60;

    /**
     * instance member
     */
    private $cookie;
    private $time;
    private $auth;
    private $authenticated_uid;
    private $authenticated_pw;

    /**
     * __costruct
     */
    public function __construct()
    {
        $this->time   = time();
        $this->cookie = self::set_safe_cookie();
    
        /**
         * サンプルなので通るUIDとPWを定義
         */
        $this->authenticated_uid = 1111;
        $this->authenticated_pw  = 'seEe37dmfTQZc';
    }
    
    /**
     * set_safe_cookie: $_COOKIEをクラス用に変換してセット
     */
    protected static function set_safe_cookie()
    {
        static $cookie;
        if (!is_null($cookie)) {
            return;
        }

        /**
         * 一応エスケープしておく
         */
        $cookie = array();
        foreach ($_COOKIE as $name => $value) {
            $safe_name  = htmlspecialchars($name);
            $safe_value = htmlspecialchars($value);
            $cookie[$safe_name] = $safe_value;
        }
        return $cookie;
    }

    /**
     * check_auth: 認証チェック
     * @return boolean
     */
    public function check_auth()
    {
        if (!is_null($this->auth)) {
            return $this->auth;
        }

        $this->auth = false;
        $uid = isset($cookie['uid']) ? (int) $cookie['uid'] : -1;
        if ($uid !== $this->authenticated_uid) {
            return $this->auth;
        }
        
        $digest   = isset($this->cookie['d']) ? $this->cookie['d'] : '';
        $time     = isset($this->cookie['t']) ? $this->cookie['t'] : '';
        $nonce    = isset($this->cookie['n']) ? $this->cookie['n'] : '';
        $valid_pw = crypt("{$this->authenticated_pw}:${time}:${nonce}", CLIENT_SALT);

        if ($digest === $valid_pw) {
            $this->set_auth_cookie();
            $this->auth = true;
        }
        return $this->auth;
    }

    /**
     * login: ログインする
     * @params int    $post_uid
     * @params string $post_pw
     */
    public function login($post_uid, $post_pw)
    {
        $uid = (int) $post_uid;
        $pw  = crypt($post_pw, SERVER_SALT);

        if ($uid === $this->authenticated_uid && $pw === $this->authenticated_pw) {
            $this->set_auth_cookie();
            $this->set_login_cookie();

            /**
             * ログイン状態にする
             */
            $this->auth = true;
        }
    }

    /**
     * set_auth_cookie: 認証に必要なクッキー情報をセットする
     */
    private function set_auth_cookie()
    {
        $nonce  = crypt($this->time, NONCE_SALT);
        $digest = crypt("{$this->authenticated_pw}:{$this->time}:${nonce}", CLIENT_SALT);
        setcookie('t', $this->time);
        setcookie('n', $nonce);
        setcookie('d', $digest);
        $this->cookie['t'] = $this->time;
        $this->cookie['n'] = $nonce;
        $this->cookie['d'] = $digest;
    }
    
    /**
     * set_login_cookie: ログインした時 uid をcookieにセットする
     */
    private function set_login_cookie()
    {
        $expire = $this->time + self::EXPIRE;
        setcookie('uid', $this->authenticated_uid, $expire);
        $this->cookie['uid'] = $this->authenticated_uid;
    }
}

$stateless_auth = new StatelessAuth();

if (isset($_POST['uid']) && isset($_POST['pw']) && !$stateless_auth->check_auth()) {
    $stateless_auth->login($_POST['uid'], $_POST['pw']);
}

if ($stateless_auth->check_auth()) {
    echo '認証済み<br>';
} else { ?>
    <form action="test.php" method="post">
        <div>uid: <input type="text" name="uid"></div>
        <div>pw: <input type="password" name="pw"></div>
        <div><input type="submit" value="ログイン"></div>
    </form>
<?php }

/**
 * benchmark
 */
$after_time = microtime() * 1000000;
echo '処理時間:' . sprintf('0.%06d', $after_time - $before_time);

exit;
```

こんな感じ。
毎回認証処理をすることになるけど、そこまでコストは無さそうです。
rails向けにステートレスで認証するgemを作ってみようかな。