Home > PEAR

PEAR Archive

PEAR::HTTP_sessionのストレージドライバにPDOを利用する

intro

先日拝聴しに行った PHP カンファレンス2007にて「大規模サイトを構築するためのいろは」みたいなセッションで「セッションを複数の web サーバで共有するにはデータベースを使うのが簡単だよ」というお話がでておりました。さらに、ストレージをデータベースにした際にセッションハンドラを簡単にデータベースに変更するためには「PEAR::HTTP_session が導入楽チンだよ」とのこと。
PEAR::HTTP_session は確かに楽チンなのですが、ドライバに使えるのが PEAR::DBPEAR::MDBPEAR::MDB2memcache といったところしかございません。
「memcache を導入すんのは面倒くせえけど、DB とか MDB とか MDB2 じゃおせえんじゃねえの」という向きがおられるかもしれませんので、僕が使ってる PDO 用のドライバを up しました。どなたでもご利用下さい。ソースは一番下にあります。

使い方

他のストレージドライバと同じディレクトリにコピーして普通に HTTP_session::setContainer() メソッドを呼び出して下さい。
HTTP_session::setContainer() は

<?php
HTTP_session::setContainer('ドライバの名前', array('オプション'));

と言う呼び出し方となっておりますので、データベースに接続したオブジェクトを渡すのであれば他のドライバと同様に

<?php
HTTP_session::setContainer('PDO', array('dsn' => $db, 'table' => 'sessiondata', 'autooptimize' => TRUE));

と言う呼び出し方で問題ありませんが、DSN を渡す場合は接続ユーザとパスワードが DSN に含まれていないので、別に渡してやる必要があります。ので

<?php
HTTP_session::setContainer('PDO', array('dsn' => $dsn, 'username' => $username, 'password' => $password, 'table' => 'sessiondata', 'autooptimize' => TRUE));

と言う形で指定して下さい。$username には接続ユーザ名、$password にはパスワードをそれぞれ代入して下さい。
後は普通にセッションを開いてあれじゃないこれじゃないと試してみて下さい。

注意点

PEAR::HTTP_session は PHP4 から利用可能ですが、PDO はPHP5.0からしか使えません。しかも5.0だと導入面倒です。5.1から本体にバンドルされてます。
そこらへんに関してはマニュアルを読んで頂いた方が理解が早いかと思います。
クエリを投げるメソッドでは PDOException を拾ってますが基本的に FALSE を返しているだけです。HTTP_Session_Container_PDO::_connect() メソッドだけ PDOException オブジェクトをそのまま返しています。

そのほか

up したクエリやスクリプトを使用して発生した障害や損害などいかなる問題についても僕は責任を取れませんので、ご利用の際は自己責任でお使い下さい。
PEAR::HTTP_session でストレージドライバに memcache を使うくらいなら memSession の方が楽チンかと思います。
セッションハンドラを上書きしているだけなのでファイルをインクルードするだけで使えます

ソースコード

<?php
 
/**
 * PEAR::HTTP_session_container の PDO ドライバ
 *
 * @author    Ryo Suyama <ryo@spais.jp>
 * @license   http://www.php.net/license/3_0.txt  PHP License 3.0
 * @version   0.0.1 2006/08/10
 */
 
require_once 'HTTP/Session/Container.php';
 
/**
 * PEAR::HTTP_session_container の PDO ドライバ
 *
 * セッション格納テーブル作成するクエリ
 * <code>
 * CREATE TABLE `sessiondata` (
 *     `id` CHAR(32) NOT NULL,
 *     `expiry` INT UNSIGNED NOT NULL DEFAULT 0,
 *     `data` TEXT NOT NULL,
 *     PRIMARY KEY (`id`)
 * );
 * </code>
 *
 * @author    Ryo Suyama <ryo@spais.jp>
 * @license   http://www.php.net/license/3_0.txt  PHP License 3.0
 * @version   0.0.1 2006/08/10
 */
class HTTP_Session_Container_PDO extends HTTP_Session_Container
{
    /**
     * PDO 接続オブジェクト
     *
     * @var object PDO
     * @access private
     */
    var $db = null;
 
    /**
     * セッションデータのキャッシュ ID
     *
     * @var mixed
     * @access private
     */
    var $crc = false;
 
    /**
     * コンストラクタ
     *
     * @param array $options Options array('dsn' => DSN, 'username' => データベースユーザ, 'password' => パスワード)
     *
     * @access public
     * @return void
     */
    function HTTP_Session_Container_PDO($options)
    {
        $this->_setDefaults();
        if (is_array($options)) {
            $this->_parseOptions($options);
        } else {
            $this->options['dsn'] = $options['dsn'];
            $this->options['username'] = $options['username'];
            $this->options['password'] = $options['password'];
        }
    }
 
