Laravel 6 – 从基本用户认证深入理解Laravel

启用Laravel的基本用户认证模块后,就会自动生成几个用户注册/登录/密码找回的页面。我们就来从这几个页面深入理解Laravel的用法。

AUTH路由图

1. 找到路由

这几个路由都由/routes/web.php文件中新增的Auth::routes(); 这一行注册的。

1.1 Facade

这个用法涉及到了Laravel的facade概念,facade是指用看起来像调用类的静态方法的语法(类名::静态方法)调用容器中对应实例的同名公有方法的一种用法。

我们可以在/config/app.php的aliases数组中找到这个Auth别名,该别名指向实际的facade类‘Auth’ => Illuminate\Support\Facades\Auth::class,

所以在路由/routes/web.php中的Auth::routes()实际上就是Illuminate\Support\Facades\Auth::routes()

然后我们再去看这个…\Facades\Auth的代码。

class Auth extends Facade
{
    protected static function getFacadeAccessor()
    {
        return 'auth';
    }
    // 实际调用的是这个方法
    public static function routes(array $options = [])
    {
        static::$app->make('router')->auth($options);
    }
}

其中getFacadeAccessor()方法是所有Facade派生类都必须重写的,并且都要返回一个字符串,该字符串指向容器中的一个绑定名。这样就可以通过这个facade来找到容器中绑定的对应实例了。

所以这里我们就要去容器中找名为”auth”的绑定。OK,那么如何找到容器中的绑定关系呢?这个其实挺麻烦的,因为/config/app.php文件中的providers数组只是定义了ServiceProvider类,所有的绑定都是在ServiceProvider类的register()方法中进行的。最简单粗暴的办法就是直接在代码编辑器中全局查找内容singleton(‘auth 或者bind(‘auth 。还有一种办法是写一个测试页面通过dd(app()); 打印出整个容器。总之,我们可以在Illuminate\Auth\AuthServiceProvider.php的register()方法中找到这个”auth”其实绑定的是一个Illuminate\Auth\AuthManager类的共享实例(单例)。

然后由于Facade基类重写了php魔术方法__callStatic() ,当系统找不到Facade::method_name()这个静态方法时,就会自动调用__callStatic() 找到在getFacadeAccessor()中关联的实例,并调用其同名公有方法(非静态)。这就是facade的实现原理。

不过实际上由于…\Facades\Auth类中已经定义了routes()静态方法,所以会直接调用它。不会再通过__callStatic()去AuthManager实例那找了(况且AuthManager也没定义routes()方法 ^_^)。

1.2 Router

所以真实的路由,我们可以在Illuminate\Routing\Router类的auth方法中找到。

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');

        // 等价与issset($options['register']) ? $options['register'] : true
        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();
        }

        // Password Confirmation Routes...
        if ($options['confirm'] ??
            class_exists($this->prependGroupNamespace('Auth\ConfirmPasswordController'))) {
            $this->confirmPassword();
        }

        // Email Verification Routes...
        if ($options['verify'] ?? false) {
            $this->emailVerification();
        }
    }

从上面的代码看出我们可以通过在/routes/web.php中给Auth::routes() 添加参数来关闭,开启某些路由。比如通过Auth::routes([‘register’=>false])来关闭注册页面的路由,通过Auth::routes([‘verify’=>true])来开启邮箱验证路由(这还要配合开启注册时候的邮箱验证来使用)。

2. 中间件

中间件提供了一种方便的机制过滤进入应用程序的 HTTP 请求。中间件是HTTP请求经过路由分发后的第一道槛。在“AUTH路由图”中我们可以看到所有的AUTH路由都关联了web中间件,还有部分关联guest,auth中间件。

2.1 定义中间件

中间件的定义文件默认都放在app/Http/Middleware 目录下。

2.2 注册中间件

定义了中间件的类之后,还需通过app/Http/Kernel.php 文件向系统注册中间件,告诉系统有哪些中间件,叫什么名字。

$middleware : 全局中间件数组,所有的HTTP请求都要经过这里的中间件。(不需要定义名字)

$routeMiddleware : 注册[中间件名 => 中间件类]的对应关系。(auth中间件、guest中间件就在这)

$middlewareGroups : 注册[中间件组名 => [中间件组] ]的对应关系。(web中间件组就在这)

如果想要为指定的路由分配中间件,那么就必须先注册中间件名或中间件组名。

2.3 关联中间件

定义并注册了中间件之后,就要把中间件分配到路由上了。

a. web中间件组是如何关联到/routes/web.php文件中的所有路由的?

App\Providers\RouteServiceProvider@mapWebRoutes() 方法中,加载/routes/web.php文件的同时就将web中间件组分配到所有的web路由组。

    protected function mapWebRoutes()
    {
        Route::middleware('web')                       // 实际上是调用 RouteRegistrar->attribute('middleware', ['web'])
             ->namespace($this->namespace)             // RouteRegistrar->attribute('namespace', $this->namespace)
             ->group(base_path('routes/web.php'));     // RouteRegistrar->group('web.php')
    }
    protected function mapApiRoutes()
    {
        Route::prefix('api')                           // 实际上是调用 RouteRegistrar->attribute('prefix', 'api')
             ->middleware('api')                       // RouteRegistrar->attribute('middleware', ['api'])
             ->namespace($this->namespace)             // RouteRegistrar->attribute('namespace', $this->namespace)
             ->group(base_path('routes/api.php'));     // RouteRegistrar->group('api.php')
    }

这几行代码看着简单,其实底层涉及很多概念,所以必须要好好说道说道了(ง •̀_•́)ง

