こんにちは。M2です。
Laravel Advent Calendar 2020の4日目を担当させていただきます。
概要
Laravelのtokenを使ったAPI認証でなるべくセキュアそうな認証方法を実装してみたので、備忘録がてらに共有します。
Laravelパスポートを使いたいところですが、使わない方向です。
環境
- Laravel 6.x
- フロント側は(Vue.js 3)だけでど何でも良い
方向性
- DBにapi_tokenを用意して、それをユーザ認証用のtokenとする
- DBに保存しているapi_tokenはHash化して保存する
- ブラウザ側ではapi_tokenを暗号化してCookieに保存する
- JavaScript側でapi_tokenにアクセスできないように、Laravel側でhttponlyに設定する
- ずっと同じapi_tokenも嫌なので、ログイン毎に更新する
api_tokenカラムの導入
$ php artisan make:migration add_api_token_to_users_table --table=users
database/migrations/${date}_add_api_token_to_users_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddApiTokenToUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('api_token', 80)->after('password')
->unique()
->nullable()
->default(null);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('api_token');
});
}
}
$ php artisan migrate
DBに保存しているapi_tokenはHash化して保存する
config/auth.php
<?php
return [
~~ 省略 ~~
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'token',
'provider' => 'users',
'hash' => true, // ここをtrueにする
],
],
~~ 省略 ~~
ブラウザ側ではapi_tokenを暗号化してCookieに保存する
app/Http/Controllers/Auth/RegisterController.php
/**
* Create a new user instance after a valid registration.
*
* @param array $data
* @return \App\User
*/
protected function create(array $data)
{
$token = $this->generateApiTokenAndSetCookie();
return User::create([
.
.
.
'api_token' => hash('sha256', $token),
]);
}
}
//$this->generateApiTokenAndSetCookie();の中身
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Cookie;
.
.
.
public function generateApiTokenAndSetCookie()
{
$token = Str::random(80);
$encryptToken = Crypt::encrypt($token);
Cookie::queue(Cookie::forget('api_token'));
Cookie::queue('api_token', $encryptToken, '10000000');
return $token;
}
api_tokenの暗号化は手動で設定しておく
app/Http/Middleware/EncryptCookies.php
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array
*/
protected $except = [
'api_token'
];
}
JavaScript側でapi_tokenにアクセスできないように、Laravel側でhttponlyに設定する
# .env
.
.
.
SESSION_SECURE_COOKIE=true
ずっと同じapi_tokenも嫌なので、ログイン毎に更新する
app/Http/Controllers/Auth/LoginController.php
.
.
.
public function login(Request $request)
{
.
.
.
if ($this->attemptLogin($request)) {
// Cookieのapi_tokenの更新して、DBのapi_tokenも更新する
$token = $this->generateApiTokenAndSetCookie();
User::where('email', $request->email)
->update(['api_token' => hash('sha256', $token)]);
return $this->sendLoginResponse($request);
}
.
.
.
}
いざAPI認証
HogeService.php
// Serviceクラスでも何でも好きなところで...
<?php
namespace App\Services;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Cookie;
.
.
.
public function getUser(string $url): ?string
{
$client = new \GuzzleHttp\Client();
if (Cookie::get('api_token') === null) {
return null;
}
$decryptToken = Crypt::decrypt(Cookie::get('api_token'), true);
$response = $client->request(
'GET',
$url,
['verify' => true]
);
$response = $response->getBody()->getContents();
$result = json_decode($response);
if (json_last_error() === JSON_ERROR_NONE) {
return json_encode($result);
} else {
return null;
}
}
}
Controllerから上記のAPIにアクセスする
HogeGetUserController.php
<?php
.
.
.
.
use App\Services\AuthApi;
/**
* @group UserInfo API
*
* APIs 会員登録系
*/
class HogeGetUserController extends Controller
{
protected $authApi;
public function __construct(AuthApi $authApi)
{
$this->authApi = $authApi;
}
.
.
.
public function userInfo(Request $request)
{
$json = $this->authApi->getUser(route(('api.requestUser')));
return $json;
}
public function requestUser(Request $request)
{
return $request->user();
}
ルーティングの設定
routes/api.php
.
.
.
Route::get('userInfo', 'HogeGetUserController@userInfo');
Route::group(['middleware' => 'auth:api'], function () {
Route::get('requestUser', 'HogeGetUserController@requestUser')->name('api.requestUser');
});
これで、ログイン後に/api/userInfoにアクセスすれば、Userの情報が取得できる。
Vue.jsから呼び出す
hoge.vue
<template>
.
.
.
</template>
<script lang="ts">
axios.get('/api/userInfo')
</script>
テストする
tests/Feature/UserTest.php
<?php
namespace Tests\Feature;
use App\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Tests\TestCase;
class UserTest extends TestCase
{
use DatabaseTransactions;
/**
*
* @return void
*/
public function test_getユーザー取得()
{
$token = Str::random(80);
$user = factory(User::class)->create([
'api_token' => hash('sha256', $token),
]);
$response = $this->actingAs($user)->json('GET', "/api/requestUser?api_token={$token}");
$json = json_decode($response->getContent());
$this->assertObjectHasAttribute('id', $json);
$this->assertObjectHasAttribute('name', $json);
$this->assertObjectHasAttribute('email', $json);
}
問題点とか
- JavaScriptからCookieにアクセスできなくても、正しいapi_tokenを叩けば、誰でも認証情報にアクセスできちゃう...
- ログイン毎にapi_tokenが更新されるとはいえ、誰か良い方法を教えてクレメンス
参考
公式:Laravel 6.x API認証
Laravel6.x + Vue.js2(TypeScript)のSPAでシンプルなAPI認証を実装する