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

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

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

**関連記事**
[Laravelの実装調査　～ログイン編・リメンバーログインもあるよ～](https://qiita.com/well/items/eb0d2c1970dfab5f6abe)
[Laravelの実装調査　～パスワードリセット編～](https://qiita.com/well/items/aa906cd07e54037a5eaf)

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

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

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

# 会員登録

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

## ルーティング

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

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


## バリデーション

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

```php:RegisterController.php:49
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行目あたり

```php:RegistersUsers.php:29
    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行目あたり

```php:RegisterController.php:64
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行目あたり

```php:BcryptHasher.php:45
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;
}
```


## 確認メール送信

<span name="確認メール送信"></span>

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

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

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


## 確認リンク生成（署名付きURL）

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

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

```php:VerifyEmail.php:59
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行目あたり

```php:UrlGenerator.php:319
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行目あたり

```php:Router.php:1191
$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行目あたり

```php:VerificationController.php:37
$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行目あたり

```php:VerifiesEmails.php:33
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行目あたり

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

## 署名の確認処理

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

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

```php:UrlGenerator.php:357
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に一時データを持つ必要がないのがスマートでいいと思います。