首先是Route::static_method() 这样的facade用法,它实际上调用的是容器中名为”router”的\Illuminate\Routing\Router 单例绑定。(在Illuminate\Routing\RoutingServiceProvider 中注册的)

然后,我们会发现在\Illuminate\Routing\Router 类中根本找不到middleware()namespace() 方法。而且它的prefix() 方法是protected的,也不能通过Route::prefix() 这种facade方式来调用。

实际上,\Illuminate\Routing\Router 实现了php的魔术方法__call($method, $parameters) 。当系统找不到$router->method() 时就会自动调用$router->__call($method, $parameters)

    public function __call($method, $parameters)
    {
        // 先尝试通过Macroable trait注册的外部方法(关于Laravel Macroable用法请自行搜索)
        if (static::hasMacro($method)) {
            return $this->macroCall($method, $parameters);
        }

        if ($method === 'middleware') {
            return (new RouteRegistrar($this))->attribute($method, is_array($parameters[0]) ? $parameters[0] : $parameters);
        }
        return (new RouteRegistrar($this))->attribute($method, $parameters[0]);
    }

所以Route::middleware()Route::prefix()Route::namespace() 最终调用的都是Illuminate\Routing\RouteRegistrar->attribute($method, $parameters) 方法。它负责将$method, $parameters转换成[属性 => 值]的形式写入自己的$attributes数组中,并返回RouteRegistrar实例本身。

由于方法返回实例本身,所以就能方便地使用php链式调用(Method Chaining)语法了:$instance->method()->another_method();

最后,Illuminate\Routing\RouteRegistrar->group() 就会调用Illuminate\Routing\Router->group() 方法,加载路由文件(或是定义路由的闭包函数),为每个路由生成Illuminate\Routing\Route 实例并关联上这组路由的属性(即middleware、prefix、namespace这些)。

所有的Illuminate\Routing\Route 实例都会被放到app(‘router’) 这个共享实例的$routes属性中,Illuminate\Routing\Router->routes 属性是一个Illuminate\Routing\RouteCollection 类型的实例。

b. 使用Controller->middleware() 关联中间件

控制器中间件是指在控制器的构造函数中使用middleware() 方法将中间件分配给该控制器。

/register路由的guest中间件就是在RegisterController控制器中定义的:

    public function __construct()
    {
        $this->middleware('guest');
    }

c. 使用Route->middleware() 关联中间件

就是在路由文件中定义路由时候使用->middleware() 来直接关联中间件。

    Route::get('admin/profile', function () {
        //
    })->middleware('auth');

3. Cookie与Session

抛开那几个全局中间件不谈,我们来看一下web中间件组中最重要的两个中间件: EncryptCookies与StartSession。

3.1 Cookie

Laravel框架生成的cookie都是自动加密解密的。这个加解密就是由EncryptCookies中间件执行的。(可以在App\Http\Middleware\EncryptCookies@except 属性中指定不加密的cookie)

来看一下EncryptCookies中间件的handle方法:

    public function handle($request, Closure $next)
    {
        return $this->encrypt($next($this->decrypt($request)));
    }

首先注意它这里的return并不是像中间件规范那样的return $next($request); ,其实$next就是指向下一个中间件的handle()方法。所以常规的return $next($request); 就是调用下一个中间件并返回,而在EncryptCookies这里是:

  1. 先调用decrypt($request) 将请求中的cookies解密,
  2. 然后通过$next($request) 调用后续的中间件继续处理请求,(next到最后一个中间件后,就会调用控制器方法,返回Response )
  3. 最后调用encrypt($response) 对上一步返回的$response中的cookies进行加密,并返回该$response。

EncryptCookies的加密器是在构造方法中由Laravel容器自动依赖注入的Illuminate\Encryption\Encrypter 绑定实例(这个绑定是在Illuminate\Encryption\EncryptionServiceProvider 中注册的)。

    public function register()
    {
        $this->app->singleton('encrypter', function ($app) {
            $config = $app->make('config')->get('app');    // 获取/config/app.php文件下的所有配置
            if (Str::startsWith($key = $this->key($config), 'base64:')) {
                // 获取config('app.key')的值,并用base64解码
                $key = base64_decode(substr($key, 7));
            }
            // 返回一个Illuminate\Encryption\Encrypter实例,作为单例绑定。
            return new Encrypter($key, $config['cipher']);
        });
    }

 

所以对于编写控制器而言,所有的cookie都是明文的,我们不需要去考虑cookie的加密解密,这些都由EncryptCookies中间件帮我们处理好了。在接收请求时自动解密,在返回响应时候自动加密。

 

3.2 Session

首先要提一下Laravel中Session的基本实现框架:

  • session manager

Illuminate\Session\SessionManager 类型,它负责创建session driver。根据不同的driver存储类型,调用对应的createArrayDriver()createFileDriver()createDatabaseDriver() 等。默认的driver存储类型由配置文件中的config(‘session.driver’)指定。

一个manager可以创建多个driver(理论如此,实际上我们一般只用一种session driver)

  • session driver

Illuminate\Session\Store 或Illuminate\Session\EncryptedStore 类型,这由config(‘session.encrypt’)配置指定。

它负责用户对session的直接操作,即get() , save() 这些。而实际的底层读写操作由handler执行。

一个driver只能包含一个handler。

  • session driver handler

它是driver在底层存储方面的实现,driver的不同存储类型实际就是对应着不同的handler。比如FileSessionHandlerDatabaseSessionHandler 等等。它们都要包含在存储层对session的read操作、write操作、destroy操作(删除指定session)以及gc操作(清除过期session)。

