###【はじめに】
最近携わっている案件で、ユーザがスマホのアプリを使って画面上に表示されているQRコードをスキャンしたら
サーバ上に存在するデータとスキャンしたユーザを紐づける、という処理を実装することがありました。
「セキュアなQRコードの処理がしたい!」というフワッとした難しい要望もあったのですが、
署名チェックによる改ざん検知や有効期限チェックが可能、という性質を持つJWT(JSON Web Token)を使えば
少しはセキュアなQRコードの処理ができるかも?と考え、JWTをQRコードに埋め込んで
表示/処理するサンプルを作ってみました。
###【前提】
-
JWT(JSON Web Token)の詳細については省略します。
-
サンプルはRailsのwebアプリとして実装しています。
-
あるQRコードを画面に表示し、ユーザはスマホのアプリからQRコードをスキャンする、というシーンを想定しています。
※今回のサンプルではモバイルアプリまでは用意していません。後続の【実際の利用例】ではcurlコマンドを使って
モバイルアプリからサーバ側へリクエストを送信すると仮定しています。 -
JWTの有効期限は5分としています。
-
表示したQRコードに含まれるJWTは1回しか処理できないものとします。
1回処理したJWTを再利用できないようにするため、DBのテーブルを使って履歴を取るようにします。 -
サンプルで利用するDBはPostgresとなっています。
DBの変更やユーザ名、パスワードなどは適宜database.ymlの設定変更で対応してください。 -
公開鍵/秘密鍵のファイルをあらかじめ用意しておきます。今回はPEM形式のファイルを事前に用意しておきます.
###【処理の流れ】
以下、サンプルの簡単な処理の流れとなります。
-
ブラウザ上でQRコードを表示します。
-
表示したQRコードをユーザがモバイルアプリでスキャンし、QRコードに埋め込まれているJWTを取得して
サーバ側へ送信します。また、JWTとともにユーザIDも送信します。今回はここの送信処理をcURLコマンドで代用します。 -
サーバ側でJWTの有効期限チェックと署名の検証を行います。
-
有効期限チェックや署名の検証でエラーが発生しなかったら、そのJWTは処理済みとして
履歴用のjwt_qrcodesテーブルのuser_idにスキャンしたユーザのユーザIDをセットします。 -
エラーが発生した場合はエラー用のレスポンス(JSONのデータ)を返します。
###【ソースの説明】
サンプルのcontrollerクラスは以下のようになっています。
(説明のために各行ごとにコメントを入れています。)
require 'jwt'
require 'rqrcode_png'
class JwtQrcodesController < ApplicationController
# updateメソッドではCSRFトークンチェックは省略します.
protect_from_forgery with: :exception, except: [:update]
# QRコード表示用のメソッドです.
def show
# dataはQRコードをスキャンしたユーザが処理したいデータとします.
data = "some data used by someone who scanned a QR Code."
# ペイロードが毎回ランダムに変わるようにするため以下のようなハッシュ値を作ります.
data_digest_with_random_hex = Digest::SHA256.hexdigest(data + SecureRandom.hex(16))
now = Time.now.to_i
# JWTのクレームをセットします.
jwt_data = {
iss: 'Jwt-Qrcode-Sample',
iat: now,
# 有効期限は300秒(5分)としています.
exp: now + 300,
data: data_digest_with_random_hex,
# ここでは仮に'test_user'というユーザがQRコードをスキャンできるものとします.
user_id: 'test_user'
}
# 秘密鍵のファイルを読み込みます.
private_key = OpenSSL::PKey::RSA.new(File.read('ssh/id_rsa_jwt_qrcode_sample'))
# JWTを作成します.
jwt = JWT.encode jwt_data, private_key, 'RS256'
# 履歴化のためにテーブルへレコードを保存します. dataはスキャンしたユーザが処理するデータです.
JwtQrcode.new(jwt: jwt, data: data).save!
# JWTをQRコードに変換し、PNG形式の画像を作ります.
# 誤り訂正レベルを'l'にしないとQRコードの模様が細かすぎてスキャンできないことがありました.
@jwt_text_qrcode = RQRCode::QRCode.new(jwt, level: 'l').to_img.resize(250, 250).to_data_url
end
# JWTの処理を行うメソッドです. 今回はcURLコマンドから呼び出します.
def update
jwt = params[:jwt]
# 公開鍵ファイルを読み込みます.
public_key = OpenSSL::PKey::RSA.new(File.read('ssh/id_rsa_jwt_qrcode_sample.pub'))
claims = nil
begin
# JWTをデコードしてチェックします.
JWT.decode jwt, public_key, true, { algorithm: 'RS256' }
rescue JWT::ExpiredSignature => err
# 署名の有効期限が切れていたらJWT::ExpiredSignatureの例外が発生します.
render json: { status: 'error', detail: err.to_s } and return
rescue JWT::VerificationError => err
# 改ざんされていたらJWT::VerificationErrorの例外が発生します.
render json: { status: 'error', detail: err.to_s } and return
end
# 履歴テーブルを検索します.
jwt_qrcode = JwtQrcode.find_by_jwt(jwt)
if jwt_qrcode.user_id
# 検索したレコードにuser_idがセットされている場合は
# 既に誰かがQRコードをスキャンしたものとみなしエラーとします.
render json: { status: 'error', detail: 'This QR Code has already been scanned.' } and return
elsif claims[0]['user_id'] != params[:user_id]
# スキャンユーザIDが'test_user'でない場合はエラーとします.
render json: { status: 'error', detail: 'Invalid user scanned the QR code.' }
elsif jwt_qrcode.update(user_id: params[:user_id])
# 履歴レコードにセットしたdataを取り出し処理します.
data = jwt_qrcode.data
・・・(省略)・・・
# 最後に正常レスポンスを返します.
render json: { status: 'ok' }
else
render json: { status: 'error', detail: 'Record update error.' }
end
end
end
また、ルーティングは以下のように単数形で設定しています。
resource :jwt_qrcode
###【実際の利用例】
Railsのアプリで実装しているので、適宜Railsの環境を整えて動かしてください。
- ブラウザから http://localhost:3000/jwt_qrcode へアクセスすると以下のようにQRコードが表示されます。
QRコードは縦、横ともに250pxで表示しています。
なお、このQRコードのJWTは以下のような値となっていました。(長いですね。。。)
eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJKd3QtUXJjb2RlLVNhbXBsZSIsImlhdCI6MTUwNzUzMjc1NSwiZXhwIjoxNTA3NTMzMDU1LCJkYXRhIjoiNTI0NTA1OTJiMTFkMWI0OGM5MzU5Y2NlNzYxMjRlZWVhZjI4N2RiZWMzNTA4ZGQ0OWE2OTE5YWIxOTExOWYwNyJ9.kmJTjJy8DkfdcmaCt_RaktdtN2XITHPbRRIIwaSF83IcYIbgd2fzcHu-lf6-1wRw-uhlGTT6Wo4M1i7p7FWn22syd3aQVyAO_u7vDTNP6klskFOBnHKHK_pqGdYrJ4IpLEUCwmAJ3TXYhPSkSF2r06E7mR9FbwNVsSQ05QkQt63ijPwkLSE-ikSiCpjg39DAjHfBe0Mac3NlLeONVLp9oRy23g54nhYK0ttTOG9ktCvTDgJki9EkrXsqht2ASWTBHe1DdOZuVXY902HHJKN0PPMbRDo99dHBKWXGMKHOzyffxzoDFIjeGqEnpJcU3IueC7hjuM8FyCCwZuCBpsHu8Q
- 次に、モバイルアプリからQRコードをスキャンしてサーバ側へ送信するというシーンを想定して、以下のようにcurlコマンドでリクエストを送信します。パラメータの"jwt"にはQRコードに含まれるJWT, "user_id"にはユーザIDをセットしています。
$ curl -X PUT http://localhost:3000/jwt_qrcode -F "jwt=eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJKd3QtUXJjb2RlLVNhbXBsZSIsImlhdCI
6MTUwNzUzMjc1NSwiZXhwIjoxNTA3NTMzMDU1LCJkYXRhIjoiNTI0NTA1OTJiMTFkMWI0OGM5MzU5Y2NlNzYxMjRlZWVhZjI4N2RiZWMzNTA4ZGQ0OWE2OT
E5YWIxOTExOWYwNyJ9.kmJTjJy8DkfdcmaCt_RaktdtN2XITHPbRRIIwaSF83IcYIbgd2fzcHu-lf6-1wRw-uhlGTT6Wo4M1i7p7FWn22syd3aQVyAO_u7v
DTNP6klskFOBnHKHK_pqGdYrJ4IpLEUCwmAJ3TXYhPSkSF2r06E7mR9FbwNVsSQ05QkQt63ijPwkLSE-ikSiCpjg39DAjHfBe0Mac3NlLeONVLp9oRy23g5
4nhYK0ttTOG9ktCvTDgJki9EkrXsqht2ASWTBHe1DdOZuVXY902HHJKN0PPMbRDo99dHBKWXGMKHOzyffxzoDFIjeGqEnpJcU3IueC7hjuM8FyCCwZuCBps
Hu8Q" -F "user_id=test_user"
{"status":"ok"}
###【エラーケース】
以下、いくつかエラーが発生する場合を試してみます。
- 試しに同じJWTを再度送信します。
$ curl -X PUT http://localhost:3000/jwt_qrcode -F "jwt=eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJKd3QtUXJjb2RlLVNhbXBsZSIsImlhdCI
6MTUwNzUzMjc1NSwiZXhwIjoxNTA3NTMzMDU1LCJkYXRhIjoiNTI0NTA1OTJiMTFkMWI0OGM5MzU5Y2NlNzYxMjRlZWVhZjI4N2RiZWMzNTA4ZGQ0OWE2OT
E5YWIxOTExOWYwNyJ9.kmJTjJy8DkfdcmaCt_RaktdtN2XITHPbRRIIwaSF83IcYIbgd2fzcHu-lf6-1wRw-uhlGTT6Wo4M1i7p7FWn22syd3aQVyAO_u7v
DTNP6klskFOBnHKHK_pqGdYrJ4IpLEUCwmAJ3TXYhPSkSF2r06E7mR9FbwNVsSQ05QkQt63ijPwkLSE-ikSiCpjg39DAjHfBe0Mac3NlLeONVLp9oRy23g5
4nhYK0ttTOG9ktCvTDgJki9EkrXsqht2ASWTBHe1DdOZuVXY902HHJKN0PPMbRDo99dHBKWXGMKHOzyffxzoDFIjeGqEnpJcU3IueC7hjuM8FyCCwZuCBps
Hu8Q" -F "user_id=test_user"
{"status":"error","detail":"This QR Code has already been scanned."}
QRコードが既にスキャンされたというエラーが返ってきました。
- 次にJWTを署名を偽造して送信します。ちょっとわかりにくいですが、署名は以下のようになっています。
kmJTjJy8DkfdcmaCt_RaktdtN2XITHPbRRIIwaSF83IcYIbgd2fzcHu-lf6-1wRw-uhlGTT6Wo4M1i7p7FWn22syd3aQVyAO_u7v
DTNP6klskFOBnHKHK_pqGdYrJ4IpLEUCwmAJ3TXYhPSkSF2r06E7mR9FbwNVsSQ05QkQt63ijPwkLSE-ikSiCpjg39DAjHfBe0Mac3NlLeONVLp9oRy23g5
4nhYK0ttTOG9ktCvTDgJki9EkrXsqht2ASWTBHe1DdOZuVXY902HHJKN0PPMbRDo99dHBKWXGMKHOzyffxzoDFIjeGqEnpJcU3IueC7hjuM8FyCCwZuCBps
Hu8Q
ここでは末尾の大文字"Q"を小文字の"q"に変えてみます。
$ curl -X PUT http://localhost:3000/jwt_qrcode -F "jwt=eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJKd3QtUXJjb2RlLVNhbXBsZSIsImlhdCI
6MTUwNzUzMjc1NSwiZXhwIjoxNTA3NTMzMDU1LCJkYXRhIjoiNTI0NTA1OTJiMTFkMWI0OGM5MzU5Y2NlNzYxMjRlZWVhZjI4N2RiZWMzNTA4ZGQ0OWE2OT
E5YWIxOTExOWYwNyJ9.kmJTjJy8DkfdcmaCt_RaktdtN2XITHPbRRIIwaSF83IcYIbgd2fzcHu-lf6-1wRw-uhlGTT6Wo4M1i7p7FWn22syd3aQVyAO_u7v
DTNP6klskFOBnHKHK_pqGdYrJ4IpLEUCwmAJ3TXYhPSkSF2r06E7mR9FbwNVsSQ05QkQt63ijPwkLSE-ikSiCpjg39DAjHfBe0Mac3NlLeONVLp9oRy23g5
4nhYK0ttTOG9ktCvTDgJki9EkrXsqht2ASWTBHe1DdOZuVXY902HHJKN0PPMbRDo99dHBKWXGMKHOzyffxzoDFIjeGqEnpJcU3IueC7hjuM8FyCCwZuCBps
Hu8q" -F "user_id=test_user"
{"status":"error","detail":"Signature verification raised"}
署名の検証でエラーが発生しました。
- 次にQRコードを表示して5分以上経過してから同じJWTを送信してみます。
$ curl -X PUT http://localhost:3000/jwt_qrcode -F "jwt=eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJKd3QtUXJjb2RlLVNhbXBsZSIsImlhdCI
6MTUwNzUzMjc1NSwiZXhwIjoxNTA3NTMzMDU1LCJkYXRhIjoiNTI0NTA1OTJiMTFkMWI0OGM5MzU5Y2NlNzYxMjRlZWVhZjI4N2RiZWMzNTA4ZGQ0OWE2OT
E5YWIxOTExOWYwNyJ9.kmJTjJy8DkfdcmaCt_RaktdtN2XITHPbRRIIwaSF83IcYIbgd2fzcHu-lf6-1wRw-uhlGTT6Wo4M1i7p7FWn22syd3aQVyAO_u7v
DTNP6klskFOBnHKHK_pqGdYrJ4IpLEUCwmAJ3TXYhPSkSF2r06E7mR9FbwNVsSQ05QkQt63ijPwkLSE-ikSiCpjg39DAjHfBe0Mac3NlLeONVLp9oRy23g5
4nhYK0ttTOG9ktCvTDgJki9EkrXsqht2ASWTBHe1DdOZuVXY902HHJKN0PPMbRDo99dHBKWXGMKHOzyffxzoDFIjeGqEnpJcU3IueC7hjuM8FyCCwZuCBps
Hu8Q" -F "user_id=test_user"
{"status":"error","detail":"Signature has expired"}
署名の有効期限が切れているというエラーが返ってきました。
- 最後にユーザIDを'test_user'ではなく'dummy'に変えてJWTを送信してみます。
$ curl -X PUT http://localhost:3000/jwt_qrcode -F "jwt=eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJKd3QtUXJjb2RlLVNhbXBsZSIsImlhdCI
6MTUwNzUzMjc1NSwiZXhwIjoxNTA3NTMzMDU1LCJkYXRhIjoiNTI0NTA1OTJiMTFkMWI0OGM5MzU5Y2NlNzYxMjRlZWVhZjI4N2RiZWMzNTA4ZGQ0OWE2OT
E5YWIxOTExOWYwNyJ9.kmJTjJy8DkfdcmaCt_RaktdtN2XITHPbRRIIwaSF83IcYIbgd2fzcHu-lf6-1wRw-uhlGTT6Wo4M1i7p7FWn22syd3aQVyAO_u7v
DTNP6klskFOBnHKHK_pqGdYrJ4IpLEUCwmAJ3TXYhPSkSF2r06E7mR9FbwNVsSQ05QkQt63ijPwkLSE-ikSiCpjg39DAjHfBe0Mac3NlLeONVLp9oRy23g5
4nhYK0ttTOG9ktCvTDgJki9EkrXsqht2ASWTBHe1DdOZuVXY902HHJKN0PPMbRDo99dHBKWXGMKHOzyffxzoDFIjeGqEnpJcU3IueC7hjuM8FyCCwZuCBps
Hu8Q" -F "user_id=dummy"
{"status":"error","detail":"Invalid user scanned the QR code."}
不正なユーザがQRコードをスキャンしたというエラーが返ってきました。
###【その他 課題、注意点など】
-
今回のサンプルではhttpsではなくhttpで動かしましたが、実際のプロダクション環境ではhttpsを使った暗号化は必須でしょう。
-
QRコード(もしくはQRコードに埋め込まれたJWT)が盗まれた場合はどうすれば良いのでしょうか?
これは難しい問題です。httpsで暗号化すれば安全だという話もありますが、中間者攻撃があるので安全とは言い切れません。
盗まれた後に悪用される期間を短くするため、JWTの有効期限を短くするくらいしか一般的には策が無いように思えます。
良いアイデアがあれば、どなたかご教授ください。(盗聴、盗難の問題はJWTに限ったことではありませんが。) -
RQRcode::QRCode.newの引数にQRコードの誤り訂正レベル(サンプルではlevel: 'l')を明示的に指定しています。
デフォルトだと'h'になるのか、手元のスマホにインストールしているQRコードスキャナのアプリでは
模様が細かすぎてスキャンできませんでした。
###【サンプルソース】
今回試したサンプルソースは以下のGithubリポジトリに置いてあります。
https://github.com/wakaken/jwt_qrcode_sample
なお、今回はRQRCodeのgemを使ってQRコードを生成しましたが、
Google ChartsのAPIを使ってQRコードを表示するソース(app/controllers/jwt_qrcode_google_charts_controller.rb)も
同梱してありますので、よろしければ参考にしてください。