Laravelで独自のクラスを使ってパスワードをハッシュ化する

今回は、以前行ったアプリケーション移行時の、ユーザーデータ移行について書こうと思います。
ユーザーへの負担を避けるため、現行のパスワードを維持したいという要件があったことがキモです。
ちなみに、移行したときのLaravelのバージョンは5.6.39です。

概要

※ [お断り] この記事では一般的な認証の仕組みなどは説明しません。

移行元は、ログインの仕組みを持つアプリケーション(Laravel製ではない)です。
このアプリケーションのユーザーデータを、別アプリケーション(Laravel製)に移行しました。

ここでいくつか注意点があります。

  • 移行後にパスワードの再設定は行わない(ユーザーへの負担を避けるため)
  • 移行後では同一アプリケーションで別認証(bcrypt)もある
  • 移行元ではアカウント名と、ハッシュ化(salt使用)されたパスワードを用いて認証している
  • 移行元ではパスワードのハッシュ値はPHP関数のhash_hmac()で生成している
  • 認証情報の読み書き・保存には、移行元・先ともにcookieでのセッションを使用する

パスワードの再設定を行わないため、ハッシュ化のプロセスを移行元と全く同じにする必要があります。
また、当然LaravelのデフォルトHashing(BcryptHasher)とは異なるプロセスなので、自作する必要がありました。

パスワードチェックの変更

デフォルト

さてまず根幹の、認証部分の処理を見ましょう。デフォルトでは EloquentUserProvider::validateCredentials です。

<?php

/**
 * Validate a user against the given credentials.
 *
 * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
 * @param  array  $credentials
 * @return bool
 */
public function validateCredentials(UserContract $user, array $credentials)
{
    $plain = $credentials['password'];
    return $this->hasher->check($plain, $user->getAuthPassword());
}

これを見るとわかりますが、パスワードしか使っていませんね。なのでsaltを渡すよう変更する必要がありそうです。

パスワードのハッシュ値との比較は、デフォルトでは AbstractHasher::check で行われています。

<?php

/**
 * Check the given plain value against a hash.
 *
 * @param  string  $value
 * @param  string  $hashedValue
 * @param  array  $options
 * @return bool
 */
public function check($value, $hashedValue, array $options = [])
{
    if (strlen($hashedValue) === 0) {
        return false;
    }
    return password_verify($value, $hashedValue);
}

PHP組み込み関数の password_verify() が使われていますね。本来はこれが望ましいのですが、今回は移行元のチェック方法と違うので変更が必要です。

パスワードチェックの重要な変更部分は以上2点です。

今回つくった処理

まずハッシュ化や比較を行うhasherを自作します。Hasher interfaceに沿って実装しましょう。今回重要なmakeとcheckの実装は以下のようにしました。

<?php

use Illuminate\Contracts\Hashing\Hasher as HasherContract;

class OldApplicationHasher implements HasherContract
{

~~~ 省略 ~~~

/**
 * Hash the given value.
 *
 * @param  string  $value
 * @param  array   $options
 * @return string
 */
public function make($value, array $options = [])
{
    $algo = config('old_application.hasher.algo');
    $magic = config('old_application.hasher.magic');
    $data = $value . ':' . $magic;
    $key = empty($options['salt']) ? $magic : $options['salt'];

    return hash_hmac($algo, $data, $key);
}

/**
 * Check the given plain value against a hash.
 *
 * @param  string  $value
 * @param  string  $hashedValue
 * @param  array   $options
 * @return bool
 */
public function check($value, $hashedValue, array $options = [])
{
    return $this->make($value, $options) === $hashedValue;
}

これは移行元のロジックをなるべくそのまま持ってきました。(ちなみに実際はOldApplicationHasherなんて名前ではありません汗)

hash_hmac()の第一引数の設定値などをどこに持つか悩んだのですが、今回はconfigに以下の通り持たせることにしました。

'hasher' => [
    'algo' => 'sha256',
    'magic' => 'abcdefghijklmnopqrstuvwxyz',
],

当然、どちらも移行元と揃えます。

これでパスワードのハッシュ値との比較処理は作成できました。次はこれを使うvalidateCredentialsを置き換えます。

EloquentUserProviderを継承し、validateCredentialsをオーバーライドしましょう。(namespaceなどの記述は省略しています)

<?php

use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;

class OldApplicationEloquentUserProvider extends EloquentUserProvider
{
    /**
     * Validate a user against the given credentials.
     *
     * @param  \Illuminate\Contracts\Auth\Authenticatable  $user
     * @param  array  $credentials
     * @return bool
     */
    public function validateCredentials(UserContract $user, array $credentials)
    {
        $plain = $credentials['password'];
        $options = [
            'salt' => $user->salt ?? '',
        ];

        return $this->hasher->check($plain, $user->getAuthPassword(), $options);
    }
}

これで、先ほど作成したcheck処理にsaltを渡すことができました。

依存解決の変更

移行元の処理を再現することはできましたが、このままではこれらの処理は使われません。デフォルトの依存解決を変更する必要があります。

まず、OldApplicationEloquentUserProviderに渡すHasherを変更するため、ServiceProviderをつくりましょう。(namespaceなどの記述は省略しています)

<?php

use Illuminate\Support\ServiceProvider;
use Path\To\OldApplicationHasher;

class OldApplicationHashServiceProvider extends ServiceProvider
{
    /**
     * Register the service provider.
     *
     * @return void
     */
    public function register()
    {
        $this->app->singleton('old_application_hash', function () {
            return new OldApplicationHasher();
        });
    }