SessionManager在创建driver的时候,实际上是先根据存储类型创建对应的handler,然后再将handler传递给driver的构造方法。Illuminate\Session\Store 类在构造实例时候需要至少传递两个实参: $name与$handler(EncryptedStore还要多一个$encrypter)。其中$name实参表示的是cookies中用来存储session_id的键名,由config(‘session.cookie’)配置指定。(默认名为’laravel_session’)。

—————————————————————————————–

现在再来看一下StartSession中间件的主要代码:

    public function __construct(SessionManager $manager)
    {
        // $manager实参由Laravel自动依赖注入。
        //   实际上容器首先会在alias数组中找到Illuminate\Session\SessionManager=>'session'。
        //     (这是在Illuminate\Foundation\Application->registerCoreContainerAliases()中注册的别名)
        //   然后再去找这个名为'session'的绑定。它是在Illuminate\Session\SessionServiceProvider中注册的。
        $this->manager = $manager;
    }

    public function handle($request, Closure $next)
    {   
        // 首先检查是否配置了config('session.driver')参数,没有的话则直接返回$next。(实际上如果没有session的话后续处理就会出错啦)
        if (! $this->sessionConfigured()) {
            return $next($request);
        }

        // 为当前的$request请求设置session(新建session或者是从存储中读取的session)
        $request->setLaravelSession(
            $session = $this->startSession($request)
        );

        // 调用垃圾收集机制(有概率的执行从存储中删除过期session的操作)
        $this->collectGarbage($session);

        // 调用后续中间件直到控制器返回$response
        $response = $next($request);

        // 在session['_previous.url']中保存当前请求的url
        $this->storeCurrentUrl($request, $session);

        // 将session->id写入cookies['session_id']中(同时会设置这个cookie的expire,httpOnly,secure等属性)
        $this->addCookieToResponse($response, $session);

        // 将session写入到存储中(比如文件或者数据库)
        // ⚠️注意⚠️ 
        //     平时的$session->put()操作只是把数据写到$session实例中,并未写入存储!!!
        //     只有在这才会调用$session->save()进行持久化保存!
        //     所以如果在控制器中直接中断执行(比如调用dd(), die()等函数),那么这里就不会被执行,session就无法保存。
        $this->saveSession($request);

        return $response;
    }

    // 返回一个Session\Store实例,这个session或者是全新的,或者是从存储中读取出来的session
    protected function startSession(Request $request)
    {
        return tap($this->getSession($request), function ($session) use ($request) {
            $session->setRequestOnHandler($request);
            $session->start();
        });
    }

    // 新建一个Session\Store实例,
    // 并且如果$request->cookies中存在有效的session_id的话,就设置该Store实例的id为cookies[session_id]。
    public function getSession(Request $request)
    {
        return tap($this->manager->driver(), function ($session) use ($request) {
            $session->setId($request->cookies->get($session->getName()));
        });
    }

其中,tap($obj, $closure) 辅助函数的意思是执行$closure($obj),然后返回$obj。

所以getSession($request) 方法的逻辑就是:

(1) 通过$this->manager->driver() 获取一个Illuminate\Session\Store 实例,即所谓的session driver。

由于该调用不带实参,所以获取的是默认driver(默认driver类型由config(‘session.driver’)指定)。

如果此时manager中还未生成该driver,则自动调用createDriver() 方法生成一个Store实例。

对于新生成的Store实例,其id是随机的,其name就是cookies中存储session_id的键名(由config(‘session.cookie’)指定)

 (2) 在闭包函数中:

首先通过$request->cookies->get($session->getName()) 从$request请求的cookies中获取session_id的值(别担心,EncryptCookies中间件已经自动解密过了)。

然后调用$session->setId()重新设置这个Store实例的id为cookies[seesion_id]。(如果cookies[session_id]不存在或异常,则保留原来的随机id)

(3) 最后返回这个$session实例。

然后startSession($request) 方法的逻辑就是:

(1) 通过$this->getSession($request) 新建一个$session实例。

(2) 通过闭包函数中的$session->start() 启动这个$session实例。

所谓的启动,就是调用handler从底层存储中读取id为$session->id的数据出来,放到$session->attributes数组中。(对于file handler来说,id就是对应的文件名)

如果在存储中找不到该id的数据,或者数据已经过期(对于file handler来说,就是session文件的修改时间已经超过config(‘session.lifetime’)未更新),那么就返回空数组给$session->attributes。

另外,如果此时$session->attributes[‘_token’]不存在的话,则新建一个。这个$session->attributes[‘_token’]就是要用来做csrf验证的csrf_token。

(3) 返回这个$session实例。

3.3 重点

  1. 所有的cookie都是由EncryptCookies 中间件自动加密解密的。收到$request时自动解密cookies,返回$response自动加密cookies。
  2. cookie与session的关联是:$request->cookie(‘session_id’) == $session->id
  3. 将$session->id写回$response->headers的cookie中,以及将$session->attributes保存到存储中。这两个操作都是在控制器方法返回$response,执行流程重新回到StartSession 中间件后才执行的!

4. /register

现在来看一个/register页面是怎么工作的。/register包括两个路由,一个是接收GET请求展示注册页面,一个是接收POST请求处理注册信息。

相应于GET请求的RegisterController->showRegistrationForm() 方法就是简单的返回auth.register视图,暂且不表。来看看关键的RegisterController->register() 方法是如何处理用户POST过来的注册信息的。

    public function register(Request $request)
    {
        $this->validator($request->all())->validate();

        event(new Registered($user = $this->create($request->all())));

        $this->guard()->login($user);

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

4.1 validation

首先来看$this->validator($request->all())->validate();$request->all()返回当前请求的所有输入信息。然后$this->validator() 根据用户输入构造一个验证器实例。

    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:8', 'confirmed'],
        ]);
    }

