Help us understand the problem. What is going on with this article?

Laravelの実装調査 ~会員登録編・署名付きリンクもあるよ~

More than 1 year has passed since last update.

仕事でやや変則的な認証処理を作る事になったので、Laravelでスキャッフォルドされる会員登録周りの実装がどうなっているのかを調べてみました。
該当箇所のコードを引用しつつ、コメントを付与する形で解説しています。
なお、会員登録周りをカスタマイズする方法を解説している記事ではないので注意してください。
あくまでLaravel内部でどういう処理が行われているのかを調べた記事になります。

調査に使用したLaravelのバージョンは5.7.22です。

関連記事
Laravelの実装調査 ~ログイン編・リメンバーログインもあるよ~
Laravelの実装調査 ~パスワードリセット編~

今回は会員登録編という事で、以下2ケースをまとめています。

  • 会員登録
  • メールアドレス確認

それでは見ていきましょう。

会員登録

ポストされたメールアドレスやパスワードをDBに保存する処理です。
以後、これらの情報を用いてログインできる様になります。

ルーティング

Illuminate\Routing\Router.php 1156行目あたり

Router.php
$this->get('register', 'Auth\RegisterController@showRegistrationForm')->name('register');
$this->post('register', 'Auth\RegisterController@register');

バリデーション

App\Http\Controllers\Auth\RegisterController.php 49行目あたり

RegisterController.php
protected function validator(array $data)
{
    return Validator::make($data, [
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
        'password' => ['required', 'string', 'min:6', 'confirmed'],
    ]);
}

処理概要

Illuminate\Foundation\Auth\RegistersUsers.php 29行目あたり

RegistersUsers.php
    public function register(Request $request)
    {
        // バリデーション
        $this->validator($request->all())->validate();

        // ユーザ情報をDBに保存 & 会員登録イベント発火
        event(new Registered($user = $this->create($request->all())));

        // ログイン
        $this->guard()->login($user);

        return $this->registered($request, $user)
                        ?: redirect($this->redirectPath());
    }

DBへの保存

ユーザ情報をDBに保存する処理です。
パスワードはbcryptでハッシュ化して保存しています。

App\Http\Controllers\Auth\RegisterController.php 64行目あたり

RegisterController.php
protected function create(array $data)
{
    return User::create([
        'name' => $data['name'],
        'email' => $data['email'],
        // パスワードはハッシュ化して保存する(次のコードスニペットを参照)
        'password' => Hash::make($data['password']), 
    ]);
}

password_hash関数を使ってハッシュを生成している。
Illuminate\Hashing\BcryptHasher.php 45行目あたり

BcryptHasher.php
public function make($value, array $options = [])
{
    // ハッシュを生成
    $hash = password_hash($value, PASSWORD_BCRYPT, [
        'cost' => $this->cost($options),
    ]);

    if ($hash === false) {
        throw new RuntimeException('Bcrypt hashing not supported.');
    }

    return $hash;
}

確認メール送信

メールアドレスが本人の所有している物かどうかを確認するために、確認メールを送信する事もできる。
確認メールは会員登録後のイベント経由で送信される。(EventServiceProvider.phpで定義されている)

Illuminate\Auth\MustVerifyEmail.php 34行目あたり

MustVerifyEmail.php
public function sendEmailVerificationNotification()
{
    $this->notify(new Notifications\VerifyEmail);
}

確認リンク生成(署名付きURL)

確認メールに埋め込むための署名付きのリンクを生成する。
ルートパラメータとしてユーザIDを付与している。

Illuminate\Auth\Notifications\VerifyEmail.php 59行目あたり

VerifyEmail.php
protected function verificationUrl($notifiable)
{
    // 一定期間で無効になる署名URLを生成する
    return URL::temporarySignedRoute(
        'verification.verify', Carbon::now()->addMinutes(60), ['id' => $notifiable->getKey()]
    );
}

ユーザID有効期限を含めたURL(絶対パス)をhash_hmacに掛けて署名(signature)を生成しているのが分かる。
Illuminate\Routing\UrlGenerator.php 319行目あたり

UrlGenerator.php
public function signedRoute($name, $parameters = [], $expiration = null, $absolute = true)
{
    $parameters = $this->formatParameters($parameters);

    if ($expiration) {
        $parameters = $parameters + ['expires' => $this->availableAt($expiration)];
    }

    ksort($parameters);

    $key = call_user_func($this->keyResolver);

    // 署名付きURLの生成
    return $this->route($name, $parameters + [
        // 署名の生成
        'signature' => hash_hmac('sha256', $this->route($name, $parameters, $absolute), $key),
    ], $absolute);
}

