基於 Laravel Passport API 的多使用者多欄位認證系統(二):多使用者登入

黃冬瓜發表於2018-01-04

2. 擴充套件多使用者登入

多使用者登入在Passport的 這個Issue 裡面有非常多的討論,其他網站例如stackoverflow的諸多問題最後還是回到了這個Issue。
有一位叫做 sfelix-martins 的熱心群眾已經開發了 擴充套件包passport-multiauth。實現原理為Issue裡面提及的,透過增加一張 oauth_access_token_providers 的擴充套件表。使用者第一次登入,透過 "provider"引數傳遞需要關聯的模型。以後每次透過Token傳參時,在middleware裡面做一層驗證,把Token對應到相應的模型。不傳 "provider"則預設為User模型。
有現成的輪子省了不少事,話不多說,直接開擼。

2–1. 引入multiauth擴充套件包

composer require smartins/passport-multiauth

2–2. 以老師和學生為例, 建立資料表。未使用常見的username欄位。

<?php

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

class CreateTeachersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('teachers', function (Blueprint $table) {
            $table->increments('id');
            $table->string('school_id');
            $table->string('teacher_name');
            $table->string('password', 60);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('teachers');
    }
}
<?php

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

class CreateStudentsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('students', function (Blueprint $table) {
            $table->increments('id');
            $table->string('school_id');
            $table->string('student_no');
            $table->string('name');
            $table->string('password', 60);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('students');
    }
}

並且在專案的app\Models資料夾,參考User, 建立對應的測試模型。

<?php

namespace App\Models;

use Laravel\Passport\HasApiTokens;
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;

class Teacher extends Authenticatable
{
    use HasApiTokens, Notifiable;

    protected $fillable = [
        'school_id', 'password',
    ];

    protected $hidden = [
        'password'
    ];

}
<?php

namespace App\Models;

use Laravel\Passport\HasApiTokens;
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;

class Student extends Authenticatable
{
    use HasApiTokens, Notifiable;

    protected $fillable = [
        'school_id', 'student_no', 'password',
    ];

    protected $hidden = [
        'password'
    ];

}

2–3. 資料表遷移

php artisan migrate

2–4. 老慣例,需要增加測試資料,建立Seeder檔案並且匯入

<?php

use Illuminate\Database\Seeder;
use App\Models\Student;
use App\Models\Teacher;

class StudentsAndTeacherSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        Student::query()->truncate();
        Student::create([
            'school_id'  => '10001',
            'student_no' => '17000003001',
            'name'       => 'Abbey',
            'password'   => bcrypt('st001')
        ]);
        Student::create([
            'school_id'  => '10002',
            'student_no' => '17000003002',
            'name'       => 'Nana',
            'password'   => bcrypt('st002')
        ]);

        Teacher::query()->truncate();
        Teacher::create([
            'school_id'    => '10001',
            'teacher_name' => 'Kathy',
            'password'     => bcrypt('tt111')
        ]);
        Teacher::create([
            'school_id'    => '10001',
            'teacher_name' => 'Jack',
            'password'     => bcrypt('tt222')
        ]);

    }
}
composer dump-autoload

php artisan db:seed --class=StudentsAndTeacherSeeder

2–5. 配置檔案 config/auth.php 中 providers 陣列增加對應的模型

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

'students' => [
            'driver' => 'eloquent',
            'model' => App\Models\Student::class,
        ],

'teachers' => [
            'driver' => 'eloquent',
            'model' => App\Models\Teacher::class,
        ],

],

2–6. 在檔案 app/Http/Kernelmiddlewares 的$middlewareGroups中,註冊自定義的PassportCustomProvider 和PassportCustomProviderAccessToken 。

class Kernel extends HttpKernel
{
    ...

    /**
     * The application's route middleware groups.
     *
     * @var array
     */
    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            // \Illuminate\Session\Middleware\AuthenticateSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],

        'api' => [
            'throttle:60,1',
            'bindings',
            \Barryvdh\Cors\HandleCors::class,
            'custom-provider',
        ],

        'custom-provider' => [
            \SMartins\PassportMultiauth\Http\Middleware\AddCustomProvider::class,
            \SMartins\PassportMultiauth\Http\Middleware\ConfigAccessTokenCustomProvider::class,
        ]
    ];

    ...
}

2–7. 在 AuthServiceProvider 增加 access token 對應的 passport routes。

use Route;
use Laravel\Passport\Passport;

class AuthServiceProvider extends ServiceProvider
{
    ...

    /**
     * Register any authentication / authorization services.
     *
     * @return void
     */
    public function boot()
    {
        $this->registerPolicies();

        Passport::routes();

        Route::group(['middleware' => 'api'], function () {
            Passport::routes(function ($router) {
                return $router->forAccessTokens();
            });
        });
    }
    ...
}