这里的Validator::make() 也是一个facade用法,实际上调用的是容器中名为”validator”的绑定实例的make() 方法。然后我们再来看一下这个“validator”是如何绑定的。

    protected function registerValidationFactory()
    {
        $this->app->singleton('validator', function ($app) {
            $validator = new Factory($app['translator'], $app);
            // 关联一个唯一性验证器
            if (isset($app['db'], $app['validation.presence'])) {
                $validator->setPresenceVerifier($app['validation.presence']);
            }
            return $validator;
        });

首先这个new Factory() 会自动加载到同命名空间的Illuminate\Validation\Factory类,然后创建一个单例绑定。

其中$app[‘translator’]是容器中绑定的Illuminate\Translation\Translator单例,负责输出信息的本地化(它会加载$app[‘config’][‘app.locale’] 配置)。

还通过’validation.presence’绑定名关联了一个DatabasePresenceVerifier单例,这是一个用来检查数据库中唯一性的验证器(比如验证用户的email在数据库中已存在),这里就不再赘述,参考唯一性验证(6的文档还不如version 5)。

现在我们通过这个Validator facade得到的其实是一个工厂类,要真正得到一个可用的验证器,就得看看它的make() 方法:

    public function make(array $data, array $rules, array $messages = [], array $customAttributes = [])
    {
        // resolve()方法返回一个验证器实例(默认是Illuminate\Validation\Validator类)
        $validator = $this->resolve(
            $data, $rules, $messages, $customAttributes
        );

        // 如果存在唯一性验证,则关联唯一性验证器
        if (! is_null($this->verifier)) {
            $validator->setPresenceVerifier($this->verifier);
        }
        // ... ...
        // ... ...
        return $validator;
    }

这样子,在RegisterController@register()方法第一行$this->validator($request->all()) 就得到了一个验证器实例。然后调用该验证器实例的validate() 方法,解析验证规则并对每个验证规则调用对应的验证方法ValidatesAttributes@validateXXX()

当验证失败时候会自动抛出一个“验证异常”throw new ValidationException($this);

4.2 处理验证异常

我现在比较关心“验证异常”是如何返回页面与错误信息的。

Laravel中的所有错误与异常都会被/app/Exceptions/Handler.php处理(官方文档)(另外,关于php错误与异常的区别请自行搜索)。

这是因为Laravel核心(Illuminate\Foundation\Http\Kernel)在启动时就会通过$bootstrappers中的HandleExceptions@bootstrap()注册全局的错误处理函数与异常处理函数:

set_error_handler([$this, 'handleError']);                 // 将error转换成ErrorException处理
set_exception_handler([$this, 'handleException']);         // 处理所有Exception
register_shutdown_function([$this, 'handleShutdown']);     // 将error转换成FatalErrorException处理

这样之后,发生的任何错误和异常最终都会被转到这个handleException() ,而在handleException() 中会调用app(ExceptionHandler::class) 这个绑定实例的render($request, Exception $e) 方法来处理异常并渲染成网页。这个绑定我们可以在\bootstrap\app.php文件中找到,它绑定是的App\Exception\Handler类的单例:

$app->singleton(
    Illuminate\Contracts\Debug\ExceptionHandler::class,
    App\Exceptions\Handler::class
);

那么对于上述抛出的“验证异常”,它的异常处理就会走到Illuminate\Foundation\Exceptions\Handler->render($request, ValidationException($validator)) ,最后再由Handler->invalid($request, $exception) 返回一个重定向响应,重定向回原来的/register页面,并且将错误信息写入到session供页面渲染。

4.3 csrf保护

现在我们再来看一下POST /register时候的csrf保护。

实际上csrf保护是由\App\Http\Middleware\VerifyCsrfToken 中间件提供的,它应该在/register路由进入控制器方法之前就被调用了。

VerifyCsrfToken属于’web’中间件组,它会处理/routes/web.php中定义的所有路由。不过对于GET/HEAD/OPTIONS这三种’read’类型的HTTP请求它不会进行csrf验证。

    public function handle($request, Closure $next)
    {
        if (
            $this->isReading($request) ||      // 判断请求是否属于GET/HEAD/OPTIONS这几‘read’类型,是则不验证csrf
            $this->runningUnitTests() ||       // 判断是否是在运行单元测试,是则不验证csrf
            $this->inExceptArray($request) ||  // 判断该路由是否在$except数组中,是则不验证csrf
            $this->tokensMatch($request)       // 验证$request中带的csrf_token是否与$session['_token']是否一致
        ) {
            // 如果$request不需要csrf验证,或者csrf验证通过。
            // 则调用$next进入后续中间件直到控制器返回$response
            return tap($next($request), function ($response) use ($request) {
                if ($this->shouldAddXsrfTokenCookie()) {
                    // 如果VerifyCsrfToken中间件有设置$addHttpCookie属性为true
                    // 将$session['_token']写入$response->header->cookies['XSRF-TOKEN']
                    $this->addCookieToResponse($request, $response);
                }
            });
        }
        // 如果csrf验证不通过,则抛出异常
        throw new TokenMismatchException('CSRF token mismatch.');
    }

在调用$this->tokensMatch($request) 方法验证$request中的csrf_token是否与$session[‘_token’]一致的时候,它首先需要通过getTokenFromRequest($request) 方法从HTTP请求中获取csrf_token。getTokenFromRequest($request) 会按顺序尝试从$request的三个地方获取csrf_token:

1、$request->input(‘_token’) 

这是通过表单POST提交时带的csrf_token。只要你在前端页面的表单中添加@csrf Blade指令即可:

<form method="POST" action="/register">
    @csrf
    ...
</form>

它最终会被渲染成:

<form method="POST" action="/register">
    <input type="hidden" name="_token" value="... ...">
    ...

2、$request->header(‘X-CSRF-TOKEN’) 

Larvel默认会将csrf_token放在HTML的<meta>标签中。如下所示:

    <!-- CSRF Token -->
    <meta name="csrf-token" content="{{ csrf_token() }}">

所以当你在前端页面手动ajax发起请求时,就可以将该meta值放到X-CSRF-TOKEN请求头中。(当然你要非得用ajax.post[‘_token’]来做也是可以)

3、$request->header(‘X-XSRF-TOKEN’) 

Laravel默认将csrf_token存放在cookies[‘XSRF-TOKEN’]中,某些前端库(比如Axios)在发送请求时候会自动将cookies[‘XSRF-TOKEN’]值写入X-XSRF-TOKEN请求头中。

需要注意的是,默认的cookies[‘XSRF-TOKEN’]是加密的,所以VerifyCsrfToken->getTokenFromRequest()方法中会强制对$request->header(‘X-XSRF-TOKEN’)进行解密,如果你手欠把cookies[‘XSRF-TOKEN’]放到加密排除数组中(EncryptCookies->except),那么对不起这会触发解密时异常DecryptException。

最后,如果csrf验证失败就会直接抛出异常。Laravel中所有异常与错误都会由App\Exceptions\Handler 捕获,并调用render($request, Exception $exception) 返回错误页面。

需要注意的是,抛出异常后,排队在VerifyCsrfToken之后的中间件以及控制器方法就不会被调用了,因为此时控制权已经交给了Handler。不过Handler最后还是要返回一个表示错误页面的$response,这个$response会被返回给VerifyCsrfToken之前的中间件,比如StartSession、EncryptCookies,最后才由$kernel调用$response->send()将错误页面发送给用户。

4.4 创建新用户

用户的POST /register请求经过一系列中间件(EncryptCookies、StartSession、VerifyCsrfToken等等)、再经过控制器方法RegisterController->register($request) 中的表单验证之后,就来到了新建用户的步骤: $this->create($request->all()) 。它会调用APP\User::create() 方法新建一个 User类实例,将它insert到数据库中并返回。

我们来看一下create()的实现,由于User类并没定义create()静态方法,那么会找到父类Illuminate\Database\Eloquent\Model ,而Model类也没有定义create()静态方法,只是定义了__callStatic()__call() 。

    public function __call($method, $parameters)
    {
        if (in_array($method, ['increment', 'decrement'])) {
            return $this->$method(...$parameters);
        }
        return $this->forwardCallTo($this->newQuery(), $method, $parameters);
    }

    public static function __callStatic($method, $parameters)
    {
        return (new static)->$method(...$parameters);
    }

这里涉及到一个php高级语法: 后期静态绑定。在__callStatic() 中的static在此时指向User类,new static 等同于new User() 。所以到这就会去尝试找User->create() 方法,然后就进到__call() 方法中去了,去调用$this->forwardCallTo($this->newQuery(), ‘create’, $parameters); 。根据forwardCallTo() 方法以及newQuery() 方法的定义,最后调用的实际是Illuminate\Database\Eloquent\Builder->create($parameter) 。另外,参数前的三个点是php的可变数量参数列表用法。

4.5 触发用户注册事件

创建完新用户后,RegisterController->register() 方法会调用event(new Registered($user); 触发一个“用户注册”事件。所有的“事件”=>“监听器”的关联都在App\Providers\EventServiceProvider 中定义,一个事件可以关联多个监听器(当然一个监听器也可以监听多个事件)。

    protected $listen = [
        Illuminate\Auth\Events\Registered::class => [
            Illuminate\Auth\Listeners\SendEmailVerificationNotification::class,
        ],
    ];

EventServiceProvider中定义了事件与监听器的关系,然后全局辅助函数event() 负责将事件分发给监听器并调用监听器的handle() 方法。

    function event(...$args)
    {
        // events是在Illuminate\Events\EventServiceProvider注册的Illuminate\Events\Dispatcher实例
        return app('events')->dispatch(...$args);
    }

“事件—监听器”与“任务—队列”的区别

注意:事件(event)是立即分发给监听器(listener)进行处理的,要把它们跟任务(job)—队列(queue)的关系区别清楚。以上面的发送注册邮件为例,将它改造成任务队列形式的话:

  1. 首先是新建一个任务类App\Jobs\SendEmail ,任务类其实有点像包含了 事件+监听器 的功能,因为它的构造方法跟事件类一样,接受要处理的信息。它的handle()方法跟监听器一样负责处理信息。
  2. 【控制器方法】在用户注册时,调用全局辅助函数dispatch(new SendEmail($user)) ,将一个新的任务实例压入任务队列(队列可以是数据库、redis等,这在/config/queue.php配置文件中定义),等待被异步处理。
    有一个有意思的地方,dispatch() 方法返回一个PendingDispatch 实例,该实例包含任务以及任务属性(放在哪个队列,延迟多久执行等)。但dispatch() 后并没有再调用该实例了,那任务是怎么被压入任务队列呢?这其实是在该实例的析构方法中自动执行的=。=#
  3. 【控制器方法】然后控制器继续执行后续操作直到返回HTTP响应给用户。
  4. 【命令行】执行php artisan queue:work 命令启动队列处理器(queue worker)。由这个queue worker从队列中取出任务并执行任务( 调用任务handle()方法)。

4.6 注册后自动登录

现在再来看一下用户注册成功后$this->guard()->login($user); 这句做了什么。(这句话等同于Auth::login($user) )

首先$this->guard()通过Auth::guard() 这个facade来调用Illuminate\Auth\AuthManager->guard()。该方法返回一个Illuminate\Auth\SessionGuard 实例(因为在/config/auth.php中定义的默认guard是’web’ guard,它的driver是session driver,它的user provider是EloquentUserProvider )。

    // 执行用户登录操作
    public function login(AuthenticatableContract $user, $remember = false)
    {
        // $user->getAuthIdentifier()返回模型的主键值(对于User模型来说就是id值)
        // $this->updateSession()会在session中写入一个键值对: sessions['login_{guard名}_{guard类型名的哈希值}'] = 用户id
        $this->updateSession($user->getAuthIdentifier());

        // 如果用户选择了“记住登录”
        if ($remember) {
            // 确认该$user有remember_token,如果没有则生成该值(Str::random(60)),并写回数据库
            $this->ensureRememberTokenIsSet($user);

            // 将该remember_token写入cookie(当然,默认是经过EncryptCookies加密的)
            // cookies['remember_{guard名}_{guard类型名的哈希值}'] = {用户id}|{remember_token值}|{用户密码(密文)}
            $this->queueRecallerCookie($user);
        }

        // 触发Login事件(默认情况下没有监听器处理该事件)
        $this->fireLoginEvent($user, $remember);
        
        // 设置当前guard->user属性,并且会触发Authenticated事件
        $this->setUser($user);
    }

这里再讲两个php的魔术方法__get()与__set()。  在Laravel中我们可以用Model->id, Model->name直接访问Model->attributes[‘id’]与Model->attributes[‘name’],就是因为Model类实现了这两个魔术方法。

4.7 注册成功后重定向

return $this->registered($request, $user) ?: redirect($this->redirectPath());  没啥好说的了,就是注册成功后的重定向。

可以通过修改$this->registered() 执行额外操作并返回自定义的重定向地址,或者修改$this->redirectTo 属性指定重定向地址。

5. /login

Laravel用户认证的核心是guard与user provider:
  • guard 定义如何对每个HTTP请求进行用户身份验证。(比如SessionGuard就会从sessions[‘login_xxx’]与cookies[‘remember_xxx’]中尝试读取用户信息,然后与user provider读取的用户信息进行对比验证)
  • user provider 定义guard如何从存储中读取真正的用户信息。
  • 每个guard必须指定一个user provider。
  • 可以定义多个guard,容器中所有guard都放在app(‘auth’)->guards[]数组中。(‘auth’绑定一个AuthManager单例)

与/register一样,用户登录同样包含两个路由:GET /login与POST /login。

5.1 guest中间件

这两个路由都经过guest中间件,我们可以在/app/Http/Kernel.php文件中找到该中间件的定义:‘guest’ => \App\Http\Middleware\RedirectIfAuthenticated::class,

    public function handle($request, Closure $next, $guard = null)
    {
        // 如果用户已登录,则返回重定向
        if (Auth::guard($guard)->check()) {
            return redirect(RouteServiceProvider::HOME);
        }
        // 否则,返回下一个中间件处理
        return $next($request);
    }

这个中间件还接收一个$guard实参表示用哪一个guard驱动来进行用户认证,同auth中间件一样。可以通过如下方式调用:

Route->middleware('guest');          // guest中间件使用默认的guard驱动
Route->middleware('guest:api');      // guest中间件使用guards['api']驱动
Controller->middleware('auth:api');  // auth中间件使用guards['api']驱动

我们来看一下guard的check()方法。Auth::guard(null) 返回一个Illuminate\Auth\SessionGuard实例。

    // 判断用户是否已登录
    public function check()
    {
        return ! is_null($this->user());
    }

    public function user()
    {
        // 如果已经设置loggedOut属性为false,则直接返回null
        if ($this->loggedOut) {
            return;
        }

        // 如果该guard->user属性已存在,就直接返回该user
        // 注意:guard实例是放在容器单例绑定的AuthManager类的guards[]数组中的,所以同一个请求中多次用到的同名guard,不会被重复创建
        if (! is_null($this->user)) {
            return $this->user;
        }

        // 尝试从sessions['login_{guard名}_{guard类型名的哈希值}']获取已登录用户的id。
        $id = $this->session->get($this->getName());

        // 如果该id存在,并且能够从数据库中获取该id的用户信息。
        // 那么将用户信息赋值给$this->user,然后触发一条Authenticated事件。
        if (! is_null($id) && $this->user = $this->provider->retrieveById($id)) {
            $this->fireAuthenticatedEvent($this->user);
        }

        // If the user is null, but we decrypt a "recaller" cookie we can attempt to
        // pull the user data on that cookie which serves as a remember cookie on
        // the application. Once we have a user we can return it to the caller.
        // 如果还没找到用户,尝试读取cookies['remember_{guard名}_{guard类型名的哈希值}'] 
        // 如果该cookie存在的话,它的值应该为{用户id}|{remember_token值}|{用户密码(密文)}。将其赋给$recaller
        if (is_null($this->user) && ! is_null($recaller = $this->recaller())) {
            // 根据该cookie中的id与token,去数据库中获取user并返回
            $this->user = $this->userFromRecaller($recaller);

            if ($this->user) {
                // 将user->id写入sessions['login_{guard名}_{guard类型名的哈希值}']
                $this->updateSession($this->user->getAuthIdentifier());
                // 触发一个Login事件
                $this->fireLoginEvent($this->user, true);
            }
        }

        return $this->user;
    }

Auth::check()其实要判断四种状态:

  • 在当前请求的中间件链中,用户信息已存在于guard中,表示已登录。
  • 在当前请求的session中,存在sessions[‘login_xxx’]并且能从数据库中取出该用户信息,则登录用户并返回。
  • 在当前请求的cookie中,存在cookies[‘remember_xxx’]并且能从数据库取出该用户信息,则登录用户并返回。
  • 上述三种情况都不存在,则表示用户未登录。

5.2 LoginController->login()方法

    public function login(Request $request)
    {
        // 调用验证器验证表单
        $this->validateLogin($request);

        // 判断是否多次尝试登录,如果超过阈值则返回“too many attempts”错误页面
        // LoginAttempts次数会以"username|ip"的键名存储在cache中
        if (method_exists($this, 'hasTooManyLoginAttempts') &&
            $this->hasTooManyLoginAttempts($request)) {
            $this->fireLockoutEvent($request);
            return $this->sendLockoutResponse($request);
        }
        // 尝试登录
        if ($this->attemptLogin($request)) {
            return $this->sendLoginResponse($request);
        }

        // 如果登录验证失败,则LoginAttempts值加一
        $this->incrementLoginAttempts($request);

        // 抛出一个ValidationException来返回错误信息
        return $this->sendFailedLoginResponse($request);
    }
    
    // 进行登录尝试,返回true or false
    protected function attemptLogin(Request $request)
    {
        return $this->guard()->attempt(
            $this->credentials($request), $request->filled('remember')
        );
    }

    protected function sendLoginResponse(Request $request)
    {
        // 重新生成session(其实就是生成新的session->id 与 新的sessions['_token'])
        $request->session()->regenerate();
        // 重置cache中的LoginAttempts
        $this->clearLoginAttempts($request);
        // 返回登录成功后的重定向
        return $this->authenticated($request, $this->guard()->user())
                ?: redirect()->intended($this->redirectPath());
    }
    public function attempt(array $credentials = [], $remember = false)
    {
        // 触发一个“尝试登录”事件
        $this->fireAttemptEvent($credentials, $remember);

        // 根据$credentials数组中除开'password'以外的信息,从数据库获取相应的用户信息
        $this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials);

        // 验证用户密码是否于$creadentials['password']一致
        if ($this->hasValidCredentials($user, $credentials)) {
            // 执行SessionGuard->login()方法(详细请查阅4.6节)
            // 大概来说就是写入sessions['login_xxx'],写入cookies['remember_xxx'],写入$guard->user, 触发"登录成功"事件
            $this->login($user, $remember);
            return true;
        }

        // 验证$credentials失败则触发"登录失败"事件
        $this->fireFailedEvent($user, $credentials);
        return false;
    }

5.3 auth中间件

用户登录成功后会自动重定向到/home页面,这是一个由’auth’中间件保护的路由,确保用户有权限访问该路由。

    // 第三个实参默认为null,如果需要的话,可以在关联中间件时用'auth:api', 'auth:web'指定guard。
    public function handle($request, Closure $next, ...$guards)
    {
        // 调用guards进行验证,验证成功进入下一步,验证失败则抛出AuthenticationException异常。
        $this->authenticate($request, $guards);

        return $next($request);
    }
    
    // 通过guard->check()方法(参考5.1小节)验证用户能否通过guard
    // 对于SeesionGuard而言就是判断用户是否已登录(包括从session中、从cookie中)
    // 如果不满足则抛出一个AuthenticationException异常
    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);
            }
        }
        $this->unauthenticated($request, $guards);
    }
    // 抛出AuthenticationException异常,该异常会被Handler捕获,并进行重定向
    protected function unauthenticated($request, array $guards)
    {
        throw new AuthenticationException(
            'Unauthenticated.', $guards, $this->redirectTo($request)
        );
    }