以下の様なURLが生成される

https://.../email/verify/1?expires=1523128426&signature=63b4744eb3aea943ddd7411d92bf2d8cea528dda011e29a9da128fac5c7e8ab7

メールアドレス確認

メールアドレスが本人の所有している物かどうかを確認するための処理
会員登録時に送ったメールに記載されているリンクに遷移すると当該処理が実行される。

ルーティング

Illuminate\Routing\Router.php 1191行目あたり

Router.php
$this->get('email/verify', 'Auth\VerificationController@show')->name('verification.notice');
$this->get('email/verify/{id}', 'Auth\VerificationController@verify')->name('verification.verify');
$this->get('email/resend', 'Auth\VerificationController@resend')->name('verification.resend');

ミドルウェア

上記のルートに対して付与されているミドルウェア
signedミドルウェアで署名を検証しリンクが改ざんされていない事をチェックする

App\Http\Controllers\Auth\VerificationController.php 37行目あたり

VerificationController.php
$this->middleware('auth');
$this->middleware('signed')->only('verify'); // 署名付きURLの検証
$this->middleware('throttle:6,1')->only('verify', 'resend'); // 1分間に6回までアクセスを許可する

処理概要

Illuminate\Foundation\Auth\VerifiesEmails.php 33行目あたり

VerifiesEmails.php
public function verify(Request $request)
{
    // 署名付きURLに付与されたユーザIDとログイン済みユーザIDが一致するか
    if ($request->route('id') != $request->user()->getKey()) {
        throw new AuthorizationException;
    }

    // メール確認済みならリダイレクト
    if ($request->user()->hasVerifiedEmail()) {
        return redirect($this->redirectPath());
    }

    // メール確認済みとする
    if ($request->user()->markEmailAsVerified()) {
        event(new Verified($request->user()));
    }

    return redirect($this->redirectPath())->with('verified', true);
}

メール確認済みかどうかの保存先

MustVerifyEmailトレイトを実装したモデルのemail_verified_atプロパティにタイムスタンプ形式で保持される。(Eloquent Modelのプロパティなのでカラム名と同義)

Illuminate\Auth\MustVerifyEmail.php 22行目あたり

MustVerifyEmail.php
public function markEmailAsVerified()
{
    return $this->forceFill([
        'email_verified_at' => $this->freshTimestamp(),
    ])->save();
}

署名の確認処理

signedミドルウェアの処理にあたります。
リンクが改ざんされていないかを調べるために署名を生成し比較する

Illuminate\Routing\UrlGenerator.php 357行目あたり

UrlGenerator.php
public function hasValidSignature(Request $request, $absolute = true)
{
    $url = $absolute ? $request->url() : '/'.$request->path();

    // ①:リクエストされたURLから'signature'を省いたURLを取得する
    $original = rtrim($url.'?'.Arr::query(
        Arr::except($request->query(), 'signature')
    ), '?');

    // ②:有効期限を取得する
    $expires = $request->query('expires');

    // ③:①で生成したURLから署名を生成
    $signature = hash_hmac('sha256', $original, call_user_func($this->keyResolver));

    // ④:③で生成した署名とリクエストに付与されていた署名を比較する
    return  hash_equals($signature, (string) $request->query('signature', '')) &&
            // 有効期限を過ぎていないか調べる
           ! ($expires && Carbon::now()->getTimestamp() > $expires);
}

メール確認が済んでいるかのチェック

verifiedミドルウェアを使用する

今回登場したクラス達

App             
└ Http            
  └ Controllers     
    └ Auth    
      ├ RegisterController
      └ VerificationController
Illuminate              
├ Auth            
│ ├ MustVerifyEmail     
│ └ Notifications       
│   └ VerifyEmail 
├ Foundation          
│ └ Auth        
│   ├ RegistersUsers  
│   └ VerifiesEmails  
├ Hashing         
│ └ BcryptHasher        
└ Routing         
  ├ Router      
  └ UrlGenerator

おわりに

以上、会員登録周りの実装調査でした。
何かしら新しい発見があったなら幸いです。

私としては、会員登録後の確認メールで署名付きリンクが使われているのは目から鱗でした。
署名付きリンクであればDBに一時データを持つ必要がないのがスマートでいいと思います。

t-kuni
主にWEBとか
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした