作りたいものがありすぎる

40歳を過ぎてプログラミングを始めた人の顛末とこれからなど

Laravel 認証カスタマイズ 複数tableを結合しての認証で Auth::user() に必要な値を入れる方法

Laravelの認証機能をカスタマイズして、認証時に3つのカラム条件で認証をし、さらに認証後にAuth::user() ファサードに複数tableからの値を取得することをしました。

環境Laravel5.6
windows10Pro 64bit
vagrant環境にて実施

そもそもの経緯として、DB構造が特殊になり、 通常の users tableのみでの認証では必要な値を取得できなくなっていました。
全体のDB設計をしてからやれば済む問題だったのですが、単数のtableでの認証を前提として、システムを作り込んでしまい。後からどうしても複数tableからの値を取得する必要に迫られて、Laravelの認証系のドキュメントやサイトをかなり読込むことになりました。

結果を先に書いておくと 複数tableをjoinしたMySQLのviewを migration で書いて、そのviewを認証用table,auth_usersとして登録し、viewから一意の値を導きます。
通常はusers tableから、 email, password と2種類のカラムから一意、かつパスワードの一致で認証をしますが、今回は unique_name, password, community_id の3つのカラム条件から認証条件を導き出しています。

table構造図

パワポで書いたのでちょっと変ですが意図は伝わるかと思います。)
f:id:sakamata:20181116045527p:plain

user は複数のコミュニティに任意に所属・登録できるというシステムで、これを中間tableである community_user でどのユーザーがどのコミュニティに所属しているかを管理しています。

さらに community_user tableにはと1対1(hasOne)の関係で追加table communities_users_statuses があり、特定のユーザーの特定のコミュニティ内での情報(権限レベルのidや表示設定、日時等)が保存されています。さらにその先には roles(権限)tableがあり、idと権限名が記載されているという、かなりがっつりなtable設計をした構造になってます。

これら

users
community_user
communities_users_statuses
roles

上記4つのtabelのからのカラムデータを、Laravelのファサード Auth::user()->name といった具合に、 Auth::user()->role も Auth::user()->user_idAuth::user()->community_id も "->"を一つ書くだけで一気に取得できる様にして、その値をアプリケーションの様々な処理のトリガーとして利用したいのです。(というかそういう風に作ってしまって後から変えるの凄いしんどい。)

まず、通常のLaravelの標準の認証系の追加をさくっとします。この辺は他のサイトにお任せします。

Laravel 5.6 認証 イントロダクション

で、色々ファイルが作られますので、それをどう変えるか、というお話です。

まず、MySQLのviewを auth_users という名前でマイグレーションファイルで作成します。通常のマイグレーションファイルの記述方法とは少し異なります。

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

// 認証時に使用される view  Auth::user() に収納される
// $credentials array ('id', 'unique_name', 'password' )
class CreateViewAuthUsersTable20181114 extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        DB::statement( 'DROP VIEW IF EXISTS auth_users' );
        DB::statement( "
            CREATE VIEW auth_users AS
            SELECT
                community_user.id,
                community_user.user_id,
                community_user.community_id,
                users.name,
                users.unique_name,
                users.email,
                users.facebook_id,
                users.password,
                users.remember_token,
                communities.user_id AS reader_id,
                communities.name AS community_unique_name,
                communities.service_name,
                communities_users_statuses.role_id,
                communities_users_statuses.hide,
                communities_users_statuses.last_access,
                communities_users_statuses.created_at,
                communities_users_statuses.updated_at,
                roles.role
            FROM community_user
            JOIN users ON (community_user.user_id = users.id)
            JOIN communities ON (community_user.community_id = communities.id)
            JOIN communities_users_statuses ON (community_user.id = communities_users_statuses.id)
            JOIN roles ON (roles.id = communities_users_statuses.role_id)
        " );
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        DB::statement( 'DROP VIEW IF EXISTS auth_users' );
    }
}

がっつりとview作成のSQL文を書いています。
DB::statement は通常のSQL文を書くという事ですね。 宣言の部分に use DB; は書かなくても大丈夫でした。書いたら逆にエラーになった。
で、以下のコマンドを実施

php artisan migrate

SQL文を走らせて view,auth_usersを作ります。
この方法はこちらのサイトを参考にさせてもらいました。

LaravelのマイグレーションでView Tableを作成する

これにより先ほど説明した4つのtableが結合されたviewが作成されます。
f:id:sakamata:20181116045531p:plain

viewは一つの大きなtableの様に扱う事ができます。出来上がったviewには所により同じ値が2度以上でますが、 unique_name password community_id の3つの値が全て重複するレコードは存在せず、これにより一意の認証が可能となります。

次にこのviewを認証用のtableとして使うための認証用モデルを作ります。
これは、標準で認証に使われているファイル app\User.php をコピーして書き換えました。

app\AuthUser.php

namespace App;

use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;

class AuthUser extends Authenticatable
{
    use Notifiable;

    /**
     * モデルと関連しているテーブル
     *
     * @var string
     */
    protected $table = 'auth_users';

    /**
     * The attributes that should be hidden for arrays.
     * JSON出力の際誤って含めいない属性という事らしい
     * @var array
     */
    protected $hidden = [
        'password', 'remember_token',
    ];

