JWT 令牌

justlovelmn發表於2020-05-18

JWT 由頭部(header)、載荷(payload)與簽名(signature)組成,一個 JWT 類似下面這樣:

{
    "typ":"JWT",
    "alg":"HS256"
}
{
    "iss":"http://larabbs.test",
    "iat":1515733500,
    "exp":1515737100,
    "nbf":1515733500,
    "jti":"c3U4VevxG2ZA1qhT",
    "sub":1,
    "prv":"23bd5c8949f600adb39e701c400872db7a5976f7"
}
signature
  • 頭部宣告瞭加密演算法;
  • 載荷中有兩個比較重要的資料,exp 是過期時間,sub 是 JWT 的主體,這裡就是使用者的 id;
  • 最後的 signature 是由伺服器進行的簽名,保證了 token 不被篡改。

JWT 最後是通過 Base64 編碼的,也就是說,它可以被翻譯回原來的樣子來的。所以不要在 JWT 中存放一些敏感資訊。

安裝 jwt-auth

jwt-auth 是 Laravel 和 lumen 的 JWT 元件,首先來安裝一下,目前最新的版本為 1.0.0-rc.5

$ composer require tymon/jwt-auth:1.0.0-rc.5

安裝完成後,我們需要設定一下 JWT 的 secret,這個 secret 很重要,用於最後的簽名,更換這個 secret 會導致之前生成的所有 token 無效。

$ php artisan jwt:secret

可以看到在 .env 檔案中,增加了一行 JWT_SECRET

修改 config/auth.php,將 api guarddriver 改為 jwt

config/auth.php

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

    'api' => [
        'driver' => 'jwt',
        'provider' => 'users',
    ],
],
.
.
.

user 模型需要繼承 Tymon\JWTAuth\Contracts\JWTSubject 介面,並實現介面的兩個方法 getJWTIdentifier()getJWTCustomClaims()

app\Models\User.php

<?php

namespace App\Models;

use Auth;
use Spatie\Permission\Traits\HasRoles;
use Tymon\JWTAuth\Contracts\JWTSubject;
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait;
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;

class User extends Authenticatable implements MustVerifyEmailContract, JWTSubject

.
.
.
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    public function getJWTCustomClaims()
    {
        return [];
    }
}

getJWTIdentifier 返回了 User 的 id,getJWTCustomClaims 是我們需要額外在 JWT 載荷中增加的自定義內容,這裡返回空陣列。開啟 tinker,執行如下程式碼,嘗試生成一個 token。

$user = User::first();
Auth::guard('api')->login($user);

jwt-auth 有兩個重要的引數,可以在 .env 中進行設定

  • JWT_TTL 生成的 token 在多少分鐘後過期,預設 60 分鐘
  • JWT_REFRESH_TTL 生成的 token,在多少分鐘內,可以重新整理獲取一個新 token,預設 20160 分鐘,14 天。

這裡需要理解一下 JWT 的過期和重新整理機制,過期很好理解,超過了這個時間,token 就無效了。重新整理時間一般比過期時間長,只要在這個重新整理時間內,即使 token 過期了, 依然可以換取一個新的 token,以達到應用長期可用,不需要重新登入的目的。

使用者登入

接著完成使用者登入的程式碼,前面設計的路由為 api/authorizations

routes/api.php

.
.
.
    // 第三方登入
    Route::post('socials/{social_type}/authorizations', 'AuthorizationsController@socialStore')
        ->where('social_type', 'weixin')
        ->name('api.socials.authorizations.store');
    // 登入
    Route::post('authorizations', 'AuthorizationsController@store')
        ->name('api.authorizations.store');
});

建立登入的 request

$ php artisan make:request Api/AuthorizationRequest

修改程式碼如下

app/Http/Requests/Api/AuthorizationRequest.php

<?php

namespace App\Http\Requests\Api;

class AuthorizationRequest extends FormRequest
{
    public function rules()
    {
        return [
            'username' => 'required|string',
            'password' => 'required|alpha_dash|min:6',
        ];
    }
}

app/Http/Controllers/Api/AuthorizationsController.php

.
.
.
use App\Http\Requests\Api\AuthorizationRequest;
.
.
.
    public function store(AuthorizationRequest $request)
    {
        $username = $request->username;

        filter_var($username, FILTER_VALIDATE_EMAIL) ?
            $credentials['email'] = $username :
            $credentials['phone'] = $username;

        $credentials['password'] = $request->password;

        if (!$token = \Auth::guard('api')->attempt($credentials)) {
            throw new AuthenticationException('使用者名稱或密碼錯誤');
        }

        return response()->json([
            'access_token' => $token,
            'token_type' => 'Bearer',
            'expires_in' => \Auth::guard('api')->factory()->getTTL() * 60
        ])->setStatusCode(201);
    }
.
.
.

使用者可以使用郵箱或者手機號登入,最後返回 token 資訊及過期時間 expires_in,單位是秒,這裡返回的結構很像 OAuth 2.0,使用方法也與 OAuth 2.0 相似。

使用 PostMan 模擬請求,使用電話和郵箱均能正確獲取 token。

登入 API 獲取 JWT 令牌

密碼錯誤會返回 401。

登入 API 獲取 JWT 令牌

儲存路由。

file

第三方登入修改

