2018.12.10

入門Laravelチュートリアル (9) ToDoアプリの認証機能を作る パート2


この連載記事では、Laravel を使用した Web アプリケーションの開発方法を紹介します。実際に(お決まりの?)ToDo アプリを開発する手順を通して Web 開発のエッセンスを学んでいただけるように書いていきます。取り扱う Laravel のバージョンは現時点で最新の 5.7 です。

👇 電子書籍版も公開しています。

第8章に引き続き、第9章でも認証機能を実装します。ログインしたユーザーは自分のフォルダのタスクだけを閲覧できる機能などを実装します。

ヘッダーの出し分け

この章はまずヘッダーの出し分け処理から始めましょう。

  • ログイン前のヘッダー
    ログイン前のヘッダー
  • ログイン後のヘッダー
    ログイン後のヘッダー

ログイン前後で右側のリンクが変化します。ログイン前はログイン画面へのリンクと会員登録画面へのリンクを表示し、ログイン後のヘッダーではユーザー名を含んだメッセージとログアウトリンクを表示します。

layout.blade.php<header> 要素を以下の内容で書き換えてください。

layout.blade.php
<header>
  <nav class="my-navbar">
    <a class="my-navbar-brand" href="/">ToDo App</a>
    <div class="my-navbar-control">
      @if(Auth::check())
        <span class="my-navbar-item">ようこそ, {{ Auth::user()->name }}さん</span><a href="#" id="logout" class="my-navbar-item">ログアウト</a>
        <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
          @csrf
        </form>
      @else
        <a class="my-navbar-item" href="{{ route('login') }}">ログイン</a><a class="my-navbar-item" href="{{ route('register') }}">会員登録</a>
      @endif
    </div>
  </nav>
</header>

ポイントは2点あります。

ログイン状態の取得

Auth クラスの check メソッドでログインしているかどうかを確認することができます 。アクセスしたユーザーがログインしていれば Auth::check()true を返し、ログインしていなければ false を返します。

テンプレートではこの Auth::check() を利用してログインしていた場合の要素とログインしていない場合の要素を出し分けています。

@if(Auth::check())
  <!-- ログインしていた場合 -->
@else
  <!-- ログインしていない場合 -->
@endif

Auth::check() とは逆の働きをする guest メソッドも存在します。つまり Auth::guest() はユーザーがログインしていない場合に true を返します。

ログインユーザーの取得

Auth::user() でログイン中のユーザーを取得できます。返却値はログインユーザーの情報が入った User モデルのインスタンスです。

Auth::check()Auth::user() はコントローラーなどでも使用することができます。実際に後述の処理でも使用します。便利なメソッドですので覚えておきましょう。

ログアウト

次にログアウト処理を実装します。

layout.blade.php@yield('scripts') の上に以下のコードを追加してください。

layout.blade.php
@if(Auth::check())
  <script>
    document.getElementById('logout').addEventListener('click', function(event) {
      event.preventDefault();
      document.getElementById('logout-form').submit();
    });
  </script>
@endif

ログアウト機能として必要なのは route('logout') の URL に POST リクエストを送信することだけです。今回はたまたまリンクの見た目(<a> 要素)でフォームを送信したかったので JavaScript を使用しています。ログアウトリンクのクリックイベントで、リンクの下に置いたフォームを送信しています。POST リクエストに必要なデータは CSRF トークンのみです。

ページに認証を求める

続いてアプリケーションの利用にログイン認証を求める機能を実装します。ログインしないとフォルダやタスクの作成・閲覧ページにアクセスできないようにします。

ページに認証を求める処理はミドルウェアを用いて実現します。ミドルウェアとは、ルートごとの処理に移る前に実行されるプログラムでした。認証状態の確認はさまざまなルートに共通して実行したい処理なのでミドルウェアで実現するのに適しています。

認証を求めるミドルウェアはデフォルトで用意されていますので、routes/web.php でルートにミドルウェアを適用します。

web.php
<?php