    // 日時表記変更の ->format('Y-m-d') を使いたいカラム名を指定する
    protected $dates = [
        'last_access',
        'created_at',
        'updated_at',
    ];
}

これが結構忘れがちですが大事です!

次に上記の app\AuthUser.php を認証時に使うように config ファイルの model を書き換えます。

config/auth.php (抜粋)

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\AuthUser::class,
        ],

また、login画面のviewファイルにはコミュニティ毎にログイン画面のURLが異なる仕様となっています。
ここにformの hidden で表示されているログイン画面の community_id の値をPOSTできる様にしています。また、標準のemailの入力欄であるフォームを ユーザーID(unique_name)の入力欄に変更しています。

resources\views\auth\login.blade.php

省略
<form method="POST" action="{{ route('login') }}" aria-label="{{ __('Login') }}">
@csrf
<input type="hidden" name="community_id" value="{{$community->id}}">
省略
<input id="unique_name" type="text" class="form-control{{ $errors->has('unique_name') ? ' is-invalid' : '' }}" name="unique_name" value="{{ old('unique_name') }}" required autofocus>

最後?に LoginController を書き換えます。

app\Http\Controllers\Auth\LoginController.php (抜粋)

// 前後省略
use DB;
use Illuminate\Support\Facades\Auth;

//Class宣言部分 省略

    // ログイン時に使用するユニークであるカラムを指定
    public function username()
    {
        return 'id';
    }

    public function authenticate(Request $request)
    {
        $request->validate([
            'unique_name' => ['required', 'string', 'min:6', 'max:40',  'regex:/^[a-zA-Z0-9@_\-.]{6,40}$/u'],
            'password' => 'required|string|min:6|max:100',
        ]);

        //  該当の community_user の id を取得
        $community_user_id = DB::table('community_user')
            ->leftJoin('users', 'users.id', '=', 'community_user.user_id')
            ->where([
                ['unique_name', $request->unique_name],
                ['community_id', $request->community_id],
        ])->pluck('community_user.id')->first();

        if (!$community_user_id) {
            // 他のコミュニティで認証が取れるか?
            $result_bool = $this->CheckOtherCommunityExists($request->unique_name, $request->password);
            if ($result_bool) {
                // 他のコミュニティにいる場合中間table等にレコード追加
                $community_user_id = $this->InsertNewStatuses($request->community_id, $request->unique_name);
            } else {
                // 他のコミュニティにいない場合
                return redirect()->back()->withErrors(array('unique_name' => 'ユーザーIDかPasswordが正しくありません'))->withInput();
            }
        }

        // community_user_id を含めた通常の承認フロー
        $credentials  = array(
            'unique_name' => $request->unique_name,
            'password' => $request->password,
            'id' => $community_user_id,
        );
        // 認証許可
        if (Auth::attempt($credentials)) {
            // session にcommunity値保存
            $request->session()->put('community_id', $request->community_id);
            $request->session()->put('community_user_id', $community_user_id);
            return redirect('/')->with('message', 'ログインしました');
        } else {
            return redirect()->back()->withErrors(array('unique_name' => 'ユーザーIDかPasswordが正しくありません'))->withInput();
        }
    }

通常は$credentials の配列に email と password のみを指定して Auth::attempt($credentials) と渡してやれば認証を結果を返してくれますが、auth_usersから一意の値として取得できる条件として id を配列に追加してやります。

        $credentials  = array(
            'unique_name' => $request->unique_name,
            'password' => $request->password,
            'id' => $community_user_id,
        );

こうすることにより、 MySQLのviewで作られた auth_users table を探しに行き、一意の値を取得して認証が行われるという訳です。
ちなみにコードの中ほどの独自メソッド群は、$community_user_id を特定する処理として、認証前に値を探しに行ったり、ユーザーがまだログインしたことのないコミュニティに初ログインした際に既存ユーザーであるか?の確認をしたり、community_user にtableに値を追加したり、と、このWebアプリケーション独自の処理を色々してます。

こっからは蛇足ですが、その処理の際に、認証を全て自分でカスタマイズできそうなヒントになりそうな処理を書いたので紹介。

    // return bool 他のコミュニティに存在するかを判定する
    public function CheckOtherCommunityExists($unique_name, $password)
    {
        $hash_password = DB::table('users')->where([
            ['unique_name', $unique_name],
        ])->pluck('password')->first();

        if ($hash_password) {
            if (Hash::check($password, $hash_password)) {
                return true;
            }
        }
        return false;
    }

上記の自作メソッドは 認証時の id と password を使って、クイックスタートで作られる認証系の処理を通さないで ユーザー認証の判定ができています。(unique_name とpassword)この処理を書いていたら図らずも結構普通な感じの認証処理になってました。 なので、単純に自分で認証処理を作りたい際は上記を参考に返り値となるboolの判定を基に Auth::login($user);

とやってしまえば、ログイン認証っぽい事はできるようです。 $user はAuth::user() に入る一意のユーザーのオブジェクト(DBのrecord)ですね。ただ、tokenに値を入れたり、リダイレクト先を指定したりの処理がどこまで必要になるかは未検証です。

Laravel 5.6 認証 イントロダクション
https://readouble.com/laravel/5.6/ja/authentication.html