👋

Laravel パスワードリセットのテスト

2023/03/25に公開

laravelとvueを使用してアプリ開発をしております。
今回はlaravel側のテストについて記述していきたいと思います。

前提

  • Laravel 8.x
  • Laravel Breeze(認証系)
  • Laravel Sanctum(SPA)
  • PHPUnit(テスト)
  • Docker

流れ

パスワードリセット処理はバックエンド側は大きく以下の流れになるかと思います。

  1. パスワードリセットの申請
  2. フロントエンド側からパスワードリセット用のtokenと新しいパスワードを受け取ってパスワードを更新

パスワードリセットの申し込み

処理自体は簡単なのですが、パスワード更新処理のために、tokenを保持しておく必要があるので、その部分が少し難しいのかなと思いました。

/** user情報 */
private $user;

/** パスワードリセット用のtoken */
private $passResetToken;

/** テスト用のパスワード */
private $testPassword = 'password'; 

protected setUp(): void
{
  parent::setUp();
  $this->user = User::factory()->create([
	  'password' => $this->testPassword,
  ]);
}


private function testPasswordResetApply(): void
{
    $this->assertGuest('web'); // ユーザーが認証されていないことを確認
    // 実際にはメールが送られないように設定
    \Notification::fake();

    // メールがまだ送られていないことを確認
    \Notification::assertNothingSent();

    // パスワードリセットのメール送信処理
    $postParams = [User::USER_EMAIL_DB => $this->user->email];
    $response = $this->post(route('password.email'), $postParams);
    $response->assertStatus(Response::HTTP_OK);

    // パスワードリセットされる対象のuserを取得
    $passResetUser = User::where(User::USER_EMAIL_DB, $this->user->email)->first();

    // パスワードリセット用のtokenを作成
    $broker = app(PasswordBroker::class);
    $this->passResetToken = $broker->createToken($passResetUser);

    // メールが1回送信されたことをチェック
    \Notification::assertSentToTimes($passResetUser, ResetPassword::class, 1);

    $this->assertGuest('web'); // ユーザーがまだ認証されていないことを確認
}

ここでのポイントは以下の箇所です。

// パスワードリセット用のtokenを作成
$broker = app(PasswordBroker::class);
$this->passResetToken = $broker->createToken($passResetUser);

なぜこういうコードになるのか気になる方は補足をご覧ください

フロントエンド側からパスワードリセット用のtokenと新しいパスワードを受け取ってパスワードを更新

パスワードを実際に更新していきます。

/**
 * メールリセットのテスト
 *
 * @return void
 */
private function testPasswordReset(): void
{
    $user = User::factory()->make();
    $postParams = [
        User::USER_EMAIL_DB => $this->user->email,
        User::USER_PASSWORD_DB => $user->password, // 新しいパスワード
        PasswordReset::PASSWORD_RESET_TOKEN_DB => $this->passResetToken,
    ];

    $response = $this->post(route('password.reset'), $postParams);
    $response->assertStatus(Response::HTTP_OK);

    $updatedUser = User::find($this->user->id);
    $this->assertTrue(\Hash::check($user->password, $updatedUser->password));

    // passwordを更新
    $this->testPassword = $user->password;

    $this->assertGuest('web'); // ユーザーがまだ認証されていないことを確認
}

ここでのポイントは以下の箇所ですかね。

$this->assertTrue(\Hash::check($user->password, $updatedUser->password));

これで新しく登録したパスワードとDBにあるパスワードが一致していることを確認しております。

補足

なぜ以下のコードでパスワードリセットのtokenが取得できるのかの説明をしていきます。

// パスワードリセット用のtokenを作成
$broker = app(PasswordBroker::class);
$this->passResetToken = $broker->createToken($passResetUser);

そのためにパスワードリセット用のメール送信処理を見ていきましょう。

