jimble

セッションと安全側の既定#

セッション#

context.session().put("user_id", 42);

// 明示的に保存する(要件 F-S-02)。自動保存はしない
context.session().save();

自動保存はしません。 save() を呼ばなければ書かれません。 「読んだだけのリクエストで毎回書き込む」を避けるためです。

変えるのは session().put(...) / remove(...) / clear() です。 session().data()(と request().session())は読み取り専用の写しで、書き換えると UnsupportedOperationException になります。 「変えた」印が付かずに保存されない、を起こさないためです。

session().destroy() のあとに put / remove / clear すると IllegalStateException です(保存先が無いので残りません)。 ログアウトのあとに入れたい値は、destroy() の前に入れるか、次のリクエストで入れてください。

保存先は application.conf で選びます。

session {
	store       = "db"     # none | db | redis | cookie
	cookie_name = "sid"
}
store 向いているところ
none セッションを使わない(既定)
db サーバーが複数台。DB がある
redis サーバーが複数台。速さが要る
cookie サーバーに何も置きたくない。中身は署名される

期限切れを掃除する#

db の保存先だけは自分で溜め込みます(Redis は TTL が消し、Cookie はそもそも置いていません)。 掃除はバッチから呼んでください。

SessionStores.defaultStore().cleanupExpired();

落とし穴

SessionStores.db().cleanupExpired() と書かないでください。 設定を Redis に変えた日もそのまま動きます—— DB のセッション表だけを掃除しつづけるだけで、例外も警告も出ません。 Redis のほうは TTL が消すので誰も困らないまま、掃除するつもりの対象が入れ替わっています。

defaultStore() から呼べば、保存先が何であっても正しくなります (掃除の要らない保存先は 0 を返します)。

自分で保存先を書く#

SessionStore を実装して、起動時に1回差し替えます。

SessionStores.replace(new MemcachedSessionStore());

context.sessionStore(...) はそのリクエストだけです。 アプリ全体の既定にしたいときは replace を使ってください—— ルートごとに指定して回ると、1本でも書き忘れたルートが別の場所を読みます。

ログインしたらセッション ID を振り直す#

ログインが通った直後に regenerateId() を呼んでください。

context.session().regenerateId();          // ID が変わる。中身は残る
context.session().put("staff_id", staffId);
context.session().save();                  // ← ここで新しい ID が発行される

ログインの前後で ID が変わらないと、ログイン前に攻撃者が仕込んだ ID のまま、 そのセッションが権限を持ちます(セッション固定化)。仕込む道はいくらでもあります ——公開ページのリンク、別のサブドメイン、ブラウザの拡張。

**中身は持ち越します。**ログインの直前に入れたもの(戻り先の URL、 入力途中のフォーム、CSRF トークン)が消えると、 ログインした瞬間に元いた場所を見失います。

落とし穴

振り直したら save() してください。regenerateId() は保存しません (自動保存をしないという決まりのままです)。振り直したあと保存せずに終わると、 古い側は消えていて新しい側は無い——つまりログインしていない状態になります。 閉じるほうに倒れるので事故にはなりませんが、 「ログインしたのに 401」の原因はここです。

保存先で効き方が違います。

store 何が起きるか
db / redis 古い行を消して、新しい ID で書き直す
cookie ID で引いていないので、中身の Cookie を書き直すだけ。古い値はすぐには無効になりません。最後に使ってから session.timeout、発行から session.absolute_timeout(既定 1 日)を過ぎると、サーバーが捨てます
none 何も起きない

発行からの上限(session.absolute_timeout)#

session.timeout(既定 30 分)は「最後に使ってから」の期限なので、使い続ければいつまでも延びます。 発行から(=ログインから。ログインで ID を振り直すため)の上限は session.absolute_timeout です。

store 既定
cookie 1 日で効く
db / redis 書いたときだけ効く(2.2.4 から。書かなければ上限なし)
session {
	store            = "db"
	absolute_timeout = 12h   # 使い続けていても、ログインから 12 時間で入り直し
}

