Laravel + React フィンガープリントを使って不正ログイン対策

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、私はセキュリティの専門ではないですが、そうは言っても開発者としてある程度のセキュリティの知識はもっておかないといけません。

例えば、「誰かになりすましてログイン」する、いわゆる「不正ログイン」は有名な芸能人の被害がニュースで報じられたりしてますよね。

そこで❗

今回はGoogleなどでも提供されている

未知の環境からログインがあったら、メールでお知らせする

という機能を「フィンガープリント」を使って実装してみたいと思います。

この場合の「フィンガープリント(指紋)」とは、ブラウザや実行環境から誰がアクセスしているのかを知ることができる個別IDのことで、今回はFingerprint.com (※1)を使って取得します。

※1: 今回は有料版のトライアル(20,000回まで無料。2022.11.2現在)を使いますが、精度は落ちるものの完全無料版も存在しています。

ぜひ何かの参考になりましたら嬉しいです。😄✨

「ワイドモニター
買ったらスペック不足で
結局 1920 x 1080 で表示中…」

開発環境: Laravel 9.x、React、Inertia.js、TailwindCSS、Vite、PHP 8.1

前提として

Laravel 9.xにログイン機能がインストールされていることが前提です。
もしまだの方は以下のページを参考にして準備を済ませておいてください。

📝 【Laravel】Vite + Inertia + React でログイン機能をインストールする

実装する手順

  1. ユーザー登録時にフィンガープリントも保存する
  2. ログインするときに再度フィンガープリントを取得し、過去のものと比較
  3. もしフィンガープリントが存在していなかったら、「新しい環境からログインされました」メールを送信してユーザーに確認してもらう

なお、IPアドレスやUserAgentを使って区別する方法もあるとは思うのですが、例えば同じ家や会社からなら同じ人と判別されちゃったりするので、今回はより精度が高いフィンガープリントを採用しました👍(フィンガープリントはシークレット・モードでも同じ文字列になります)

Fingerprint.com へ登録する

まず 登録ページ から必要な情報を送信すると認証用メールが送られてきますので、この中にあるコードをコピーし「CONFIRM YOUR EMAIL」ボタンをクリックします。

入力画面が表示されるので、ここへコードを入力します。

すると、アプリの詳細フォームが表示されるので以下を参考にして入力し「SUBMIT」ボタンをクリックしてください。

  • What is your application name?: アプリ(プロジェクト)の名前は??
  • What is your domain name?: ドメインは??
  • What challenge are you trying to solve?: 何のために使う??
  • Where are your users located?: ユーザーのいる場所は??

インストール先を聞かれるのでSDKsをクリックします。

すると、各種インストール方法が表示されますが、パッケージのインストールは次の項目で紹介しますので、今のところは「DONE」ボタンをクリックしてユーザー登録を完了させてください。(あとで同じものを表示できます)

これでFingerprint.comへの登録は完了です。

パッケージをインストールする

続いてnpm(JavaScript)パッケージをインストールします。
以下のコマンドを実行してください。

npm install @fingerprintjs/fingerprintjs-pro --save-dev

モデル&マイグレーションをつくる

次にDB周りの準備をします。
以下のコマンドを実行してください。

php artisan make:model UserFingerprint -m

すると、ファイルが2つ作成されるのでそれぞれ中身を次のようにします。

app/Models/UserFingerprint.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class UserFingerprint extends Model
{
    use HasFactory;

    protected $guarded = ['id']; // 👈 ここを追加しました
}

