Laravel Breezeのパスワードリセットで、ユーザーのメールアドレス登録有無を隠す&レートリミットを設定

Breezeのパスワードリセット時メールアドレス登録有無を隠す&レートリミット設定 Breeze
※当サイトはアフィリエイト広告を掲載しています。

Laravel Breeze、便利ですよね。私のような零細フリーランスが関わる比較的小さなWebアプリケーションであれば、大抵はこれで必要充分。

しかもコストを抑えられるのでお客さんにも喜ばれます。

そんなBreezeで開発中、ちょっとした問題となることが2点ありました。

  • パスワードリセット時の挙動で、メールアドレス登録の有無が分かってしまう。
  • 何度でもパスワードリセットできてしまう。

案件のために、今回はその機能の実装を検討することにしました。

私と同じように、この2点が気になる(気になっていた)ケースはそこそこあるのではないでしょうか。

ですので、せっかくなので実装した内容をまとめてみました。あくまで私のやり方ですので、ベストかとうかは分かりませんが、ご紹介したいと思います。

何が問題!?

本件は問題、というほどのことではありません。問題だったら、世界中で使われ・支持されているLaravelで提供されていないことでしょう。

案件でもとめられるか、そうでないかということを基準に実装を検討するのが良いと思います。

メールアドレスの登録有無が分かってはだめなの?

Breezeのデフォルトでは、パスワードをリセットする際、存在するメールアドレスの場合は「メールを送信しました」のメッセージ。存在しないメールアドレスの場合はエラーメッセージが出ます。

以下は、英語のままですがその違いです。

メールが登録されていない場合と、登録されている場合のスクリーンショット

つまりは、そのシステム内にメールアドレス登録の有無がバレバレです。

例えば、人にこっそり隠れて使いたいようなサイトがあったとします。そこでinfo@laranote.jpでリセットメールが送信できたら、私がそのサイトのユーザーだとばれてしまい、恥ずかしい思いをするというわけですね。

ですから、サイトによってはこういった要素は隠したいことはあります。

例えそのようなサイトでなくとも、「余計な情報は表示させない」というのは、Webシステムのセキュリティを考える上で基本的な考え方です。

レートリミットが無いとだめなの!?

何度でもパスワードリセットできてしまうことを、レートリミット(要は回数制限)が無いという表現ができます。

レートリミットが設定されていない=リセットの試行をし放題です。ログインのような実害はありませんが、数千・数万回と試行すれば、実在するユーザーにリセットメールが送信されてしまうかもしれません。

また、DBへのアクセスも発生するので、無用な攻撃などを排除するために、レートリミットの設定はあってもよいかなとも思います。

Laravel Breezeのログインには、レートリミットの機能が備わっています。不正なログインを防ぐため、ログイン時にはマストだという考えなのでしょう。

メールアドレス登録の有無を隠す

というわけで、まずはメールアドレスの登録の有無を隠すことを対策していきます。

問題のファイルは、app\Http\Controllers\Auth\PasswordResetLinkController.phpです。

storeメソッドでレスポンスを返しているようでした。

私の場合はあらかじめusersガードが設定されています。以後ガードを指定したPassword::broker('users')などの部分は読みかえてください。

    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'email' => ['required', 'email'],
        ]);

        // We will send the password reset link to this user. Once we have attempted
        // to send the link, we will examine the response then see the message we
        // need to show to the user. Finally, we'll send out a proper response.
        $status = Password::broker('users')->sendResetLink(
            $request->only('email')
        );

        return $status == Password::RESET_LINK_SENT
                    ? back()->with('status', __($status))
                    : back()->withInput($request->only('email'))
                            ->withErrors(['email' => __($status)]);
    }

対策後

14 – 17行目の処理を足します。これで、ユーザーが見つからなかったメールアドレスを入力しても、パスワードリセットリンクを送信した表示が出ます。