NewPasswordController.php
/**
* パスワードリセットのメール送信処理
*
* @param  \App\Http\Requests\Auth\PasswordResetRequest  $request
*
* @throws \Illuminate\Validation\ValidationException
*/
public function sendEmail(PasswordResetRequest $request): JsonResponse
{
// パスワードリセット用のメールを送信
$status = \Password::sendResetLink(
    $request->only(User::USER_EMAIL_DB)
);

return $status == \Password::RESET_LINK_SENT
		? response()->json(true)
		: response()->json(false);
}

\Password::sendResetLinkでメールを送信しています。

では、Passowrdファサードを見ていきましょう。

以下を見る感じ、Passwordファサードの実装は別の箇所にありそうですね。

Password.php
    /**
     * Get the registered name of the component.
     *
     * @return string
     */
    protected static function getFacadeAccessor()
    {
        return 'auth.password';
    }

こういうときの実装クラスとの紐付けはServiceProviderにあります。
ということで、PasswordResetServiceProviderを見ていきましょう。

PasswordResetServiceProvider.php
    /**
     * Register the password broker instance.
     *
     * @return void
     */
    protected function registerPasswordBroker()
    {
        $this->app->singleton('auth.password', function ($app) {
            return new PasswordBrokerManager($app);
        });

        $this->app->bind('auth.password.broker', function ($app) {
            return $app->make('auth.password')->broker();
        });
    }

ありますね。
auth.passwordの実装はPasswordBrokerManagerにありそうです。
さらに、'auth.password.broker'を使えば、PasswordBrokerManagerbroker()を取得できます。

では、PasswordBrokerManager内を見ていきましょう。

PasswordBrokerManager.php
/**
 * Attempt to get the broker from the local cache.
 *
 * @param  string|null  $name
 * @return \Illuminate\Contracts\Auth\PasswordBroker
 */
public function broker($name = null)
{
    $name = $name ?: $this->getDefaultDriver();

    return $this->brokers[$name] ?? ($this->brokers[$name] = $this->resolve($name));
}

/**
 * Resolve the given broker.
 *
 * @param  string  $name
 * @return \Illuminate\Contracts\Auth\PasswordBroker
 *
 * @throws \InvalidArgumentException
 */
