やりたいこと

Auth コンポーネントでログイン認証する際に、パスワードとセットにするアカウントのフィールドを複数の候補を対象にしたい場合の話です。

たとえば「ユーザー名でもメールアドレスでもOK」みたいな。

これをコントローラ側ではなく、コンポーネント側で実装したい。

コントローラ側で実装する参考ページ

コントローラ側での回避策としてはこちらが参考になりました。
[参考1]『http://cakephp.jp/modules/newbb/viewtopic.php?topic_id=1128&forum=8&post_id=2297#forumpost2299
[参考2]『CakePHPでユーザーIDかメールアドレスどちらでもログインできるようにする方法

[参考1]は入力値に”@”が含まれているかどうかを手がかりに検索対象のフィールド名をコントロールする方法、[参考2]は複数のフィールドを順に検索していく方法です。

さて、これらとは別の方法としてコンポーネント側で対応するという手が考えられますが、検索で見つからなかったので書いておきます。

手順など

  1. 認証用のクラス(Form2Authenticate)をつくる
  2. Controller から認証用のクラスとして Form2 を指定して、フィールド名を設定する
  3. ログインフォームのフィールド名を一ヶ所変える
  4. メリット・デメリット、そして課題
  5. まとめ

1. 認証用のクラス(Form2Authenticate)をつくる

FormAuthenticate というクラスが Controller/Component/Auth というディレクトリにあります。(※AuthComponent は認証の種類ごとに適切な認証用のクラスを用いており、フォームからの認証は通常はこれを呼び出します)

これを継承して Form2Authenticate クラスを作成し、そこで _findUser というメソッドを次のようにオーバーライドします。(※ _findUser はFormAuthenticate の基底クラスである BaseAuthenticate で宣言・定義されている)

/**
 * Controller/Component/Auth/Form2Authenticate.php
 */
App::uses('FormAuthenticate', 'Controller/Component/Auth');

class Form2Authenticate extends FormAuthenticate {

/**
 *
 * @param Mixed $conditions The username/identifier, or an array of find conditions.
 * @param Mixed $password The password, only use if passing as $conditions = 'username'.
 * @return Mixed Either false on failure, or an array of user data.
 */
	protected function _findUser($conditions, $password = null) {
		$fields = $this->settings['fields'];
		// 複数のフィールド名が '_or_' で連結されていないと判断した場合は通常の処理へ
		if (!strpos($fields['username'], '_or_')) {
			return parent::_findUser($conditions, $password);
		}

		$userModel = $this->settings['userModel'];
		list(, $model) = pluginSplit($userModel);

		if (!is_array($conditions)) {
			if (!$password) {
				return false;
			}
			$username = $conditions;
			// ユーザー名のクエリを OR 条件で構成する
			$conditions = array(
				'or' => array_fill_keys(
					array_map(
						function($field) use ($model) { return $model . '.' . $field; },
						explode('_or_', $fields['username'])
					),
					$username
				),
				$model . '.' . $fields['password'] => $this->_password($password),
			);
		}
		if (!empty($this->settings['scope'])) {
			$conditions = array_merge($conditions, $this->settings['scope']);
		}
		$result = ClassRegistry::init($userModel)->find('first', array(
			'conditions' => $conditions,
			'recursive' => $this->settings['recursive'],
			'contain' => $this->settings['contain'],
		));

		if (empty($result) || empty($result[$model])) {
			return false;
		}
		$user = $result[$model];
		if (
			isset($conditions[$model . '.' . $fields['password']]) ||
			isset($conditions[$fields['password']])
		) {
			unset($user[$fields['password']]);
		}
		unset($result[$model]);
		return array_merge($user, $result);
	}
}

コードの大半はオリジナルのままで、コメントしている箇所に独自の処理を施しています。

ポイントとなるのはモデルに投げる検索条件を OR で作成していることです。

つまり、普通は

'User.username' => 'ズボラッカ'

になるところを

'or' => array(
    'User.email' => 'ズボラッカ',
    'User.username' => 'ズボラッカ',
),

としています。

一度のクエリで済みますが、OR検索になるためINDEXが効かず(効きにくく)、ゆえに大量のレコードを検索する場合には不向きです。

2.Controller から認証用のクラスとして Form2 を指定して、フィールド名を設定する

class UsersController extends AppController {
    public $components = array(
        'Auth' => array(
            'authenticate' => array(
                'Form2' => array(
                    'fields' => array(
                        // `email` または `username` をログインに用いる
                        'username' => 'email_or_username',
                    ),
                ),
            ),
        ),
    );
}

3.ログインフォームのフィールド名を一ヶ所変える

# users/login.ctp
echo $this->Form->create($model, array('action' => 'login'));
// コントローラで指定したフィールド名にする。この場合は "email" または "username" をログインに用いる
echo $this->Form->input('email_or_username', 'label' => __('Email or Username')); 
echo $this->Form->input('password');
echo $this->Form->end('submit');

4.メリット・デメリット、そして課題

上記の[参考1][参考2]と比較した考察と課題

メリット

  • コンポーネントなので使い回しが出来る
  • コントローラとビューに記述するコード量は比較的少ない
  • 柔軟な設計ができる
  • データベースへの問い合わせが一回で済む([参考2]との比較)

デメリット

  • OR検索になるのでINDEXが効かない(あるいは難しい) → 大量のユーザーには不向き
  • ファイルがひとつ増える
  • テーブル作成時、フィールド名を付けるときのルールがひとつ増える(この場合だと `email_or_username` という名前のフィールドはログインには使用できない )

課題

  • _findUser の第一引数に配列が渡されることがあるみたいなのだけど、それを想定していない。
  • テスト書いてない。
  • OR検索時の最適化を未だよく理解できていない。

 5.まとめ

一日経って冷静に考えれば考えるほど、OR検索がかなりやばいボトルネックになる悪寒が続々と背中から走りまくってきました。

それと同時に[参考1]のやりかたが一番パフォーマンス的には良いだろうし、処理もそれほど複雑にはならないでしょうという気がしてきて、今となってはコントローラで文字列の判定を行なってからそれに応じたクエリを発行するというシステムこそ最善ではないかと思う次第です。

このエントリにあるやり方は、とりあえず少人数のシステム限定で。