Contents

Laravel api authentication

之前一直有遇到一個問題是經由 routes/api.php 的路由怎麼樣也無法取得 Auth::user() 的資料

也無法做出正確的使用者驗證,此篇針對此問題紀錄解決方案與問題原因


了解 Middleware groups

middleware 中介層,為 HTTP request 進入 Controller 之前的中間過濾/處理器

middleware groups 也就是一組中介層,套用該 middleware groups 的路由需依序進行各中介層的檢查

Laravel 的 middleware groups 預設有兩組,webapi

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php // app/Http/Kernel.php

protected $middlewareGroups = [
    // web middleware group
    'web' => [
        // ...
        \Illuminate\Session\Middleware\StartSession::class,
        // ...
        \App\Http\Middleware\VerifyCsrfToken::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],

    // api middleware group
    'api' => [
        'throttle:api',  // 對照下面的 $routeMiddleware 可知為執行 ThrottleRequests 並以 api 為參數
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],
];

protected $routeMiddleware = [
    // ...
    'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
];

套用 web 的 route 會依序執行:StartSession, VerifyCsrfToken, SubstituteBindings 等 middleware

套用 api 的 route 會依序執行:ThrottleRequests(以 ‘api’ 為參數) 和 SubstituteBindings 兩個 middleware


並且由 RouteServiceProvider 可知:

routes/api.php 預設使用 api middleware group

routes/web.php 預設使用 web middleware group

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php // app/Providers/RouteServiceProvider.php

/**
 * Define your route model bindings, pattern filters, etc.
 *
 * @return void
 */
public function boot()
{
    $this->configureRateLimiting();

    $this->routes(function () {
        Route::prefix('api')
            ->middleware('api')
            ->namespace($this->namespace)
            ->group(base_path('routes/api.php'));

        Route::middleware('web')
            ->namespace($this->namespace)
            ->group(base_path('routes/web.php'));
    });
}

了解 Authenticate guards

Laravel 預設的 authenticate guards 也有兩組,用來決定以什麼樣的方式進行使用者驗證

取了同樣的名稱,一個是 web,一個是 api

為了必免與 middleware group 混淆,以下稱為 web guardapi guard

web guard 的 driver 是 session, api guard 的 driver 是 token

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php // config/auth.php

'guards' => [
    // web guard
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    // api guard
    'api' => [
        'driver' => 'token',
        'provider' => 'users',
        'hash' => false,
    ],
],

分析為何無法在 api routes 驗證使用者

routes/api.php 預設的內容如下

1
2
3
4
<?php
Route::middleware('auth:api')->get('/user', function (Request $request) {
  return $request->user();
});

嘗試 fetch 這個 api,或是改成 Route::middleware('auth:web'),卻都返回 {message: 'Unauthenticated'}

有兩層原因,一個是 session 問題,一個是 token 設定問題


如果要通過使用者認證,一定要通過任一個 authenticate guard

web guard 是透過 session 認證,api guard 是透過 token 驗證


session 問題 => 不能使用 web guard

無法使用web guard,意即不能採取Route::middleware('auth:web')的方式

原因是 routes/api.php 預設使用 api middleware group,其中的中介層中沒有經過 StartSession

所以就算選擇使用 web guard,也會因為沒有 session 可以取得而認證失敗

事實上 laravel 官方也不建議讓 api 路由再藉由 session 的方式做使用者認證,應該採取較嚴謹的 token 做法

所以在 routes/api.php 的預設程式碼才會看到預設傳給 auth middleware 的認證護衛是 api guard

token 設定問題 => 需額外設定

但如果使用 api guard,就必須做好 token 相對應的設定

而 token 驗證的方式太多元,laravel 並沒有預先配置好任一種方式

而很多開發者,可能都把 api routes 寫到 web.php,走 web middlewareweb guard,一樣以 session 做認證

所以官方就將這部份作為一個進階彈性的設定選項,沒有預設載入這部分的配置


簡言之,官方預設:

routes/web.phpweb middleware group,有經過 StartSession,可以使用 web guard 以 session 驗證

routes/api.phpapi middleware group,沒經過 StartSession,只能使用 api guard 以 token 驗證

但是 token 驗證的方式請自行搞定