    /**
     * データベースへの接続とインスタンスの生成メソッド
     *
     * @param string $dsn DSN
     * @param string $username データベースユーザ
     * @param string $password パスワード
     *
     * @access private
     * @return mixed   エラーの場合は PDOException を返す そうでない場合は TRUE
     */
    function _connect($dsn, $username, $password)
    {
        try {
            if (is_string($dsn) && is_string($username) && is_string($password)) {
                $this->db = new PDO($dsn, $username, $password);
            } else if (is_object($dsn) && is_a($dsn, 'PDO')) {
                $this->db = $dsn;
            } else {
                return new PEAR_Error("The given dsn was not valid in file " . __FILE__
                                      . " at line " . __LINE__,
                                      41,
                                      PEAR_ERROR_RETURN,
                                      null,
                                      null
                                      );
 
            }
            return true;
        } catch(PDOException $e) {
            return $e;
        }
    }
 
    /**
     * オプションのデフォルト設定メソッド
     *
     * @access private
     * @return void
     */
    function _setDefaults()
    {
        $this->options['dsn']          = null;
        $this->options['username']     = 'username';
        $this->options['password']     = 'password';
        $this->options['table']        = 'sessiondata';
        $this->options['autooptimize'] = false;
    }
 
    /**
     * HTTP_Session_Container_PDO::_connect() のエイリアスみたいなもの
     * TRUE が返ってくれば TRUE を、そうでない場合は FALSE を返す
     *
     * @param string $save_path    Save path
     * @param string $session_name Session name
     *
     * @return bool
     */
    function open($save_path, $session_name)
    {
        $c = $this->_connect($this->options['dsn'], $this->options['username'], $this->options['password']);
        return $c === TRUE? $c: FALSE;
    }
 
    /**
     * Free resources
     *
     * @return bool
     */
    function close()
    {
        return true;
    }
 
    /**
     * Read session data
     *
     * @param string $id Session id
     *
     * @return mixed
     */
    function read($id)
    {
        try {
            $query = sprintf("SELECT data FROM %s WHERE id = '%s' AND expiry >= %d",
                             $this->options['table'],
                             md5($id),
                             time());
            $r = $this->db->query($query);
            $result = $r->fetch(PDO::FETCH_NUM);
 
            $this->crc = strlen($result[0]) . crc32($result[0]);
            return $result[0];
        } catch(PDOException $e) {
            return false;
        }
    }
 
    /**
     * Write session data
     *
     * @param string $id   Session id
     * @param mixed  $data Data
     *
     * @return bool
     */
    function write($id, $data)
    {
        try {
            if ((false !== $this->crc) &&
                ($this->crc === strlen($data) . crc32($data))) {
                // $_SESSION hasn't been touched, no need to update the blob column
                $query = sprintf("UPDATE %s SET expiry = %d WHERE id = '%s'",
                                 $this->options['table'],
                                 time() + ini_get('session.gc_maxlifetime'),
                                 md5($id));
            } else {
                // Check if table row already exists
                $query = sprintf("SELECT COUNT(id) FROM %s WHERE id = '%s'",
                                 $this->options['table'],
                                 md5($id));
                $r = $this->db->query($query);
                $result = $r->fetch(PDO::FETCH_NUM);
                if (0 == intval($result[0])) {
                    // Insert new row into table
                    $query = sprintf("INSERT INTO %s (id, expiry, data) VALUES ('%s', %d, '%s')",
                                     $this->options['table'],
                                     md5($id),
                                     time() + ini_get('session.gc_maxlifetime'),
                                     $data);
                } else {
                    // Update existing row
                    $query = sprintf("UPDATE %s SET expiry = %d, data = '%s' WHERE id = '%s'",
                                     $this->options['table'],
                                     time() + ini_get('session.gc_maxlifetime'),
                                     $data,
                                     md5($id));
                }
            }
            $r = $this->db->query($query);
            $result = $r->fetch(PDO::FETCH_ASSOC);
 
            return true;
        } catch(PDOException $e) {
            return false;
        }
    }
 
    /**
     * Destroy session data
     *
     * @param string $id Session id
     *
     * @return bool
     */
    function destroy($id)
    {
        try {
            $query = sprintf("DELETE FROM %s WHERE id = '%s'",
                             $this->options['table'],
                             md5($id));
            $r = $this->db->query($query);
            $result = $r->fetch(PDO::FETCH_ASSOC);
 
            return true;
        } catch(PDOException $e) {
            return false;
        }
    }
 
