Laravel Passport 中基於密碼進行認證

媽的,認證(Authentication)功能的水有夠深……

前置知識

Laravel 內建的用戶認證守衛(Authentication Guard)

Laravel 內建兩種用戶認證守衛 Driver: sessiontoken

  1. session :傳統 WEB 認證方式,於瀏覽器中置入 session cookie 以辨識使用者身份,預設用於 web 路由( routes/web.php )。
  2. token :API 認證方式,藉由 Bearer Token 或其它方式(置於 GET Query、Request Input 或 HTTP Basic Auth)獲得 Token 進行認證。

通常來說,在傳統的 Server Side Render 應用程式中,採用 session guard 最為常見,同時也是 php artisan make:auth 優先預設採用的方案。

然而,隨著 Single Page Application(SPA)與 Client Side Render 的應用程式盛行,漸漸開始有人考慮使用 token guard。

事實上,內建的 token guard 是相當陽春的:只能存放一組 token、無法設定 token 的時效性、無法設定 token 的可存取範圍……。
一般而言,TokenGuard.php 僅作為示範如何擴展 Guard 的官方範例。

註:可以在官方文件中的 Authentication # Adding Custom Guards 學習如何擴展 Guard。

對此,官方提供 Laravel/Passport 套件作為擴展 Auth Guard 的一項手段。

實作

略過 Laravel 的安裝及 Passport 的設定,這些都可以在官方文件中找到。

實作「註冊」功能

<?php
// app/Http/Controllers/Api/AuthController.php
namespace App\Http\Controllers\Auth;
use Hash;
use App\User;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use use Illuminate\Http\JsonResponse;
class AuthController extends Controller
{
public function register(Request $request): JsonResponse
{
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8'],
]);
        $user = User::create([
'name' => $request->get('name'),
'email' => $request->get('email'),
'password' => Hash::make($request->get('password')),
]);
        return response()->json($user);
}
}
// routes/api.php
Route::post('register', 'Api\AuthController@register')
->name('api.register')

實作「登入」功能

在 OAuth2 應用程式中,「傳統登入」的實際意義是:使用帳號(email)及密碼取得可以使用的 Access Token。

<?php
// app/Http/Controllers/Api/AuthController.php
// ...
class AuthController extends Controller
{
// ...
    public function login(Request $request)
{
// Use the default password_client.
// It is generated by `php artisan passport:install`
$client = Client::where('password_client', true)->first();
        // Password client is not found.
if (!$client) { abort(500); }
        // Make request `POST /oauth/tokens` for getting token.
$authRequest = Request::create(
route('passport.token'),
'POST',
[
'grant_type' => 'password',
'client_id' => $client->id,
'client_secret' => $client->secret,
'username' => $request->get('email'),
'password' => $request->get('password'),
]);
         return app()->handle($authRequest);
}
}
// routes/api.php
Route::post('register', 'Api\AuthController@register')
->name('api.register');
Route::post('login', 'Api\AuthController@login')
->name('api.login');

預設而言,客戶端是不曉得 Password Client 的 Client ID 與 Client Secret,它只知道用戶的帳號密碼(通常由用戶自行輸入)。

為了解決這個問題,我們先從資料庫取得 Passport 安裝後預設的 Password Client 的 Client ID 及 Client Secret,之後再建立一個「內部 Request」打向預設的 POST /oauth/tokens 路由以取得 Access Token。

我們稍候再來重新檢視並重構這個「內部 Request」。

實作「登出」功能

「登出」在 OAuth 的意義是:使目前使用的 Access Token 失效,首先我們要先確定來自客戶端的 Access Token 可以存取到使用者。

// routes/api.php
Route::middleware('auth:api')
->post('logout', 'Api\AuthController@logout')
->name('api.logout');

藉由加上 middleware('auth:api') 以確定這個用戶是已經登入(Request 中包含有效的 Access Token)。

// app/Http/Controllers/Api/AuthController.php
// ...
class AuthController extends Controller
{
// ...
    public function logout(Request $request): JsonResponse
{
// Get access token from requested user.
$accessToken = $request->user()->token();
// $accessToken = auth()->user()->token();
// $accessToken = Auth::user()->token();
        $accessToken->revoke();
        return response()->json(['status' => 'success']);
}
}

重構「登入」功能

在先前的「登入」功能中,我們建立了一個「內部 Request」並向應用程式本身發出請求。

這樣的方法有其優點也有其缺點,最明顯的是可以降低程式間的耦合與提高複用性,但同時也存在要重新 Bootup 一次框架的缺點(事實上這個做法不會整個重新 Bootup Laravel,但仍會產生一定程度的開銷)。