配置 token authentication

核心邏輯

其實 token 驗證的核心邏輯很簡單,就是給 users 資料表加上一個 api_token 的欄位,

並且在 request 的時候,也要傳入該 token 的值

比方說,現在已登入的使用者,其紀錄在資料表中對應的 api_tokena9lkjw7eoruosdvlaskjerioudoj...

那發出的 api request 就要傳入一樣的 token

1
2
3
4
5
axios.get('api/...', {
  params: {
    api_token: 'a9lkjw7eoruosdvlaskjerioudoj...'
  }
})

至於為什麼是要加上 api_token 這個欄位,是參考自官方文件的說明 (https://laravel.com/docs/5.8/api-authentication#introduction)

步驟 0: 確認預設設定

確認 api guard 使用 token driver

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<?php // config/auth.php
'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'token',
        'provider' => 'users',
        'hash' => false,
    ],
],

步驟 1: 新增 api_token 欄位

php artisan make:migration add_api_token_to_users

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php

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

class AddApiTokenToUsers extends Migration
{
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('api_token', 60)
                ->unique()
                ->nullable()
                ->default(null)
                ->after('password');
        });
    }

    public function down()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('api_token');
        });
    }
}

php artisan migrate

步驟 2:於登入時產生 api token

註冊 login listener

php artisan make:listener SuccessLogin

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php // app/Listeners/SuccessLogin.php

namespace App\Listeners;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;

class SuccessLogin
{
    public function handle($event)
    {
        $user = Auth::user();
        $user->api_token = Str::random(60);
        $user->save();
    }
}

EventServiceProvider 加入 listener

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php // app/Providers/EventServiceProvider.php

namespace App\Providers;

// ...
use App\Listeners\SuccessLogin;
use Illuminate\Auth\Events\Login;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        // ...
        Login::class => [
            SuccessLogin::class,
        ]
    ];
}

步驟 3:使 axios 預設帶入 token

將 api token 與 csrf token 放到 meta tag 上

1
2
3
4
<meta name="csrf-token" content="{{ csrf_token() }}">
@auth
    <meta name="api-token" content="{{ Auth::user()->api_token }}">
@endauth

將 api token 與 csrf token 設定到 axios 的 header

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// bootstrap.js

window.axios = require('axios');

window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

const csrfMetaTag = document.querySelector('meta[name="csrf-token"]');
csrfMetaTag
  ? window.axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfMetaTag.getAttribute('content')
  : console.log('csrf token not found');

const apiMetaTag = document.querySelector('meta[name="api-token"]');
apiMetaTag
  ? window.axios.defaults.headers.common['Authorization'] = "Bearer " + apiMetaTag.getAttribute('content')
  : console.log('api token not found');

步驟 4:完成

確認 routes/api.php 預設內容

1
2
3
4
<?php // routes/api.php
Route::middleware('auth:api')->get('/user', function (Request $request) {
    return $request->user();
});

進行測試:

1
2
3
4
// app.js
axios.get('http://127.0.0.1:8000/api/user').then(res => {
  console.log(res);
});

重新登入應該就可以看到 log 出 axios 回傳的 user data 了解


結語

上述的步驟 2 和 3 的方式是參考自 這篇文章 的做法

雖然這樣的做法還是將 api token 放到 meta tag 上了,但每次登入都會進行 token 的代換

已經可以算是最簡單的方式

如果要再更嚴謹的話,應該是要建立一個 controller 更新 token

再將 token 傳回給前端框架使用,或是存到 window.localStorage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php

namespace App\Http\Controllers;

use Illuminate\Support\Str;
use Illuminate\Http\Request;

class ApiTokenController extends Controller
{
    /**
     * Update the authenticated user's API token.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function update(Request $request)
    {
        $token = Str::random(60);

        $request->user()->forceFill([
            'api_token' => hash('sha256', $token), // 這邊用 hash 的話,config/auth.php 的 hash 設定就要改成 true
        ])->save();

        return ['token' => $token];
    }
}

但就如前面所說,token驗證的方式實在有很多種方式,

只要了解其核心邏輯,開發者其實可以自行決定要怎麼做,

希望這篇文章有幫助到和我遇到一樣問題的人


參考資料