    /**
     * Replicate session data to table specified in option 'replicateBeforeDestroy'
     *
     * @param string $targetTable Table to replicate to
     * @param string $id          Id of record to replicate
     *
     * @access private
     * @return bool
     */
    function replicate($targetTable, $id = null)
    {
        try {
            if (is_null($id)) {
                $id = HTTP_Session::id();
            }
 
            // Check if table row already exists
            $query = sprintf("SELECT COUNT(id) FROM %s WHERE id = '%s'",
                             $targetTable,
                             md5($id));
            $r = $this->db->query($query);
            $result = $r->fetch(PDO::FETCH_ASSOC);
 
            // Insert new row into dest table
            if (0 == intval($result)) {
                $query = sprintf("INSERT INTO %s SELECT * FROM %s WHERE id = '%s'",
                                 $targetTable,
                                 $this->options['table'],
                                 md5($id));
 
            } else {
                // Update existing row
                $query = sprintf("UPDATE %s dst, %s src SET dst.expiry = src.expiry, dst.data = src.data WHERE dst.id = src.id AND src.id = '%s'",
                                 $targetTable,
                                 $this->options['table'],
                                 md5($id));
            }
 
            $r = $this->db->query($query);
            $result = $r->fetch(PDO::FETCH_ASSOC);
 
            return true;
        } catch(PDOException $e) {
            return false;
        }
    }
 
    /**
     * Garbage collection
     *
     * @param int $maxlifetime Maximum lifetime
     *
     * @return bool
     */
    function gc($maxlifetime)
    {
        try {
            $query = sprintf("DELETE FROM %s WHERE expiry <%d",
                             $this->options['table'],
                             time());
            $r = $this->db->query($query);
            $result = $r->fetch(PDO::FETCH_ASSOC);
            if ($this->options['autooptimize']) {
                switch($this->db->getAttribute(PDO::ATTR_DRIVER_NAME)) {
                case 'mysql':
                    $query = sprintf("OPTIMIZE TABLE %s", $this->options['table']);
                    break;
                case 'pgsql':
                    $query = sprintf("VACUUM %s", $this->options['table']);
                    break;
                default:
                    $query = null;
                    break;
                }
                if (isset($query)) {
                    $r = $this->db->query($query);
                    $result = $r->fetch(PDO::FETCH_ASSOC);
                }
            }
 
            return true;
        } catch(PDOException $e) {
            return false;
        }
    }
}
?>

HTTP_session_Container_PDO.zip

PEAR::Authの認証に独自のルールを追加する

会員制サイトなどでは、蓄積した情報をなるべく変更せずにアカウントの有効・無効を切り替える必要があることがままあります。
PEAR::Auth は便利ですが認証プロセスそのものに干渉するためのメソッドがありません(と思います)。
セッションが開かれている状況でセッションを継続するかどうかを判断するためのコールバック関数などはありますが、それでは認証が通った後なので認証プロセスに干渉することとはちと違います。
そもそもストレージコンテナが複数種類から選べる以上、あらかじめ独自の認証ルールが追加できる実装などないと考えた方が健康的なんでしょうね。

というわけなので PEAR::Auth の認証プロセスに独自の認証ルールを追加してみました。
ストレージコンテナは MySQL ドライバは PEAR::MDB2 で、バージョンとか細かいことはとりあえずあっちのほうへポイ。
仕様に応じて実装を変える必要はありますが、内容的にはとても単純なものなので「ここをいじればいい」というところさえおさえておけば色々と流用することも可能(なはず)です。なわけでバージョンとかはポイ。

話ははしょって PEAR::Auth とストレージコンテナ用のドライバ PEAR::Auth::Container::MDB2 を継承したクラスがある前提で進めます。

継承して実際にいじるのは
・PEAR::Auth を継承したクラスに独自ルール用のメンバ変数を追加。
・PEAR::Auth の login() メソッドでストレージドライバに処理を渡している部分。
・PEAR::Auth::Container::MDB2 でストレージコンテナに認証データを問い合わせている部分。
といったところです。

まずはメンバ変数の追加。名前などはまあケースバイケースでアレなわけですから、今回僕は

<? php
var $_orgRule = array('alive' => 1);
?>

つう感じにしました。
配列のキーがカラム名で、中身が WHERE 句用の値っつうわけです。

そしたら次は PEAR::Auth を継承したクラスの login() メソッドをオーバーライドします。PEAR::Auth の login() メソッドはこんな感じですね。

