システム構成における「フロントエンド/バックエンド」という区分けは、今や当たり前のものとなりました。
かつてはWebページのデザイン、そのコンテンツとデータ表示、認証動作など、すべてがごっちゃになっていましたが、「フロントエンド/バックエンド」で区分けすることでシステム管理やメンテナンスの効率が上がったため、今後もこの区分けはますます推進されていくことが予想されます。
そのなかで、フロントエンド側のHTMLとバックエンド側のシステムとの連携で認証動作の中核を担うのが、今回紹介するJWT(JSON Web Token)です。
そこで本記事では、そもそもJWTとは何か、JWTの実装例について説明します。
目次
そもそもJWTとは?
HTMLファイルでは認証・認可ができないのがこれまでの常識でしたが、Ajax以降、バックエンドのAPIとの通信を行うことで動的なページと同様に認証動作が可能になりました。そして、それらの認証作業を定式化して一定の仕様(RFC 7519 – JSON Web Token)に落とし込んだものが、JWTです。
同様の機能を備えた仕様としては、「Fernet token」、「Branca Token」、「PASETO」などがあり、JWT同様にスタティックなページで認証作業が行えます。ですが、これらのなかではJWTが現時点でもっともメジャーな仕様と言われています。
これらの仕様はトークンベースの認証システムと言われていて、以下のような流れで作動しています。
- [F] ユーザーIDとパスワードをバックエンドに渡します。
- [B] ログインができれば認証し、フロントエンドにトークンを返します(*2)
- [F] トークンをLocal Storageなどに保存して、取得したトークンを認証サーバーに送信します
- [B] 正しいトークンだったら、ユーザー情報を返します
- [F] 以降トークンを使ってユーザー情報にアクセスできるようになります。
※(*2)の部分の認証方法は、Backendのシステムに依存するためJWTの仕様には含まれていません。
ベテランのエンジニアの方なら経験があると思いますが、かつては違うシステム同士の連携としてワンタイム・トークンやワンタイムパスワードなどを実装していました。今回ご紹介するJWTもざっくり言えば、かつてのワンタイムトークンに非常によく似た機構なのです。
また、JWTはセキュリティーに問題があり「使ってはいけいない」という話もありますが、実のところは「使い方を間違えるとセキュリティーホールがある」というものなので、十分に留意しながら使用すれば問題はありません。
このときに留意すべき点は以下の3つです。
- httpsを必ず使う
- 暗号化アルゴリズムをnone(署名なし)にしない
- トークンのライフサイクルをできるだけ適切にしてリフレッシュする
JWTの構成要素「Token」とは?
JWTは「JSON」、「Web」、「Token」の3要素で構成されており、ここでは「Token」について説明します。
Tokenは、2つのドットで区切られた「HEADER:ALGORITHM & TOKEN TYPE」、「PAYLOAD:DATA」、「VERIFY SIGNATURE」という3つのセクションで生成されており、[ヘッダ].[ペイロード].[署名]という構成になっています。
eyJhbGciOiJIUzI1NiIsICJ0eXAiOiJKV1QifQ==.eyJzdWIiOiIwMDEyMzQiLCJuYW1lIjoiVGFybyBMaWciLCJpYXQiOiIxNjcxNTk2MDY2In0=.bdlILb+uDIaIDRsZbxzQYjz+yB8UQGUu44fHyVhMbq4=
こういったやたらと長い文字列になっています。ヘッダとペイロードはBase64で暗号化されます。
HEADER:ALGORITHM & TOKEN TYPE
まずは「HEADER:ALGORITHM & TOKEN TYP」について説明します。
{
"alg": "HS256",
"typ": "JWT"
}
ヘッダはいつもだいたい決まっていて、アルゴリズムの指定とトークンタイプになっています。
% echo -n '{"alg":"HS256", "typ":"JWT"}' | base64
eyJhbGciOiJIUzI1NiIsICJ0eXAiOiJKV1QifQ==
こうなります。
PAYLOAD:DATA
続いて、「PAYLOAD:DATA」について説明します。
{
"sub": "001234",
"name": "Taro Lig",
"iat": 1671596066
}
ペイロードにはユーザーの認証情報が入ります。認証システムに依存しますが、通常はユーザーID、ユーザー名、登録されたEmail、トークンの発行時刻認証APIのエンドポイントのURLなどの、ユーザーをアイデンティファイできる情報や認証に関連する様々な情報を入れます。
echo -n '{"sub":"001234","name":"Taro Lig","iat":"1671596066"}' | base64
eyJzdWIiOiIwMDEyMzQiLCJuYW1lIjoiVGFybyBMaWciLCJpYXQiOiIxNjcxNTk2MDY2In0=
こうなります。
VERIFY SIGNATURE
最後に「VERIFY SIGNATURE」について説明します。
% echo -n 'eyJhbGciOiJIUzI1NiIsICJ0eXAiOiJKV1QifQ==.eyJzdWIiOiIwMDEyMzQiLCJuYW1lIjoiVGFybyBMaWciLCJpYXQiOiIxNjcxNTk2MDY2In0=' | openssl dgst -binary -sha256 -hmac 'fooobar' | base64
bdlILb+uDIaIDRsZbxzQYjz+yB8UQGUu44fHyVhMbq4=
署名はやや複雑です。[エンコードされたヘッダ]+[エンコードされたペイロード]を秘密鍵でHMAC-SHA256を使ってハッシュ化したものになります。[エンコードされたヘッダ]+[エンコードされたペイロード]は署名なしトークンと呼ばれることもあります。fooobarの部分は任意の秘密鍵(的なもの)を入れてください。
ここではBashでトークンを生成してみましたが、各プログラム言語でも同じ規則に従ってトークンを生成することができます。
また、これらはすでにライブラリー化されていたり、すでにフレームワークにデフォルトで入っていたりするため、自身のシステム内で確認してみてください。例えば、「Django(python)」だと「djangorestframework-jwt」、「Laravel(php)」だと「php-open-source-saver/jwt-auth」などのライブラリーがあります。
実際にシステムに投入する際にスクラッチで書くことはごく稀だと思いますが、仕組みだけでもきちんと知っておくとよいと思います。
JWTの実装例(Laravel)
では、実際にJWTを「Laravel」というフレームワークで実装してみます。
パッケージのインストール
Laravelでは上述の通りすでに便利なライブラリーが存在するため、それをインストールして実際の動作と感触を確かめてみましょう。ここでは、すでにLaravelの開発環境があって、ユーザー認証ができる状態になっていることを前提として、最新の「Laravel9」と「php-open-source-saver/jwt-auth」を使ってみます。
まずは、パッケージをインストールします。
$ composer require php-open-source-saver/jwt-auth
パッケージのconfigファイルを生成します。
$ php artisan vendor:publish --provider="PHPOpenSourceSaver\JWTAuth\Providers\LaravelServiceProvider"
秘密鍵を追加します(先程のfoobarの部分です)。
$ php artisan jwt:secret
これでパッケージのインストールは完了です。
APIの追加
JWTは独立した認証システムですが、バックエンドのAPIと通信することで成り立っているため、実際にAPIも作らなければ動作を実感できません。
Laravelはお手本通りに設定すると「/login」で認証作業を行います。それを前提にして設定します。
// app/Models/User.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use PHPOpenSourceSaver\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'email_verified_at' => 'datetime',
];
/**
* getJWTIdentifier()を追加
*/
public function getJWTIdentifier()
{
return $this->getKey();
}
/**
* getJWTCustomClaims()を追加
*/
public function getJWTCustomClaims()
{
return [];
}
}
Classに「implements JWTSubject」を加え、「PHPOpenSourceSaver\JWTAuth\Contracts\JWTSubject;」、「getJWTIdentifierメソッド」と「getJWTCustomClaimsメソッド」を追加します。
// app/config/auth.php
'guards' => [
/* web page */
'web' => [
'driver' => 'session',
'provider' => 'users',
],
/* api with token */
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],1 n
],
デフォルトのガードはWebにしておきたいので、defaultの項目はそのままにしておきます。APIのガードをJWTにします。
// routes/api.php
// 認証なし
Route::group(['prefix' => 'auth'], function(){
Route::post("login", [AuthController::class, 'login']);
});
// 認証あり
Route::group(['prefix' => 'auth', 'middleware' => 'auth:api'], function(){
Route::get('me', [AuthController::class, 'me']);
// Route::get("dashboard", [AuthController::class, 'dashboard']);
});
それから認証用のエンドポイントを作成するために「routes/api.php」を修正します。
APIへのアクセスとWebページへのアクセスを上手に分けたり、認証項目を追加したりと実際にはいろいろと修正が必要な部分がありますが、基本的なJWTの設定は以上です。
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request;
class AuthController extends Controller
{
/**
* Get a JWT via given credentials.
*
* @return \Illuminate\Http\JsonResponse
*/
public function login(Request $request)
{
$credentials = request(['email', 'password']);
if (! $token = Auth::guard('api')->attempt($credentials)) {
return response()->json(['error' => 'Unauthorized'], 401);
}
return $this->respondWithToken($token);
}
/**
* Get the authenticated User.
*
* @return \Illuminate\Http\JsonResponse
*/
public function me()
{
return response()->json(auth()->user());
}
/**
* Log the user out (Invalidate the token).
*
* @return \Illuminate\Http\JsonResponse
*/
public function logout()
{
Auth::guard('api')->logout();
return response()->json(['message' => 'Successfully logged out']);
}
/**
* Refresh a token.
*
* @return \Illuminate\Http\JsonResponse
*/
public function refresh()
{
try {
return $this->respondWithToken(Auth::guard('api')->refresh());
} catch (\Tymon\JWTAuth\Exceptions\JWTException $e) {
return response()->json(['error' => 'Unauthorized'], 401);
}
}
/**
* Get the token array structure.
*
* @param string $token
*
* @return \Illuminate\Http\JsonResponse
*/
protected function respondWithToken($token)
{
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => Auth::guard('api')->factory()->getTTL() * 60
]);
}
}
「Controllers/AuthController.php」を新規で作成します。
JWTの認証に必要なコードは以上です。設定が終わったらLaravelのキャッシュはすべてクリアし、オプティマイズしてみましょう。
新規登録でいくつかのユーザーを登録し(既存のユーザーでも構いません)、JWTで認証動作を確認してみます。PostmanやInsomniaなどのAPI開発用のソフトを使ってアクセスしてみましょう。上級者はcurlなどでアクセスしてみるとよいと思います。
postでアクセスして、ユーザーID(Email)とパスワードを送信します。認証がうまくいけばトークンが返されます。
次にトークンを使って認証情報を取得します。
トークンを使ってアクセスするとユーザー情報が取得できます。
さいごに
今回はJWTについて解説しました。
LIGでは、JWTに対応したエンジニアがお客様のビジネス成功に向けて支援させていただいておりますので、ぜひ一度お問い合わせください。