書いておくと、盗まれたセッション ID を使い続けられる時間に上限ができます。 db / redis で既定にしていないのは、上げた日に、1 日以上続けて使っている人がまとめてログアウトされるのを避けるためです。 過ぎたセッションは読めず、ID も作り直します(remember-me を使っていれば、そこから入り直します)。

CSRF#

path("/form", () -> {

	/*
	 * 状態を変えるものだけ検証する。
	 * GET / HEAD / OPTIONS / TRACE は素通しする(Csrf.SAFE_METHODS)。
	 */
	before(Csrf::verify);

	get("", FormController::show);
	post("", FormController::submit);

});

Csrf::verify を before に置くと、その下のルートが守られます。 GET HEAD OPTIONS TRACE は素通しです(Csrf.SAFE_METHODS)。

トークンは Csrf.token(context) で取り、フォームの hidden(名前は csrf_token)に入れます。 無ければ発行して Cookie に載せるので、フォームを出すページで呼んでください。 (context.request().csrfToken() は、送られてきた csrf-token ヘッダを読むだけで、発行はしません。)

送られたトークンは、X-CSRF-Token ヘッダ → フォームの本文 → JSON の本文の順に読みます。 クエリ文字列(?csrf_token=...)では受け付けません(2.2.4 から)。URL に載ったトークンは、アクセスログや Referer に残るためです。

トークンの寿命は csrf.max_age(既定 1 日)です。 0.6.x までは cookie.max_age(既定 1 年)に相乗りしていたので、 アプリが自分の都合で cookie.max_age を短くすると CSRF トークンも一緒に短くなり、出るのは「CSRF トークンがありません」の 403 だけでした。

トークンをセッションに結びつける(csrf.bind_session)#

既定は double submit cookie です。Cookie のトークンと、送られてきたトークンが同じなら通します。 Cookie には署名があるので、攻撃者が値を作ることはできません。 ただし、トークンは利用者に結びついていません。同じ親ドメインの下に Cookie を書ける場所(別のサブドメインなど)を攻撃者が持っていると、 攻撃者が自分で受け取った正しいトークンを被害者のブラウザに植え付けて、それを使って送らせられます。

csrf {
	bind_session = true   # 2.2.4 から。既定 false
}

true にすると、次のように変わります。

置き場 Cookie ではなくセッション。session.store が db / redis / cookie のどれかでないと、使ったところで例外です
ログイン セッション ID を振り直すとき(Auth.login・二要素認証の完了など)に、トークンも作り直します
新しいトークン 作り直した応答の X-CSRF-Token ヘッダで返します

SPA は、応答に X-CSRF-Token ヘッダがあったら、手元のトークンを差し替えてください。 ページを読み直さずにログインする SPA は、ログインの前に受け取ったトークンを持ち続けるので、差し替えないとログインのあとの POST が 403 になります。

const response = await fetch("/login", { method: "POST", headers: { "X-CSRF-Token": csrfToken }, body });
csrfToken = response.headers.get("X-CSRF-Token") ?? csrfToken;

別のオリジンから呼ぶなら、CORS で Access-Control-Expose-Headers: X-CSRF-Token を出さないと、ヘッダを読めません。

ログアウトのあとは、トークンがありません(セッションごと捨てるため)。次にフォームを出すか、トークンを返すルートを呼んで受け取り直してください。

true にした時点で開いているフォームは、1度だけ 403 になります(置き場が変わるため)。

Flash#

リダイレクトの先へ1回だけ渡すものです。

context.flash().put("message", "保存しました");
context.response().redirect("/");

読んだ時点で消えます。Cookie に署名して入れています。

jimble の Cookie は既定で Secure が付きます。HTTPS でしか送られません。

ローカルは http なので、そのままだとブラウザが Cookie を返しません。 セッションも CSRF も Flash も、エラーを出さずに効かなくなります。

これは静かに壊れる典型なので、jimble は env=local かつ cookie.secure = true のときに起動時に WARN を出します。

cookie.secure = true のままです(env=local)。
ローカルは http なので、ブラウザは Cookie を送り返しません。
application.conf に次を足してください。
  cookie { secure = false }

jimble new の雛形には最初からこの節が入っています。 本番へ出すときは消してください。