<? php
    function login()
    {
        $login_ok = false;
        $this->_loadStorage();
 
        // Check if using challenge response
        (isset($this->post['authsecret']) && $this->post['authsecret'] == 1)
            ? $usingChap = true
            : $usingChap = false;
 
 
        // When the user has already entered a username, we have to validate it.
        if (!empty($this->username)) {
            if (true === $this->storage->fetchData($this->username, $this->password, $usingChap)) {
                $this->session['challengekey'] = md5($this->username.$this->password);
                $login_ok = true;
            }
        }
 
        if (!empty($this->username) && $login_ok) {
            $this->setAuth($this->username);
            if (is_callable($this->loginCallback)) {
                call_user_func_array($this->loginCallback, array($this->username, $this));
            }
        }
 
        // If the login failed or the user entered no username,
        // output the login screen again.
        if (!empty($this->username) && !$login_ok) {
            $this->status = AUTH_WRONG_LOGIN;
            if (is_callable($this->loginFailedCallback)) {
                call_user_func_array($this->loginFailedCallback, array($this->username, $this));
            }
        }
 
        if ((empty($this->username) || !$login_ok) && $this->showLogin) {
            if (is_callable($this->loginFunction)) {
                call_user_func_array($this->loginFunction, array($this->username, $this->status, $this));
            } else {
                // BC fix Auth used to use drawLogin for this
                // call is sub classes implement this
                if (is_callable(array($this, 'drawLogin'))) {
                    return $this->drawLogin($this->username, $this);
                }
 
                // New Login form
                include_once 'Auth/Frontend/Html.php';
                return Auth_Frontend_Html::render($this, $this->username);
            }
        } else {
            return;
        }
    }
?>

実際に書き換える部分は

<? php
        if (!empty($this->username)) {
            if (true === $this->storage->fetchData($this->username, $this->password, $usingChap)) {
                $this->session['challengekey'] = md5($this->username.$this->password);
                $login_ok = true;
            }
        }
?>

この部分です。
Auth::_loadStorage() メソッドでストレージドライバが $this->storage にロードされます。
要するに PEAR::Auth::Container::MDB2 がロードされるわけですね。
ここで PEAR::Auth::Container::MDB2::fetchData() メソッドの引数に先ほど追加したメンバ変数を追加します。

<? php
        if (!empty($this->username)) {
            if (true === $this->storage->fetchData($this->username, $this->password, $usingChap, $this->_orgRule)) {
                $this->session['challengekey'] = md5($this->username.$this->password);
                $login_ok = true;
            }
        }
?>

こんな感じ。
これで PEAR::Auth を継承したクラスの方はおしまい。次は PEAR::Auth::Container::MDB2 の継承クラスをいじります。
いじるのは PEAR::Auth::Container::MDB2::fetchData() メソッドだけです。

<? php
    function fetchData($username, $password, $isChallengeResponse=false)
    {
        // Prepare for a database query
        $err = $this->_prepare();
        if ($err !== true) {
            return PEAR::raiseError($err->getMessage(), $err->getCode());
        }
 
        //Check if db_fields contains a *, if so assume all columns are selected
        if (is_string($this->options['db_fields'])
            && strstr($this->options['db_fields'], '*')) {
            $sql_from = '*';
        } else {
            $sql_from = $this->options['final_usernamecol'].
                ", ".$this->options['final_passwordcol'];
 
            if (strlen($fields = $this->_quoteDBFields()) > 0) {
                $sql_from .= ', '.$fields;
            }
        }
        $query = sprintf("SELECT %s FROM %s WHERE %s = %s",
                         $sql_from,
                         $this->options['final_table'],
                         $this->options['final_usernamecol'],
                         $this->db->quote($username, 'text')
                         );
 
        $res = $this->db->queryRow($query, null, MDB2_FETCHMODE_ASSOC);
        if (MDB2::isError($res) || PEAR::isError($res)) {
            return PEAR::raiseError($res->getMessage(), $res->getCode());
        }
        if (!is_array($res)) {
            $this->activeUser = '';
            return false;
        }
 
        // Perform trimming here before the hashing
        $password = trim($password, "\r\n");
        $res[$this->options['passwordcol']] = trim($res[$this->options['passwordcol']], "\r\n");
        // If using Challenge Response md5 the pass with the secret
        if ($isChallengeResponse) {
            $res[$this->options['passwordcol']] =
                md5($res[$this->options['passwordcol']].$this->_auth_obj->session['loginchallenege']);
            // UGLY cannot avoid without modifying verifyPassword
            if ($this->options['cryptType'] == 'md5') {
                $res[$this->options['passwordcol']] = md5($res[$this->options['passwordcol']]);
            }
        }
        if ($this->verifyPassword($password,
                                  $res[$this->options['passwordcol']],
                                  $this->options['cryptType'])) {
            // Store additional field values in the session
            foreach ($res as $key => $value) {
                if ($key == $this->options['passwordcol'] ||
                    $key == $this->options['usernamecol']) {
                    continue;
                }
                // Use reference to the auth object if exists
                // This is because the auth session variable can change so a static call to setAuthData does not make sense
                $this->_auth_obj->setAuthData($key, $value);
            }
            return true;
        }
 
        $this->activeUser = $res[$this->options['usernamecol']];
        return false;
    }
?>

このメソッドをこんな風に書き換えます。