Route::group(['middleware' => 'auth'], function() {
    Route::get('/', 'HomeController@index')->name('home');

    Route::get('/folders/{id}/tasks', 'TaskController@index')->name('tasks.index');

    Route::get('/folders/create', 'FolderController@showCreateForm')->name('folders.create');
    Route::post('/folders/create', 'FolderController@create');

    Route::get('/folders/{id}/tasks/create', 'TaskController@showCreateForm')->name('tasks.create');
    Route::post('/folders/{id}/tasks/create', 'TaskController@create');

    Route::get('/folders/{id}/tasks/{task_id}/edit', 'TaskController@showEditForm')->name('tasks.edit');
    Route::post('/folders/{id}/tasks/{task_id}/edit', 'TaskController@edit');
});

Auth::routes();

Auth::routes() 以外のルートをルートグループにまとめています。

Route::group(['middleware' => 'auth'], function() {
    // いままで定義してきたルート
});

ルートグループはいくつかのルートに対して一括で機能を追加したい場合に使用します。今回は認証ミドルウェアを複数のルートに一括して適用するために使いました。

ミドルウェアは 'auth' という名前で指定されていますが、app/Http/Kernel.php というファイルに実際のクラスと名前の定義があります。

Kernel.php
protected $routeMiddleware = [
    'auth' => \App\Http\Middleware\Authenticate::class,
    // ...
];

このミドルウェアはアクセスしたユーザーの認証状態をチェックして、ログインしていたらそのままコントローラーメソッド(または次のミドルウェア)に処理を渡します。ログインしていなければログイン画面にリダイレクトさせます。

ミドルウェアはリクエストの交通整理をする、と言うこともできるかもしれません。

ログイン前のみ閲覧できるページ

次に、先ほどとは逆に認証「されていない」ことを確かめるミドルウェアを編集します。

ログインページや会員登録ページはログインしていないときにだけアクセスできるべきですね。すでにログインしているユーザーがそのようなページにアクセスすることを防ぐミドルウェアは用意されていますが、一部編集する必要があります。

app/Http/Middleware/RedirectIfAuthenticated.php を編集してください。

RedirectIfAuthenticated.php
class RedirectIfAuthenticated
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @param  string|null  $guard
     * @return mixed
     */
    public function handle($request, Closure $next, $guard = null)
    {
        if (Auth::guard($guard)->check()) {
            return redirect('/'); // ★ 引数を変更
        }

        return $next($request);
    }
}

redirect() の引数を '/home' から '/' に変更します。

このミドルウェアはユーザーが認証済みの場合はコントローラーなどの後続のプログラムに処理を渡さずにリダイレクトしてしまいます。リダイレクト先がデフォルトでは '/home' と記述されていますが、今回のアプリケーションにはそのような URL を持つページは存在しないのでホームページの URL / に書き換えます。

ちなみに RedirectIfAuthenticated ミドルウェアは会員登録コントローラーやログインコントローラーのコンストラクタで適用されています。

RegisterController.php
public function __construct()
{
    $this->middleware('guest');
}
LoginController.php
public function __construct()
{
    $this->middleware('guest')->except('logout');
}

'guest' という名前は Kernel.php で定義されています。

Kernel.php
protected $routeMiddleware = [
    // ...
    'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
    // ...
];

このように、ルーティングではなくコントローラーのコンストラクタでミドルウェアを適用する方法もあります。

ユーザーとしてフォルダを作成する

さて、基本的な認証機能は実装できたので、フォルダ作成処理をブラッシュアップします。ログインユーザーに紐づくフォルダデータを保存できるようにしましょう。フォルダコントローラーの create メソッドを編集してください(★印の行のみ)。

FolderController.php
// ★ Authクラスをインポートする
use Illuminate\Support\Facades\Auth;

class FolderController extends Controller
{
    // 中略