database/migrations/****_**_**_******_create_user_fingerprints_table.php

// 省略

public function up()
{
    Schema::create('user_fingerprints', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('user_id')->comment('ユーザーID');
        $table->string('fingerprint')->comment('フィンガープリント');
        $table->timestamps();

        $table->foreign('user_id')->references('id')->on('users');
    });
}

// 省略

usersテーブル&user_fingerprintsテーブル は、「1対多」のリレーション(つまり、1人のユーザーがたくさんフィンガープリントを持つことができる)にしています。

では、この状態でマイグレーションを実行しましょう。
以下のコマンドを実行してください。

php artisan migrate

すると実際のテーブルはこうなりました。

では、準備は整いました。

次から本格的にコードを書いていく部分になります。
楽しんでやっていきましょう❗

ユーザー登録の送信にフィンガープリントを追加する

今回は「ユーザーは最低1つフィンガープリントを持っている」状態にします。

そのため、ユーザー登録する時にフィンガープリントも一緒に送信し保存するようにします。

ということでまずはビューです。

resources/js/Pages/Auth/Register.jsx

// 省略

import FingerprintJS from '@fingerprintjs/fingerprintjs-pro'; // 👈 ここを追加しました

export default function Register() {
    const { data, setData, post, processing, errors, reset } = useForm({
        name: '',
        email: '',
        password: '',
        password_confirmation: '',
        fingerprint: '', // 👈 ここを追加しました
    });

    // 省略

    // Fingerprint 👇 ここを追加しました
    useEffect(() => {

        const fpPromise = FingerprintJS.load({
            apiKey: "(ここにあなたの API キー)",
            region: "ap"
        });

        fpPromise
            .then(fp => fp.get())
            .then(result => {

                setData('fingerprint', result.visitorId);

            });

    }, []);

    return (
        <Guest>

// 省略

※ 追加したのは、「import」「Fingerprint 部分」の2ヶ所です。

なお、(ここにあなたの Api キー)という部分には先ほど登録したFingerprint.comのページ左側メニューから「API Keys」をクリック。

キーの一覧が表示されるので、作成されたキーをコピペしてください。(なお、今回はテストですので直書きですが、.envにセットして使えるようにしておいた方がベターです👍)

そして、コントローラーです。

app/Http/Controllers/Auth/RegisteredUserController.php

// 省略

use App\Models\UserFingerprint;

class RegisteredUserController extends Controller
{
    // 省略

    public function store(Request $request)
    {
        $request->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users',
            'password' => ['required', 'confirmed', Rules\Password::defaults()],
            'fingerprint' => ['required', 'string', 'max:255', 'unique:user_fingerprints'], // 👈 ここを追加しました
        ]);

        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);

        // 👇 ここを追加しました(本来はトランザクションを使うべきです)
        UserFingerprint::create([
            'user_id' => $user->id,
            'fingerprint' => $request->fingerprint,
        ]);

        event(new Registered($user));

        Auth::login($user);

        return redirect(RouteServiceProvider::HOME);
    }
}

ここ中に追加した機能は、「フィンガープリントのバリデーション」と「フィンガープリントの登録」の2つです。

なお、今回の改変でusersuser_fingerprintsテーブルの2つへ同時に登録をしているので、本来は トランザクション を使って「あいた!usersテーブルだけに登録されちゃった…😫」とならないようにしておくべきです。(今回はテストなので省略しています)

これでユーザー登録されるときには必ずフィンガープリントが登録されるようになりました。

未知の環境からのログインを検知する部分をつくる

では、次がメインの「未知の環境からのログインを検知する」部分です。

まずはビューです。(先ほどとほぼ同じです。本番環境では共通化したほうがいいかもしれません🤔)

resources/js/Pages/Auth/Login.jsx

// 省略

import FingerprintJS from '@fingerprintjs/fingerprintjs-pro'; //  ここを追加しました

export default function Login({ status, canResetPassword }) {
    const { data, setData, post, processing, errors, reset } = useForm({
        email: '',
        password: '',
        remember: '',
        fingerprint: '', // 👈 ここを追加しました
    });

    // 省略

    // Fingerprint ここを追加しました
    useEffect(() => {

        const fpPromise = FingerprintJS.load({
            apiKey: "(ここにあなたの API キー)",
            region: "ap"
        });

        fpPromise
            .then(fp => fp.get())
            .then(result => {

                setData('fingerprint', result.visitorId);

            });

    }, []);

    return (
        <Guest>
            
// 省略

※ ここでも(ここにあなたの API キー)はご自身のキーをセットしてください。

続いて、ログイン認証をする部分です。

app/Http/Requests/Auth/LoginRequest.php

// 省略

use App\Models\UserFingerprint; // 👈 ここを追加しました
use Illuminate\Support\Facades\Mail; // 👈 ここを追加しました

class LoginRequest extends FormRequest
{
    // 省略

    public function authenticate()
    {
        $this->ensureIsNotRateLimited();

        if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
            RateLimiter::hit($this->throttleKey());

            throw ValidationException::withMessages([
                'email' => trans('auth.failed'),
            ]);
        }

        // 👇 ここを追加しました
        if(! $this->filled('fingerprint')) { // fingerprint が空の場合

            Auth::logout();

            throw ValidationException::withMessages([
                'fingerprint' => 'フィンガープリントは必須です',
            ]);

        }

        $fingerprint = $this->fingerprint;
        $user_id = Auth::id();
        $fingerprint_exists = UserFingerprint::where('user_id', $user_id)
            ->where('fingerprint', $fingerprint)
            ->exists();

        if($fingerprint_exists === false) { // フィンガープリントが未登録の場合

            // ユーザーにメール通知する
            $this->sendNotificationEmail();

        }

        RateLimiter::clear($this->throttleKey());
    }

    // 省略

    // 👇 ここを追加しました
    private function sendNotificationEmail()
    {
        $user = $this->user();
        $fingerprint = $this->fingerprint;
        Mail::to($user)->send(new UnauthorizedLoginCheck($user, $fingerprint));
    }
}

この中で追加した手順は以下のとおりです。

  1. フィンガープリントが空ならエラーにする
  2. ユーザーの登録済みフィンガープリントを取得
  3. 今回取得したフィンガープリントが未登録の場合はユーザーにメールを送信する

メール送信部分(Mailable)をつくる

続いて先ほどLoginRequest.phpでセットしたメール送信部分ができていないので追加します。

以下のコマンドを実行してください。

php artisan make:mail UnauthorizedLoginCheck

するとファイルができるので、中身を以下のように変更します。

app/Mail/UnauthorizedLoginCheck.php

// 省略

use Illuminate\Support\Facades\URL; // 👈 ここを追加しました

class UnauthorizedLoginCheck extends Mailable
{
    // 省略

    /**
     * Get the message envelope.
     *
     * @return \Illuminate\Mail\Mailables\Envelope
     */
    public function envelope()
    {
        return new Envelope(
            subject: '新しい環境からログインされました', // 👈 ここを追加しました
        );
    }

    /**
     * Get the message content definition.
     *
     * @return \Illuminate\Mail\Mailables\Content
     */
    public function content()
    {
        // 👇 ここを追加しました
        $temporary_signed_url = URL::temporarySignedRoute(
            'accept_fingerprint.save',
            now()->addMinutes(30),
            [
                'user' => $this->user->id,
                'fingerprint' => $this->fingerprint,
            ]
        );

        return new Content(
            view: 'emails.unauthorized_login_check',
            with: [
                'user' => $this->user,
                'temporary_signed_url' => $temporary_signed_url
            ],
        );
    }

