PHP
laravel
authentication
laravel5
laravel5.5

LaravelでワンタイムURL認証をサクッと実現するTIPS

More than 1 year has passed since last update.

はじめに

WEBサービスに新しくユーザー登録を行う場合、
ユーザー登録確定用のワンタイムURL作って、それをメールなどで通知して登録確定をさせたい場合ってよくあると思います。
これをLaravelでサクっと実装する方法を共有します。

情報が既出かどうか調べましたが、API認証の話題ばかりだったので、
Laravelの中身を確認しながら実装してみました。
※もっと良いやり方があれば教えてください。

環境

  • PHP7.0
  • Laravel 5.5(他のバージョンでもイケる想定)

方針

  1. TokenGuardは情報が無いし、たまに出てくる情報の評判が宜しくないから使わない
  2. API認証はSPAとか作りたいわけではないので使わない
  3. 既存のユーザー認証について、アレコレ言っている人は居ないのでコレを使って実現する

やること

  1. パスワードで使用するカラムの変更
  2. 認証で使うテーブルの作成
  3. 認証で使うテーブルのEloquentモデルの作成
  4. ワンタイムURL認証用のguardの追加

実装

パスワードで使用するカラムの変更

LaravelのEloquentモデルを使ったユーザー認証では、パスワードで使用するカラム名が「password」で固定されています。
(ユーザー名のカラム名は変えられる癖に・・・後で要望出しとこ)

で、この固定されている部分は、「Illuminate\Auth\EloquentUserProvider」で実装されています。
※auth.phpのproviderがeloquentって書いてあるヤツはこのクラスです。

このクラスのアダプタを作っても良かったのですが、PHPってメソッドオーバーライドするとIDEが警告出してきて鬱陶しいので、
実装自体も簡単だしコンポジットして作ることにします。

EloquentProviderの中身をまるっとコピーして、以下のメソッドだけ変えます。

  • retrieveByCredentials
  • validateCredentials
OneTimeUrlEloquentProvider.php
public function retrieveByCredentials(array $credentials)
{
    if (empty($credentials)) {
        return;
    }

    // First we will add each credential element to the query as a where clause.
    // Then we can execute the query and, if we found a user, return it in a
    // Eloquent User "model" that will be utilized by the Guard instances.
    $query = $this->createModel()->newQuery();

    foreach ($credentials as $key => $value) {
        if (! Str::contains($key, 'password')) { // ←ここのpasswordをone_time_url変える
            $query->where($key, $value);
        }
    }

    return $query->first();
}

public function validateCredentials(UserContract $user, array $credentials)
{
    $plain = $credentials['password']; // ←ここのpasswordをone_time_url変える

    return $this->hasher->check($plain, $user->getAuthPassword()); // ←ハッシュ化して比較する必要はないので「hash_equals(plain, $user->getAuthPassword())」での比較に書き換える
}

認証で使うテーブルの作成

$ php artisan make:migration create_one_time_urls_table

中身は以下

create_one_time_urls_table.php
public function up()
{
    Schema::create('one_time_urls', function (Blueprint $table) {
        $table->increments('id');
        $table->string('one_time_url', 64)->unsigned()->default(1); // 生成したURLを入れる
        $table->integer('pseudo_password_id')->unsigned()->default(1); // ←疑似的なパスワード(何でも良い)
        $table->dateTime('generated_at');
        $table->dateTime('completed_at')->nullable();
    });
}

Laravelのユーザー認証は、ユーザー名とパスワードが必須の指定で、これに付加情報を渡せます。
で、ここが大切なのですが、
Laravelのユーザー認証は、「ユーザー名と付加情報に紐づくカラム」を取って、そのパスワードを比較するという手法を取っています。
(自分でSQL書くと、SQLで突き合せて終わりなので、ちょっと新鮮であった)
なので、認証データは「ユーザー名と付加情報」でユニークでないといけません。
ユニークになるのは、one_time_urlなので、これがユーザー名相当になります。
パスワードは何でも良い(この時点でパスワードがない)ので、疑似的なパスワードカラムを用意して適当な値をセットしておけば良いです。
ちなみにですが、私はワンタイムURLは色々な機能が生成するので、どの機能が生成したかを区分値として保存しておき、
この区分値をパスワードにしています。
こうすることで、関係ない機能のワンタイムURLでのリクエスト弾くようにしています。

認証で使うテーブルのEloquentモデルの作成

$ php artisan make:model OneTimeUrl

Eloquentモデルを認証用のモデルにするには、

  • Illuminate\Contracts\Auth\Authenticatableを実装する
  • Illuminate\Auth\Authenticatableトレイトを使わないといけない です。

また、認証で使うメソッドをオーバーライドする必要があります。
全部書くと以下の実装になります。

OneTimeUrl.php
use Carbon\Carbon;
use Illuminate\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;

class OneTimeUrl extends Model implements AuthenticatableContract
{
    use Authenticatable;

    public $timestamps = false;
    protected $fillable = ['one_time_url', 'pseudo_password_id', 'generated_at', 'completed_at'];


    // 以下は認証系のoverride
    /**
     * Get the login username to be used by the controller.
     *
     * @return string
     */
    public function username()
    {
        return 'one_time_url'; // ← ユーザー名に使用するカラム名を上書きする
    }

    /**
     * Get the password for the user.
     *
     * @return string
     */
    public function getAuthPassword()
    {
        return $this->pseudo_password_id; // ← パスワードの比較で使用するデータを上書きする
    }

    /**
     * Set the token value for the "remember me" session.
     *
     * @param  string  $value
     * @return void
     */
    public function setRememberToken($value)
    {
        $this->rememberTokenName = null; // ← remember_tokenは使わないので、こうしておく。
                                         //   こうしておかないとremember_tokenカラムがない場合、エラーで落ちる
    }

}

ワンタイムURL認証用のguardの追加

config/auth.phpに追加します。

auth.php
'guards' => [
    'one-time-url' => [
        'driver' => 'session',
        'provider' => 'one-time-urls',
    ]
],

'providers' => [
    'one-time-urls' => [
        'driver' => 'one_time_url',
        'model' => App\Model\OneTimeUrl::class,
    ],
],

また、AuthServiceProviderで、はじめに作ったOneTimeUrlEloquentProviderを紐づけます。

AuthServiceProvider.php
public function boot()
{
    $this->registerPolicies();

    //以下を追加
    $this->app['auth']->provider('one_time_url', function($app, array $config) {
        // Return an instance of Illuminate\Contracts\Auth\UserProvider...
        return new OneTimeUrlEloquentProvider($app['hash'], $config['model']);
    });
}

利用側の実装

コントローラでもミドルウェアでもどこでも良いので、以下を使えばログインできます。
(ルーティングは省略)

サンプルはミドルウェアでの実装
※completed_atが登録されている場合は、URLを無効扱いにしたいため、nullであることをユーザー情報の抽出条件に追加してます。

usage.php
$one_time_url = object_get($request, 'one_time_url');

if (!Auth::guard('one-time-url')->attempt(['one_time_url' => one_time_url, 'pseudo_password_id' => 1, 'completed_at' => null])) {
    abort(404);
}

ログイン後は、Auth::guard('one-time-url')->check()や、Auth::guard('one-time-url')->user()を使えるので、
ワンタイムURL認証後に続けて何かをさせたい場合は、ワンタイムURLを引き回さなくて良くなります。

最後に

Laravelのユーザー認証周りを自分で使ったことは無かったので、
裏側がどうなっているかなど色々知れて良かったです。
もっと良い実装方法があれば教えてください。