はじめに
認証方式の1つであるJWTについてのまとめと使用例
JWTとは
JSON Web Tokenの略
認証情報を含むJSONをbase64エンコードしたものに署名を付与したもの
利用例
- クライアント側から認証情報(例:ユーザー名、パスワード)をサーバーに送信
- サーバー側で認証情報を確認し、認証OKの場合JWTを発行し、クライアント側に返却
- クライアントは次回以降、JWTを付与したリクエストを送信
- サーバー側はJWTを検証する
なお、JWTの暗号化アルゴリズムは大きく分けて2種類ある。
共通鍵方式
HS256というアルゴリズムを使用する。
認証サーバとリソースサーバが同じ場合はこの方式が使われることが多い。公開鍵/秘密鍵方式
RS256というアルゴリズムを使用する。
認証サーバとリソースサーバが別々の場合にこの方式が使われる。
認証サーバに秘密鍵、リソースサーバに公開鍵が配置される。
JWTの構造
JWTの例は以下
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidGFyb3UiLCJpYXQiOjE1OTIyNDAxODF9.vgsytL2KiAp-LXFSSmVXObia0bStoZqOCdYoEXdRaz8
※JWT公式に貼り付けると内容がわかる
形式は[ヘッダー].[ペイロード].[署名]となる。
ヘッダー
{ "alg": "HS256", "typ": "JWT" }
署名の暗号化方式とトークンの種類を設定
ペイロード
{ "user": "tarou", "iat": 1592240181 }
実際のデータの中身
base64エンコードしているだけなので、パスワードとかの重要情報を含んではいけない。
上記の例以外にもトークンの有効期限や発行者などの情報を設定することもできる。
署名
ヘッダーとペイロードを鍵で暗号化した値
鍵はサーバー側で管理しておく。
署名を検証することによって、データの改ざんが行われていないかチェックすることができる。
実際に使ってみた
Node.js/ExpressでAPIを作ってみる。
作成するAPIは以下2つ
- JWT発行API
- 認証必須API
今回は共通鍵方式によるJWTで認証を実現する。
ソースの説明
全体像が分かったほうがいい方のために、ソース全部貼ります。
// ➀おまじない const express = require("express"); const jwt = require("jsonwebtoken"); const PORT = 3000; const app = express(); app.use(express.json()) app.use(express.urlencoded({ extended: true })); // ➁鍵 const SECRET_KEY = "abcdefg"; // ➂JWT発行API app.post('/login', (req, res) => { // 動作確認用に全ユーザーログインOK const payload = { user: req.body.user }; const option = { expiresIn: '1m' } const token = jwt.sign(payload, SECRET_KEY, option); res.json({ message: "create token", token: token }); }); // ➃認証用ミドルウェア const auth = (req, res, next) => { // リクエストヘッダーからトークンの取得 let token = ''; if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') { token = req.headers.authorization.split(' ')[1]; } else { return next('token none'); } // トークンの検証 jwt.verify(token, SECRET_KEY, function(err, decoded) { if (err) { // 認証NGの場合 next(err.message); } else { // 認証OKの場合 req.decoded = decoded; next(); } }); } // ➄認証必須API app.get('/user', auth, (req, res) => { res.send(200, `your name is ${req.decoded.user}!`); }); // ➅エラーハンドリング app.use((err, req, res, next)=>{ res.send(500, err) }) // ➆サーバ起動 app.listen(PORT, () => console.info('listen: ', PORT));
ソース内の項番に沿って、説明します。
➀おまじない
「おまじない」という表現はあまり好きではないが、とりあえずここはExpressでサーバーを立ち上げるための記述なので、飛ばします。➁鍵
暗号化に使用する鍵
本来であれば、環境変数や別ファイルで管理すべきだが、今回は動作確認が目的なのでべた書き➂JWT発行API
クライアント側はこのAPIを呼んで、JWTを発行してもらう。
ここでは、有効期限が1分のJWTを発行して、レスポンスに含める。➃認証用ミドルウェア
次にクライアント側からJWTが送られてきた際に、検証を行うミドルウェアを作成する。
今回はリクエストヘッダのauthorizationにBearerスキームで送られてくる想定。
ここでは、以下のケースで場合分けしている。
「トークンがない場合」:➅エラーハンドリングに飛ぶ
「トークン認証NGの場合」:➅エラーハンドリングに飛ぶ
「トークン認証OKの場合」:➄認証必須APIに飛ぶ➄認証必須API
クライアント側は➂JWT発行APIで発行されたJWTをリクエストヘッダのauthorizationにBearerスキームで設定してこのAPIを呼ぶ。
app.get()の第二引数で➃認証用ミドルウェアを指定しているので、まずトークンの検証が行われ、認証OKの場合のみステータス200のレスポンスが返される。➅エラーハンドリング
Expressの技術なので、詳しくは説明しないが、next(XXX)された場合、このエラーハンドリングが使われる。
next()の引数で渡された値がerrに設定され、それをそのままクライアント側に返却している。➆サーバ起動
これもExpressの技術なので、ここでは特に説明しません。
動作確認
APIサーバを起動します。
> node .\index.js listen: 3000
Postmanを使って動作確認。
トークンが返ってきました!
次にトークンの有効期限が切れないうちに認証必須APIを呼ぶ。
リクエストヘッダのauthorizationにBearerスキームでトークンを設定するのを忘れずに
自分の名前が返ってきました!
認証が成功した証。
ステータス500で無効なトークンとのメッセージが返ってきました!
次に有効期限切れの場合
もう1分経ったので、正しいトークンを送信しても…
期限切れのメッセージ!
完璧ですね。
ちなみに今回生成されたJWT。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidGFyb3UiLCJpYXQiOjE1OTIzMjUxMzksImV4cCI6MTU5MjMyNTE5OX0.Uoqk6Yz129DKQCcvpSKhAw3Ncjln6ILucWAz_1ZLFhg
これをJWT公式に貼り付けると以下のようになる。
トークンの保存場所とサーバへの送信方法
いろんな方法があるよう
また、後述する脆弱性との兼ね合いもあり、何がベストプラクティスなのかは正直全く分かっていない。
- サーバー側でcookieに保存して、そのままやりとりする
- クライアント側はcookieもしくはweb strageに保存して、必要な場合のみリクエストヘッダに付与する
- Authorizationに設定する場合は、Bearerスキームが一般的?
- リクエストボディに入れてもいい
脆弱性
JWTについて調べていると、脆弱性の指摘について、いろいろな記事を見かけた。
ただ、自分の知識が足らず全てを理解することはできなかったので、以下にメモ程度として残しておく。
- cookieに保存するとCSRFの恐れがある
- web strageに保存するとXSSの恐れがある
- cookie or web strageに保存して、使う場合だけリクエストヘッダに含める
- 有効期限を過ぎるまで無効化する方法がないため、有効期限は極力短くすること
- ということは、セッション管理などでは使えなさそう
- 上記の脆弱性も様々な手法で回避できる?
思ったこと
JWTを理解することはそこまで難しくないし、実際に試すことも簡単だったが、
JWTを使いこなすには、OAuthなどの認証方式や、XSS・CSRFなどの攻撃手法などを理解する必要があり、結構ハードルが高そう。