// 省略

ちなみにこの中でつくっているのが「時間制限つき認証URL」で、今回は30分だけ有効なURLをつくっています。

※ なお(今回はテストのため)URLにフィンガープリントが入るようになっていますが、あまりセキュリティ上いいとは言えません。そのため、本番環境では先にDBテーブルに登録し、UUIDをキーとしてデータ検索&有効化するといいでしょう。

続いてメール文面です。(ここだけBladeファイルなので気をつけてください)

resources/views/emails/unauthorized_login_check.blade.php

新しい環境からログインされました。<br>
このログインに心当たりはありますか?<br><br>

<a href="{{ $temporary_signed_url }}">はい(次回からは確認しない)</a><br><br>
<a href="">いいえ</a><br><br>

※ 上記のURLは30分のみ有効です。

新しいフィンガープリントを認証(追加)するページをつくる

先ほどのメール文面で「はい(次回からは確認しない)」がクリックされたとき実行されるページで、新しいフィンガープリントを追加します。(つまり、次からはメール送信されなくなる)

※ なお、今回はつくりませんが「いいえ」の場合は、全ログインを強制ログアウトさせるようにしてもいいでしょう。また、パスワードの変更を要求してもいいかもしれません👍

では、まずはルートです。

routes/web.php

use App\Http\Controllers\AcceptFingerprintController;

