1. 認証(Authentication)
異なるユーザーからのリクエストを区別するために、サーバーはクライアントのリクエストに基づいてユーザーの身元を確認する必要があります。これが認証です。
人間とコンピュータのインタラクションにおいて、認証とは特定の情報にアクセスするためにユーザーにログインを求めることを意味します。ユーザーの身元を確認するために、ユーザーはユーザーとサーバーだけが知っている情報(認証要素)、例えばユーザー名とパスワードを提供しなければなりません。
Web環境において、一般的な認証スキームは大きく2つのカテゴリに分けられます:
-
Sessionベースの認証
-
Tokenベースの認証
Sessionベースのスキームでは、ログイン成功後、サーバーはユーザーの身元情報をSessionに保存し、Session IDをCookie経由でクライアントに渡します。その後のデータリクエストにはCookieが添付され、サーバーはCookieに含まれるSession IDに基づいてユーザーを識別します。
一方、Tokenベースのスキームでは、サーバーはユーザーの身元情報に基づいてTokenを生成し、クライアントに発行します。クライアントはTokenを保管し、その後のデータリクエストに添付します。サーバーはリクエストを受け取ると、Tokenを検証・解析してユーザーの身元を特定します。プロセスは以下の通りです:
[caption id="attachment_1957" align="alignnone" width="810"]
token based login[/caption]
P.S. ユーザー名/パスワードは知識要素に属します。その他に所有要素と生体要素があります:
-
知識要素:ユーザーがログイン時に知っていなければならないもの。ユーザー名、パスワードなど。
-
所有要素:ユーザーがログイン時に持っていなければならないもの。ワンタイムパスワードトークン、IDカードなど。
-
生体要素:個人の生物学的特徴。指紋、虹彩、顔など。
P.S. Authentication(認証)と Authorization(認可)は異なります。前者は身元を確認し、後者は権限を確認します。
2. Token
認証におけるTokenは身分証明書のようなもので、サーバーによって発行・検証され、有効期間内であれば合法性を持ちます。つまり、「人」(ユーザー)ではなく「証」(Token)を見て判断します。
Session方式ではユーザーの身元情報(Sessionレコード形式)はサーバー側に保存されます。一方、Token方式では(Token形式で)クライアント側に保存され、サーバーはTokenの合法性を検証するだけです。この違いはシングルサインオン(SSO)のシナリオで最も顕著に現れます:
-
SessionベースのSSO:Sessionの同期とCookieの共有を検討します。例えば、ログイン成功後にレスポンスCookieのdomainを兄弟アプリのドメインを含むワイルドカード形式に設定し、すべてのアプリが認証サービスからSessionを同期するようにします。
-
TokenベースのSSO:Tokenの共有を検討します。例えば、兄弟アプリに遷移する際にURLパラメータでTokenを渡します。
Tokenは暗号化されたSessionレコードのようなもので、ユーザーIDなどの身元情報のほか、Tokenの発行時間、有効期限など、Tokenの合法性検証に使用されるメタ情報が含まれています。例えば:
{
// 身份信息
user_id: 9527,
// Token元信息
issued_at: '2012年3月5日12時0分',
expiration_time: '1日'
}
// 暗号化後
895u3485y3748%^HGdsbafjhb
このTokenを持つリクエストは、サーバーによってユーザー9527からのメッセージであると見なされます。1日後にTokenが期限切れになり無効になるまで、サーバーはそのTokenが表すユーザーの身元を認め続けます。
Tokenの形式は様々ですが、その中でも JSON Web Token は人気のあるToken仕様の一つです。
3. JSON Web Token
JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties.
端的に言えば、二者間で転送されるクレーム(宣言)を安全に表現するためのコンパクトでURLセーフな通信仕様(JWT)です。URLを通じて転送することが可能です。
P.S. クレームは任意のメッセージで構いません。例えばユーザー認証のシナリオでは「私はユーザーXXXです」、友達リクエストでは「ユーザーAがユーザーBを友達に追加しました」といった具合です。
Tokenの形式
JWTのTokenは、Header、Payload、Signatureの3つの部分で構成されます。例:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJvdGhlckZpZWxkIjoiZXRjLiJ9.BkrYadYHS66ZrONzFoA91gBJK0iE-S2ZYOWCpRhgFJQ
2つの . 記号で3つの部分が区切られています:
Header.Payload.Signature
意味としては、HeaderはTokenに関連する基本的なメタ情報(Tokenのタイプ、暗号化方式(アルゴリズム)など)を表します。具体的には以下の通りです( alg は必須、他は任意):
-
typ:Token type -
cty:Content type -
alg:メッセージ認証コードのアルゴリズム
PayloadはTokenが運ぶデータおよびその他のTokenメタ情報を表します。仕様で定義されている標準的なフィールドは以下の通りです:
-
iss:Issuer、発行者 -
sub:Subject、Token情報の主題(そのJWTが誰についての情報を運んでいるか) -
aud:Audience、受信者 -
exp:Expiration Time、有効期限 -
nbf:Not (valid) Before、有効開始時刻 -
iat:Issued at、発行時刻 -
jti:JWT ID、一意識別子
これらのフィールドはすべて任意です。Payloadが正当なJSONであれば問題ありません。
生成
Tokenの3つの部分は以下のようになります:
Base64エンコードされたHeader.Base64エンコードされたPayload.前の2つの部分を指定のアルゴリズムで暗号化した結果
例えば、以下のような場合:
// JOSE Header
const header = JSON.stringify({"typ":"JWT", "alg":"HS256"});
// JWT Claims Set
const claims = JSON.stringify({
"iss":"joe", "exp":1300819380, "http://example.com/is_root":true
});
JOSE HeaderとJWT Claims SetをそれぞれBase64エンコードして、JWT TokenのHeaderとPayloadの部分を得���す:
const tokenHeader = Buffer.from(header).toString('base64');
const tokenPayload = Buffer.from(header).toString('base64');
次に、HeaderとPayloadを . 記号で連結し、HMAC SHA-256アルゴリズム(Headerの alg フィールドで指定された暗号化アルゴリズム)で暗号化してSignature部分を得ます:
// https://www.npmjs.com/package/jwa
const jwa = require('jwa');
const hmac = jwa('HS256');
const toSign = `${tokenHeader}.${tokenPayload}`;
const tokenSignature = hmac.sign(toSign, 'mySecret');
最後に、Signatureも . 記号で末尾に連結します:
const token = `${toSign}.${tokenSignature}`;
結果:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJvdGhlckZpZWxkIjoiZXRjLiJ9.BkrYadYHS66ZrONzFoA91gBJK0iE-S2ZYOWCpRhgFJQ
P.S. JWT.IO でこのTokenを解析・検証できます。
P.S. Base64エンコードされたHeaderとPayloadは、末尾のイコール( padding trailing equals )を取り除く必要があることに注意してください。
転送
サーバーでTokenを生成した後、レスポンスボディに入れてクライアントに渡します。
クライアントはTokenを受け取ると、LocalStorageまたはSessionStorageに保存します。その後のデータリクエストでは、リクエストヘッダーの Authentication フィールドにTokenを入れてサーバーに送ります:
Authorization: Bearer <jwt_token>
サーバーはデータリクエストを受け取ると、 Authorization フィールドからTokenを取り出し、その合法性を検証し、Tokenの内容をさらに解析してユーザーの身元を特定します。
検証
Tokenの合法性を検証するには、いくつかのことを確認する必要があります:
-
Tokenが期限切れになっていないか
-
自分が発行したものか
Payload部分を解析(直接Base64デコード)して、 iat 、 nbf 、 exp の3つの時間関連フィールドを取り出し、以下の関係を満たしているかチェックします:
iat(発行時刻) <= nbf(有効開始時刻) < 現在時刻 < exp(有効期限)
次に、Tokenの最初の2つの部分( Header.Payload )を取り出し、署名(Signature)をもう一度計算して、計算結果が一致するか確認します。
解析
Tokenが正当であることを確認した後は、単にPayloadをBase64デコードするだけで、Tokenに含まれるデータを知ることができます。例:
const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJvdGhlckZpZWxkIjoiZXRjLiJ9.BkrYadYHS66ZrONzFoA91gBJK0iE-S2ZYOWCpRhgFJQ';
const [_, rawPayload,] = token.split('.');
const payload = JSON.parse(new Buffer(rawPayload, 'base64').toString());
// { iss: 'joe',
// exp: 1300819380,
// 'http://example.com/is_root': true,
// otherField: 'etc.' }
4. ログイン
Session方式では、Cookieの仕組みによってログインが非常に簡単です(クライアントはほぼ意識しません)。ユーザー名とパスワードをPostして200が返ってくれば、それ以降はログイン済みユーザーとなります。
一方、Token方式では、必ずしもTokenをCookieに書き込むわけではありません。例えばSSOのシナリオでは、URL経由でアプリに直接返されることもあります。そのため、ログイン後の身分証明書はクライアントにとって意識されるものであり、クライアントはTokenを受け取り管理する必要があります:
-
Tokenを保存する
-
データリクエスト時にTokenを添付する
-
遷移時にTokenを兄弟アプリと共有する
-
ユーザーがログアウトした後にTokenを削除する
同様に、データリクエスト時もCookie経由でTokenを送るとは限らず、リクエストヘッダーのAuthenticationフィールドを使用することもあります。
5. データ操作
データリクエストを送信する際、Tokenを Bearer <jwt_token> の形式でAuthorizationフィールドに記入します:
Authorization: Bearer <jwt_token>
P.S. *Bearer(持参人認証)*はToken認証とも呼ばれます。私たちがよく知るBasic(基本認証)やDigest(ダイジェスト認証)と同様に、HTTPベースの認証方式の一つです。
サーバーはリクエストを受け取ると、このフィールドからTokenを取り出して検証を行います。検証を通過した後、期待されるデータや操作結果をレスポンスとしてクライアントに返します。
6. ログアウト
Sessionベースの認証において、ログアウト操作とはSession内の対応するレコードを削除することです。Sessionモードでは、ユーザーのログイン状態はサーバー側のSessionレコードのみを基準としているため、サーバーにその関係を忘れさせれば、その後クライアントから送られてくる合図(媚眼)は認識されなくなります。
しかし、Token認証は異なります。Token自体が完全な状態情報を持っており、サーバーの役割はTokenを発行する認証局(CA)に近いです。発行されたTokenは自動的に期限切れになるまで合法であり、サーバーはTokenを検証するだけでは、合法なTokenとすでに無効化された(が形式は合法な)Tokenを区別できません。
では、Tokenを即座に無効化する方法はありますか?
実は、HTTPSが依存するSSL証明書にも同じ問題があり、そのため Certificate revocation list (証明書失効リスト、CRL)が存在します:
暗号学において、証明書失効リスト(CRL)とは、「予定されていた有効期限よりも前に発行元の認証局(CA)によって取り消され、もはや信頼すべきではないデジタル証明書のリスト」のことです。
CRLは証明書のブラックリストであり、即座に無効化する必要がある合法な証明書を記録するために使用されます。CAは証明書を検証する前にブラックリストをチェックすることで、すでに無効化された合法なTokenを識別します。
例えば:
// 1.ブラックリストの維持
const tokenBlackLists = [];
function invalidateToken(token) {
if (!isTokenInvalidated(token)) {
tokenBlackLists.push(token);
}
}
function removeInalidatedToken(token) {
if (isTokenInvalidated(token)) {
tokenBlackLists.splice(tokenBlackLists.indexOf(token), 1);
}
}
function isTokenInvalidated(token) {
return tokenBlackLists.includes(token);
}
// 2.ログアウト時にブラックリストに追加
router.get('/logout', ensureAuthenticated, (req, res, next) => {
// tokenを無効化する
invalidateToken(res.locals.token);
res.status(200).json({
status: 'success'
});
});
// 3.期限切れ時にブラックリストから削���
// 4.操作時にブラックリストに含まれていないか検証
decodeToken(token, (err, payload) => {
if (err) {
// 期限切れのTokenをブラックリストから削除
removeInalidatedToken(token);
return res.status(401).json({
status: 'Token has expired'
});
} else {
// 無効化されたTokenかチェック
if (isTokenInvalidated(token)) {
return res.status(401).json({
status: 'Token has been invalidated'
});
}
//...
}
});
P.S. 上記の例では、ブラックリストはメモリ内にのみ保持されているため、サーバー再起動時に失われます。より完全な実装としては、ブラックリストへの追加・削除(期限切れ時)をデータベースに保存し、検証時はメモリキャッシュを利用し、再起動時はデータベースから読み込んでロードするようにすべきです。
ブラックリスト以外にも、以下のような一般的な戦略があります:
-
クライアントのTokenを削除する:発行済みのTokenを破棄します。Tokenが消えればログイン状態も存在しなくなります。しかし、サーバー側では依然としてそのTokenが合法であると見なされるため、安全ではありません。
-
有効期限が非常に短いTokenを使い、頻繁に更新(ローテーション)する:有効期限が十分に短ければ、自動的な失効が即時の失効と同等になります。しかし、短すぎると状態を維持するというメリットが失われます。
-
Tokenにログアウト時間を付与する:ログアウト時間もパスワードのようにデータベースに保存・検証し、パスワード変更と同じようにTokenを即座に無効化します。ただし、フィールドを一つ多く保存・取得・検証する必要があるため、パフォーマンスに影響します。
必要であれば、これら4つの戦略を併用することも可能です。例えば、どの戦略を採用するにせよ、クライアントのTokenは当然削除すべきです。
P.S. JWTを即座に無効化する方法に関する詳細な議論は、以下を参照してください:
7. FAQ
-
JWTのPayloadは安全ですか?
安全ではありません。単にBase64エンコードされているだけで、平文での転送と同等です。そのため、機密データを含めないでください。
-
ユーザーが入力したパスワードをクライアント側で暗号化する必要はありますか?
暗号化の必要はありません。そのまま平文で送ります。クライアント側のパスワードの安全性はSSLによって保証されます。
-
サーバーがパスワードを受け取った後はどのように暗号化すべきですか?
一般的な方法は、ハッシュにソルトを加える(Adding Salt to Hashing)ことです。詳細は Adding Salt to Hashing: A Better Way to Store Passwords を参照してください。
コメントはまだありません