    /**
     * Get the services provided by the provider.
     *
     * @return array
     */
    public function provides()
    {
        return ['old_application_hash'];
    }
}

このServiceProviderを作成したあと、AppServiceProviderで以下のように登録しておきます。

<?php

$this->app->register(OldApplicationHashServiceProvider::class, [], true);

認証のproviderはデフォルトではEloquentUserProviderですが、Auth::providerで変更ができます。
こちらもAppServiceProviderで、以下のように記述しましょう。

<?php

Auth::provider('old_application_eloquent', function ($app, array $config) {
    return new OldApplicationEloquentUserProvider($app['old_application_hash'], $config['model']);
});

このold_application_eloquentというproviderを使うよう、auth.phpを変更します。

'providers' => [
    'users' => [
        'driver' => 'old_application_eloquent',
        'model' => Path\To\User::class,
    ],

これでようやく依存解決で自作のものが注入されるようになります。

saltなどの保存

saltの保存はどのタイミングで行うか迷ったのですが、今回はパスワードの保存時に同時に行いました。

<?php

/**
 * Set password attributes
 *
 * @param string $password
 * @return void
 */
public function setPasswordAttribute($password)
{
    $options = [
        'salt' => Str::random(10),  // saltの生成(この文字数も移行元と同一にする)
    ];
    $this->attributes['salt'] = $options['salt'];
    $this->attributes['password'] = app('old_application_hash')->make($password, $options);  // ハッシュ値の生成
}

詳細は省きますが、このsetPasswordAttributeメソッドはEloquentでpasswordフィールドに値が設定されると呼び出されます。
また、$this->attributes['salt'], $this->attributes['password'] へ代入していますが、これで保存されると考えてください。

まとめ

  • 関連するクラスがいくつかあるので、処理の流れを把握して必要なものだけを置き換える
  • ハッシュ化の処理だけ差し替えることができて、Laravelは便利だなぁ

今回さらに問題を複雑にしたのは、saltというパスワード以外のカラムの存在と、bcryptと共存しなければならないという要件です。 それらがなければ、Hasherクラスのみ自作してhashing.phpのdriver(つまりデフォルト)を変更するのみで良かったのですが・・・。

ユーザーデータが存在するアプリケーションの移行時には、こういった選択肢も検討してみてください。

余談

2019年を振り返ると、2018年よりもさらにプロダクトの成長のことばかり考えていたような気がします。
個人的なチャレンジとしては、マーケティングなどの基礎を学習したり、また別軸では IJ Cast というポッドキャストを始めたりなどしていました。

他には、個人的なライフイベントもありいかに時間を有効に使うかを考えた年でもありました。
新しい技術にアンテナを貼る・試すのは日常的にやっていましたが、気になるもの全てに対してやるのは時間が足りません。
そのため「プロダクトに貢献するか?」を特に重視した技術選定になりました。


これは イノベーター・ジャパン Advent Calendar 2019 の2日目の記事でした。
明日は @ogichon がマラソンネタを書くようです 👀