    public function create(CreateFolder $request)
    {
        // フォルダモデルのインスタンスを作成する
        $folder = new Folder();
        // タイトルに入力値を代入する
        $folder->title = $request->title;

        // ★ ユーザーに紐づけて保存
        Auth::user()->folders()->save($folder);

        // 以下略

新しい知識は必要ありません。ここまでに扱った知識だけで実装できてしまいます。

Auth::user() でユーザーモデルが取得できるので、前章で定義したリレーションを利用してフォルダモデルを保存しています。タスク作成を実装した以下のコードと同じパターンですね。

$current_folder->tasks()->save($task);

ユーザーとしてタスクを表示する

次に、タスク一覧画面でログインユーザーに紐づくフォルダのみを表示します。タスクコントローラーの index メソッドを編集してください(★印の行のみ)。

TaskController.php
// ★ Authクラスをインポートする
use Illuminate\Support\Facades\Auth;

class TaskController extends Controller
{
    public function index(int $id)
    {
        // ★ ユーザーのフォルダを取得する
        $folders = Auth::user()->folders()->get();

        // 以下略

Folder::all() でデータベースに保存されているフォルダデータすべてを取得していた処理から、ログインユーザーが持つフォルダのみを取得する記述に書き換えています。

ホームページをブラッシュアップする

ここまでで、ToDoアプリにユーザーという概念を取り入れることができました。アプリの利用にはログインを求め、ログインしたユーザーはフォルダを作成し、タスクを追加して、自分のフォルダとそのタスクのみを閲覧することができます。

おまけのような感じですが、さらにホームページをブラッシュアップしたいと思います。今はタスク一覧画面へのリンクがないので使いづらいですよね。ログインしたあとに、必ずホームページに遷移するのではなく、フォルダを作成済みであればタスク一覧ページにリダイレクトさせるようにします。一つもフォルダを作成していなければフォルダ作成ページへのリンクを含んだホームページをレスポンスします。

HomeController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class HomeController extends Controller
{
    public function index()
    {
        // ログインユーザーを取得する
        $user = Auth::user();

        // ログインユーザーに紐づくフォルダを一つ取得する
        $folder = $user->folders()->first();

        // まだ一つもフォルダを作っていなければホームページをレスポンスする
        if (is_null($folder)) {
            return view('home');
        }

        // フォルダがあればそのフォルダのタスク一覧にリダイレクトする
        return redirect()->route('tasks.index', [
            'id' => $folder->id,
        ]);
    }
}

ではブラウザで確認してみましょう!

ログインしたのちいくつかフォルダやタスクを作成し、ログアウトしてもう一度ログインしてみましょう。タスク一覧ページに遷移できましたか?一覧に表示されているフォルダはログインユーザーが持つものだけでしょうか?

さらにログアウトして新しいユーザーを会員登録画面から作成しましょう。今度は登録後にホームページに遷移しましたか?

パスワード再設定機能

この章の最後に、パスワード再設定機能を実装します。ログインや会員登録と同様にこちらもコントローラーは用意されていますので基本的にはテンプレートを書くだけで OK です。

パスワード再設定の流れ

まず Laravel が提供するパスワード再設定のフローを説明しておきます。

  1. パスワード再設定メール送信ページでメールアドレスを入力〜送信する。
  2. メールアドレスが登録済みであれば、パスワード再設定ページへのリンクをそのアドレス宛てに送信する。
  3. メールに記載されたリンクからパスワード再設定ページへアクセスする。
  4. パスワード再設定ページで新しいパスワードを登録する。

パスワードを忘れたなどの理由でログインできない状況で本人確認をするために一度メール送信をはさんでいます。

メールの接続設定

まずは Laravel アプリケーションからメールを送信できる環境を設定します。以前の記事で紹介した、Mailtrap というサービスを使った方法にしたがって設定を行ってください。

Mailtrap のアカウントを作成(無料)すると Demo inbox が利用できるようになります。Demo inbox の SMTP 情報(Credentials)のうち Username と Password を設定ファイル .env のメール設定箇所に記載します。

.env
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=ここに Username を記載する
MAIL_PASSWORD=ここに Password を記載する
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=todoapp@email.com
MAIL_FROM_NAME="ToDo App"

もう一ヶ所 .env を編集する必要があります。

.env
APP_URL=http://todos.test

メールに記載されるパスワード再設定ページへのリンク URL は上記 APP_URL の値を用いて生成するので、ご自分の開発環境に合わせてアプリケーションの URL を記載してください。

テンプレート

テンプレートは「パスワード再設定メール送信ページ」と「パスワード再設定ページ」の2つを作成します。

パスワード再設定メール送信ページ

resources/views/auth/passwords/email.blade.php を以下の内容で作成してください。

@extends('layout')

@section('content')
  <div class="container">
    <div class="row">
      <div class="col col-md-offset-3 col-md-6">
        <nav class="panel panel-default">
          <div class="panel-heading">パスワード再発行</div>
          <div class="panel-body">
            @if (session('status'))
              <div class="alert alert-success" role="alert">
                {{ session('status') }}
              </div>
            @endif
            <form action="{{ route('password.email') }}" method="POST">
              @csrf
              <div class="form-group">
                <label for="email">メールアドレス</label>
                <input type="text" class="form-control" id="email" name="email" />
              </div>
              <div class="text-right">
                <button type="submit" class="btn btn-primary">再発行リンクを送る</button>
              </div>
            </form>
          </div>
        </nav>
      </div>
    </div>
  </div>
@endsection

パスワード再設定ページ

resources/views/auth/passwords/reset.blade.php を以下の内容で作成してください。

@extends('layout')

@section('content')
  <div class="container">
    <div class="row">
      <div class="col col-md-offset-3 col-md-6">
        <nav class="panel panel-default">
          <div class="panel-heading">パスワード再発行</div>
          <div class="panel-body">
            <form action="{{ route('password.update') }}" method="POST">
              @csrf
              <div class="form-group">
                <label for="email">メールアドレス</label>
                <input type="text" class="form-control" id="email" name="email" />
              </div>
              <div class="form-group">
                <label for="password">新しいパスワード</label>
                <input type="password" class="form-control" id="password" name="password" />
              </div>
              <div class="form-group">
                <label for="password-confirm">新しいパスワード(確認)</label>
                <input type="password" class="form-control" id="password-confirm" name="password_confirmation" />
              </div>
              <div class="text-right">
                <button type="submit" class="btn btn-primary">送信</button>
              </div>
            </form>
          </div>
        </nav>
      </div>
    </div>
  </div>
@endsection

コントローラー

パスワードの再設定が完了したあとのリダイレクト先をデフォルトから変更します。

app/Http/Controllers/Auth/ResetPasswordController.php を編集してください。

ResetPasswordController.php
protected $redirectTo = '/';

ここまでできたらブラウザから確認してみましょう。

ログイン画面に「パスワードの変更はこちらから」というリンクを用意してあるので、そちらからパスワード再設定メール送信ページにアクセスしましょう。メールアドレスを入力して「再発行リンクを送る」ボタンをクリックすると mailtrap の Demo inbox にメールが届くはずです。

メール内の「Reset Password」ボタンをクリックすると、パスワード再設定ページが開きます。

いかがでしょう?パスワードは変更できたでしょうか?

メッセージの日本語化

バリデーションメッセージを日本語化します。

まず入力欄の項目名ですが、ForgotPasswordController.phpResetPasswordController.php をどちらも修正するのは少々手間なので、validation.php で指定する方法を紹介します。

validation.php
'attributes' => [
    'email' => 'メールアドレス',
    'password' => 'パスワード',
    'token' => 'トークン',
],

validation.php の一番下に記述されている attributes というキーの値を追加します。ここに指定された内容はアプリケーション全体で有効になります。

次にパスワード再発行のフローで用いられるメッセージを日本語化します。英語版の passwords.php メッセージファイルを日本語版のディレクトリにコピーしてください。

$ cp ./resources/lang/en/passwords.php ./resources/lang/jp/

メッセージをそれぞれ日本語に直します。

passwords.php
'reset' => 'パスワードを再設定しました。',
'sent' => 'パスワード再設定リンクを送信しました。',
'token' => 'トークンが無効です。',
'user' => "入力されたメールアドレスのユーザーは見つかりませんでした。",

メールの内容を変更する

届いたメールはデフォルトでは Laravel が用意した内容です。見た目はそれなりに綺麗ですが文章は英語ですし、あくまでサンプルです。そこでオリジナルの本文を送信する方法を紹介します。

テンプレート

メールの本文もテンプレートで作ります。resources/views/mail/password-reset.blade.php を以下の内容で作成してください。

password-reset.blade.php
<a href="{{ route('password.reset', ['token' => $token]) }}">
  パスワード再設定リンク
</a>

今回はリンクテキストだけを表示しています。

Mailable クラス

Laravel では Mailable クラスがメールの送信を司ります。

コマンドラインからクラスの雛形を作成しましょう。

$ php artisan make:mail ResetPassword

雛形 app/Mail/ResetPassword.php を以下の内容で編集してください。

ResetPassword.php
<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;

class ResetPassword extends Mailable
{
    use Queueable, SerializesModels;

    private $token;

    /**
     * Create a new message instance.
     *
     * @param $token
     */
    public function __construct($token)
    {
        $this->token = $token;
    }

    /**
     * Build the message.
     *
     * @return $this
     */
    public function build()
    {
        return $this
            ->subject('パスワード再設定')
            ->view('mail.password-reset');
    }
}

このチュートリアルではメール送信機能については簡単な紹介にとどめます。詳しい内容はこちらの記事やマニュアル(🇺🇸 公式 / 🇯🇵 日本語)を参照してください。

送信処理

最後に、パスワード再設定メールを送るときは先ほど作成した ResetPassword クラスを使うように app/User.php を編集します。

User.php
use App\Mail\ResetPassword; // ★ 追加
use Illuminate\Support\Facades\Mail; // ★ 追加

class User extends Authenticatable
{
    // 略

    /**
     * ★ パスワード再設定メールを送信する
     */
    public function sendPasswordResetNotification($token)
    {
        Mail::to($this)->send(new ResetPassword($token));
    }

    // 略
}

sendPasswordResetNotification メソッドはパスワード再設定メール送信時に呼ばれます。

これでメール本文の内容変更は完了です。再度ブラウザから確認しましょう。
オリジナルメールが送信できていますか?

仕組み

パスワード再設定機能の最後に少し再設定処理の仕組みを紹介します。

メールでパスワード再設定ページへのリンクを送って本人確認するとしても、誰でも調べれば分かるような URL を送っては意味がないですね。そこで送信されるパスワード再設定ページへのリンクにはランダムな文字列(トークン)が含まれています。

トークンは password_resets テーブルで管理されます。database/migrations に最初から入っていた 2014_10_12_100000_create_password_resets_table.php が password_resets テーブルを作成するマイグレーションファイルです。

public function up()
{
    Schema::create('password_resets', function (Blueprint $table) {
        $table->string('email')->index();
        $table->string('token');
        $table->timestamp('created_at')->nullable();
    });
}

つまりこのようなテーブルですね。

カラム
email VARCHAR(255)
token VARCHAR(255)
created_at TIMESTAMP

パスワード再設定リンクをメールで送信する際に、

  1. まずトークンを発行します。予測できないランダムな文字列です。
  2. トークンと入力されたメールアドレスの組を password_resets テーブルに保存します。
  3. トークンを含めたパスワード再設定リンクをメール送信します。
    例:http://your.domain/password/reset/qwerasdfzxcv

そしてユーザーがそのリンク URL にアクセスすると、

  1. URL のトークン部分を再設定フォームの <input type="hidden"> の値として埋め込みます。
  2. ユーザーはメールアドレスと新パスワードとともにトークンも送信することになります。
  3. password_resets テーブルに保存したトークンとメールアドレスの組と上記の入力値を比較して一致した場合のみパスワードを変更します。

このように メール送信とトークン、password_resets テーブルを組み合わせてログインできない状態でも本人確認を実現しているのです。

🤩 🤩 🤩

第9章はこれでおしまいです。
ここまでのソースコードはリポジトリ(chapter09 ブランチ)を参照してください。

この章までの実装でも動くことは動くのですが、例えば存在しないフォルダ ID を含む URL でアクセスされた場合はどうなるでしょう?ログインユーザーが自分のものではないフォルダの ID についてタスク作成のリクエストを実行した場合はどうでしょうか?

次の10章ではこのような意図しないアクセスにも耐えられるよう、エラー処理を追加します。

アプリケーションの実装自体は10章で完了します。11章では作成した Laravel アプリケーションをインターネットに公開する方法を紹介してこのチュートリアルはおしまいです。

連載記事