// 30日
context.cookies().put("last_post", String.valueOf(id), 30L * 24 * 60 * 60);

String lastPost = context.cookies().get("last_post");

書いた値は同じリクエストの中で読み返せます。 受信した Cookie しか見えないと、 発行したばかりの CSRF トークンを読み直すたびに別のものが出てしまうためです。

属性(Path や Max-Age)を自分で決めた Cookie を書くときは、署名するかどうかを名前で選びます(1.5.0 から)。

context.cookies().putSigned(new Cookie("user", id).path("/app").maxAge(3600));    // 署名する(get で読める)
context.cookies().putUnsigned(new Cookie("theme", "dark").httpOnly(false));      // 署名しない(JavaScript に読ませる)

1.x の put(Cookie) は署名しなかったので、2.0 で消しました(2.0 への移行)。

署名は Cookie の名前に結びついています(2.2.3 から)。ある名前の Cookie に付いた署名を、別の名前の Cookie に移しても通りません。 2.2.2 までは値だけに署名していたので、アプリが入力をフラッシュに入れるなど、攻撃者の決めた値に署名させる場所が1つでもあると、 その署名を user などの Cookie に移し替えて使えました。 2.2.2 までの署名は、既定では読みません(cookie.accept_legacy_signature、2.5.2 から既定 false)。2.2.2 以前から上げるときだけ、移す間 true にしてください。sid と csrf_token は読んだときに書き直します。 署名は有効期限を含みません。Max-Age を守るのはブラウザなので、利用者を表す値を署名つき Cookie だけで信じないでください(セッションに入れてください)。

応答を送ったあとに Cookie を書くと IllegalStateException です(もう届きません)。send() より前に書いてください。

読むときは context.cookies().get("名前") か context.request().cookie("名前") です。 署名が合わなかった値はここに入りません(改ざんされた値がアプリに渡らないようにするためです)。

注意

unsignCookie("名前") は署名を検証しません。 名前に反して、返るのは受信した生の値です。無いときは null ではなく空文字が返ります。 検証済みの値が要るなら cookie("名前") を使ってください。

鍵は application.conf の cookie.secret(Cookie セッションは session.secret)で、 本番では環境変数から渡してください。

cookie {
	secret = ${?COOKIE_SECRET}
}

鍵を設定しないと、署名の機能が丸ごと効きません。 例外は出ず、Cookie は普通に読み書きできるので、 効いていないことに気づく手がかりがありません。起動時に警告を出しています。

あとから署名を入れるとき#

落とし穴

cookie.secret を初めて設定した瞬間、いま配ってある Cookie が全部いっぺんに捨てられます。 sid も csrf_token も remember も flash もです——全員ログアウト、フォームは 403。 例外もログも出ません(署名の合わない Cookie を捨てるのは、正しい動きだからです)。

移行期間を作ってください。

cookie {
	secret          = ${?COOKIE_SECRET}
	accept_unsigned = true    # ← 入れ替えが済むまでの間だけ
}

accept_unsigned = true の間は、署名の無い Cookie も読みます。 書くほうは最初から署名するので、放っておけば署名つきに入れ替わります。

**入れ替わったら false に戻してください。**戻さないと、 署名を外した Cookie が通り続けるので、署名を入れた意味がなくなります。 通した回数は cookie.unsigned に出ます——これが 0 になってから戻します。

鍵を入れ替える#

鍵は入れ替えられます。誰もログアウトしません。

新しい鍵を secret に、いままでの鍵を previous_secrets に書きます。 書くのは常に secret、読むときだけ previous_secrets も試します。

cookie {
	secret           = ${?COOKIE_SECRET}       # 新しい鍵
	previous_secrets = [${?COOKIE_SECRET_OLD}] # いままでの鍵
}

session {
	secret           = ${?SESSION_SECRET}
	previous_secrets = [${?SESSION_SECRET_OLD}]
}

手順は3段です。

  1. 新しい鍵を先頭にして、いままでの鍵を previous_secrets に残す。 デプロイします
  2. 待つ。 アクセスしてきた人から順に、古い鍵の Cookie が新しい鍵で書き直されます
  3. previous_secrets を消す。 もう一度デプロイして完了です