<? php
    function fetchData($username, $password, $isChallengeResponse=false, $orgRule)
    {
        // Prepare for a database query
        $err = $this->_prepare();
        if ($err !== true) {
            return PEAR::raiseError($err->getMessage(), $err->getCode());
        }
 
    //追加した処理
        $orgRuleWhere = '';
        if(is_array($orgRule)){
                foreach($orgRule as $orgRuleColumn => $orgRuleValue){
                        $orgRuleWhere .= " AND `$orgRuleColumn` = '$orgRuleValue'";
                }
        }
 
        //Check if db_fields contains a *, if so assume all columns are selected
        if (is_string($this->options['db_fields'])
            && strstr($this->options['db_fields'], '*')) {
            $sql_from = '*';
        } else {
            $sql_from = $this->options['final_usernamecol'].
                ", ".$this->options['final_passwordcol'];
 
            if (strlen($fields = $this->_quoteDBFields()) > 0) {
                $sql_from .= ', '.$fields;
            }
        }
        //ここもちろっと変更
        $query = sprintf("SELECT %s FROM %s WHERE %s = %s%s",
                         $sql_from,
                         $this->options['final_table'],
                         $this->options['final_usernamecol'],
                         $this->db->quote($username, 'text'),
                         $orgRuleWhere
                         );
 
        $res = $this->db->queryRow($query, null, MDB2_FETCHMODE_ASSOC);
        if (MDB2::isError($res) || PEAR::isError($res)) {
            return PEAR::raiseError($res->getMessage(), $res->getCode());
        }
        if (!is_array($res)) {
            $this->activeUser = '';
            return false;
        }
 
        // Perform trimming here before the hashing
        $password = trim($password, "\r\n");
        $res[$this->options['passwordcol']] = trim($res[$this->options['passwordcol']], "\r\n");
        // If using Challenge Response md5 the pass with the secret
        if ($isChallengeResponse) {
            $res[$this->options['passwordcol']] =
                md5($res[$this->options['passwordcol']].$this->_auth_obj->session['loginchallenege']);
            // UGLY cannot avoid without modifying verifyPassword
            if ($this->options['cryptType'] == 'md5') {
                $res[$this->options['passwordcol']] = md5($res[$this->options['passwordcol']]);
            }
        }
        if ($this->verifyPassword($password,
                                  $res[$this->options['passwordcol']],
                                  $this->options['cryptType'])) {
            // Store additional field values in the session
            foreach ($res as $key => $value) {
                if ($key == $this->options['passwordcol'] ||
                    $key == $this->options['usernamecol']) {
                    continue;
                }
                // Use reference to the auth object if exists
                // This is because the auth session variable can change so a static call to setAuthData does not make sense
                $this->_auth_obj->setAuthData($key, $value);
            }
            return true;
        }
 
        $this->activeUser = $res[$this->options['usernamecol']];
        return false;
    }
?>

これで認証用のSQLの WHERE 句に `alive` = ‘1′ が追加されました。
もっと複雑な条件が必要な場合は適時その条件を加味したオーバーライドをすればよいだけですね。ストレージドライバを別のものにしても、PEAR::Auth でコールされるのはストレージドライバの fetchData() メソッドなので、PEAR::Auth 側ではそんなにいじる必要はないのではないかと思いました。

後はアレですね、継承したクラスのファイルは PEAR ディレクトリじゃなくてアプリのクラスライブラリディレクトリとかにディレクトリ構造を維持したままぶっこんでおくと保守性が高まってよいのではないでしょうか。
それとか僕の場合で言うと例えば日付データなんかは DATE_FORMAT(`カラム名`,’%Y’) AS `year`,DATE_FORMAT(`カラム名`,’%m’) AS `month` みたいな感じにして PEAR::Auth::getAuthData() で取得、みたいなことをしてます。あんま詳しくないのでよくわからないのですが、こうやってストレージ側であらかじめデータを成型するのと、スクリプト側で成型するのでは、どちらの方がベストプラクティスなんですかね。
別にベストプラクティスって言いたいだけとかじゃないですよ。

というわけで以上、現場から生中継でお送りしました。

PEAR::Authを使いやすくする

みなさん、PEAR つかってますか?
PHP には PEAR という便利なクラスライブラリがありますが、便利といえど万能ではありません。
現場レベルでは「汎用性があるため、細かいレベルでの実装は自分で追加する。」という使い方が(僕の中では)一般的だと思います。

PEAR を頻繁に使っている人の多くと同じように僕も必ず PEAR は継承して独自クラスを作っています。
なぜなら、例えば PEAR::Auth はデフォルトで20種類近くのデータストレージに対応(独自の実装で増やせる)していますが、汎用的であるゆえに各スクリプトでそのままインスタンスを生成するのではスクリプトの管理が煩雑になるからです。と思います。僕はそうです。

他にも、すごい簡単な(認証回数をカウントする、最終ログイン時間をデータベースに UPDATE など)処理であれば Auth::setLoginCallback() でログイン時にコールバックする関数を定義すればよいのですが、同一ドメイン内でお互いに干渉しない認証レベルを設けたいなどといった場合では、継承してメソッドをオーバーライドするほうが開発の手間が省けます。と思います。僕はそうです。