6. 用户登出

6.1 POST /logout路由

    public function logout(Request $request)
    {
        // 从guard中登出
        $this->guard()->logout();
        // 删除原session,并返回一个新的session
        $request->session()->invalidate();
        // 重新生成sessions['_token'] (csrf_token)
        $request->session()->regenerateToken();
        // 进行登出后的重定向
        return $this->loggedOut($request) ?: redirect('/');
    }

来看一下这个$this->guard()->logout() 方法具体做了啥。这实际上引用的是SessionGuard实例

    public function logout()
    {   // 获取当前用户
        $user = $this->user();
        
        // 删除sessions['login_{guard名}_{guard类型名的哈希值}']
        // 删除cookies['remember_{guard名}_{guard类型名的哈希值}'] 
        $this->clearUserDataFromStorage();
       
        // 刷新remember_token
        if (! is_null($this->user) && ! empty($user->getRememberToken())) {
            $this->cycleRememberToken($user);
        }

        // 触发logout事件
        if (isset($this->events)) {
            $this->events->dispatch(new Logout($this->name, $user));
        }

        // Once we have fired the logout event we will clear the users out of memory
        $this->user = null;
        $this->loggedOut = true;
    }

 

6.2 登出当前设备SessionGuard->logoutCurrentDevice()

SessionGuard的logout()方法会刷新用户表的remember_token,这会导致登出所有设备,这点一直是被吐槽的。直到2019年8月的时候,才添加了一个logoutCurrentDevice() 方法,只登出当前设备,不刷新remember_token。(但是因为这个函数是后面才添加的,官方文档6.x,7.x里甚至都还没提及。失望(´・_・`))

    public function logoutCurrentDevice()
    {
        $user = $this->user();

        $this->clearUserDataFromStorage();

        if (isset($this->events)) {
            $this->events->dispatch(new CurrentDeviceLogout($this->name, $user));
        }

        $this->user = null;
        $this->loggedOut = true;
    }

 

6.3 登出其他设备Auth::logoutOtherDevices()

必须启用AuthenticateSession中间件,它会在session中维护一个’password_hash’键值。

Auth::logoutOtherDevices() 实际调用的是Illuminate\Auth\SessionGuard->logoutOtherDevices($password, $attribute = ‘password’)

    public function logoutOtherDevices($password, $attribute = 'password')
    {
        // 首先确认$guard->user有值(已通过验证的用户)
        if (! $this->user()) {
            return;
        }
        // 全局辅助函数tap($obj, $callback=null)的用法请参考官方说明
        $result = tap($this->user()->forceFill([
            // 重新生成加密后的密码。即使是同一个明文,两次Hash::make()得到的密文也是不同的!!!
            $attribute => Hash::make($password),
        ]))->save();  // 将用户信息写入数据库

        // 重新写入cookies['remember_xxx'] = {用户id}|{remember_token值}|{用户密码(密文)}
        if ($this->recaller() ||
            $this->getCookieJar()->hasQueued($this->getRecallerName())) {
            $this->queueRecallerCookie($this->user());
        }

        $this->fireOtherDeviceLogoutEvent($this->user());
        return $result;
    }

但是要想让logoutOtherDevices生效,还必须在/app/Http/Kernel.php文件取消对web中间件组中的\Illuminate\Session\Middleware\AuthenticateSession::class 中间件的注释,启动该中间件。

    // 这个中间件会尝试读取cookie和session中保存的hashedPassword与用户密码密文进行对比,不一致的则登出用户。
    // 所以如果用户在logoutOtherDevices中刷新了密码密文之后,其他登录设备再次访问时就会被该中间件强制登出。
    public function handle($request, Closure $next)
    {
        if (! $request->hasSession() || ! $request->user()) {
            return $next($request);
        }

        // 判断用户是否从cookies['remember_xxx']中登录的
        // 实际调用的是SessionGuard->viaRemember()方法
        if ($this->auth->viaRemember()) {
            // 从cookies['remember_xxx']中获取用户密码的密文
            $passwordHash = explode('|', $request->cookies->get($this->auth->getRecallerName()))[2];
            // 如果密码不一致,则登出用户
            if ($passwordHash != $request->user()->getAuthPassword()) {
                $this->logout($request);
            }
        }

        // 如果不存在sessions['password_hash'],则将用户的密码密文存在该session键中。
        if (! $request->session()->has('password_hash')) {
            $this->storePasswordHashInSession($request);
        }
        // 如果sessions['password_hash']与用户的密码密文不一致,则登出用户
        if ($request->session()->get('password_hash') !== $request->user()->getAuthPassword()) {
            $this->logout($request);
        }

        return tap($next($request), function () use ($request) {
            // 这一步的意思大概是怕后续中间件或控制器方法又修改了用户密码密文(比如调用了logoutOtherDevices)
            // 所以在响应返回给用户前再次刷新sessions['password_hash']
            $this->storePasswordHashInSession($request);
        });
    }

7. 记住登录

我们来总结一下记住登录是如何实现的:

1、用户在浏览器A勾选“记住登录”登录时

将登录用户的id写入sessions[‘login_{guard名}_{guard类型名的哈希值}’] 后。检查用户模型的remember_token属性是否有值,没有的话就自动生成该值(60位随机数),有的话就用原来的。然后将该值写入cookies[‘remember_{guard名}_{guard类型名的哈希值}’] ,这就是“记住登录”的cookie,它的值应该等于:{用户id}|{remember_token值}|{用户密码密文}

2、用户在浏览器B构造“记住登录”登录时候

同上面一样,不过这时候就直接用数据库中已生成的remember_token值就好了。并写回cookies。(所以同一个用户,不管他在哪里“Remember Me”登录,他的remember_token都是一样的。)

3、用户在浏览器A再次访问网站时

只要用户访问的路由有经过’auth’或’guest’中间件,它们都会自动调用Illuminate\Auth\Middleware\Authenticate->authenticate()方法尝试从session或者cookie中自动登录用户(底层调用SessionGuard->user() ):

如果session还没过期,就尝试从sessions[‘login_xxx’]获取用户id。如果数据库中存在该id的用户,就自动登录该用户。

如果用户已经很久没访问网站,session已过期。那么尝试读取cookies[‘remember_xxx’],并用其中的id与remember_token去与数据库中该用户对比,如果一致则自动登录该用户。

如果开启了’web’组的AuthenticateSession 中间件,那么还会尝试读取sessions[‘password_hash’]或cookies[‘remember_xxx’]中的password_hash,将之与用户的密码密文比较,如果不一致则强制登出用户。这样只要用户一更新密码密文,其他设备的已登录状态就会失效。

4、用户在浏览器A退出登录

guard->logout()负责删除sessions[‘login_xxx’],删除cookies[‘remember_xxx’],并刷新数据库中的remember_token。

这就导致一个缺陷。只要浏览器B的session失效后,由于它的cookies[‘remember_xxx’]还是旧的,那么它就无法再依靠cookie自动登录了,也就是说它的“记住登录”失效了!

SessionGuard->logoutCurrentDevice() 提供了只登出当前设备,不刷新remember_token的功能)

5、用户在浏览器A修改密码

Laravel默认是没有开启‘web’组中AuthenticateSession中间件的。而如第3点中说讲,SessionGuard->user()自动登录用户时只需要用到session中的用户id或cookies[‘remember_xxx’]中的id与remember_token,不涉及到用户密码。这样就无法在用户修改密码后自动踢出其他设备的登录状态。

所以要想让用户在浏览器A修改密码后自动登出其他设备,就必须启用AuthenticateSession中间件。

 

 

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top