Laravel Sailでsanctumを利用したAPIトークン認証を行う
Laravel Sailでlaravelプロジェクトを起動している前提で進めていきます。
環境
PC
機種 : MacBook Pro 2021(M1 Max)
OS : Monterey(12.2.1)
Laravel Sailのバージョン
PHP version : 8.1.9
Laravel version : 9.25.1
sanctumのインストール
$ ./vendor/bin/sail composer require laravel/sanctum
$ ./vendor/bin/sail php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
$ ./vendor/bin/sail php artisan migrate
実行して気づきましたが、Laravel Sailには最初からsanctumが入っているぽいです。
APIトークン発行エンドポイントの作成、実行
トークンを発行するためのエンドポイントを作成していきます。
$ ./vendor/bin/sail php artisan make:controller PostSanctumTokenController
+Route::post('/sanctum/token', \App\Http\Controllers\PostSanctumTokenController::class);
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class PostSanctumTokenController extends Controller
{
public const TOKEN_NAME = 'app_api_token';
public function __invoke(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
return response()->json([
'message' => 'no user'
]);
}
return response()->json([
'accessToken' => $user->createToken(self::TOKEN_NAME)->plainTextToken
]);
}
}
ユーザーを作成しておきます。
$ ./vendor/bin/sail php artisan tinker
>use App\Models\User;
>User::create([
'name' => 'lightkun',
'email' => 'lightkun@example.com',
'password' => Hash::make('password'),
]);
postmanを実行してみます。
トークンの1|os7NScDXtLJliVb6ShYLllebBuXjzVn6UoIgu0xP
が返ってきました。
認証後に利用可能なAPIの作成、実行
トークンは返ってきたので今度は認証できるかを試してみます。
$ ./vendor/bin/sail php artisan make:controller GetFirstController
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class GetFirstController extends Controller
{
public function __invoke(Request $request)
{
return response()->json([
'status' => "OK"
]);
}
}
+Route::get('/first', \App\Http\Controllers\GetFirstController::class)->middleware('auth:sanctum');
postmanを実行してみます。実行にはAuthorizationヘッダーに"Bearer" + 取得したトークン
を設定する必要があります。
成功しました。
Authorizationなしだとエラーになります。未ログインなのでapp/Http/Middleware/Authenticate.php
でroute('login')
にリダイレクトしようとしたが、そんなルーティングないよってエラーになりました(単純に作成していないだけです)。認証は失敗しているので、正しい挙動です。
深堀りしてみる
トークン発行
App\Http\Controllers\PostSanctumTokenController.php
の$user->createToken(self::TOKEN_NAME)
の部分について見てみます。引数のnameはぶっちゃけなんでも良さそうです。
$token = $this->tokens()->create(...);
でpersonal_access_tokensテーブルにレコードを新規登録していました
...
/**
* Create a new personal access token for the user.
*
* @param string $name
* @param array $abilities
* @param \DateTimeInterface|null $expiresAt
* @return \Laravel\Sanctum\NewAccessToken
*/
public function createToken(string $name, array $abilities = ['*'], DateTimeInterface $expiresAt = null)
{
$token = $this->tokens()->create([
'name' => $name,
'token' => hash('sha256', $plainTextToken = Str::random(40)),
'abilities' => $abilities,
'expires_at' => $expiresAt,
]);
return new NewAccessToken($token, $token->getKey().'|'.$plainTextToken);
}
...
postmanのレスポンスのトークンが1|XXX
となっていた|
の前の数字はなんなの?と思ったので、$token->getKey()
の部分も見てみます。personal_access_tokensテーブルのidじゃないかなと思っていたら、ズバリその通りのようでした。
...
/**
* Get the value of the model's primary key.
*
* @return mixed
*/
public function getKey()
{
return $this->getAttribute($this->getKeyName());
}
...
トークン認証
routes/api.phpの->middleware('auth:sanctum');
の部分を見ていきます。:
の後ろのsanctumは引数のようですね。
ミドルウェアのパラメータはルート定義時に、ミドルウェア名とパラメータを「:」で区切って指定します。複数のパラメーターはコンマで区切る必要があります。
まず実行されるauthミドルウェアは以下で登録されています。
...
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
...
app/Http/Middleware/Authenticate.php
をみてみましたが、redirectTo()しかないので、親クラスのvendor/laravel/framework/src/Illuminate/Auth/Middleware/Authenticate.php
を見に行きます。
ここがメインでゴニョゴニョやっている箇所ではないかと思います。
...
/**
* Determine if the user is logged in to any of the given guards.
*
* @param \Illuminate\Http\Request $request
* @param array $guards
* @return void
*
* @throws \Illuminate\Auth\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);
}
...
$this->auth->guard($guard)->check()
で認証処理をやっているはずです。xdebugでStep実行しながら見ましたが、挙動がいまいち理解できない部分もありました。
理解が100%ではないながらも、結局のところはvendor/laravel/sanctum/src/Guard.php
の__invoke()
が認証処理のメイン部分ぽいです。ここでトークンが有効化をチェックしています。
...
/**
* Retrieve the authenticated user for the incoming request.
*
* @param \Illuminate\Http\Request $request
* @return mixed
*/
public function __invoke(Request $request)
{
foreach (Arr::wrap(config('sanctum.guard', 'web')) as $guard) {
if ($user = $this->auth->guard($guard)->user()) {
return $this->supportsTokens($user)
? $user->withAccessToken(new TransientToken)
: $user;
}
}
if ($token = $this->getTokenFromRequest($request)) {
$model = Sanctum::$personalAccessTokenModel;
$accessToken = $model::findToken($token);
if (! $this->isValidAccessToken($accessToken) ||
! $this->supportsTokens($accessToken->tokenable)) {
return;
}
$tokenable = $accessToken->tokenable->withAccessToken(
$accessToken
);
event(new TokenAuthenticated($accessToken));
if (method_exists($accessToken->getConnection(), 'hasModifiedRecords') &&
method_exists($accessToken->getConnection(), 'setRecordModificationState')) {
tap($accessToken->getConnection()->hasModifiedRecords(), function ($hasModifiedRecords) use ($accessToken) {
$accessToken->forceFill(['last_used_at' => now()])->save();
$accessToken->getConnection()->setRecordModificationState($hasModifiedRecords);
});
} else {
$accessToken->forceFill(['last_used_at' => now()])->save();
}
return $tokenable;
}
}
...
上記
...
$accessToken = $model::findToken($token);
...
のfindToken()
をみてみると、リクエストヘッダーに設定したトークンの|
より前の部分を取り除いている処理がありました。
...
/**
* Find the token instance matching the given token.
*
* @param string $token
* @return static|null
*/
public static function findToken($token)
{
if (strpos($token, '|') === false) {
return static::where('token', hash('sha256', $token))->first();
}
[$id, $token] = explode('|', $token, 2);
if ($instance = static::find($id)) {
return hash_equals($instance->token, hash('sha256', $token)) ? $instance : null;
}
}
...
そのため、トークンは1|os7NScDXtLJliVb6ShYLllebBuXjzVn6UoIgu0xPZ
、または1|
を取り除いたos7NScDXtLJliVb6ShYLllebBuXjzVn6UoIgu0xP
のどちらを設定しても認証できるみたいです。実際にpostmanも1|
有り無しの両方とも成功しました。
Discussion