オーバーライドするときは多少なりともソースを読むと、メンテナンス性の高い実装が可能になります。
有名な PEAR:HTML_QuickForm などではフォーム要素の定義を XML で定義して、ロジックとデザインだけではなく、ロジックとデザインとモデルに分離したりもできるので、データベースのスキーマを元に XML を定義すれば大幅に開発コストが削減できます。

<? php
class common{
/**
 * getConfig
 * @param string $src
 * @param string $parser
 * @param array $attr
 * @return array
 **/
  function getConfig($src, $parser = 'XML', $attr = array('encoding'=>'UTF-8')){
    $classConfig = new Config();
    $xmlConfig   = $classConfig->parseConfig($src,$parser,$attr);
    $array = $xmlConfig->toArray();
    foreach ($array['root'] as $toplevel => $values){
      return $values;
    }
  }
}
?>

上のソースはスタティックにコールできる PEAR::Config を簡単に呼び出すメソッドです。
PEAR::Config で XML を解析すると array['root'] 以下に定義が配列としてロードされますが、ここでは array['root'] 以下だけを返しています。
さらにファイルの種別も XML だし、エンコードは UTF-8 で統一しているため引数を省略できるようにしておきます。

<? php
$path = 'ファイルのパス';
$config = common::getConfig($path);
?>

すると、このくらいのコードで設定ファイルをロードできるようになります。

<?
class myForm extends HTML_QuickForm{
  /**
   * Constructor
   **/
  function myForm($formName, $formConfig, $method = 'post', $action = NULL){
    $this->_schemaOfForm  = isset($formConfig['form']) ? $formConfig['form']: NULL;
    $this->_schemaOfRules = isset($formConfig['rule']) ? $formConfig['rule']: NULL;
 
    $attributes=null;
    $target='';
    $trackSubmit=false;
 
    return HTML_QuickForm::HTML_QuickForm($formName, $method, $action, $target, $attributes, $trackSubmit);
  }
  /**
   * parseForm
   * @string $type
   * @return void
   **/
  function parseForm($type){
 
    $this->_schemaOfForm[$type]['elements'] = (isset($this->_schemaOfForm[$type]['elements']['@']['type']))?
      array($this->_schemaOfForm[$type]['elements']): $this->_schemaOfForm[$type]['elements'];
 
    foreach($this->_schemaOfForm[$type]['elements'] as $element){
      switch($element['@']['type']){
 
      case 'select':
        $this->_referElements[$element['@']['element_name']] =& $this->addElement($element['@']['type'],
                      $element['@']['element_name'],
                      $element['@']['label'],
                      $element['option'],
                      $element['attribute']['@']);
        if(isset($element['attribute']['@']['selected']) && $element['attribute']['@'] == 'now'){
          $this->$element['@']['element_name']->setSelected(date($element['attribute']['@']['format']));
        }
        break;
 
      case 'password':
        $group[$element['@']['element_name']] =& HTML_QuickForm::createElement(
                    $element['@']['type'],
                    $element['@']['element_name'],
                    '',
                    $element['attribute']['@']);
        $group[$element['@']['element_name']]->setPersistantFreeze(true);
        $this->addGroup(array($group[$element['@']['element_name']]),
                    $element['@']['element_name'],
                    $element['@']['label'],
                    '',  false);
        break;
 
      case 'checkbox':
        $this->_referElements[$element['@']['element_name']] =& $this->addElement($element['@']['type'],
                      $element['@']['element_name'],
                      $element['@']['label'],
                      $element['@']['label'],
                      $element['attribute']['@']);
        break;
 
      case 'advcheckbox':
        $this->_referElements[$element['@']['element_name']] =& $this->addElement($element['@']['type'],
                      $element['@']['element_name'],
                      $element['@']['label'],
                      $element['@']['label'],
                      $element['attribute']['@'],
                      array(0,1));
        break;
 
      default:
        $this->_referElements[$element['@']['element_name']] =& $this->addElement($element['@']['type'],
                      $element['@']['element_name'],
                      $element['@']['label'],
                      $element['attribute']['@']);
      }
    }
  }
}
?>

(長いので一部省略していますしそもそも大体こんな感じという程度です)
PEAR::HTML_Quickform を敬称したクラス myForm に parseForm というメソッドを追加しています。
myForm::parseForm() メソッドではインスタンス生成時にロードした、フォームを定義した XML ファイルを元にフォーム要素を定義しています。
また、フォーム要素を定義するときはメンバ変数としてフォーム定義へのリファレンスを宣言しておくことで、あとでフォーム要素をいじくるときなどに便利です。

