はじめに
JWT(JSON Web Token)をちゃんと理解して使っていますか?
自分はフワッとした認識で使っていましたが、ライブラリの実装を見て理解できた部分があったので、ここに残しておこうと思います。
-
firebase/php-jwtのソースコードを参考にしています - 今回載せているサンプルコードは理解のために作っているので、あくまで参考です
参考にしたもの
JWTの利点
JWTが認証トークンとして多く利用されている理由:
- 自己完結型: トークン自体に必要な情報(クレーム)を含めることができる
- 有効期限管理: expクレームなどにより有効期限を設定・検証できる
- 可読性: Base64エンコードされているため、内容を確認しやすい
- ステートレス: サーバー側でセッション状態を管理する必要がない
この通りエンコードされているだけで暗号化はされていないので、機密情報は含めないようにしましょう。
また、あくまでトークンなので認証以外の用途でも利用可能です。
JWTの構造
JWTは3つの部分から構成されています:
- ヘッダ (Header)
- ペイロード (Payload) - ユーザ情報
- 署名 (Signature)
ヘッダー.ペイロード.署名
サンプルコード
public function create(): string
{
// 作る時は秘密鍵を使う(本来は環境変数など安全な形で管理する)
$privateKey = $this->createPrivateKey();
// JWTのペイロードを作成
$payload = $this->createPayload();
// JWTのヘッダーを作成
$header = $this->createHeader();
// JWTの署名を作成
$signature = $this->buildSignature($header, $payload, $privateKey);
// JWTを組み立てる
return $this->buildJWT($header, $payload, $signature);
}
1. ヘッダ (Header)
トークンのメタデータを含む部分です:
-
typ: トークンの種類(通常は"JWT") -
alg: 署名アルゴリズム
{
"typ": "JWT",
"alg": "EdDSA"
}
サンプルコード
private function createHeader(): array
{
return [
'typ' => 'JWT',
'alg' => 'EdDSA', // 公開鍵暗号方式を使う
];
}
2. ペイロード (Payload) - ユーザ情報
どこまで含めるのが良いかの判断材料についてはよく分かっていないので、知りたいところです。
予約済みのクレーム(claim):
-
iss: 発行者(Issuer) -
aud: 対象者(Audience) -
iat: 発行時刻(Issued At) -
nbf: 有効開始時刻(Not Before) -
exp: 有効期限(Expiration)
サンプルコード
private function createPayload(): array
{
$now = new \DateTimeImmutable;
$nbf = $now->modify('+1 minute');
$exp = $now->modify('+1 month');
return [
'iss' => 'https://example.com', // 発行者(Issuer)
'aud' => 'myApp', // 対象者(Audience)
'iat' => $now->getTimestamp(), // 発行時刻(Issued At)
'nbf' => $nbf->getTimestamp(), // 有効開始時刻(Not Before)
'exp' => $exp->getTimestamp(), // 有効期限(Expiration)
'profile' => [ // アプリ固有のカスタムクレーム(ユーザー、アプリID、など)
'user_id' => 'user-123',
'app_id' => 'app-123',
'roles' => ["admin", "user"],
],
];
}
3. 署名 (Signature)
署名は以下の要素から生成されます:
- エンコードされたヘッダ
- エンコードされたペイロード
- 秘密鍵
- ヘッダで指定されたアルゴリズム
署名により、トークンが改ざんされていないことを検証できます。
サンプルコード
private function buildSignature(array $header, array $payload, string $privateKey): string
{
$segments = [];
$segments[] = $this->urlSafeB64Encode(\json_encode($header, \JSON_UNESCAPED_SLASHES));
$segments[] = $this->urlSafeB64Encode(\json_encode($payload, \JSON_UNESCAPED_SLASHES));
$signing_input = \implode('.', $segments);
return $this->sign_sodium_crypto($signing_input, $privateKey);
}
PHP実装例
firebase/php-jwtのFirebase\JWT::encode()を参考に作ってみました
サンプルコード
<?php
namespace App\Services;
class CreateJwtService
{
public function create(): string
{
// 作る時は秘密鍵を使う(本来は環境変数など安全な形で管理する)
$privateKey = $this->createPrivateKey();
// JWTのペイロードを作成
$payload = $this->createPayload();
// JWTのヘッダーを作成
$header = $this->createHeader();
// JWTの署名を作成
$signature = $this->buildSignature($header, $payload, $privateKey);
// JWTを組み立てる
return $this->buildJWT($header, $payload, $signature);
}
private function createPrivateKey(): string
{
// 鍵ペアを生成
$keypair = sodium_crypto_sign_keypair();
// 秘密鍵(署名用秘密鍵)を抽出
$privateKeyBin = sodium_crypto_sign_secretkey($keypair);
return base64_encode($privateKeyBin);
}
private function createPayload(): array
{
$now = new \DateTimeImmutable;
$nbf = $now->modify('+1 minute');
$exp = $now->modify('+1 month');
return [
'iss' => 'https://example.com', // 発行者(Issuer)
'aud' => 'myApp', // 対象者(Audience)
'iat' => $now->getTimestamp(), // 発行時刻(Issued At)
'nbf' => $nbf->getTimestamp(), // 有効開始時刻(Not Before)
'exp' => $exp->getTimestamp(), // 有効期限(Expiration)
'profile' => [ // アプリ固有のカスタムクレーム(ユーザー、アプリID、など)
'user_id' => 'user-123',
'app_id' => 'app-123',
'roles' => ["admin", "user"],
],
];
}
private function createHeader(): array
{
return [
'typ' => 'JWT',
'alg' => 'EdDSA', // 公開鍵暗号方式を使う
];
}
private function buildSignature(array $header, array $payload, string $privateKey): string
{
$segments = [];
$segments[] = $this->urlSafeB64Encode(\json_encode($header, \JSON_UNESCAPED_SLASHES));
$segments[] = $this->urlSafeB64Encode(\json_encode($payload, \JSON_UNESCAPED_SLASHES));
$signing_input = \implode('.', $segments);
return $this->sign_sodium_crypto($signing_input, $privateKey);
}
private function buildJWT(array $header, array $payload, string $signature): string
{
$segments = [];
$segments[] = $this->urlSafeB64Encode(\json_encode($header, \JSON_UNESCAPED_SLASHES));
$segments[] = $this->urlSafeB64Encode(\json_encode($payload, \JSON_UNESCAPED_SLASHES));
$segments[] = $this->urlSafeB64Encode($signature);
return \implode('.', $segments);
}
// 便利系メソッド群
private function urlSafeB64Encode(string $input): string
{
return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_'));
}
private function sign_sodium_crypto(string $msg, string $key): string
{
$lines = array_filter(explode("\n", $key));
$key = base64_decode((string) end($lines));
return sodium_crypto_sign_detached($msg, $key);
}
生成結果の確認
上記のコードを実行すると、以下のようなJWTが生成されます:
eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwiYXVkIjoibXlBcHAiLCJpYXQiOjE3NTQ2MjU1OTEsIm5iZiI6MTc1NDYyNTY1MSwiZXhwIjoxNzU3MzAzOTkxLCJwcm9maWxlIjp7InVzZXJfaWQiOiJ1c2VyLTEyMyIsImFwcF9pZCI6ImFwcC0xMjMiLCJyb2xlcyI6WyJhZG1pbiIsInVzZXIiXX19.R3em4GRxGqfZ5sF1K_yU--DtiZszE1goorMEpTZ9pYcytUS_bmhmd16zz2HQrTdBS_EeXBP1NRxZHjP17iFCDA
この文字列をjwt.ioでデコードすると、先ほど設定したヘッダーとペイロードの内容を確認できます。
まとめ
本格的な実装ではfirebase/php-jwtのような実績のあるライブラリの使用をお勧めします。自作実装は学習目的に留め、本番環境では十分にテストされたライブラリを活用しましょう。
GoQSystemでは一緒に働いてくれる仲間を募集中です!
ご興味がある方は以下リンクよりご確認ください。