いつ 3 に進めるか#

勘で決めないでください。 古い鍵で読めた回数がメトリクス(Metrics.snapshot())に出ます。

名前 意味
cookie.stale_secret 古い鍵で署名された Cookie を読んだ回数
session.stale_secret 古い鍵で暗号化されたセッションを読んだ回数

これが増えなくなったら、古い鍵を捨てられます。 起動ログにも本数が出ます(鍵=cookie=2, session=2)。 2 のままなら入れ替えの途中、previous_secrets を消し忘れていればここで気づけます。

いつまでも 0 にならない場合は、その分だけ「たまにしか来ない人」が残っているということです。 Cookie の寿命(cookie.max_age。既定は1年)が過ぎれば必ず消えるので、それが最長の待ち時間です。

枠組みが出す sid と csrf_token は自動で署名し直します。 アプリが cookies().put(...) で書いた Cookie は自動では書き直しません—— 有効期限をいくつにすべきかは、書いた側にしか分からないためです(ブラウザは有効期限を送ってきません)。

if (context.cookies().isStale("last_post")) {
	context.cookies().put("last_post", context.cookies().get("last_post"), 30 * 24 * 60 * 60);
}

書き直さなくても読めますが、その Cookie が消えるまで古い鍵を捨てられません。

hash.password.pepper は守りになりません#

名前に反して、ハッシュの前の入力には混ぜていません。bcrypt で作ったハッシュの後ろに繋げて保存し、照合のときに外して捨てているだけです(1.x から引き継いだ形で、変えると保存済みのハッシュが照合できなくなります)。 DB が漏れたときに総当たりを難しくする効果はありません。DB が漏れたときの守りが要るなら、hash.password.encrypt = true と cipher.key で、ハッシュを暗号化して保存してください。

パスワードの鍵は入れ替えられません#

cipher.key と hash.password.pepper はこの仕組みの対象外です。 これらで作った値は Cookie ではなく DB のパスワードカラムに入っていて、 書き直せるのはログインに成功した瞬間だけです(平文がそのときしか無いため)。 つまり、しばらくログインしていない人がいる限り古い鍵を捨てられません。

替えるなら、アプリ側でこうします。

  1. 新しい鍵で createHash するように切り替える
  2. ログイン成功時に、古い方式のハッシュだったら作り直して保存する (check(input, hash, encrypted) で方式を指定して照合できます)
  3. 全員が入れ替わったことを確認してから古い鍵を捨てる

3 を確認する手立ては jimble にはありません。「最終ログイン日時が古い人を数える」など、 アプリ側で持っている情報で判断してください。

パスワード#

PasswordUtil は BCrypt でハッシュ化します。そのうえで暗号化するかどうかは選べます。

String hash = PasswordUtil.createHash(password);

if (PasswordUtil.check(input, user.getString("password"))) {
	// 一致した
}
hash {
	password {
		# cipher.* を書くなら、これも必ず書く(書かないと起動時に落ちる)
		encrypt = true
		pepper  = ${?PASSWORD_PEPPER}
	}
}

cipher {
	key = ${?CIPHER_KEY}   # 16 / 24 / 32 バイト
	iv  = ${?CIPHER_IV}    # 16 バイト
}

既定は「鍵があれば暗号化する」です。 固定の false にすると、暗号化されたハッシュを 保存している既存のアプリが、鍵を設定しているのに平文として照合してしまい、 全員ログインできなくなります。 どちらで動いているかは起動時のログに出ます (パスワード暗号化=あり)。明示したいときは encrypt を書いてください。

暗号化を後から入れる・外すときは、保存済みのハッシュを作り直す必要があります。 createHash(password, encrypt) と check(input, hash, encrypted) で片方ずつ指定できます。

CipherUtil は AES/CBC で IV が固定です。既に保存されている暗号文を読むために残してあります。 新しく暗号化するものには使わないでください。 同じ平文が必ず同じ暗号文になり、改ざん検知もありません。 新しく作るものは Aead(AES-256-GCM)を使います。