タイトルは「PEAR::Authを使いやすくする」なのにモロ PEAR::HTML_QuickForm の話になってしまっておりますが、まあ要するにこういうことができるので、そのまま使うんじゃなくてオーバーライドするメソッドがなくとも継承しておいたほうが後々楽ですよと僕は言いたいわけです。

最後に、PEAR::Auth にユーザーレベルの概念を追加する場合は $this->session(PEAR::Auth で利用するセッション変数へのリファレンス)に type などを宣言して、$this->checkAuth() をオーバーライドして type を引数にしてログイン可能レベルを判断したりすると簡単に追加できます。
「このページは(管理者レベル)の認証が必要です」みたいな。

それではみなさん、素敵な PEAR ライフを!

PEAR::HTML_QuickFormにワンタイムチケットを実装する

いわゆる CSRF 対策として、整合性を検証出来ない値(リファラ、リモートホスト、クッキーに焼いた値など)をキーとするのは、そもそもサーバーサイドからレスポンスされたリソースであるかどうかの確認には使えないですよねと思う僕が PEAR::HTML_QuickForm を使ってワンタイムチケットを実装してみようと思います。
ワンタイムチケットとは、サーバが発行したチケットのトークン(半券)を含めたフォームを発行することで、”本当にサーバが発行したフォームである”と言う検証を行うための実装です。

言うなれば合言葉です。「山」「川」「豊」みたいな。
合言葉なので当然片方の合言葉からもう片方予測出来てはいけませんし、ワンタイムと言う名前の通り合言葉は一回こっきりで捨てられます。この時点で「山」「川」「豊」はダメすぎると言わざるを得ません。「山」ってなった瞬間に「川」って脊髄反射で答えそうですし。
で、その”予測できてはいけない”と言う点に関してはチケットとトークンの関係を以下のようにすることで予測できない間柄にします。
$token = sha1(SALT.$ticket);
SALT 文字列とチケットの sha1 チェックサムをトークンとして使うっつう意味です。凄い簡単ですけどとりあえず破られるような事は無いと思います。

ちょっと横道にずれますが SALT とはまあ塩なんですけど、例えば”cynicaltaro”と言うパスワードがあったとします。こんなパスワード辞書攻撃でバツッとやられてしまいますね。そんな「もしかしたら推測できてしまうかも」と言う文字列に対して前なり後ろなりに付け加える文字列を指します。
もし SALT に”oiegnp4-etr%f#asf”という文字列が定義されていたら、先ほどのパスワードは”oiegnp4-etr%f#asfcynicaltaro”になると言う寸法です。こうすることでさっきまであんなに弱かったパスワードも今では立派に大きくなりました、となるわけです。
逆にサーバでは SALT がなんなのか分かっている為、特に実装方法を気にする必要は無いわけです。

それでは実際に実装してみます。
実装には PEAR::HTML_QuickForm を継承したクラス myForm を使います。
特に明記しない場合大文字はリテラルです。session_start() とかインクルードとかは省きます。

<? php
class myForm extends HTML_QuickForm {
 function setOnetimeTicket(){
  $ticket = sha1(array_sum(explode(" ", microtime())));
  $token  = sha1(SALT.$ticket);
  $_SESSION['ticket'] = $ticket;
  $this->setDefaults(array('token'=>$token);
 }
 
 function verifyOnetimeTicket($ticket){
  $compare = sha1(SALT.$ticket);
  return isset($this->_submitValues['token']) && $this->_submitValues['token'] === $compare? true: false;
 }
}
?>

トークンはフォームに hidden な要素として定義すればいいので、myForm::setOnetimeTicket() メソッドの前に定義しておきます。
因みにリクエストと検証のタイミングから、要素の定義→検証メソッド→チケット発行メソッド、と言う流れが一番分かりやすいかと思います。
要するに検証メソッドの前にチケット発行しても正しいの当たり前じゃん。つうことであり、要素を定義するまえに検証しても False じゃんつうことです。
普通な PHP のコードで言えば return $token == sha1(SALT.$_SESSION['ticket'])? true: false; の前に $token = $_POST['token']; が無いとダメだよねっつうことです。

簡単に解説すると、myForm::setOnetimeTicket() メソッドはすでに HTML_QuickForm:addElement() メソッドで定義されている token の value にチケットのトークンをセットします。
同時にチケット自体はセッション変数として保存。もし万が一このページを読んでワンタイムチケットを実装したとしても、チケットの値自体の生成ルールは独自に実装されることをお勧めします。

myForm::verifyOnetimeTicket() メソッドは単純に引数 $ticket とトークンが正しい組み合わせであった場合に True を返すと言うだけのメソッドです。
False だったらサイトのトップページにでもリダイレクトしてあげましょう。

とまあこの程度のコードでワンタイムチケットが実装できるからわけわかんない正規表現とかでリファラチェックとか不毛な事をするよりは健康的ですよね。と言いたかっただけです。

[補足] セッションは既に開始している前提です。

PEAR::HTML_QuickformとPEAR::DB_DataObjectの連携

PEAR::HTML_Quickformはフォームを生成するクラス。オブジェクトとして扱えるので慣れるととても便利。
PEAR::DB_DataObjectはデータベースのテーブルにアクセス周りのラップクラス。複雑な事は苦手だけど定型的なデータアクセスなんかだとSQLレスにすっきりとしたコーディングができますよ!的なクラス。

データベースアクセスにスクリプトから毎回やれサニタイズだなんだって気にしてSQLを生成するのはナンセンスだけど、自分でデータベースアクセス周りをラップするクラスを作るのも面倒だよなー。って時にはいいかもしれないですね。PEARのクラスライブラリ同士を連携させた例をあまり見かけなかったので確認がてらメモ。

ソース見づらくなっても後でコピーして使えなくなっていやなので、入力値の評価やデータベースアクセスに対してのレスポンスなんかの処理はなんもしてません。

HTML_Quickformを継承して例えば

