Laravelでデータベースにデータを暗号化して格納する方法と、暗号化したデータを自動的に複合する必要があったので、いろいろとぐぐってみました。
いろいろなやり方があったのですが、この方法がよさげだったので、採用しました。
その方法について紹介します。
まずは、暗号化したり復号したりするための、Encryptableというトレイトを作成します。
<?php
namespace App;
use Illuminate\Support\Facades\Crypt;
trait Encryptable
{
public function getAttribute($key)
{
$value = parent::getAttribute($key);
if (in_array($key, $this->encryptable)) {
$value = Crypt::decrypt($value);
}
return $value;
}
public function setAttribute($key, $value)
{
if (in_array($key, $this->encryptable)) {
$value = empty($value) ? '' : $value;
$value = Crypt::encrypt($value);
}
return parent::setAttribute($key, $value);
}
}
値が存在しない場合(empty)は、空文字列として取り扱います。
個人情報とみなされるデータは、住所、氏名、電話番号など、どれも文字列型なので、問題ないと思います。
このEncryptableトレイトを、暗号化したいモデルに適用してやります。
そして、暗号化したいカラムを $encryptable 配列で指定します。
以下は、Laravelで標準的に使用される、ログイン情報を保持するための User クラスでは email を暗号化の対象とします。
ログインするときには、name と password でログインするシステムにしています。この場合、name を暗号化してしまうとログインできなくなってしまいました。
なので、もしかするとメールアドレスとパスワードでログインする方式を採用しているシステムでは、メールアドレスを暗号化するとログインできなくなるかもしれません。ここは確認してませんので、メールアドレスでログインさせる人は、メールアドレスを暗号化してもログインできるかどうか試してみてください(できなさそうな)。
<?php
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
use App\Notifications\PasswordReset;
use App\Encryptable;
class User extends Authenticatable implements MustVerifyEmail
{
use Notifiable;
use HasRoles;
use Encryptable;
protected $fillable = [
'name', 'email', 'password',
];
protected $hidden = [
'password', 'remember_token',
];
public $encryptable = [
'email'
];
protected $casts = [
'email_verified_at' => 'datetime',
];
public function sendPasswordResetNotification($token)
{
$this->notify(new PasswordReset($token));
}
}
以下は Member クラスです。Memberクラスは、Userと紐付けて会員情報を管理するためのクラスで、会員の氏名や郵便番号、住所、電話番号を保持していますので、id以外のすべてを暗号化します。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;
use App\Encryptable;
class Member extends Model
{
use HasFactory;
use Encryptable;
protected $guarded = [ 'id' ];
public $encryptable = [
'name', 'zip', 'address1', 'address2', 'phone',
];
}
ちなみに、User クラスは安定しているので fillable を使用していますが、システムによって保持する情報が異なることが多い会員情報に対応する Member クラスについては fillable ではなく guarded で id だけを指定しています。fillable を使っていると、カラムを追加したのに fillable にカラム名を追加し忘れてデータを保存できないという Laravelあるある問題を回避できます。
これで、個人情報を暗号化して格納できるようになりました。
ここで注意しないといけない点があります。
暗号化したデータは、元の文字列の何倍もの長さになります。
一方、Laravelで作成したmigration内で暗号化対象のカラムをstring型で定義していると、長さが不足して復号できずにDecryptExceptionが発生してしまいます。
最初からtext型で作っていればいいのですが、string型で定義している暗号化対象のカラムがあるのならば、text型に変更するmigrationを作ります。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AlterMembersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('members', function (Blueprint $table) {
$table->text('name')->change();
$table->text('address1')->change();
$table->text('address2')->change();
$table->text('phone')->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('members', function (Blueprint $table) {
$table->string('name', 255)->change();
$table->string('address1', 255)->change();
$table->string('address2', 255)->change();
$table->string('phone', 255)->change();
});
}
}
これで、個人情報に関するデータをすべて暗号化して保存できるようになりました。
が、さらに問題が発生します。
ユーザー登録時にメールアドレスを登録するのですが、暗号化しない場合はこちらのバリデーションでうまくチェックできます。
'email' => ['required', 'string', 'email:strict,dns', 'max:255', 'unique:users'],
「unique:users」によって、登録済みのメールアドレスだったらバリデーションエラーになるのです。
しかし、メールアドレスを暗号化して格納している場合は、このバリデーションではチェックできなくなってしまいます。
格納したメールアドレスを復号して比較するのが手っ取り早いです。
Varidatorのクラスを作成して、そのクラスでバリデーションを行います。
すべての登録済みユーザーを取得して、総当たりで比較していきます。
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
use App\User;
class EmailUniqueValidator implements Rule
{
/**
* Create a new rule instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param mixed $value
* @return bool
*/
public function passes($attribute, $value)
{
$users = User::all();
foreach($users as $u) {
$email = $u->email;
if ($email == $value) {
return false;
}
}
return true;
}
/**
* Get the validation error message.
*
* @return string
*/
public function message()
{
return 'このメールアドレスは使用できません。';
}
}
そして、メールアドレスのバリデーション部分は、次のように書き換えます。
'email' => ['required', 'string', 'email:strict,dns', 'max:255', new EmailUniqueValidator, ],
しかし、やはり負荷が気になります。会員登録数が増えれば増えるほど登録時の一時的な負荷が増えてしまいます。
全ユーザーのデータを取得せずに、わずかなデータだけを取得するように変更しましょう。
メールアドレスに対してmd5のハッシュ値を生成して、そのハッシュ値を書き込むカラムを作り、格納しておきます。
新規登録しようとしているメールアドレスのハッシュ値でクエリした結果と照合すれば、たまたま奇跡的にハッシュ値が一致するメールアドレスがあったとしても0個でしょう。Facebook並のユーザー数がいたとしても、同一のハッシュ値を持つメールアドレスが1個見つかることはあり得ない確率(1/2128)なので。(詳しくはmd5をぐぐってください)
public function passes($attribute, $value)
{
$md5 = md5($value);
$users = User::where('md5', $md5)->get();
foreach($users as $u) {
$email = $u->email;
if ($email == $value) {
return false;
}
}
return true;
}