// 省略

Route::prefix('accept_fingerprint')->controller(AcceptFingerprintController::class)->group(function(){

    Route::get('{user}/{fingerprint}', 'save')->name('accept_fingerprint.save');

});

続いてコントローラーです。
以下のコマンドを実行してください。

php artisan make:controller AcceptFingerprintController

すると、ファイルが作成されるので中身を以下のようにします。

app/Http/Controllers/AcceptFingerprintController.php

<?php

namespace App\Http\Controllers;

use App\Models\User;
use App\Models\UserFingerprint;
use Illuminate\Http\Request;

class AcceptFingerprintController extends Controller
{
    public function save(Request $request, User $user, string $fingerprint): string
    {
        if (! $request->hasValidSignature()) {

            abort(401);

        }

        // 新しいフィンガープリントを保存する
        $user_fingerprint = UserFingerprint::firstOrNew([
            'user_id' => $user->id,
            'fingerprint' => $fingerprint,
        ]);
        $user_fingerprint->user_id = $user->id;
        $user_fingerprint->fingerprint = $fingerprint;
        $user_fingerprint->save();

        return 'この環境からのログインを認証しました'; // 本番ではページを表示させてください
    }
}

hasValidSignature()が先ほどの認証URLが正しいかどうかチェックしている部分になります。

※ それにしてもPHPも型をつける形式が主流になるんでしょうか。正直なところコードが多くなるので好きじゃなかったりするんですけどね…😅

これで作業は完了です❗
おつかれさまでした😄👍

テストしてみる

では実際にテストしてみましょう❗
まず、DBを空の状態にしたいので以下のコマンドを実行します。

php artisan migrate:fresh

では、「山田太郎」さんでユーザー登録してみましょう。
ブラウザで「http://******/register」へアクセスし、必要な項目を入力して「Register」ボタンをクリックします。

すると・・・・・・

はい❗
登録されてログイン状態になりました。

では、DBテーブルを見てみましょう。

はい❗
想定通りusersuser_fingerprintsにデータが追加されました。

では、先ほど登録されたフィンガープリントを手動で「xxxxxxxxxxxxxxxxxxxxx」に変更します。

そして、この状態で一旦ログアウトし、再度ログインしてみましょう。

どうなったでしょうか・・・・・・

はい❗
フィンガープリントが登録されていない(別環境からログインした)ということで通知メールが届きました。

では、このメール内の「はい(次回からは確認しない)」リンクをクリックしてみましょう。

うまくいくでしょうか・・・・・・??

はい❗
登録完了メッセージが表示されました。

では、本当に新しいフィンガープリントが登録されているのかチェックしておきましょう。

はい❗
同じユーザー用に新しいフィンガープリントが登録されました。

(なお、もう一度ログアウトしてログインしてみましたが、通知メールは届きませんでした)

すべて成功です😄👍

企業様へのご提案

フィンガープリントを使うと、今回のような「アカウントのっとり防止」だけでなく以下のような機能をつくることができます。

  • 登録されていないフィンガープリントからの実行は拒否する(例えば支払い申請など)
  • アカウントを友人や家族でシェアして利用されるのを防ぐ
  • ボット(機械的なアクセス)を抽出する

以上のような機能をご希望の方はぜひお問い合わせからご相談ください。
お待ちしております。😄✨

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回は「フィンガープリント」を使って不正ログイン対策をしてみました。

フィンガープリントの仕組みがどうなっているかは分かりませんが、確かにシークレット・モードに切り替えても同じ文字列を取得できますので、精度は高いんじゃないかという印象です。

なお、フィンガープリントはCookieのように利用に当たっては本人からの許可が必要な場合もあるようなので、そのあたりはウェブサイトの管理者は気をつけておいたほうがいいかもしれません。

システム開発者は勉強すること多いですね😅

ぜひ皆さんも挑戦してみてくださいね。

ではでは〜❗

「パネルヒーターで
足が冷えなくなりました🤗」

このエントリーをはてなブックマークに追加       follow us in feedly