在此提供另一個方法讓 login method 內可以直接產生 Bearer Token,避免內部 Request 的開銷。

<?php
// app/Traits/PasswordToken.php
// Ref: https://github.com/laravel/passport/issues/71
namespace App\Traits;

use App\User;
use DateTime;
use GuzzleHttp\Psr7\Response;
use Illuminate\Events\Dispatcher;
use Laravel\Passport\Bridge\AccessToken;
use Laravel\Passport\Bridge\AccessTokenRepository;
use Laravel\Passport\Bridge\Client;
use Laravel\Passport\Bridge\RefreshTokenRepository;
use Laravel\Passport\Passport;
use Laravel\Passport\TokenRepository;
use League\OAuth2\Server\CryptKey;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException;
use League\OAuth2\Server\ResponseTypes\BearerTokenResponse;


trait PassportToken
{
private function generateUniqueIdentifier($length = 40)
{
try {
return bin2hex(random_bytes($length));
// @codeCoverageIgnoreStart
} catch (\TypeError $e) {
throw OAuthServerException::serverError('An unexpected error has occurred');
} catch (\Error $e) {
throw OAuthServerException::serverError('An unexpected error has occurred');
} catch (\Exception $e) {
// If you get this message, the CSPRNG failed hard.
throw OAuthServerException::serverError('Could not generate a random string');
}
// @codeCoverageIgnoreEnd
}

private function issueRefreshToken(AccessTokenEntityInterface $accessToken)
{
$maxGenerationAttempts = 10;
$refreshTokenRepository = app(RefreshTokenRepository::class);

$refreshToken = $refreshTokenRepository->getNewRefreshToken();
$refreshToken->setExpiryDateTime((new \DateTime())->add(Passport::refreshTokensExpireIn()));
$refreshToken->setAccessToken($accessToken);

while ($maxGenerationAttempts-- > 0) {
$refreshToken->setIdentifier($this->generateUniqueIdentifier());
try {
$refreshTokenRepository->persistNewRefreshToken($refreshToken);

return $refreshToken;
} catch (UniqueTokenIdentifierConstraintViolationException $e) {
if ($maxGenerationAttempts === 0) {
throw $e;
}
}
}
}

protected function createPassportTokenByUser(User $user, $clientId)
{
$accessToken = new AccessToken($user->id);
$accessToken->setIdentifier($this->generateUniqueIdentifier());
$accessToken->setClient(new Client($clientId, null, null));
$accessToken->setExpiryDateTime((new \DateTime())->add(Passport::tokensExpireIn()));

$accessTokenRepository = new AccessTokenRepository(new TokenRepository(), new Dispatcher());
$accessTokenRepository->persistNewAccessToken($accessToken);
$refreshToken = $this->issueRefreshToken($accessToken);

return [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
];
}

protected function sendBearerTokenResponse($accessToken, $refreshToken)
{
$response = new BearerTokenResponse();
$response->setAccessToken($accessToken);
$response->setRefreshToken($refreshToken);

$privateKey = new CryptKey('file://'.Passport::keyPath('oauth-private.key'));

$response->setPrivateKey($privateKey);
$response->setEncryptionKey(app('encrypter')->getKey());

return $response->generateHttpResponse(new Response);
}

/**
*
@param \App\Entities\User $user
*
@param $clientId
*
@param bool $output default = true
*
@return array | \League\OAuth2\Server\ResponseTypes\BearerTokenResponse
*/
protected function getBearerTokenByUser(User $user, $clientId, $output = true)
{
$passportToken = $this->createPassportTokenByUser($user, $clientId);
$bearerToken = $this->sendBearerTokenResponse($passportToken['access_token'], $passportToken['refresh_token']);

if (! $output) {
$bearerToken = json_decode($bearerToken->getBody()->__toString(), true);
}

return $bearerToken;
}
}

之後,在需要建立 Password Bearer Token 的地方 use PasswordToken; 即可調用 getBearerTokenByUser()

<?php
// app/Http/Controllers/Api/AuthController.php
class AuthController extends Controller
{
use PasswordToken;
    public function login(Request $request)
{
$client = Client::where('password_client', true)->first();
        $user = User::where('email', $request->email)->first();
        // Remember to check user password !!!
if (!Hash::check($request->password, $user->password) {
abort(401);
}
        return response()->json(
$this->getBearerTokenByUser($user, $client, false)
);
}

參考資料

  1. Which OAuth 2.0 grant should I implement?
  2. Building SPAs with Laravel 5 and Vue.js 2
  3. Obtain access token and refresh token without a http request # 71