回憶一下上一節,第三方登入後,其實也應該同登入註冊一樣的資訊,應當避免程式碼重複,我們可以簡單封裝一下。

app/Http/Controllers/Api/AuthorizationsController.php

.
.
.
    protected function respondWithToken($token)
    {
        return response()->json([
            'access_token' => $token,
            'token_type' => 'Bearer',
            'expires_in' => auth('api')->factory()->getTTL() * 60
        ]);
    }
.
.
.

增加了 respondWithToken 方法,這樣登入和第三方登入都能通過該方法返回。完整的 controller 程式碼如下

<?php

namespace App\Http\Controllers\Api;

use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Http\Request;
use Illuminate\Auth\AuthenticationException;
use App\Http\Requests\Api\AuthorizationRequest;
use App\Http\Requests\Api\SocialAuthorizationRequest;

class AuthorizationsController extends Controller
{
    public function store(AuthorizationRequest $request)
    {
        $username = $request->username;

        filter_var($username, FILTER_VALIDATE_EMAIL) ?
            $credentials['email'] = $username :
            $credentials['phone'] = $username;

        $credentials['password'] = $request->password;

        if (!$token = \Auth::guard('api')->attempt($credentials)) {
            throw new AuthenticationException('使用者名稱或密碼錯誤');
        }

        return $this->respondWithToken($token)->setStatusCode(201);
    }

    public function socialStore($type, SocialAuthorizationRequest $request)
    {
        $driver = \Socialite::driver($type);

        try {
            if ($code = $request->code) {
                $response = $driver->getAccessTokenResponse($code);
                $token = Arr::get($response, 'access_token');
            } else {
                $token = $request->access_token;

                if ($type == 'weixin') {
                    $driver->setOpenId($request->openid);
                }
            }

            $oauthUser = $driver->userFromToken($token);
        } catch (\Exception $e) {
            throw new AuthenticationException('引數錯誤,未獲取使用者資訊');
        }

        switch ($type) {
        case 'weixin':
            $unionid = $oauthUser->offsetExists('unionid') ? $oauthUser->offsetGet('unionid') : null;

            if ($unionid) {
                $user = User::where('weixin_unionid', $unionid)->first();
            } else {
                $user = User::where('weixin_openid', $oauthUser->getId())->first();
            }

            // 沒有使用者,預設建立一個使用者
            if (!$user) {
                $user = User::create([
                    'name' => $oauthUser->getNickname(),
                    'avatar' => $oauthUser->getAvatar(),
                    'weixin_openid' => $oauthUser->getId(),
                    'weixin_unionid' => $unionid,
                ]);
            }

            break;
        }

        $token= auth('api')->login($user);

        return $this->respondWithToken($token)->setStatusCode(201);
    }

    protected function respondWithToken($token)
    {
        return response()->json([
            'access_token' => $token,
            'token_type' => 'Bearer',
            'expires_in' => auth('api')->factory()->getTTL() * 60
        ]);
    }
}

第三方登入獲取 user 後,我們可以使用 login 方法為某一個使用者模型生成 token。

重新整理 / 刪除 token

任何一個永久有效的 token 都是相當危險的,通過任意方式洩露了 token 之後,使用者的相關資訊都有可能被利用。所以為了安全考慮,任何一種令牌的機制,都會有過期時間,過期時間一般也不會太長。那麼 token 過期以後,難道要使用者重新登入嗎?像 OAuth 2.0 有 refresh_token 可以用來重新整理一個過期的 access_token,jwt-auth 同樣也為我們提供了重新整理的機制,只要在可重新整理的時間範圍內,即使 token 過期了,依然可以呼叫介面,換取一個新的 token。這對於 APP 長期保持使用者登入狀態是十分重要的。

刪除和重新整理 token 的路由我設計為:

  • PUT /api/authorizations/current —— 替換當前的授權憑證;
  • DELETE /api/authorizations/current —— 刪除當前的授權憑證。

首先增加路由
routes/api.php

.
.
.
    // 重新整理token
    Route::put('authorizations/current', 'AuthorizationsController@update')
        ->name('authorizations.update');
    // 刪除token
    Route::delete('authorizations/current', 'AuthorizationsController@destroy')
        ->name('authorizations.destroy');
.
.
.

app/Http/Controllers/Api/AuthorizationsController.php

.
.
.
    public function update()
    {
        $token = auth('api')->refresh();
        return $this->respondWithToken($token);
    }

    public function destroy()
    {
        auth('api')->logout();
        return response(null, 204);
    }
.
.
.

兩個方法我們都需要提交當前的 token,正確的提交方式是在增加 Authorization Header。

Authorization: Bearer {token}

呼叫重新整理 token 介面,返回了重新整理後的 token 資訊。

登入 API 獲取 JWT 令牌

不過 PostMan 有更加方便的方式

登入 API 獲取 JWT 令牌

選擇 Authorization, 選擇其中的 Bearer Token,直接填寫 token 即可。同樣嘗試呼叫刪除 token 介面:

登入 API 獲取 JWT 令牌

刪除 token 的場景就是使用者退出 APP,將當前的 token 禁用掉。注意刪除使用的 HTTP 方法是 DELETE,返回的狀態碼是 204,因為對於刪除這類的事件,只需要告訴客戶端成功了,沒什麼需要返回的資訊。

記得在 PostMan 儲存重新整理和刪除的介面。

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

相關文章