2–8. 先測試下Teacher的登入,帳號和密碼參照Seeder。

這是最常見的ID和密碼登入方式,使用者名稱或手機號或郵箱登入均類似。
按照文件,需要在引數裡面增加provider=teachers,對應上面2–4.

curl --request POST \
  --url http://multiauth.test/oauth/token \
  --header 'accept: application/json' \
  --header 'content-type: application/x-www-form-urlencoded' \
  --data 'school_id=10001&password=tt111&client_id=2&client_secret=secret&grant_type=password&scope=&provider=teachers'

不出意外,可以看見以下的結果

{"error":"invalid_request","message":"The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.","hint":"Check the `username` parameter"}

既然一定要username,那我把school_id換成username試試:

curl --request POST \
  --url http://multiauth.test/oauth/token \
  --header 'accept: application/json' \
  --header 'content-type: application/x-www-form-urlencoded' \
  --data 'username=10001&password=tt111&client_id=2&client_secret=secret&grant_type=password&scope=&provider=teachers'

然鵝,還是報錯登陸不上,因為資料結構裡面壓根就沒有username這個欄位啊。另外冒出來的email是什麼東西。

SQLSTATE[42S22]: Column not found: 1054 Unknown column ‘email’ in ‘where clause’ (SQL: select * from `teachers` where `email` = 10001 limit 1)

What the f….算了,還是先查下網上有沒有類似的問題吧。
這個Issue 裡面,有人提出了一個簡單的解決方案,使用官方文件中並未提及的 findForPassport函式。這個函式會把username對映為需要匹配的unique欄位。既然都到這一步了,那還是追一下原始碼吧,在vendor/laravel/passport/src/Bridge/UserRepository.php 的41行,出現了這個函式。

if (method_exists($model, 'findForPassport')) {
            $user = (new $model)->findForPassport($username);
        } else {
            $user = (new $model)->where('email', $username)->first();
        }

如果在model裡面存在findForPassport這個函式,則返回對應的model。
如果不存在,則去取email欄位。因為我的Teacher表也沒有email欄位,所以報錯 Unknown column ‘email’ 。

這段程式碼隸屬於getUserEntityByUserCredentials這個函式,那哪裡又呼叫了這個函式呢? 繼續閱讀原始碼+1.
在 vendor/league/oauth2-server/src/Grant/PasswordGrant.php,有一個validateUserd的函式呼叫了getUserEntityByUserCredentials。
並且username是寫死的!!!

所以如果前端不傳username,直接丟擲invalidRequest異常,對應前面的 ”hint”:”Check the username parameter”

protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client)
    {
        $username = $this->getRequestParameter('username', $request);
        if (is_null($username)) {
            throw OAuthServerException::invalidRequest('username');
        }

$password = $this->getRequestParameter('password', $request);
        if (is_null($password)) {
            throw OAuthServerException::invalidRequest('password');
        }

$user = $this->userRepository->getUserEntityByUserCredentials(
            $username,
            $password,
            $this->getIdentifier(),
            $client
        );

老老實實的按照Issues修改model,再次測試,前端必須傳遞username。

<?php

namespace App\Models;

use Laravel\Passport\HasApiTokens;
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;

class Teacher extends Authenticatable
{
    use HasApiTokens, Notifiable;

    protected $fillable = [
        'school_id', 'password',
    ];

    protected $hidden = [
        'password'
    ];

    public function findForPassport($username) {
        return $this->where('school_id', $username)->first();
    }

}
curl --request POST \
  --url http://multiauth.test/oauth/token \
  --header 'accept: application/json' \
  --header 'content-type: application/x-www-form-urlencoded' \
  --data 'username=10001&password=tt111&client_id=2&client_secret=secret&grant_type=password&scope=&provider=teachers'

現在可以成功登入,獲取Teacher 的 Token了

{“token_type”:”Bearer”,”expires_in”:31536000,”access_token”:”eyJ0e...",”refresh_token”:”def50...”}

透過Token獲取模型也很簡單,傳遞 ‘api’ guard 到 user()即可。
修改app\routes\api.php如下:

use Illuminate\Http\Request;

Route::get('/user', function (Request $request) {
    return $request->user('api');
});

curl 測試

curl -X GET -H "Accept: application/json" -H "Authorization: Bearer eyJ0eX..." http://multiauth.test/api/user

成功獲取到Teacher資訊

{"id":1,"school_id":"10001","teacher_name":"Kathy","created_at":"2018-01-04 21:26:44","updated_at":"2018-01-04 21:26:44"}

如果使用1–10獲取到的User的Token去訪問相同的 api/user,會返回User的資料。 至此多使用者登入基本成功。

基於 Laravel Passport API 的多使用者多欄位認證系統(三):多欄位登入

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章