TL;DR
LumenでJWTを使ってみた。
Lumenとは?
Laravelと同じ設計思想を持つマイクロフレームワーク。
Laravelをよりシンプルにしたものと考えればよい
今回Lumenを選んだのは、JWTを用いたAPIのためにフレームワークを使いたいが、Laravelを使うほど大袈裟じゃないと思ったので。
Lumenのインストール
Lumen 5.6をインストールしてみる。プロジェクト名は「lumen-api」で。
$ composer create-project --prefer-dist laravel/lumen lumen-api
Valetを使っていればすぐに確認できる。
デフォルト画面も非常にシンプル。
.envのAPP_KEYが空
これはよろしくない。
Laravelだとインストールしたときにデフォルトでよしなにしてくれていたはずだけど。
しかも
$ php artisan key:generate
There are no commands defined in the "key" namespace.
うーむ。。。
を参考にして、
$ php -r "require 'vendor/autoload.php'; echo str_random(32).PHP_EOL;"
の出力結果を.env
のAPP_KEYに記述する。
ファサードとEloquentを使えるようにする
これもLumenの導入記事でよく見かける。
// $app->withFacades();
// $app->withEloquent();
のコメントアウトを外す。
マイグレーション
簡単なAPIのためにとりあえずユーザテーブルを作る。
$ php artisan make:auth
Command "make:auth" is not defined.
Did you mean one of these?
make:migration
make:seeder
こちらもまじか。。。
さすがシンプルを極めたフレームワーク。手厚いサポートは期待するなということなのでしょう。
$ php artisan make:migration create_users_table
Created Migration: 2018_05_24_083938_create_users_table
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('email')->unique();
$table->string('password');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('users');
}
}
データベースを作って、.env
にDB情報を記述したらマイグレーションを実行。
$ php artisan migrate
Migration table created successfully.
Migrating: 2018_05_24_083938_create_users_table
Migrated: 2018_05_24_083938_create_users_table
ユーザデータを作る
make:auth
が使えればログイン画面や登録画面も同時に作られるのだが、いかんせんLumenは装備が少ない。
Tシャツ短パンで表参道を歩いているかのような気分だ(それでもお洒落な人はお洒落なのだけど)
ユーザの登録は今回の目的からずれてくるので、ここは一気にシーダーで作ってしまいます。
何故か既にUser.php
は用意されている。
database/factories/ModelFactory.php
を編集。
$factory->define(App\User::class, function (Faker\Generator $faker) {
return [
'name' => $faker->name,
'email' => $faker->email,
'password' => password_hash('pass', PASSWORD_DEFAULT),
];
});
続いて
database/seeds/DatabaseSeeder.php
をコピーした
database/seeds/UsersTableSeeder.php
を編集する。
<?php
use Illuminate\Database\Seeder;
class UsersTableSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
factory(App\User::class, 10)->create();
}
}
database/seeds/DatabaseSeeder.php
のコメントアウトを外す。
<?php
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
$this->call('UsersTableSeeder');
}
}
シーダー実行。
$ php artisan db:seed
Seeding: UsersTableSeeder
In Container.php line 767:
Class UsersTableSeeder does not exist
$ composer dump-autoload
Generating optimized autoload files
$ php artisan db:seed
Seeding: UsersTableSeeder
失敗したのでcomposer dump-autoload
を実行しました。
確認するとusersテーブルにそれっぽいデータが登録されているはず。
ここまでで準備は完了。ここからJWTに移る。
JWTとは?
https://jwt.io
https://qiita.com/shnmorimoto/items/a38690929d7d84bbdea6
JSON Web Token
認証に使うトークンのこと。
出来る限りステートレスにしたいということで、サーバサイドでのセッション管理をなくすために考え出されたものらしい。
認証の流れが以下のリンク先に画像としてある。
認証を含むAPIを作る場合、このトークンを利用する認証がいちばん理に適っていると思います。
じゃあLaravelではLaravel Passportを使えばという気にもなるが、そこまでがっつりだとLumenを選択している意味もなくなりそうなので、ここは素のJWTを使ってみることにした。
には、それぞれの言語で使用出来るライブラリと、そのライブラリがどれくらいセキュアかの一覧がある。
その中で私が選んだのは、
すべてのチェックをクリアしていて、Auth0がサポートしているというのも信頼出来る。
まずはインストール。
composer require lcobucci/jwt
使い方は以下から。
トークンを返す認証のエンドポイントを作る。
$router->post(
'auth/login',
[
'uses' => 'AuthController@authenticate'
]
);
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use App\User;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Hmac\Sha256;
class AuthController extends Controller
{
private function jwt(User $user)
{
$signer = new Sha256();
$token = (new Builder())->setIssuer('http://lumen-api.test')
->setAudience('http://lumen-api.test')
->setId(uniqid(), true)
->setIssuedAt(time())
->setNotBefore(time() + 60)
->setExpiration(time() + 3600)
->set('uid', $user->id)
->sign($signer, env('JWT_SECRET'))
->getToken();
return $token;
}
public function authenticate(Request $request)
{
$this->validate($request, [
'email' => 'required|email',
'password' => 'required'
]);
$user = User::where('email', $request->input('email'))->first();
if (!$user) {
return response()->json(['error' => 'ログインできませんでした。'], 400);
}
if (Hash::check($request->input('password'), $user->password)) {
return response()->json($this->jwt($user)->__toString(), 200);
}
return response()->json(['error' => 'ログインできませんでした。'], 400);
}
}
また、署名用に.env
にJWT_SECRETを追加する。
メールアドレスとパスワードでログインしてみる。
$ curl -X POST -d "email=dummy@dummy-sample.com" -d "password=pass" http://lumen-api.test/auth/login
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjViMDY4N2Q5NzY0ZWUifQ.eyJpc3MiOiJodHRwOlwvXC9sdW1lbi1hcGkudGVzdCIsImF1ZCI6Imh0dHA6XC9cL2x1bWVuLWFwaS50ZXN0IiwianRpIjoiNWIwNjg3ZDk3NjRlZSIsImlhdCI6MTUyNzE1NDY0OSwibmJmIjoxNTI3MTU0NzA5LCJleHAiOjE1MjcxNTgyNDksInVpZCI6M30.Mbg0Xk_RaAKMTg8f64zfFPvCU-X3oiQdqlE658MqpRQ"
トークンが返ってきました。
トークンはピリオドで分かれた3ブロックからなる。順にヘッダー、ペイロード、署名となっている。
APIの作成
次は実際にこれでAPIで認証できるか確認してみる。
はじめにJWTのミドルウェアを作る。
<?php
namespace App\Http\Middleware;
use Closure;
use Exception;
use Lcobucci\JWT\Parser;
use Lcobucci\JWT\ValidationData;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use App\User;
class JwtMiddleware
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
$token = $request->get('token');
if (!$token) {
return response()->json([
'error' => 'Token not provided.'
], 401);
}
$signer = new Sha256();
$data = new ValidationData();
$data->setIssuer('http://lumen-api.test');
$data->setAudience('http://lumen-api.test');
$data->setCurrentTime(time() + 60);
try {
$token = (new Parser())->parse((string) $token);
if (!$token->validate($data)) {
throw new Exception('バリデーションエラーです。');
}
if (!$token->verify($signer, env('JWT_SECRET'))) {
throw new Exception('署名のエラーです。');
}
$user = User::findOrFail($token->getClaim('uid'));
} catch (Exception $e) {
return response()->json(['error' => $e->getMessage()], 400);
}
$request->user = $user;
return $next($request);
}
}
続いて、
$app->routeMiddleware([
'jwt.auth' => App\Http\Middleware\JwtMiddleware::class,
]);
<?php
$router->get('/', function () use ($router) {
return $router->app->version();
});
$router->post(
'auth/login',
[
'uses' => 'AuthController@authenticate'
]
);
$router->group(
['prefix' => 'api', 'middleware' => 'jwt.auth'],
function () use ($router) {
$router->get('user', 'ApiController@user');
}
);
トークンに含まれているユーザIDからデータを取得して返すAPIを作る。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ApiController extends Controller
{
public function user(Request $request)
{
return response()->json($request->user, 200);
}
}
$ curl http://lumen-api.test/api/user?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjViMDY4YjljMjRmZDMifQ.eyJpc3MiOiJodHRwOlwvXC9sdW1lbi1hcGkudGVzdCIsImF1ZCI6Imh0dHA6XC9cL2x1bWVuLWFwaS50ZXN0IiwianRpIjoiNWIwNjhiOWMyNGZkMyIsImlhdCI6MTUyNzE1NTYxMiwibmJmIjoxNTI3MTU1NjcyLCJleHAiOjE1MjcxNTkyMTIsInVpZCI6M30.JxY1DWPnSGGMojcJm1lhuwHCAIL0L6I9yRbOZ9zGqrk
{"id":3,"name":"Cesar Schmidt","email”:”dummy@dummy-sample.com","created_at":"2018-05-24 08:49:24","updated_at":"2018-05-24 08:49:24"}
ミドルウェアによってトークン認証を行い、トークンに含まれているユーザIDでDBからデータを取得してきている。
不正なトークンだとエラーが返されるだろうか。
署名ブロックを書き換えた場合。
$ curl http://lumen-api.test/api/user?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjViMDY4YjljMjRmZDMifQ.eyJpc3MiOiJodHRwOlwvXC9sdW1lbi1hcGkudGVzdCIsImF1ZCI6Imh0dHA6XC9cL2x1bWVuLWFwaS50ZXN0IiwianRpIjoiNWIwNjhiOWMyNGZkMyIsImlhdCI6MTUyNzE1NTYxMiwibmJmIjoxNTI3MTU1NjcyLCJleHAiOjE1MjcxNTkyMTIsInVpZCI6M30.JxY1DWPnSGGMojcJm1lhuwHCAIL0L6I9yRbOZ9zGqr
{"error":"署名のエラーです。"}
トークンの有効期限を過ぎた場合。
$ curl http://lumen-api.test/api/user?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjViMDY5NDZiOGY2YTQifQ.eyJpc3MiOiJodHRwOlwvXC9sdW1lbi1hcGkudGVzdCIsImF1ZCI6Imh0dHA6XC9cL2x1bWVuLWFwaS50ZXN0IiwianRpIjoiNWIwNjk0NmI4ZjZhNCIsImlhdCI6MTUyNzE1Nzg2NywibmJmIjoxNTI3MTU3OTI3LCJleHAiOjE1MjcxNTc4NzcsInVpZCI6M30.FGV3ynPtUxcSXgzQYo9vy-Vcgyk6ZfrQS4ZWyNuZATo
{"error":"バリデーションエラーです。"}
エラーが返されることを確認できた。
実際に使う場合はもう少し工夫したほうがいいかもしれない。
参考
https://medium.com/tech-tajawal/jwt-authentication-for-lumen-5-6-2376fd38d454
ほぼ上記の記事の和訳みたいになってしまったぐらい助かった。感謝
https://lumen.laravel.com
https://stackoverflow.com/questions/30344141/lumen-micro-framework-php-artisan-keygenerate
https://jwt.io
https://qiita.com/shnmorimoto/items/a38690929d7d84bbdea6
https://github.com/lcobucci/jwt
https://github.com/lcobucci/jwt/blob/3.2/README.md
https://techblog.yahoo.co.jp/advent-calendar-2017/jwt/