protected function resolve($name)
{
    $config = $this->getConfig($name);

    if (is_null($config)) {
        throw new InvalidArgumentException("Password resetter [{$name}] is not defined.");
    }

    // The password broker uses a token repository to validate tokens and send user
    // password e-mails, as well as validating that password reset process as an
    // aggregate service of sorts providing a convenient interface for resets.
    return new PasswordBroker(
        $this->createTokenRepository($config),
        $this->app['auth']->createUserProvider($config['provider'] ?? null)
    );

どうやら、PasswordBrokerManagerbroker()PasswordBrokerのインスタンスを返してそうですね。
さらに、そのときに$this->createTokenRepository($config),部分でtokenを作成してそうです。

では、PasswordBrokerを見てみましょう。

<?php

namespace Illuminate\Auth\Passwords;

use Closure;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Contracts\Auth\PasswordBroker as PasswordBrokerContract;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Support\Arr;
use UnexpectedValueException;

class PasswordBroker implements PasswordBrokerContract
{
    /**
     * The password token repository.
     *
     * @var \Illuminate\Auth\Passwords\TokenRepositoryInterface
     */
    protected $tokens;

    /**
     * The user provider implementation.
     *
     * @var \Illuminate\Contracts\Auth\UserProvider
     */
    protected $users;

    /**
     * Create a new password broker instance.
     *
     * @param  \Illuminate\Auth\Passwords\TokenRepositoryInterface  $tokens
     * @param  \Illuminate\Contracts\Auth\UserProvider  $users
     * @return void
     */
    public function __construct(TokenRepositoryInterface $tokens, UserProvider $users)
    {
        $this->users = $users;
        $this->tokens = $tokens;
    }

    /**
     * Send a password reset link to a user.
     *
     * @param  array  $credentials
     * @param  \Closure|null  $callback
     * @return string
     */
    public function sendResetLink(array $credentials, Closure $callback = null)
    {
        // First we will check to see if we found a user at the given credentials and
        // if we did not we will redirect back to this current URI with a piece of
        // "flash" data in the session to indicate to the developers the errors.
        $user = $this->getUser($credentials);

        if (is_null($user)) {
            return static::INVALID_USER;
        }

        if ($this->tokens->recentlyCreatedToken($user)) {
            return static::RESET_THROTTLED;
        }

        $token = $this->tokens->create($user);

        if ($callback) {
            $callback($user, $token);
        } else {
            // Once we have the reset token, we are ready to send the message out to this
            // user with a link to reset their password. We will then redirect back to
            // the current URI having nothing set in the session to indicate errors.
            $user->sendPasswordResetNotification($token);
        }

        return static::RESET_LINK_SENT;
    }

    /**
     * Reset the password for the given token.
     *
     * @param  array  $credentials
     * @param  \Closure  $callback
     * @return mixed
     */
    public function reset(array $credentials, Closure $callback)
    {
        $user = $this->validateReset($credentials);

        // If the responses from the validate method is not a user instance, we will
        // assume that it is a redirect and simply return it from this method and
        // the user is properly redirected having an error message on the post.
        if (! $user instanceof CanResetPasswordContract) {
            return $user;
        }

        $password = $credentials['password'];

        // Once the reset has been validated, we'll call the given callback with the
        // new password. This gives the user an opportunity to store the password
        // in their persistent storage. Then we'll delete the token and return.
        $callback($user, $password);

        $this->tokens->delete($user);

        return static::PASSWORD_RESET;
    }

    /**
     * Validate a password reset for the given credentials.
     *
     * @param  array  $credentials
     * @return \Illuminate\Contracts\Auth\CanResetPassword|string
     */
    protected function validateReset(array $credentials)
    {
        if (is_null($user = $this->getUser($credentials))) {
            return static::INVALID_USER;
        }

        if (! $this->tokens->exists($user, $credentials['token'])) {
            return static::INVALID_TOKEN;
        }

        return $user;
    }

    /**
     * Get the user for the given credentials.
     *
     * @param  array  $credentials
     * @return \Illuminate\Contracts\Auth\CanResetPassword|null
     *
     * @throws \UnexpectedValueException
     */
    public function getUser(array $credentials)
    {
        $credentials = Arr::except($credentials, ['token']);

        $user = $this->users->retrieveByCredentials($credentials);

        if ($user && ! $user instanceof CanResetPasswordContract) {
            throw new UnexpectedValueException('User must implement CanResetPassword interface.');
        }

        return $user;
    }

    /**
     * Create a new password reset token for the given user.
     *
     * @param  \Illuminate\Contracts\Auth\CanResetPassword  $user
     * @return string
     */
    public function createToken(CanResetPasswordContract $user)
    {
        return $this->tokens->create($user);
    }

    /**
     * Delete password reset tokens of the given user.
     *
     * @param  \Illuminate\Contracts\Auth\CanResetPassword  $user
     * @return void
     */
    public function deleteToken(CanResetPasswordContract $user)
    {
        $this->tokens->delete($user);
    }

    /**
     * Validate the given password reset token.
     *
     * @param  \Illuminate\Contracts\Auth\CanResetPassword  $user
     * @param  string  $token
     * @return bool
     */
    public function tokenExists(CanResetPasswordContract $user, $token)
    {
        return $this->tokens->exists($user, $token);
    }

    /**
     * Get the password reset token repository implementation.
     *
     * @return \Illuminate\Auth\Passwords\TokenRepositoryInterface
     */
    public function getRepository()
    {
        return $this->tokens;
    }
}

すると、createTokenでtokenを作成できそうですね。
ここでtokenを作成するようにします。

よって、以下の処理によってパスワードリセット用のtokenを作成することができます。

// パスワードリセット用のtokenを作成
$broker = app(PasswordBroker::class);
$this->passResetToken = $broker->createToken($passResetUser);

Discussion