public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'email' => ['required', 'email'],
        ]);

        // We will send the password reset link to this user. Once we have attempted
        // to send the link, we will examine the response then see the message we
        // need to show to the user. Finally, we'll send out a proper response.
        $status = Password::broker('users')->sendResetLink(
            $request->only('email')
        );

        // ユーザーが見つからない場合も、リセットリンクを送ったことにする
        if($status == Password::INVALID_USER) {
            $status = Password::RESET_LINK_SENT;
        }

        return $status == Password::RESET_LINK_SENT
                    ? back()->with('status', __($status))
                    : back()->withInput($request->only('email'))
                            ->withErrors(['email' => __($status)]);
    }

文言の変更

「リセットメールを送信しました」だと、「メールが届かない」とユーザーから問い合わせを受けることになるのは目に見えています。

そこで、

「メールアドレスが登録されている場合は、リセットURLが記載されたメールを送信しました」

などに表現を変えても良いかもしれません。

私の場合は、多言語ファイルがlang\ja\passwords.phpにあったので、そこを変えました。sentキーに対応する値を修正します。

<?php

return [
    'reset' => 'パスワードをリセットしました。',
    'sent' => 'メールアドレスが登録されている場合は、リセットURLが記載されたメールを送信しました',
    'throttled' => '規定回数に達したため再試行はしばらく出来ません。',
    'token' => 'このリセットURLは無効です。',
    'user' => 'このメールアドレスに一致するユーザーは存在しません。',
];

元がLaravel8以下からアップデートしたシステムだと、resources配下に言語ファイルがあるかもしれません。多言語ファイルが存在しない場合は、php artisan lang:publishlang\enが作成できますので、enをコピーしてjaを作ります。

多言語対応サイトになる可能性が全く無ければ、以下の20行目の様に直接書き込んでも良いです。

    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'email' => ['required', 'email'],
        ]);

        // We will send the password reset link to this user. Once we have attempted
        // to send the link, we will examine the response then see the message we
        // need to show to the user. Finally, we'll send out a proper response.
        $status = Password::broker('users')->sendResetLink(
            $request->only('email')
        );

        // ユーザーが見つからない場合も、リセットリンクを送ったことにする
        if($status == Password::INVALID_USER) {
            $status = Password::RESET_LINK_SENT;
        }

        return $status == Password::RESET_LINK_SENT
                    ? back()->with('status', 'メールアドレスが登録されている場合は、リセットURLが記載されたメールを送信しました')
                    : back()->withInput($request->only('email'))
                            ->withErrors(['email' => __($status)]);
    }

これで目的の1つが達成できました。

レートリミットを設ける

Laravelにはレートリミットの仕組みがあるので、ゼロから作るより簡単です。

しかも今回は、Breezeのログイン時に使用する、app\Http\Requests\Auth\LoginRequest.phpを流用してしまおうと思います。

バリデーションをフォームリクエストに変更

app\Http\Controllers\Auth\PasswordResetLinkController.phpは、現在Requestクラスを使ってバリデーションしています。

    public function store(Request $request): RedirectResponse
    {
        $request->validate([
            'email' => ['required', 'email'],
        ]);

Breezeのログインのレートリミットの機能を使用するために、これをフォームリクエストを使用するように変更します。

まずは以下のコマンドでフォームリクエストのファイルを作成します。

php artisan make:request Auth/PasswordResetRequest

バリデーションの機能をコピー

作成したファイルのauthorizeメソッドをtrueにし、元々のバリデーションルールをコピーします。

<?php

namespace App\Http\Requests\Auth;

use Illuminate\Foundation\Http\FormRequest;

class PasswordResetRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'email'],
        ];
    }
}

ログインのレートリミットをコピペ&修正

同じ階層に、Breezeのレートリミットが実装されているLoginRequest.phpがありますので、必要なコードをコピーします。

sendResetLinkメソッドは、ログイン用のauthenticateを参考に作りました。柔軟に書けますが、以下は私のコード例です。

<?php

namespace App\Http\Requests\Auth;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Password;
use Illuminate\Auth\Events\Lockout;

class PasswordResetRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'email' => ['required', 'email'],
        ];
    }

    public function sendResetLink(): string
    {
        // レートリミットの確認
        $this->ensureIsNotRateLimited();

        // パスワードリセットリンクの送信
        $status = Password::broker('users')->sendResetLink(
            $this->only('email')
        );

        if ($status == Password::RESET_LINK_SENT) {
            // リセットリンク送信が成功したらレートリミットをクリア
            RateLimiter::clear($this->throttleKey());
        } else {
            // 失敗した場合、リミットをカウント
            // 300秒 = 5分。指定しなければ60秒
            RateLimiter::hit($this->throttleKey(), 300); 


            // 【必要な方のみ】
            // ユーザーが存在しない場合も成功sutatusにする
            if ($status == Password::INVALID_USER) {
                $status = Password::RESET_LINK_SENT;
            }
        }
        return $status;
    }

    /**
     * Ensure the login request is not rate limited.
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function ensureIsNotRateLimited(): void
    {
        if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
            return;
        }

        event(new Lockout($this));

        $seconds = RateLimiter::availableIn($this->throttleKey());

        throw ValidationException::withMessages([
            'email' => trans('auth.throttle', [
                'seconds' => $seconds,
                'minutes' => ceil($seconds / 60),
            ]),
        ]);
    }

    /**
     * Get the rate limiting throttle key for the request.
     */
    public function throttleKey(): string
    {
        // IPだけでキーを設定する
        return Str::transliterate($this->ip());

        // ご希望によりログイン時と同様、メールアドレス+IPでも
        // return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
    }
}

コントローラを修正

こんな感じでコントローラーを実装しました。割とPasswordResetRequestに処理を投げてしまっていますが、それはログイン時のauthenticateも同じなのでまあよしとします。

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
use Illuminate\Support\Facades\Password;
use App\Http\Requests\Auth\PasswordResetRequest;

class PasswordResetLinkController extends Controller
{
    /**
     * Display the password reset link request view.
     */
    public function create(): View
    {
        return view('web.auth.forgot-password');
    }

    /**
     * Handle an incoming password reset link request.
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    public function store(PasswordResetRequest $request): RedirectResponse
    {
        // パスワードリセットリンクの送信を試行
        $status = $request->sendResetLink();

        return $status == Password::RESET_LINK_SENT
                    ? back()->with('status', __($status))
                    : back()->withInput($request->only('email'))
                            ->withErrors(['email' => __($status)]);
    }
}

レートリミット時の文言修正

私の環境では、レートリミット時に「ログイン試行の規定数に達しました」と出て少しおかしいので、汎用的なメッセージに変更します。

lang\ja\auth.phpthrottleキーに対する値を以下の様に変更しました。

<?php

return [
    'password' => '入力されたパスワードが正しくありません。',
    'failed' => 'ログインに失敗しました。',
    'throttle' => '試行の規定数に達しました。:seconds秒後に再度お試しください。',
];

おまけ:処理時間にランダム性を持たせる

リセットメールを送信する際、処理時間が長くなる関係で、メールアドレスの登録有無が推測できてしまう場合があります。

これが問題になるケースは少ないとは思いますが、ちょっとした工夫で判別しにくくすることも可能です。

以下の7行目のように、処理を止めるusleep関数と、パラメータ内でランダムな数値を返すrand関数を用いることで、1秒の範囲でランダムに遅延させることができます。

public function store(PasswordResetRequest $request): RedirectResponse
    {
        // パスワードリセットリンクの送信を試行
        $status = $request->sendResetLink();

        // 0〜1秒の範囲でランダムな遅延(1秒 = 1,000,000マイクロ秒)
        usleep(rand(0, 1000000)); 

        return $status == Password::RESET_LINK_SENT
                    ? back()->with('status', __($status))
                    : back()->withInput($request->only('email'))
                            ->withErrors(['email' => __($status)]);
    }

これだと分からなくなりますね。

もちろんパラメータ次第で、任意の秒数にもできます。……が、ここまで求められることはそれほど多くはないのかもしれません。

まとめ

今回は、Laravel Breezeの初期動作を変更し、より私の案件に沿うように変更しました。

繰り返しになりますが、これは必須ではありません。

LaravelやBreezeは、私など遠く及ばない程の上級エンジニアの方々によってメンテナンスされているので、初期状態で使っても多くの場合では問題ではないのだと思います。

とは言え、個人的には今回のようにする方がしっくりきますね。セキュリティが気になる案件では、実装を検討してみてはいかがでしょうか。

コメント

タイトルとURLをコピーしました