  • cnvFormToDb(リクエストをテーブルの型に変換するメソッド)
  • update(リクエストをテーブルにUPDATEするメソッド)
  • insert(リクエストをテーブルにINSERTするメソッド)

なんてメソッドを追加してみます。ソースは以下の様な塩梅。
ちろっとした解説はソースの下に。

<?php
class Form extends HTML_Quickform{
    var $DBO;
    var $_tableName;
    /*
        UPDATEする場合まずUPDATEする
        レコードを取得必要があるので、
        WHEREに指定するカラム名
    */
    var $_primary_keyName = 'id';
    function Form($name,$method,$action,$tablename){
        $this->_form->HTML_Quickform($name,$method,$action);
        $this->_tableName = $tablename;
        $this->DBO = DB_DataObject::factory($this->_tableName);
    }
   
    function cnvFormToDb($values){
        $result = array();
        foreach($values as $column=>$value){
            switch($column){
                case 'username':
                //処理
                break;
            }
            $result = array_merge($result,array($column=>$value));
        }
        return $result;
    }
   
    function update(){
        $values = $this->cnvFormToDb($this->_form->submitValues);
        if($this->DBO->get($this->_primary_keyName,$this->$this->_primary_keyName)==0)return false;
        $this->DBO->setFrom($values);
        $this->DBO->update();
    }
   
    function insert(){
        $values = $this->cnvFormToDb($this->_form->submitValues);
        /*
            テーブルスキーマを取得
        */
        $this->DBO->table();
        /*
            引数をテーブルのカラム名=>保存する値
            としておく事で取得したテーブルスキーマに
            存在するカラムのデータがセットされる
        */
        $this->DBO->setFrom($values);
        /*
            テーブルのキーに当たるカラムは
            setFromメソッドではセットできないので
            キーとなるカラムがあった場合には
            keysメソッドでテーブルのキーを取得して
            キーのカラム名と同じインデックスの値をセット
        */
        if(count($this->DBO->keys())!=0){
            foreach ($this->DBO->keys() as $column){
                $this->DBO->$column = $values[$column];
            }
        }
        $this->DBO-update();
    }
}
$Form = new Form($name,$method,$action,$tablename);
//HTML_Quickformの処理
if(isset($_POST['SUBMIT'])){
    if($Form->update())$Form->insert();
}
?>

後はフォームのHTMLをSmartyかなんかで成型したり。
テーブルのカラム名をインデックスに、保存したい値で連想配列を作成して、テーブルスキーマを取得しておいてsetFromメソッドの引数に先ほどの配列をセットすれば、テーブルスキーマにある値だけがINSERTされるので、submitやhiddenなんかも気にせずPEAR::HTML_QuickformのsubmitValuesなんかをそのまま突っ込んで使えますよというもの。
cnvFormToDbメソッドの役割としては、PEAR::DB_DataObjectでDATE型のデータをテーブルに保存するときはdate(’U',$date)なんかでUNIXタイムスタンプに変更する必要があったり、電話番号で三つのinput要素をハイフンでimplodeしたりっつうことに使います。まあケースバイケースで。

例えば上記クラスのようにHTML_Quickformを継承せずに、共通モジュールとしてスタティックに呼び出せるデータベースアクセスクラスなんかを作ってもデータベース周りの定型な処理の見通しがよくなって便利ですね。
こういう共通モジュールを作っておくとPEAR::HTML_AJAXなんかと連携して、フォームに入力した郵便番号でページ簡易無しに住所を表示、とか会員制サイトの登録時のアカウントが重複しているかのチェックなどの複数の機能に対しても単一のメソッドで済んでしまうので開発が結構楽になると思います。

如何に楽をするか、それこそが最大の課題だ。  ~僕~

ホーム > PEAR

Search
Feeds
Meta

Return to page top