tohokuaikiのチラシの裏

技術的ネタとか。

Laravelのauthを追ってみたメモ

$artisan make:auth
したという前提で。

Laravel5.4でマルチ認証(userとadmin)を実装する方法 | 大分のITコンサルタント | 高橋商店 というのをやってみて、認証の中で振り分けをしたかったというケース。

普通にファーストアクセスで認証必要なURLにアクセスした場合

URL→routes→Controllerなので…

最初はController

認証を掛けたいController内でAuthを呼ぶ。

<?php
class UserController extends Controller
{
    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('auth:user,user1,user2,user3');
    }

ってやると、MiddleWareで登録されたauthが起動する。

app/Http/Kernel.php

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

のauthにあるauthとさっきの$this->middleware('authのauthが対応している。auth:user, user1,user2,user3のuser1,user2,user3はMiddleWare(この場合Authenticate)の$guards引数になる。

ちなみに、$routeMiddleWareというのは$middleWareと違ってControllerでこうやって$this->middleware()して任意に起動させるもので、$middleWareは全てのrouteで起動する。

Authenticate.php

で、この起動するインスタンスは app/Http/Middleware/Authenticate.php なので、このインスタンスのhandleメソッドが実行される。

これは、Illuminate\Auth\Middleware\Authenticateを継承しているので、

Illuminate\Auth\Middleware\Authenticate
<?php
    public function handle($request, Closure $next, ...$guards)
    {
        $this->authenticate($request, $guards);

        return $next($request);
    }

がメソッド。で、この$guardsがさっきの$this->middleware('auth:user,user1,user2,user3')した引数になってて、

<?php
$guards = ['user', 'user1', 'user2', 'user3', ];

となっている。

Illuminate\Auth\Middleware\Authenticate

この $guardsはIlluminate\Auth\Middleware\Authenticate->authenticate()で使われて、

Illuminate\Auth\Middleware\Authenticate
<?php
    protected function authenticate($request, array $guards)
    {
        if (empty($guards)) {
            $guards = [null];
        }

        foreach ($guards as $guard) {
            if ($this->auth->guard($guard)->check()) {
                return $this->auth->shouldUse($guard);
            }
        }

        throw new AuthenticationException(
            'Unauthenticated.', $guards, $this->redirectTo($request)
        );
    }

となっている。このforeach($guardsに $guards = ['user', 'user1', 'user2', 'user3', ]; が入ってくる。この場合4つのGuardsを使うことになる。

で、この$this->authは Illuminate/Auth/AuthManager.phpインスタンス

Illuminate\Auth\AuthManager と config/auth.php

ここで、check()メソッドを持つインスタンスをconfig/auth.phpに基づいて返す。

auth.phpでは、'guards' => に入っているもので

config/auth.php
<?php
    'defaults' => [
        'guard' => 'web',
        'passwords' => 'users',
    ],
   ///////////////////////
    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'user' => [
          'driver' => 'session',
          'provider' => 'custom_users',
        ],
   ///////////////////////
    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\User::class,
        ],

となっているので、$this->middleware('auth:user')とすると、driverにsession/providerにcustom_usersを使ったものがcheck()を実行する。
ちなみに、$this->middleware('auth')だと、defaultsのguardは'web'なのでこれまたdriverにsession/providerにusersを使ったものが実行される。

AuthのProviderは、Modelとつながっている

と、ここまでで、普通にControllerで$this->middleware('auth')としたときにはguardsのwebが呼ばれて、そのコードはproviderで示されるusersなのである。

で、Illuminate\Auth\Middleware\Authenticate::authenticate()の

Illuminate\Auth\Middleware\Authenticate::authenticate()
<?php
            if ($this->auth->guard($guard)->check()) {
                return $this->auth->shouldUse($guard);
            }

では、$this->authがIlluminate\Auth\AuthManagerなので、そのguardメソッドを見るとAuthManager::resolve()で$guardに沿ったインスタンスを作っている。

'web'の場合、driverがsessionなのでAuthManager::createSessionDriver()である。これは$this->auth->guard($guard) がIlluminate\Auth\SessionGuardを作っている。

このSessionGuardインスタンスは、以下の経緯で作られる$providerを持っている。

Illuminate\Auth\CreatesUserProviders::createUserProvider($provider)   
で、$providerがeloquentであり、特に$providerに対するカスタムなCreatorが無い   
  ↓  
Illuminate\Auth\CreatesUserProviders::createEloquentProvider($config)   
これはEloquentUserProviderインスタンスをnewする。  
  ↓  
new EloquentUserProvider($this->app['hash'], $config['model']);  

また、SessionGuardインスタンスは、識別子$name(この場合は'user')と$sessionと(Http)Request $requestを持っている。

Illuminate\Auth\SessionGuard のcheck()

は、Illuminate\Auth\GuardHelpers::check()のtraitで、SessionGuard::user()の返り値がNULLでないかをチェックしている。
このuser()メソッドは

  • ログアウトのタイミングならnull
  • SessionGuard::$user (Illuminate\Contracts\Auth\Authenticatable) があればそれを返す。これは既にログイン処理がされている場合にはSessionからRestoreされるっぽい。

とかまぁなんかそんな感じ。

ここまでで、このSessionGuard::$userが得られれば認証通過。

ログインが認可されているセッションCOOKIEを持っている場合

SessionGuard::user()で$this->userを作成する処理を行う。

Illuminate\Auth\SessionGuard::user()
<?php
        if (! is_null($id) && $this->user = $this->provider->retrieveById($id)) {
            $this->fireAuthenticatedEvent($this->user);
        }

の部分。以降では逐一DBにアクセスする必要は無くなる。

「永続ログイン」のチェックを入れてログインしていると、

Illuminate\Auth\SessionGuard::user()
<?php
        if (is_null($this->user) && ! is_null($recaller = $this->recaller())) {
            $this->user = $this->userFromRecaller($recaller);

の部分で$this->userの復帰をしてくれる。

LaravelはSessionを自前で実装している。

…とここまで読んで、LaravelはPHPの生のセッション機能を使ってなくて独自でSession構築しているということに気が付いた。えー。秘密情報をCOOKIEに入れてるのかな?…あれ?これ以前も「すげー」って思った記憶あるな…いや、それはRailsだったっけ?Laravelのデフォルトだとセッション情報はファイルで storage/framework/sessions に保存してる。

ログインの時の処理。

DBに接続する処理があるのはここの部分だけですね。

Auth\LoginController@login

メソッドとしてはこのメソッドなので
Illuminate\Foundation\Auth\AuthenticatesUsers::login()を見に行く。

Illuminate\Foundation\Auth\AuthenticatesUsers
<?php
   public function login(Request $request)
    {
        $this->validateLogin($request);

        // If the class is using the ThrottlesLogins trait, we can automatically throttle
        // the login attempts for this application. We'll key this by the username and
        // the IP address of the client making these requests into this application.
        if (method_exists($this, 'hasTooManyLoginAttempts') &&
            $this->hasTooManyLoginAttempts($request)) {
            $this->fireLockoutEvent($request);

            return $this->sendLockoutResponse($request);
        }

        if ($this->attemptLogin($request)) {
            return $this->sendLoginResponse($request);
        }

        // If the login attempt was unsuccessful we will increment the number of attempts
        // to login and redirect the user back to the login form. Of course, when this
        // user surpasses their maximum number of attempts they will get locked out.
        $this->incrementLoginAttempts($request);

        return $this->sendFailedLoginResponse($request);
    }

validateLogin()

ここで、ユーザー名とパスワードの入力チェック。独自のチェックを入れたかったら、ControllerでOverrideすればいい。

attemptLogin()

hasTooManyLoginAttempts()は、多重ログインPOSTの防止かな。あまり読んでない。
ログイン処理のメインはattemptLogin()で

Illuminate\Foundation\Auth\AuthenticatesUsers
<?php
    protected function attemptLogin(Request $request)
    {
        return $this->guard()->attempt(
            $this->credentials($request), $request->filled('remember')
        );
    }

で、guard()で得たインスタンス(SessionGuard)のattempt()を実行する。これは

Illuminate\Auth\SessionGuard
<?php
        $this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials);

        if ($this->hasValidCredentials($user, $credentials)) {
            $this->login($user, $remember);

            return true;
        }

DBにつなぐところは、 $this->provider->retrieveByCredentials()の所。このproviderはlluminate\Auth\EloquentUserProviderである。このretrieveByCredentialsメソッドが

lluminate\Auth\EloquentUserProvider
<?php
   public function retrieveByCredentials(array $credentials)
    {
        if (empty($credentials) ||
           (count($credentials) === 1 &&
            array_key_exists('password', $credentials))) {
            return;
        }

        // First we will add each credential element to the query as a where clause.
        // Then we can execute the query and, if we found a user, return it in a
        // Eloquent User "model" that will be utilized by the Guard instances.
        $query = $this->newModelQuery();

        foreach ($credentials as $key => $value) {
            if (Str::contains($key, 'password')) {
                continue;
            }

            if (is_array($value) || $value instanceof Arrayable) {
                $query->whereIn($key, $value);
            } else {
                $query->where($key, $value);
            }
        }

        return $query->first();
    }

となっている。newModelQuery()でQueryを取っているが、このORMはauth.phpに記述した 'providers' => ['users' => [ のmodelのApp\User::classとなる。

ちなみにこの定義が無いとデフォルトのグローバルの\Illuminate\Database\Eloquent\Builderがnewされて作られる。…が、ほとんど意味が無いだろう。テーブル持って無いし。ちゃんと定義しろということですね。これで、メールアドレスを指定でDBから1行を抜き出す。

で、その後は更にhasValidCredentials()に流れて、

Illuminate\Auth\SessionGuard
<?php
    protected function hasValidCredentials($user, $credentials)
    {
        return ! is_null($user) && $this->provider->validateCredentials($user, $credentials);
    }

で、この$this->providerはIlluminate\Auth\EloquentUserProviderのインスタンスで、validateCredentialsでパスワードと比較している。

Illuminate\Auth\EloquentUserProvider
<?php
    public function validateCredentials(UserContract $user, array $credentials)
    {
        $plain = $credentials['password'];

        return $this->hasher->check($plain, $user->getAuthPassword());
    }

ちなみに、この時UserNameとして何を使いたいかというと、Illuminate\Foundation\Auth\AuthenticatesUsersのusername()メソッドをControllerで上書きすればよい。デフォルトだと以下のようにemailが使われる。

Illuminate\Foundation\Auth\AuthenticatesUsers
<?php
    public function username()
    {
        return 'email';
    }

ログインに失敗した時

単純に権限のない所に入ってしまった場合

これは、Illuminate\Auth\Middleware\Authenticate->authenticate()で例外AuthenticationExceptionを投げられる。

これは、app\Exceptions\Handler::unauthenticated() でキャッチされる。

app\Exceptions\Handler
<?php
    protected function unauthenticated($request, AuthenticationException $exception)
    {
      if ($request->expectsJson()) {
        return response()->json(['error' => 'Unauthenticated.'], 401);
      }

      //追加
      if (in_array('admin', $exception->guards())) {
        return redirect()->guest('admin/login');
      }
      //追加

      return redirect()->guest('login');
    }

エラーハンドリング

で、なんでAuthenticationException エラーの時だけunauthenticatedメソッドが使われるかというと、最終的にはapp\Exceptions\Handler::render()でキャッチされるのだけど、 Illuminate\Foundation\Exceptions\Handlerで、

Illuminate\Foundation\Exceptions\Handler
<?php
    public function render($request, Exception $e)
    {
        if (method_exists($e, 'render') && $response = $e->render($request)) {
            return Router::toResponse($request, $response);
        } elseif ($e instanceof Responsable) {
            return $e->toResponse($request);
        }

        $e = $this->prepareException($e);

        if ($e instanceof HttpResponseException) {
            return $e->getResponse();
        } elseif ($e instanceof AuthenticationException) {
            return $this->unauthenticated($request, $e);
        } elseif ($e instanceof ValidationException) {
            return $this->convertValidationExceptionToResponse($e, $request);
        }

        return $request->expectsJson()
                        ? $this->prepareJsonResponse($request, $e)
                        : $this->prepareResponse($request, $e);
    }

となっていて、AuthenticationExceptionの例外の場合はunauthenticated()メソッドが実行されるため。

権限のないところに入ったら、所定のログイン場所に飛ばしたい

投げられる認証の例外AuthenticationExceptionのnewはapp\Http\Middleware\Authenticateの継承元である

Illuminate\Auth\Middleware\Authenticate
<?php
   protected function authenticate($request, array $guards)
    {
        // 省略
        throw new AuthenticationException(
            'Unauthenticated.', $guards, $this->redirectTo($request)
        );
    }

なので、$this->redirectTo($request)がキーになるので、app\Http\Middleware\AuthenticateのredirectTo先を変更してやると良い。

…が、このMiddlewareの$thisは自分がadminなのかuserなのかが分からないので、どこにredirectするべきかが分からない…

というのは、

Illuminate\Auth\Middleware\Authenticate
<?php
    protected function authenticate($request, array $guards)
    {
        /// 省略
        throw new AuthenticationException(
            'Unauthenticated.', $guards, $this->redirectTo($request)
        );
    }

とnewしているところで$guardsを渡してくれてない…

ということで、Http\Middleware\Authenticateにguardsを渡してその先頭を取る

どのguardsで引っかかったかというのはコアのAuthenticateでは取れないので、

app\Http\Middleware\Authenticate
<?php
    /**
     * @var Array
     */
    private $guards ;

    public function handle($request, Closure $next, ...$guards)
    {
        $this->guards = $guards;

として、handle時に持たせてやり、configのauthにredirectoToを生やして

config/auth.php
<?php
   'guards' => [
        'user' => [
            'driver' => 'session',
            'provider' => 'users',
            'redirectTo' => 'user.login'
        ],

redirectoToで使用した。

app\Http\Middleware\Authenticate
<?php
    protected function redirectTo($request)
    {
        $redirectTo = sprintf('auth.guards.%s.redirectTo', current($this->guards));
        $redirectTo = \Config::get($redirectTo) ?? '';
        if (!$request->expectsJson()) {
            return route($redirectTo);
        }
    }

んー。まぁ、こんな感じで。

Auth::routes()でのRouteの追加

routes/web.phpに Auth::routes(); を記載すると、Illuminate\Routing\Router->auth()が実行される。

Illuminate\Routing\Router
<?php
    public function auth(array $options = [])
    {
        // Authentication Routes...
        $this->get('login', 'Auth\LoginController@showLoginForm')->name('login');
        $this->post('login', 'Auth\LoginController@login');
        $this->post('logout', 'Auth\LoginController@logout')->name('logout');

        // Registration Routes...
        if ($options['register'] ?? true) {
            $this->get('register', 'Auth\RegisterController@showRegistrationForm')->name('register');
            $this->post('register', 'Auth\RegisterController@register');
        }

        // Password Reset Routes...
        if ($options['reset'] ?? true) {
            $this->resetPassword(); // このメソッドで以下の4つ追加
//        $this->get('password/reset', 'Auth\ForgotPasswordController@showLinkRequestForm')->name('password.request');
//        $this->post('password/email', 'Auth\ForgotPasswordController@sendResetLinkEmail')->name('password.email');
//        $this->get('password/reset/{token}', 'Auth\ResetPasswordController@showResetForm')->name('password.reset');
//        $this->post('password/reset', 'Auth\ResetPasswordController@reset')->name('password.update');
        }

        // Email Verification Routes...
        if ($options['verify'] ?? false) {
            $this->emailVerification(); // このメソッドで以下の3つ追加
//        $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');
        }
    }

となっているので、手動で追加したい場合はこのrouteを記載する。

Login時のSessionGuardをうまく働かせる

ログインした状態で認証をパスする場合、Illuminate\Auth\SessionGuard->user()でセッションを見るんだけど、この場合

$this->middleware('auth:user')

で保存しなければならない。チェックする時が「auth:user」でなので。これを指定せずにいると、デフォルトのwebが使われてセッションに保存されてしまって認証状態ではないと判断してしまう。

SessionGuard::login() でSessionにデータを保存する処理を追ってみる

結局、SessionGuardをnewする所で$nameを与えているので、

Illuminate\Foundation\Auth\AuthenticatesUsers.php
<?php
    protected function guard()
    {
        return Auth::guard();
    }

ここで$nameを与えてないので仕方ない…

ということで、このAuth::gurad($name)っていう感じのClassを作ってOverrideする。

app\Http\Controllers\Auth\LoginController
<?php
// use Illuminate\Foundation\Auth\AuthenticatesUsers
class LoginController extends Controller
{
    use AuthenticatesUsers;

    protected $guard_name = 'user';

とControllerで$nameを指定できるようにしオリジナルのを使わないようにして、自前のAuthenticatesUsersを作る。

で、同じディレクトリに

app\Http\Controllers\Auth\AuthenticatesUsers
<?php
namespace App\Http\Controllers\Auth;

use Illuminate\Foundation\Auth\AuthenticatesUsers as AuthenticatesUsersOrig;
use Illuminate\Support\Facades\Auth;

trait AuthenticatesUsers
{
    use  AuthenticatesUsersOrig;

    protected function guard()
    {
        return Auth::guard($this->guard_name);
    }
}

とやっておく。