posts = data.getDataList("posts");}
${data.getString("title")}
@for(Data post : posts)
${post.getString(Post.title)}
${post.getString(Post.body)}
@endfor
```
- `${ }` は **HTML エスケープされます**。生で出すには `$unsafe{ }` を使います
- `@param` と `@import` があるので、テンプレートの中でも型が効きます
- `Post.title` のような `Column` で引けるので、テンプレートに文字列のキーを撒かずに済みます
## 枠を共有する
ページごとに `` から書くと、**ヘッダを直すのに全部を直すことになります。**
枠は1枚のレイアウトに閉じ込めて、ページは中身だけを書きます。
`src/main/jte/layout/page.jte`:
```jte
@import gg.jte.Content
@import io.jimble.util.data.Data
@param Data data
@param Content content
${data.getString("title")}
@template.tag.nav(current = data.getString("nav"))
${content}
```
`src/main/jte/pages/home.jte`:
```jte
@import io.jimble.util.data.Data
@param Data data
@template.layout.page(data = data, content = @`
ここが中身です。
`)
```
`Content` が「あとで描くもの」です。`@` とバッククォートで囲んだかたまりを渡すと、
レイアウトの `${content}` の位置に入ります。
**呼び出し名はディレクトリ構成そのままです。**
`src/main/jte/layout/page.jte` なら `@template.layout.page(...)`、
`src/main/jte/tag/nav.jte` なら `@template.tag.nav(...)`。
## 部品
同じ仕組みで、使い回す部品も作れます。
`src/main/jte/tag/card.jte`:
```jte
@import gg.jte.Content
@param String title
@param Content body
@param String note = ""
${title}
${body}
@if(!note.isEmpty())
${note}
@endif
```
- **`@param` には既定値を書けます**(`= ""`)。書かなければ呼ぶ側は省略できます
- **部品は `Data` ではなく、必要なものだけを受け取るほうが使い回せます。**
`Data` を渡すと、呼ぶ側が「どのキーが要るのか」をテンプレートの中身を読まないと分かりません
実際に動いているものは `examples/approval-pages` にあります。
> [!TIP]
> **エラー画面もレイアウトを共有してください。**
> 共有していないと、404 のときだけ見た目の違うサイトになります。
## コメント
**jte のコメントは `<%--` と `--%>` です。**
```jte
<%-- ここはコメント。出力に出ません --%>
```
> [!WARNING]
> **`@*` と `*@` で囲む書き方はコメントになりません。**
> そのまま出力され、しかも**中に書いた `@template.` は実行されます**。
> レイアウトの中に、そのレイアウト自身の呼び出し例をコメントのつもりで書くと、
> **無限再帰して `StackOverflowError` になります**(実際に踏みました)。
>
> **コメントは入れ子にできません。**閉じの記号を説明文の中に書くと、そこで終わります。
>
> **HTML のコメント(``)も既定では出力から消えます。**
> ブラウザや外の道具に残したい印は、テンプレートではなく静的ファイル側に置いてください。
## ビルド
`io.jimble.jte` プラグインを入れると、`compileJava` の前に
`src/main/jte` を Java に変換します。
```kotlin
plugins {
application
id("io.jimble.jte")
}
```
**テンプレートの間違いはビルドで落ちます。** 実行時に404を見てから気づくことはありません。
設定できることは [Gradle プラグイン](./gradle) にあります。
コンパイラ(`gg.jte:jte`)はビルドのときだけ使い、
アプリの実行時クラスパスには `gg.jte:jte-runtime` しか入りません。
## ホットリロード
`./gradlew jimbleRun` で起動していれば、`.jte` を直した時点で
変換とビルドが走り、次のリクエストで反映されます。
---
# 静的ファイルと SPA
配り方は3つあります。**どれもルーティング本体とは別のハンドラ**です(要件 F-W-20)。
| やりたいこと | 使うもの | 当たらなかったら |
| --- | --- | --- |
| CSS・JS・画像をそのまま配る | `AssetHandler` | 404 |
| SPA(どのパスでも同じ index.html) | `SpaHandler` | いちばん近い `index.html` |
| MPA(ディレクトリごとに index.html) | `MpaHandler` | 拡張子が無ければ `<パス>/index.html` |
**中身はクラスパスから読みます。** jar に入れたまま配れますし、
開発中は `build/resources/main` から読まれます。**どちらでも同じ動きです**(要件 F-W-18)。
## 静的ファイル
```java
install(() -> AssetHandler.mount("/assets", "test-assets"));
```
第2引数は**クラスパス上のディレクトリ**です。ファイルシステムのパスではありません。
`AssetHandler.mount("/assets", "assets")` なら
`src/main/resources/assets/app.css` が `/assets/app.css` で出ます。
`GET` と `HEAD` の2本(`/assets/*`)が登録されます。
> [!WARNING]
> **`/assets` 自身(末尾なし)には応えません。**登録されるのは `/assets/*` だけです。
> SPA と MPA は接頭辞そのものも登録するので、そこだけ形が違います。
登録されたルートは `routes()` で受け取れます。**属性を付けられます。**
```java
AssetController assets = AssetHandler.mount("/assets", "assets");
for (Route route : assets.routes()) {
route.attribute(NO_AUTH, true);
}
install(() -> assets);
```
`SpaHandler.mount(...)` / `MpaHandler.mount(...)` でも `routes()` は同じように使えます。
ただし**本数が違います**——SPA と MPA は接頭辞自身も登録するので4本です
(`/app` と `/app/*` の `GET` と `HEAD`)。
### キャッシュ
| 拡張子 | `Cache-Control` |
| --- | --- |
| `.js` `.css` `.woff` `.woff2` | `public,max-age=31536000,immutable` |
| そのほか | `public,max-age=,must-revalidate` |
動的なレスポンスの既定は `no-store` です(要件 F-X-07)。**静的配信だけがここを上書きします。**
```conf
assets {
max_age = 0s # 通常のファイル(単位を書く。素の数値は起動時に落ちる)
immutable_max_age = 365d # 内容ハッシュ付きのファイル向け(1年)
etag = true
if_modified_since = true
}
```
> [!WARN]
> `immutable` が付くのは**拡張子だけ**で判断しています。
> `app.js` を中身を変えて置き直しても、**ブラウザは1年取りに来ません。**
> ファイル名にハッシュを入れる(`app.9f3a1c.js`)か、`assets.immutable_max_age` を下げてください。
### 条件付き GET
- `ETag` は弱い ETag(パス・更新時刻・サイズの md5)
- `Last-Modified` は `URLConnection` の更新時刻
- 判定は **`If-None-Match` が先**。無ければ `If-Modified-Since`
- 一致したら **本文を読まずに 304**
### 無いもの
| | |
| --- | --- |
| Range(部分取得) | **ありません。**`206` は返りません |
| 個別の圧縮 | **ありません。**サーバー全体の `server.compression`(helidon)に任せています |
| ディレクトリ一覧 | 出しません |
## SPA
```java
install(() -> SpaHandler.mount("/app", "test-spa"));
```
`/app/...` に来たとき、こう探します。
1. 実ファイルがあれば**それを返す**(`AssetHandler` と同じヘッダ)
2. 無ければ、そのパスから上へ順に `index.html` を探す
3. 最後にベース直下の `index.html`
4. それも無ければ 404
**通常のルートと共存します。**`/api/items` を先に書いてあれば SPA には食われません。
ツリーの優先順(固定セグメント > パスパラメータ > ワイルドカード)がそのまま効きます。
### パスごとに index.html を書き換える
クローラや OGP のために `` や `` を差し替えたいときに使います。
```java
install(() -> SpaHandler.mount("/app", "test-spa", spa -> spa
.route("/app/items/{id}", (context, html) ->
html.replace("",
"item " + SpaRewriter.escapeHtml(context.request().bodyPath().getString("id")) + ""))
));
```
パスの書き方は本体のルートと同じです(`{id}`、末尾の `/*`)。
**マッチも本体と同じルートツリーがやります**(要件 D-75)。
| | |
| --- | --- |
| 優先順 | 固定 > 変数 > ワイルドカード。**書いた順には依存しない** |
| パスパラメータ | `context.request().bodyPath().getString("id")` |
| 重複したパス | 登録時に落ちる |
| 本体のルート一覧 | **混ざらない**(別インスタンス。要件 F-W-20) |
> [!NOTE]
> 書き換えたときだけ `ETag` と `Last-Modified` は付きません(中身がリクエストごとに変わるため)。
> `Cache-Control` は `public,max-age=,must-revalidate` になります。
## MPA
```java
install(() -> MpaHandler.mount("/docs", "test-mpa"));
```
拡張子があればファイル、無ければ `<パス>/index.html` を返します。
`/docs/guide` → `docs/guide/index.html` です。
## 安全側の既定
パスは配る前に必ず検査します。**危ないものは 404** です(何があるかを教えないため)。
弾くもの:NUL 文字、バックスラッシュ、空のセグメント(`//`)、`.` と `..` のセグメント。
> [!TIP]
> `%2e%2e` のようなパーセントエンコードも弾けます。
> セグメントごとにデコードしてから検査するので、`..` が復元されたところで捕まります。
## 開発中に直したとき
`jimbleRun` の監視対象には `.html` / `.js` / `.css` が入っています。
保存すればリソースがビルドし直され、次のリクエストで反映されます([ホットリロード](./hot-reload))。
> [!TRAP]
> **アプリを止めずにファイルだけ差し替えると、古いままになることがあります。**
> 「ファイルがあるか」と `index.html` の中身をメモリに覚えているためで、
> 消す口は `Resources.clearCache()` しかありません。`jimbleRun` を使っていれば入れ替えのたびに作り直されます。
---
# セッションと安全側の既定
## セッション
```java
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` で選びます。
```conf
session {
store = "db" # none | db | redis | cookie
cookie_name = "sid"
}
```
| store | 向いているところ |
| --- | --- |
| `none` | セッションを使わない(既定) |
| `db` | サーバーが複数台。DB がある |
| `redis` | サーバーが複数台。速さが要る |
| `cookie` | サーバーに何も置きたくない。中身は署名される |
### 期限切れを掃除する
`db` の保存先だけは**自分で溜め込みます**(Redis は TTL が消し、Cookie はそもそも置いていません)。
掃除はバッチから呼んでください。
```java
SessionStores.defaultStore().cleanupExpired();
```
> [!TRAP]
> **`SessionStores.db().cleanupExpired()` と書かないでください。**
> 設定を Redis に変えた日も**そのまま動きます**——
> DB のセッション表だけを掃除しつづけるだけで、例外も警告も出ません。
> Redis のほうは TTL が消すので**誰も困らないまま、掃除するつもりの対象が入れ替わっています。**
>
> `defaultStore()` から呼べば、保存先が何であっても正しくなります
> (掃除の要らない保存先は `0` を返します)。
### 自分で保存先を書く
`SessionStore` を実装して、起動時に1回差し替えます。
```java
SessionStores.replace(new MemcachedSessionStore());
```
`context.sessionStore(...)` は**そのリクエストだけ**です。
アプリ全体の既定にしたいときは `replace` を使ってください——
ルートごとに指定して回ると、**1本でも書き忘れたルートが別の場所を読みます。**
### ログインしたらセッション ID を振り直す
**ログインが通った直後に `regenerateId()` を呼んでください。**
```java
context.session().regenerateId(); // ID が変わる。中身は残る
context.session().put("staff_id", staffId);
context.session().save(); // ← ここで新しい ID が発行される
```
ログインの前後で ID が変わらないと、**ログイン前に攻撃者が仕込んだ ID のまま、
そのセッションが権限を持ちます**(セッション固定化)。仕込む道はいくらでもあります
——公開ページのリンク、別のサブドメイン、ブラウザの拡張。
**中身は持ち越します。**ログインの直前に入れたもの(戻り先の URL、
入力途中のフォーム、CSRF トークン)が消えると、
**ログインした瞬間に元いた場所を見失います。**
> [!TRAP]
> **振り直したら `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 から。書かなければ上限なし) |
```conf
session {
store = "db"
absolute_timeout = 12h # 使い続けていても、ログインから 12 時間で入り直し
}
```
書いておくと、**盗まれたセッション ID を使い続けられる時間に上限ができます**。
`db` / `redis` で既定にしていないのは、上げた日に、1 日以上続けて使っている人がまとめてログアウトされるのを避けるためです。
過ぎたセッションは読めず、ID も作り直します(remember-me を使っていれば、そこから入り直します)。
## CSRF
```java
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 を書ける場所(別のサブドメインなど)を攻撃者が持っていると、
**攻撃者が自分で受け取った正しいトークン**を被害者のブラウザに植え付けて、それを使って送らせられます。
```conf
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 になります。
```javascript
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回だけ渡すものです。
```java
context.flash().put("message", "保存しました");
context.response().redirect("/");
```
読んだ時点で消えます。Cookie に署名して入れています。
## Cookie の既定は secure = true
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` の雛形には最初からこの節が入っています。
**本番へ出すときは消してください。**
## Cookie
```java
// 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 から)。
```java
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 への移行](./migrate-2))。
**署名は 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("名前")` です。**
署名が合わなかった値はここに入りません(改ざんされた値がアプリに渡らないようにするためです)。
> [!WARN]
> **`unsignCookie("名前")` は署名を検証しません。**
> 名前に反して、返るのは**受信した生の値**です。無いときは `null` ではなく**空文字**が返ります。
> 検証済みの値が要るなら `cookie("名前")` を使ってください。
鍵は `application.conf` の `cookie.secret`(Cookie セッションは `session.secret`)で、
本番では環境変数から渡してください。
```conf
cookie {
secret = ${?COOKIE_SECRET}
}
```
**鍵を設定しないと、署名の機能が丸ごと効きません。** 例外は出ず、Cookie は普通に読み書きできるので、
効いていないことに気づく手がかりがありません。起動時に警告を出しています。
### あとから署名を入れるとき
> [!TRAP]
> **`cookie.secret` を初めて設定した瞬間、いま配ってある Cookie が全部いっぺんに捨てられます。**
> `sid` も `csrf_token` も `remember` も flash もです——**全員ログアウト、フォームは 403**。
> **例外もログも出ません**(署名の合わない Cookie を捨てるのは、正しい動きだからです)。
**移行期間を作ってください。**
```conf
cookie {
secret = ${?COOKIE_SECRET}
accept_unsigned = true # ← 入れ替えが済むまでの間だけ
}
```
`accept_unsigned = true` の間は、**署名の無い Cookie も読みます**。
書くほうは最初から署名するので、**放っておけば署名つきに入れ替わります**。
**入れ替わったら false に戻してください。**戻さないと、
**署名を外した Cookie が通り続ける**ので、署名を入れた意味がなくなります。
通した回数は `cookie.unsigned` に出ます——**これが 0 になってから**戻します。
## 鍵を入れ替える
**鍵は入れ替えられます。誰もログアウトしません。**
新しい鍵を `secret` に、いままでの鍵を `previous_secrets` に書きます。
**書くのは常に `secret`、読むときだけ `previous_secrets` も試します。**
```conf
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年)が過ぎれば必ず消えるので、それが最長の待ち時間です。
### 自分で書いた Cookie は自分で書き直してください
枠組みが出す `sid` と `csrf_token` は自動で署名し直します。
**アプリが `cookies().put(...)` で書いた Cookie は自動では書き直しません**——
有効期限をいくつにすべきかは、書いた側にしか分からないためです(ブラウザは有効期限を送ってきません)。
```java
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 でハッシュ化します。**そのうえで暗号化するかどうかは選べます。**
```java
String hash = PasswordUtil.createHash(password);
if (PasswordUtil.check(input, user.getString("password"))) {
// 一致した
}
```
```conf
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)を使います。
---
# レートリミット
**ルートに宣言します。**書いたところにだけかかります。
```java
// 1本だけ
post("/api/login", handler)
.attribute(RateLimit.KEY, RateLimit.perIp(5, Duration.ofMinutes(1)));
// このブロック全部
path("/api", () -> {
rateLimit(RateLimit.perIp(60, Duration.ofMinutes(1)));
get("/items", handler);
get("/items/{id}", handler);
});
```
`before` と同じで**書いたブロックに付きます**(パスのノードではありません)。
内側に別のものを書けば内側が勝ち、ルートが属性で持っていればそれが勝ちます。
## 数え方
**トークンバケット**です。`duration` かけて `limit` 個まで戻ります。
**短いバーストは通り、続けて叩くと止まります。**
> [!NOTE]
> 固定の窓(「1分あたり N 回」)にしなかったのは、
> 窓の境目で**一瞬 2N 回通ってしまう**ためです。
## 数える単位
```java
RateLimit.perIp(60, Duration.ofMinutes(1)); // 送信元ごと
RateLimit.of(context -> context.session().id(), 10, Duration.ofMinutes(1)); // ログインごと
```
> [!TRAP]
> **`perIp` は `server.trust_proxy` を見ます。**プロキシの後ろに置いているのに
> `false` のままだと、**全員がロードバランサの IP として1つに数えられます**
> ([サーバー設定](./server))。
**宣言が違えば、同じ人でも別に数えます**(2.5.2 から)。上の例なら、`/api` を叩いてもログインの 5 回は戻りません。
回数と時間がまったく同じ宣言どうしは、同じ枠を分け合います。
社内からのアクセスなど、数えたくないものは外せます。
```java
RateLimit.perIp(60, Duration.ofMinutes(1))
.exclude(context -> context.request().proxyAddress().startsWith("10."));
```
## 止めたときに返るもの
| | |
| --- | --- |
| ステータス | **429** |
| `Retry-After` | 待つ秒数(切り上げ。最低 1) |
| `X-RateLimit-Limit` / `X-RateLimit-Remaining` | 上限と残り |
**`before` より前で止めます。**認証や DB を触らせないためです。
## 置き場
```conf
rate_limit {
store = "memory" # memory | redis | db
enabled = true
}
```
| | 台をまたぐか | 向き |
| --- | --- | --- |
| `memory`(既定) | **またがない** | 1台のとき |
| `redis` | またぐ | 複数台に並べるとき |
| `db` | またぐ | Redis が無くて、**回数が少ない**とき |
> [!WARN]
> **`memory` のまま複数台に並べると、全体では台数ぶん通ります。**
> 3台なら 3 倍です。並べるなら `redis` にしてください。
> [!NOTE]
> `redis` は Lua で数えます。「残り」と「最後に足した時刻」を
> **一緒に読み書きしないと数え落ちる**ためです。
> `db` は `SELECT ... FOR UPDATE` なので、**高い頻度には向きません**
> (ログインの試行回数のような用途向け)。テーブルは初めて使うときに作ります。
> [!TRAP]
> **数えられなかったときは通します。**Redis が落ちているだけで
> サイト全体が 429 になるほうが困るからです。
> ただし**黙っては通しません**(`流量制限を数えられませんでした(通します)` がログに出ます)。
---
# ログインと認可
**注釈はありません。**`before` を1行と、ルートに付ける属性だけです。
```java
public class App extends JimbleApp {
{
before(Auth::guard); // これだけ
path("/public", () -> {
attribute(Auth.PUBLIC, true); // ブロックごと公開
attribute(Auth.NO_SESSION, true);
get("/guide", Guide::show);
});
get("/login", Login::show).attribute(Auth.PUBLIC, true);
post("/login", Login::submit).attribute(Auth.PUBLIC, true);
get("/requests", RequestController::list); // 既定で要ログイン
get("/approvals", ApprovalController::list).attribute(Auth.ROLE, "approver");
}
}
```
`before(Auth::guard)` は**いちばん最初に**登録してください。
セッションを使うかどうかをここで決めるので、
**先に誰かが `session()` を触ると間に合いません。**
## 既定は「閉じている」
`Auth.PUBLIC` の既定は `false`(=ログインが要る)です。
**ルートを足した人が何も書かなければ閉じています。**
逆にすると、書き忘れたルートが黙って開きます。
どちらも「書き忘れ」ですが、**倒れる先が違います。**
## ルート属性
| 属性 | 既定 | 何を決めるか |
| --- | --- | --- |
| `Auth.PUBLIC` | `false` | ログインが要らないか |
| `Auth.ROLE` | `""` | 要る役割。空なら問わない |
| `Auth.NO_SESSION` | `false` | セッションをまったく使わないか |
| `Auth.FULL_AUTH` | `false` | **いまパスワードを入れた人**だけが通れるか(下記) |
| `Auth.REALM` | `""` | ログインの種別。**同じブラウザで別々にログインさせる**とき(下記「ログインの種別でセッションを分ける」) |
ブロックに書けば、その中のルート全部に付きます([ルーティング](./routing))。
1本だけ違うなら、そのルートで上書きします。
> [!TRAP]
> **`PUBLIC` と `NO_SESSION` は別の判断です。**
> ログインの入口は**ログインが要らないが、セッションは要ります**——
> CSRF トークンも、ログイン後のセッションも、そこで持つからです。
>
> 一緒にすると**ログイン画面自身がセッションを持てず、誰もログインできなくなります**
> (302 は返るのに、次のリクエストで 401 になります。実際に踏みました)。
>
> `NO_SESSION` を付けてよいのは、**本当に公開のページだけ**です。
## ログインさせる
```java
Data staff = findStaff(loginId);
if (!Auth.checkPassword(password, staff.isEmpty() ? null : staff.getString("password_hash"))) {
context.flash().put("message", "ログインIDかパスワードが違います");
context.response().redirect("/login");
return;
}
Auth.login(context, Principal.of(
staff.getLong("id"), staff.getString("name"), staff.getString("role")));
context.response().redirect("/me");
```
`Auth.attemptLogin` が、**待たせる → 照合する → 成功なら数えたものを消す**までやります。
`Auth.login` は**セッション ID を振り直してから**入れて、**保存まで**します
([セッションとセキュリティ](./session-security))。
> [!TRAP]
> **`PasswordUtil.check` を直に呼ばないでください。**
> ハッシュが `null` のとき**即座に `false` を返す**ので、
> **利用者がいないほうが目に見えて速くなります**(BCrypt は遅いのが仕事です)。
> 応答時間で**どの ID が存在するかを外から数えられます。**
>
> `Auth.attemptLogin` は、相手がいなくても**1回まわしてから** `false` を返します。
> **メッセージも分けないでください**——分けたら時間を合わせた意味がありません。
## 何度も間違えられたとき
`Auth.attemptLogin` は**失敗した回数を数えていて、次の試行を待たせます。**
書くことはありません(上のコードのままです)。
```
失敗 1〜3 回目 … 待たせない(打ち間違い)
4 回目 … 1 秒
5 回目 … 2 秒
6 回目 … 4 秒 …… 上限(既定 300 秒)まで
```
まだ待ち時間が残っていれば **429 と `Retry-After`** を返します
(401 と同じく、画面へ飛ばすかどうかは `error()` が決めます)。
24 時間間が空けば数え直します。
**「N 回で M 分ロック」にはしていません。**
アカウント単位で止める仕組みは**そのまま嫌がらせの道具になる**からです——
わざと間違えるだけで、その人を締め出せます。
待ち時間を倍にしていく形なら、攻撃者から見た試行速度は実質ゼロになり、
**正規の利用者は数秒待つだけ**で済みます。
> [!TRAP]
> **数える単位は「入力されたログイン ID」です。**
> 見つかった利用者の DB 上の ID を渡すと、**居ない ID のときだけ数えられません。**
> 総当たりは居ない ID から始まりますし、
> **「待たされるかどうか」でどの ID が在るかが分かってしまいます。**
| | |
| --- | --- |
| 置き場 | `auth_attempt` テーブル。**DB が無ければ何もしません**(ログに1度だけ出ます) |
| 単位 | ログイン ID(**大小と前後の空白はそろえて** SHA-256 で保存します。平文では残りません) |
| 設定 | `auth.lockout.*`([設定](./config)) |
| 掃除 | 自動です(失敗を数えたついでに、1時間に1度)。手で呼ぶなら `Lockout.cleanup()` |
| 解除 | `Lockout.clear(loginId)` |
**[流量制限](./ratelimit)の代わりにはなりません。**
流量制限は **IP ごと**なので、1つのアカウントに 1000 個の IP から1回ずつ来ると発火しません。
こちらは**アカウントごと**なので、誰から来ても数えます。**両方掛けてください。**
## いま誰か
```java
Principal me = Auth.principal(context);
me.id(); // 0 なら未ログイン
me.name();
me.hasRole("approver");
```
**`null` は返りません。**ログインしていなければ `Principal.ANONYMOUS` です。
`Principal` が持つのは **id / 表示名 / 役割の3つだけ**です。
利用者のオブジェクトを丸ごとセッションに入れると、
- DB で名前を直しても**ログインし直すまで古いまま**
- 権限を剥奪しても**セッションが切れるまで効かない**
- Cookie セッションなら、**その全部がブラウザへ出ていく**
残りは要るときに DB から引いてください。引くのが重いなら[キャッシュ](./cache)の仕事です。
## ログアウト
```java
Auth.logout(context);
```
**セッションを丸ごと捨てます。**ログインの鍵だけ消すと、
買い物かごや下書きが**次の利用者に見えます**(共用の端末で効きます)。
`Auth.REALM` を付けたルートでは、**その種別のログインだけ**を終えます(下記)。
## 利用者を締め出す(ほかの端末からログアウトさせる)
```java
// パスワードを変えたあと:いまの端末だけ残して、ほかのログインを全部終わらせる
Auth.revokeOthers(context);
// 管理画面・バッチ・MQ から:その人のログインを全部終わらせる(WebContext は要りません)
Auth.revoke(staffId);
Auth.revoke("operator", staffId); // 種別つき
```
**締め出された端末は、次のリクエストで `Auth.guard` が弾きます。**その種別のログインを消し、
ふつうのルートなら 401 です。**remember-me の記憶も一緒に消える**ので、Cookie で入り直すこともできません。
セッションを探して消すのではありません。**利用者ごとの番号(世代)を1つ上げ、それより古い番号でログインしたセッションを信じない**作りです。
そのため、中身が利用者のブラウザにある **Cookie セッションでも効きます。**
| | |
| --- | --- |
| 効くところ | `Auth.guard` を通るリクエスト。**`Auth.PUBLIC` のルートでも**、締め出された人をログイン中に見せません(`Auth.NO_SESSION` のルートはセッションを読まないので見ません) |
| 遅れ | 呼んだ台では即座に。**複数台では、ほかの台で効くまで最大 5 秒**(`auth.revocation.cache_ttl`。`0s` にすれば毎回 DB を引きます)。その間隔で見に行くのは「誰かが締め出されたか」の1行だけで、締め出しが無ければ利用者ごとには引き直しません(2.5.2 から。最長 1 分で引き直します) |
| 置き場 | `auth_revocation` テーブル(初めて要ったときに作ります)。**締め出したことのある人だけ**行ができます |
| 種別 | 種別つきの `revoke` は、**その種別のログインだけ**を終えます。同じセッションのほかの種別のログインは残ります |
| 上げたとき | 上げる前のセッションは、締め出さなければそのまま入れます(**上げた日に誰もログアウトされません**) |
| DB が無いとき | `revoke` は `IllegalStateException`。`guard` は比べません |
| 失敗 | 書けなければ `revoke` が、引けなければ `guard` が例外を投げます(**締め出した人を通さない**ため) |
| 設定 | `auth.revocation.*`([設定](./config)) |
> [!TRAP]
> **これからのログインは止めません。**締め出したあとでも、パスワードを知っていれば入り直せます。
> 盗まれたアカウントを止めるなら、**先にパスワードを変えるかアカウントを止めてから** `revoke` を呼んでください。
> [!NOTE]
> **役割を変えたら `revoke` してください。**役割はログインのときにセッションへ写すので、
> そのままだと次にログインするまで古い役割のまま入れます。
>
> **張りっぱなしの WebSocket は切りません。**接続の一覧はアプリが持っているので、切るならそこから切ってください([WebSocket](./websocket))。
> 開いている SSE は、寿命の上限(既定 5 分)で必ず終わります。
## ログインの種別でセッションを分ける
運用者の画面と利用者の管理画面のように、**同じブラウザで両方にログインしたい**ときは、
ブロックに `Auth.REALM` を付けます。種別ごとにセッションの中の置き場所が分かれるので、
**片方にログインしても、もう片方のログインは消えません。**
```java
path("/ops", () -> {
attribute(Auth.REALM, "operator");
attribute(Auth.ROLE, "ops");
post("/login", Ops::login).attribute(Auth.PUBLIC, true);
post("/login/code", Ops::code).attribute(Auth.PUBLIC, true);
post("/logout", Ops::logout);
get("/me", Ops::me);
});
path("/admin", () -> {
attribute(Auth.REALM, "member");
attribute(Auth.ROLE, "member");
// ...
});
```
コードは変わりません。`Auth.login` / `Auth.principal` / `Auth.guard` / `Auth.fullyAuthenticated` / `Auth.logout`、
二要素認証の途中の状態(`Mfa.pending` / `isPending` / `complete`)は、**いまのルートの種別で読み書きします。**
| | 種別を付けたルートでは |
| --- | --- |
| `Auth.login` | その種別の置き場所に入れる。セッション ID は振り直す(**ほかの種別のログインは持ち越す**) |
| `Auth.principal` / `guard` | その種別の置き場所だけを見る。別の種別にだけログインしていれば 401 |
| `Auth.logout` | **その種別だけ**を終える(remember-me・二要素認証の途中の状態も)。ほかの種別が残っていればセッション ID を振り直して保ち、残っていなければ丸ごと捨てる |
| `Auth.logoutAll` | すべての種別から出る(丸ごと捨てる) |
種別を決めたルートの外から扱うときは、`Auth.principal(context, "operator")` のように種別を渡します。
> [!TRAP]
> **ログインの入口・コードを入れる口・ログアウトも、同じ種別のブロックに置いてください。**
> 別のブロックに置くと、ログインした先と読みに行く先が食い違い、**入ったはずなのに 401** になります
> (閉じる側に倒れるので、漏れはしません)。
> [!TRAP]
> **remember-me を使うなら、`Remember` の種別も同じ名前にしてください。**
> `Auth.logout` は同じ名前の種別の記憶だけを消すので、名前が違うと記憶が残ります。
> そこで、**`Remember.issue` の種別がルートの種別と違えば `IllegalStateException` で止めます**(ログインのときに気づきます)。
> **`Remember.restore` は、種別が違えば何もしません。**アプリ全体に置いた `before(Remember.restore(...))`(種別なし)は
> 種別を付けたブロックには効かないので、そのブロックには同じ名前の `restore` を別に置いてください。
>
> **そのとき `before(Auth::guard)` もブロックの中に置いてください。**アプリ全体の `before` はブロックの `before` より先に走るので、
> アプリ全体に guard があると、**ブロックの `restore` が思い出す前に 401 になります**(覚えていても毎回ログインを求められます)。
```java
path("/ops", () -> {
attribute(Auth.REALM, "operator");
before(Remember.restore("operator", Ops::findStaff)); // guard より先に
before(Auth::guard);
// ...
});
path("", () -> { // 種別なしの画面もブロックにまとめる
before(Remember.restore(App::findMember));
before(Auth::guard);
// ...
});
```
> [!NOTE]
> **種別を付けなければ、これまでどおりです。**置き場所の鍵(`__auth_id` など)も、
> ログアウトで丸ごと捨てる動きも変わりません。上げる前のセッションも、そのまま読めます。
> 二要素認証と remember-me の種別(D-182 / D-183。ID の名前空間)とは別の設定ですが、
> ふつうは同じ名前にそろえます。
## 401 と 403 の返し方
`Auth.guard` は `HttpException` を投げるだけです。
**画面へ飛ばすか JSON を返すかは、アプリの `error()` が決めます。**
```java
error((context, cause, statusCode) -> {
if (statusCode == 401 && !context.request().acceptJson()) {
context.response().redirect("/login");
return;
}
context.response().code(statusCode).json("error", (statusCode < 500 ? cause.getMessage() : "サーバーで問題が起きました"));
});
```
**役割が足りないときは 403 で、401 ではありません。**
ログインし直しても結果が変わらないことを、状態コードで言います。
401 を返すと、利用者は**入り直せば見られると思って何度も試します。**
## ログインしたままにする(remember-me)
```java
{
before(Remember.restore(App::findPrincipal)); // 先に「思い出す」
before(Auth::guard); // そのあと見張る
post("/password", Password::change).attribute(Auth.FULL_AUTH, true);
}
// id から引き直す。役割をここで引くので、権限を剥奪すればすぐ効く
private static Principal findPrincipal (long id) {
Data staff = findStaff(id);
return staff.isEmpty() ? null
: Principal.of(staff.getLong("id"), staff.getString("name"), staff.getString("role"));
}
```
ログインのときに、**印が付いていたときだけ**覚えます。
```java
Auth.login(context, principal);
if ("1".equals(request.getString("remember"))) {
Remember.issue(context, principal);
}
```
> [!TRAP]
> **`before(Remember.restore(...))` は `before(Auth::guard)` より先に置いてください。**
> あとに置くと、**guard が「ログインしていない」と決めたあとで思い出す**ことになります。
>
> **いつも `issue` を呼ばないでください。**共用の端末で、**次の人が入れます。**
### Cookie を盗まれても、パスワードは変えられない
**これが remember-me を出せる理由です。**
思い出して戻ってきた人は「いまパスワードを入れた人」ではないので、
`attribute(Auth.FULL_AUTH, true)` を付けたルートでは **401** になります。
パスワードの変更・退会・決済・連絡先の変更に付けてください。
コードの中で見るなら `Auth.fullyAuthenticated(context)` ですが、
**ルート属性のほうが書き忘れが起きません。**
**パスワードを入れてからの時間で切るなら `auth.full_auth_max_age`**(既定 0s で切らない)。
`15m` にすると、ログインから 15 分を過ぎたセッションは、`Auth.FULL_AUTH` のルートで 401 になります(もう一度パスワードを入れさせてください)。
セッションを盗まれても、大事な操作ができる時間が限られます。
### 盗まれたら気づく
Cookie には **`selector:validator`** の2つが入っていて、
**validator は使うたびに作り直します。**
盗まれた Cookie と本物の Cookie は同時に生きられないので、
**回転前の値が使われたら、それが盗まれた合図**です。
合図を見つけたら、**その利用者の記憶を全部消します**(ログにも残ります)。
先に使ったのが本人か盗んだ側かは**区別できない**ので、
片方だけ消すと**本人だけが締め出されて盗んだ側が残る**ことがあります。
| | |
| --- | --- |
| 置き場 | `auth_remember` テーブル。**DB が無ければ何もしません** |
| Cookie | `selector:validator`。**validator は SHA-256 にして保存**します(selector は引くための鍵なのでそのまま) |
| 期限 | 最後に使ってから 30 日、**かつ**発行から 90 日(使い続けても、いつかは必ず切れます) |
| 猶予 | 回した直後の 60 秒は古いほうも通します(**並列のリクエストで勝手にログアウトさせない**ため) |
| ログアウト | `Auth.logout` が消します |
| パスワード変更 | **`Auth.revokeOthers(context)` を呼んでください**(下記。[利用者を締め出す](#利用者を締め出すほかの端末からログアウトさせる)) |
| 設定 | `auth.remember.*`([設定](./config)) |
> [!TRAP]
> **パスワードを変えたら `Auth.revokeOthers(context)` を呼んでください。**
> 呼ばないと、**盗まれた Cookie とセッションはそのまま使えます**——変えた意味がありません。
> **`Remember.forgetAll(userId)` だけでは足りません。**消えるのは remember-me の記憶だけで、
> ほかの端末でいまログインしているセッションはそのまま入れます(`revokeOthers` は中で `forgetAll` も呼びます)。
### ログインの種別が複数あるとき
運用者の画面と利用者の管理画面のように、**別々の表から引く ID** があるなら、
**種別(realm)を渡してください。**渡さないと記憶は利用者 ID だけで持つので、
**運用者として覚えた Cookie が、利用者の画面で「同じ ID の利用者」として思い出されます**——
パスワードを入れずに、別の人として入れてしまいます。
```java
path("/ops", () -> {
attribute(Auth.REALM, "operator"); // ログインの種別も同じにする
before(Remember.restore("operator", Ops::findStaff));
before(Auth::guard);
});
Remember.issue(context, principal, "operator"); // ログインのとき
Auth.revoke("operator", staffId); // 締め出すとき(記憶も消えます)
```
- **Cookie の名前が種別ごとに分かれます**(`remember_operator` のように、設定の名前 + `_` + 種別)。
同じホストで両方にログインしていても、互いの Cookie を上書きしません
- **思い出すときは、記憶の種別も見ます。**Cookie の名前を取り違えても、別の種別の人としては入れません
- **思い出した人は、記憶の種別でログインします**(2.5.2 から)。種別の無いルートで `restore("operator", ...)` が走っても、
種別なしの利用者としては入りません
- `forgetAll` と盗用の検知で消えるのは、**その種別のその人の記憶だけ**です
- **`Auth.logout` は、種別なしと、このアプリが使っている種別の記憶をすべて消します**
(セッションごと捨てるので、どの種別のログインも終わります)
- **種別を渡さなければ、これまでどおりです。**Cookie の名前も変わらず、上げる前の記憶で上げたあとも入れます
## 「Google でログイン」(OpenID Connect)
```java
{
before(Auth::guard);
// どちらも「ログインは要らないが、セッションは要る」
get("/auth/google", Oidc.start("google"))
.attribute(Auth.PUBLIC, true);
// 第3引数はコードを入れる画面。二要素認証を使うなら渡す
get("/auth/google/callback", Oidc.callback("google", App::findOrCreate, "/login/code"))
.attribute(Auth.PUBLIC, true);
}
// 名乗ってきた相手を、アプリの利用者に結び付ける。入れたくなければ null
private static Principal findOrCreate (OidcUser user) {
Data staff = findByOidcKey(user.key()); // "google:1234567890"
return staff.isEmpty() ? null
: Principal.of(staff.getLong("id"), staff.getString("name"), staff.getString("role"));
}
```
設定は `auth.oidc.<名前>.*`([設定](./config))。**`client_secret` は環境変数から**入れてください。
`authorization_endpoint` などは書かなければ **discovery**(`issuer` + `/.well-known/openid-configuration`)で引きます。
**起動時には引きません**——引くと、プロバイダが落ちているあいだアプリが起動しなくなります。
> [!TRAP]
> **`Auth.NO_SESSION` を付けないでください。**`state` も `nonce` も PKCE の検証子も
> セッションに置くので、付けると**戻ってきたときに何も残っていません**。
>
> `Auth.PUBLIC` は要ります(まだログインしていない人が通る道なので)。
> **この2つが別の判断だという話は上のとおりです。**
### 認可コード + PKCE だけ
暗黙フローは持っていません。`start` が **state / nonce / PKCE の検証子**を作ってセッションに置き、
`callback` が突き合わせます。
| | 何を防ぐか |
| --- | --- |
| `state` | **ログイン CSRF。**攻撃者のコードを踏ませて、**被害者を攻撃者のアカウントでログインさせる**手 |
| `nonce` | **ID トークンの使い回し。**1度取れたトークンを何度でも持ち込む手 |
| **PKCE**(S256) | **認可コードの横取り。**検証子を知らないとトークンに換えられません |
`state` は**読んだ時点で捨てます**(1回だけにするのが役目なので)。
### ID トークンの検証
**外部のライブラリを入れていません。**JWKS が返す `n`/`e`(RSA)と `x`/`y`(EC)は
JDK の `KeyFactory` で公開鍵に戻せて、署名の検証も `java.security.Signature` がやります。
見ているのは次のとおりです。**どれも「見ていない」だけで通ってしまう**ところです。
| | |
| --- | --- |
| `alg` | **ヘッダを信じません。**表に載っている RS/ES だけを通します。`none` は「署名が空でも検証したことになる」、`HS256` は**公開鍵を共有鍵として渡すと誰でも署名できる** |
| `kid` | その鍵で検証します。JWKS 側が `alg` を名乗っていれば、それとも一致すること |
| `iss` | **1文字違わず一致。**前方一致にすると `https://accounts.google.com.evil.jp` が通ります |
| `aud` | 自分が入っていること。**複数あれば `azp` も**(別のクライアント宛てを持ち込ませない) |
| `exp` / `iat` / `nbf` | 時計のずれぶんだけ緩めます。**未来に発行されたことになっているものは通しません** |
| `nonce` | こちらが送ったものと一致すること |
**断る理由は返しません。**「nonce が合いません」と返すと、**どこまで通ったかを外から測れます**。
ログにだけ残して、返すのは 401 です。
> [!TRAP]
> **メールが同じでも、既存の利用者に自動で結び付けません。**
> 引くのは `provider + sub` の組(`user.key()`)だけです。
>
> メール一致で自動的に結び付けると、**メールを検証していないプロバイダが1つ混ざるだけで
> アカウント乗っ取りになります**。既存のアカウントに紐付けたいなら、
> **ログイン済みの状態で明示的に「連携する」**操作をさせてください。
>
> `user.emailVerified()` は見られますが、**それを信じるかどうかはアプリの判断**です。
### 二要素認証と一緒に使うとき
**コードを入れる画面のパスを、第3引数で渡してください。**
渡しておくと、二要素認証を有効にしている人が「Google でログイン」から来たときに、
パスワードで入るときと同じように `Mfa.pending` へ倒してその画面へ飛ばします。
> [!TRAP]
> **渡さないと、その人は入れません**(401 になります)。
> 0.6.0 では**黙ってログインさせていた**ので、
> **「Google でログイン」を選ぶだけで二要素が飛んでいました**。
> 素通りさせるくらいなら入れないほうがよい、という判断です(1.0)。
ログインの種別が複数あるなら(下の「ログインの種別が複数あるとき」)、**第4引数に種別**を渡します。
```java
get("/ops/auth/google/callback"
, Oidc.callback("google", Ops::findStaff, "/ops/login/code", "operator"))
.attribute(Auth.PUBLIC, true);
```
### やらないこと
| | |
| --- | --- |
| アクセストークンの保管 | しません。ここは「誰がログインしたか」までです。外部 API を叩くなら、返ってきたトークンをアプリが自分で持ってください(保管・暗号化・失効・スコープの追加同意まで面倒を見ることになります) |
| jimble が認可サーバーになる | ありません |
| ルートを自動で生やす | しません。`get("/auth/google", ...)` と自分で書きます([原則](./principles)) |
## 二要素認証(TOTP)
パスワードが合ったあと、**まだログインさせずに**コードを待ちます。
```java
// パスワードが合ったあと
if (Mfa.isActive(staff.getLong("id"))) {
Mfa.pending(context, principal); // ログインさせない。セッションに預けるだけ
context.response().redirect("/login/code");
return;
}
Auth.login(context, principal);
```
```java
// POST /login/code
if (!Mfa.complete(context, request.getString("code"))) {
context.flash().put("message", "コードが違います");
}
context.response().redirect(Mfa.isPending(context) ? "/login/code" : "/");
```
`complete` が true を返したときは、**中で `Auth.login` まで済んでいます。**
> [!TRAP]
> **コードを入れるまでは「ログインしていない」扱いです。**
> `Auth.principal(context)` は `Principal.ANONYMOUS` を返し、
> `/login/code` 以外のルートは通れません。
>
> つまり **`/login/code` には `Auth.PUBLIC` が要ります**(まだログインしていない人が通る道なので)。
> `Auth.NO_SESSION` は付けないでください——途中の人はセッションに預けてあります。
### 登録
```java
Mfa.Enrollment enrollment = Mfa.enroll(staffId, "member1@example.com");
// enrollment.uri() を QR にして見せる(otpauth://totp/...)
// enrollment.recoveryCodes() は「この一度だけ」見せる
```
**`enroll` だけでは有効になりません。**
認証アプリに入れてもらってから、出てきたコードで `Mfa.activate(staffId, code)` を呼びます。
```java
if (!Mfa.activate(staffId, request.getString("code"))) {
context.flash().put("message", "コードが合いません。もう一度お試しください");
}
```
> [!TRAP]
> **2段階にしてあるのは、締め出さないためです。**
> `enroll` の時点で有効にすると、**QR の読み取りに失敗した人が二度と入れなくなります。**
### 登録し直すとき(端末を替えたなど)
**もう有効な人が `enroll` を呼んでも、いまの秘密鍵と回復コードはそのまま効きます**(2.2.4 から)。
新しい秘密鍵と回復コードは控えに置かれ、**新しいほうのコードで `activate` が通った時点で入れ替わります**。
| | いまの設定 | 新しい設定 |
| --- | --- | --- |
| `enroll` のあと | 使える | まだ使えない(`verify` も回復コードも通らない) |
| `activate` が通ったあと | 使えなくなる(回復コードも消える) | 使える |
途中でやめても、二要素認証は外れません。もう一度 `enroll` を呼ぶと、前の控えは捨てて新しく作り直します。
> 2.2.3 までは、`enroll` を呼んだ時点でいまの設定を消していました。途中でやめると外れたままになり、
> **セッションを盗んだ人が登録の画面を開くだけで、二要素認証を外せました。**
> 控えの列(`auth_mfa.staged_secret` など)は、上げたあと最初に使ったときに自動で足します。
### 秘密鍵は暗号化して持ちます
**`auth.mfa.secret_key` が設定されていなければ、`enroll` は例外を投げて断ります。**
TOTP の秘密鍵は、パスワードのハッシュとは違います。
ハッシュは漏れても解くのに手間がかかりますが、**秘密鍵は漏れたらその場でコードが作れます。**
平文で持つと、「2要素を入れてあるのに、DB が漏れたら全員突破される」ものになります。
> [!TRAP]
> **`cipher.key` を流用しないでください。**
> `cipher.*` を書くと、**`hash.password.encrypt` も書かないと起動しません**。
>
> **いま平文の BCrypt を保存しているアプリが、二要素認証のために `cipher.key` を
> 設定して `encrypt = true` と書くと、保存済みのパスワードが「暗号化済み」として読まれ、
> 全員入れなくなります。** 返るのは「IDかパスワードが違います」だけです。
> **鍵を分けてあるのはこのためです**——二要素には `auth.mfa.secret_key` を使ってください。
### 回復コード
認証アプリを入れた端末は失くします。回復コードはそのための逃げ道です。
| | |
| --- | --- |
| 出る場所 | `enroll` の戻り値だけ。**DB には `auth.mfa.secret_key` を鍵にした HMAC しか残りません**(2.2.3 までに出したものは SHA-256 のまま使えます) |
| 数 | 10 個(`auth.mfa.recovery_codes`) |
| 使い方 | コードの欄にそのまま入れます。`verify` が認証アプリ → 回復コードの順に見ます |
| 使ったら | **消えます。**1つは1回だけです |
| 残りの数 | `Mfa.remainingRecoveryCodes(userId)` |
**残りが 0 になっても知らせません。**画面に出すかどうかはアプリの判断です。
### 総当たりへの備え
| | |
| --- | --- |
| 待たせる | `Lockout` と同じ仕掛けです(鍵は `mfa:<利用者 ID>`)。**6桁は 100 万通りしかないので、抑えないと1日で当たります** |
| 待たせ方 | 3回までは待たせず、そのあと 1 → 2 → 4 … 秒。上限 300 秒。超えたら **429** |
| 窓 | 前後1つ(`auth.mfa.window`)。**広げるほど当たりも増えます**——10 にすると当たりが 21 通りぶんになります |
| 使い回し | **一度通ったコードは、その窓が終わるまで通りません。**同じコードを覗き見て入れ直す手を止めます |
| 猶予 | パスワードが通ってからコードまで 300 秒(`auth.mfa.pending`)。過ぎたら**はじめからやり直し**です |
### やめるとき
```java
post("/mfa/disable", Mfa2::disable).attribute(Auth.FULL_AUTH, true);
```
> [!TRAP]
> **`Mfa.disable` を呼ぶ前に、本人であることを確かめてください。**
> ここが緩いと、**Cookie を盗んだ側が2要素を外せます**——入れた意味がなくなります。
> `attribute(Auth.FULL_AUTH, true)` を付けて、**いまパスワードを入れた人だけ**にしてください。
### ログインの種別が複数あるとき
運用者の画面と利用者の管理画面のように、**別々の表から引く ID** があるなら、
**種別(realm)を渡してください。**渡さないと、`staff.id = 1` と `member.id = 1` が
**同じ「利用者 1」として扱われます**——片方が登録すると**もう片方の秘密鍵を上書きし**、
片方のコードでもう片方のログインが通ります。
```java
// 運用者のログイン
if (Mfa.isActive("operator", staff.id())) {
Mfa.pending(context, principal, "operator"); // 種別はセッションに預かる
context.response().redirect("/ops/login/code");
return;
}
// POST /ops/login/code — complete() は pending で預けた種別で確かめる
Mfa.complete(context, code);
// 登録・有効化・やめる・回復コードの数
Mfa.enroll("operator", staff.id(), staff.email());
Mfa.activate("operator", staff.id(), code);
Mfa.disable("operator", staff.id());
Mfa.remainingRecoveryCodes("operator", staff.id());
```
- **`complete()` に種別を渡す口はありません。**`pending` で預けた種別で確かめます——
コードを受け取る側が種別を選べると、**別の種別の秘密鍵で確かめさせる道**ができるからです
- 種別に使えるのは**英数字・`_`・`-` の 64 文字まで**です。空文字は「種別なし」です
- **種別を渡さない呼び方は、これまでどおり動きます。**種別を入れる前に登録した人は「種別なし」になり、
同じコードで入れます(表に列を足すのは、最初に使ったときに枠組みがやります)
- 総当たりの数え(`Lockout`)も種別ごとです
### 仕様と、やらないこと
| | |
| --- | --- |
| 方式 | TOTP(RFC 6238 / RFC 4226)。HMAC-SHA1、6桁、30 秒。**認証アプリが揃って読めるのがこれだけ**だからです |
| 置き場 | `auth_mfa` と `auth_mfa_recovery`。**DB が要ります**(無ければ `enroll` は例外) |
| QR 画像 | 作りません。`enrollment.uri()` を渡すので、画面側で描いてください(依存を増やさないため) |
| SMS / メール | ありません。SMS は SIM の乗っ取りで抜かれます |
| WebAuthn / パスキー | 2つ目の要素としては使いません。**パスキーだけでログインする形があります**(2.4.0。下の「パスキー」)。パスキーで入った人にはコードを聞きません |
| 「信頼した端末」 | ありません。remember-me が近いことをしますが、**別のものです**(あちらはパスワードの代わり) |
動いているものは `examples/approval-auth` にあります(ログイン → コード → 登録 → 解除まで)。
## API のトークン(Authorization: Bearer)
**連携やスクリプトのために、利用者が API のトークンを発行できます**(2.4.0 から)。
DB に持つ不透明なトークンです(JWT ではありません。すぐ失効でき、鍵の管理が要りません)。
```java
before(ApiToken.authenticate(App::findPrincipal)); // Remember.restore・Csrf::verify・Auth::guard より先に
before(Auth::guard);
path("/api", () -> {
attribute(ApiToken.ACCEPT, true); // トークンを受けるのは、宣言したルートだけ
get("/requests", Api::list).attribute(ApiToken.SCOPE, "requests:read");
post("/requests", Api::create).attribute(ApiToken.SCOPE, "requests:write");
});
```
```bash
curl -H "Authorization: Bearer jbt_..." https://example.com/api/requests
```
### 発行・一覧・削除
```java
// パスワードを入れて入った人の画面から(FULL_AUTH のルート)
ApiToken.Issued issued = ApiToken.issue(me.id(), "経費の連携", Set.of("requests:read"), Duration.ofDays(90));
// issued.token() を「この一度だけ」見せる。DB にはハッシュしか残らない
List tokens = ApiToken.list("", me.id()); // id・name・scopes・created_at・expires_at・last_used_at(トークンそのものは無い)
ApiToken.revoke("", me.id(), id); // すぐ使えなくなる。ほかの人のものは消さない
```
| | |
| --- | --- |
| 形 | `jbt_` + 256 ビットの乱数。頭が決まっているので、漏れたトークンを見つける道具(GitHub の secret scanning など)が拾えます |
| 期限 | 発行のときに決めます(`Duration.ZERO` で無期限。勧めません) |
| スコープ | 英小文字・数字・`: . _ -`。ルートの `ApiToken.SCOPE` がトークンに無ければ **403** |
| 置き場 | `auth_api_token` テーブル(SHA-256 だけ)。**DB が要ります** |
### トークンで入った人
| | |
| --- | --- |
| ログイン | **そのリクエストのあいだだけ**ログインしています。セッションの Cookie は出しません |
| 役割 | `Auth.ROLE` はふつうに効きます(`lookup` が返した、いまの役割) |
| `Auth.FULL_AUTH` | **入れません**(401)。パスワードの変更や退会は、トークンではさせません |
| スコープ | `ApiToken.scopes(context)` で読めます。**セッションで入った人には、スコープは関係ありません** |
| CSRF | `Csrf.verify` は見ません(ブラウザは Authorization を勝手に付けないので、別のサイトからは送れません) |
### 管理画面と顧客で分ける
トークンは**ログインの種別(`Auth.REALM`)ごと**に持ちます。管理画面のトークンで顧客の API には入れず、逆もできません。
**管理者と顧客で利用者の表が別なら、種別も受け取る `lookup` を渡します**(2.5.10 から)。
ID だけを受け取る形では、ID 1 のトークンが来たときに、管理者と顧客のどちらの ID 1 かを引き分けられません。
```java
before(ApiToken.authenticate((realm, id) -> realm.equals("operator") ? App.findStaff(id) : App.findMember(id)));
path("/admin", () -> {
attribute(Auth.REALM, "operator");
attribute(ApiToken.ACCEPT, true);
...
});
path("/shop", () -> {
attribute(Auth.REALM, "member");
attribute(ApiToken.ACCEPT, true);
...
});
```
発行・一覧・削除にも種別を渡します(`ApiToken.issue("operator", staff.id(), ...)`・`ApiToken.list("member", customer.id())`)。
パスキーも同じく種別ごとに持ちます(下の「パスキー」)。
### テナントで分ける(SaaS)
クライアントごとにドメインが違う SaaS では、**トークンの利用者のテナントと、リクエストのテナント(Host)を `lookup` の中で照合します**(2.5.11 から)。
リクエスト・種別・ID を受け取る `lookup` を渡し、テナントが違えば `null` を返します。
```java
before(ApiToken.authenticate((c, realm, id) -> {
// クライアントの表から引く(下の「クライアントごとにドメインが違うとき(SaaS)」と同じ)
Tenant tenant = Tenants.byHost(c.request().host()).orElse(null);
Principal member = App.findMember(id);
// ほかのテナントの利用者なら null(止めた人と同じ 401。テナントが違うことは応答で分からない)
return tenant != null && member != null && App.tenantOf(id) == tenant.id() ? member : null;
}));
```
照合しないと、テナント A の利用者のトークンをテナント B のホストに送ったときに、`lookup` がテナント A の利用者を返して**通ってしまいます**。
| `lookup` の中で | |
| --- | --- |
| 使ってよい | `c.request()`(`host()`・ヘッダー・パス)、`c.route()`(ルートの属性) |
| 使えない | `c.session()`(例外になり 500。トークンのリクエストにセッションの Cookie を出さないため)、`c.response()`(断るときは `null` を返す)、`Auth.principal(c)`(まだログインしていない) |
`lookup` が例外を投げると 500 です(中身は返しません)。断るときは例外ではなく `null` を返します。
> [!NOTE]
> **セッションで入る人の照合は、これとは別に要ります。**セッションの Cookie も、ほかのテナントのホストへ送れるからです。
> `Auth::guard` のあとに、ログイン中の利用者のテナントと Host のテナントを比べる before を置いてください。
> パスキーは、ログインのハンドラの中で `Passkey.login(c, rp(c), credential, id -> ...)` を呼ぶので、その `lookup` で `c` を使って照合できます。
### 断るとき
| | 状態コード |
| --- | --- |
| `ApiToken.ACCEPT` の無いルートに Bearer が来た | 401 |
| トークンが違う・期限切れ・消した | 401(`WWW-Authenticate: Bearer error="invalid_token"`) |
| **`Auth.revoke` / `Auth.revokeOthers` のあと**(それより前に発行したもの) | 401。セッションと同じ世代で見るので、締め出した・パスワードを変えた瞬間に止まります |
| `lookup` が null を返した(利用者を止めた・消した) | 401 |
| スコープが足りない | 403(`error="insufficient_scope"`) |
> [!TRAP]
> **`ApiToken.authenticate` は before のいちばん先に置きます。**先にセッションを読まれると、
> トークンのリクエストにセッションの Cookie を出すことになるので、例外にして知らせます。
> 置き忘れて Bearer が来たときは、Auth.guard が1度だけ WARN を出します。
>
> **発行は `Auth.FULL_AUTH` のルートから。**セッションを盗んだ人にトークンを作らせないためです。
## パスキー(パスワードなしのログイン)
**パスキーだけでログインできます**(2.4.0 から)。ログイン ID も入れません——ブラウザが、このサイトのパスキーの候補を出します。
パスキーは端末の本人確認(指紋・顔・PIN)込みなので、通った人は `Auth.login` でログインさせます。
**二要素認証のコードは聞きません**(パスキーそのものが「持っている端末」と「本人確認」の2つを満たします)。`Auth.FULL_AUTH` のルートにも入れます。
```java
// 登録(ログインしている人。パスワードを入れ直した人だけ)
post("/passkey/register/options", c -> c.response().json(Passkey.registrationOptions(c, loginIdOf(c))))
.attribute(Auth.FULL_AUTH, true);
post("/passkey/register", c -> {
Passkey.register(c, c.request().bodyJson(), "ノート PC"); // 第3引数は利用者が見分ける名前
c.response().json("ok", true);
}).attribute(Auth.FULL_AUTH, true);
// ログイン(誰でも。NO_SESSION は付けない)
post("/passkey/login/options", c -> c.response().json(Passkey.loginOptions(c))).attribute(Auth.PUBLIC, true);
post("/passkey/login", c -> {
if (!Passkey.login(c, c.request().bodyJson(), App::findPrincipal)) { // 利用者 ID → Principal(入れないなら null)
throw new HttpException(401, "パスキーでログインできませんでした");
}
c.response().json("ok", true);
}).attribute(Auth.PUBLIC, true);
// ブラウザ側の JS
get("/passkey.js", Passkey.script()).attribute(Auth.PUBLIC, true);
```
ブラウザ側は、同梱の JS を読んで呼ぶだけです。
```html
```
失敗すると `Error` を投げます(`error.status` にサーバーの状態コード。利用者が取り消すと `error.name === "NotAllowedError"`)。
確かめるときに一緒に送りたいもの(ラベルなど)は `extra: { label: "ノート PC" }`、入力欄の候補を待っているのをボタンで止めるなら `signal`(`AbortController`)を渡します。
動く例は examples/approval-auth の `login.jte` と `passkeys.jte` です。
`csrf.bind_session = true` でログインのあとにトークンが変わっても、JS が `X-CSRF-Token` を読んで差し替えます(`onCsrfToken` で受け取れます)。
### 設定
```conf
auth {
passkey {
rp_id = "example.com" # パスキーを結びつけるドメイン(必須)
rp_name = "承認ワークフロー" # 登録のときにブラウザが出す名前(空なら rp_id)
origins = ["https://example.com"] # 受け付けるオリジン(空なら https:// + rp_id)
timeout = 5m # 始めてから終えるまで
}
}
```
> [!TRAP]
> **`rp_id` をあとから変えると、登録したパスキーが全部使えなくなります。**パスキーはドメインに結びついているためです。
> サブドメインで動かすなら、親のドメイン(`example.com`)を書いておくと、`app.example.com` からも `admin.example.com` からも使えます。
>
> 手元で試すなら `rp_id = "localhost"`、`origins = ["http://localhost:9000"]`(ブラウザは localhost だけ http を許します)。
### クライアントごとにドメインが違うとき(SaaS)
**`rp_id` を設定ではなく、ルートの中で渡せます。**`PasskeyRp` を作って、4つのメソッドに同じものを渡します。
```java
PasskeyRp rp(WebContext c) {
// クライアントの表から引く。Host ヘッダをそのまま rp_id にしない(知らないドメインは 404)
Tenant tenant = Tenants.byHost(c.request().host()).orElseThrow(() -> new HttpException(404, "見つかりません"));
return PasskeyRp.of(tenant.domain(), tenant.name()); // オリジンは https://
}
post("/passkey/login/options", c -> c.response().json(Passkey.loginOptions(c, rp(c)))).attribute(Auth.PUBLIC, true);
post("/passkey/login", c -> {
if (!Passkey.login(c, rp(c), c.request().bodyJson(), App::findPrincipal)) {
throw new HttpException(401, "パスキーでログインできませんでした");
}
c.response().json("ok", true);
}).attribute(Auth.PUBLIC, true);
// 登録も Passkey.registrationOptions(c, rp(c), 名前) / Passkey.register(c, rp(c), credential, ラベル)
```
| | |
| --- | --- |
| 分け方 | パスキーは rp_id ごとに持ちます。**クライアント A で登録したパスキーは、クライアント B では使えません**(jimble が表の rp_id で断ります。認証器も rp_id ごとに鍵を分けます) |
| つなぎ間違い | options を出したときの rp_id をセッションに置き、確かめるときに違えば断ります(手続きのやり直し) |
| 形の確かめ | `PasskeyRp` は、rp_id がドメインの形か、オリジンのホストが rp_id かそのサブドメインか(http は localhost だけ)を作ったときに確かめます。別のクライアントのドメインを並べると例外です |
| 一覧 | `Passkey.list(realm, rp, id)` でそのクライアントのものだけ。`Passkey.list(id)` はどのクライアントのものも返します(`rp_id` が付きます) |
> クライアントが独自ドメイン(`login.client-a.co.jp`)と共通のサブドメイン(`client-a.saas.example`)の両方を持つなら、
> パスキーはどちらか1つの rp_id に結びつきます。ログインの画面をどちらのドメインで出すかを先に決めてください。
### 何を確かめるか
| | |
| --- | --- |
| チャレンジ | セッションに置き、**1度しか使えません**。`timeout` を過ぎたものは断ります。登録のチャレンジは、出したときにログインしていた人にしか使えません |
| オリジン | `origins` に無いものは断ります。別のサイトの iframe の中からの呼び出し(`crossOrigin`)も断ります |
| 本人確認 | **端末の本人確認(UV)を必ず求めます。**押しただけ(UP)のセキュリティキーは通りません |
| 署名 | 登録した公開鍵で確かめます(ES256 / EdDSA / RS256。どれも JDK だけで確かめます) |
| 利用者 | ブラウザが返す利用者のハンドルが、登録したものと一致すること |
| 複製 | 署名の回数が戻っていたら断ります(同期するパスキーはいつも 0 なので、そのときは見ません) |
**認証器の証明書(attestation)は確かめません**(`attestation: "none"`)。パスキーのほとんどは証明書を出さず、
「どのメーカーの認証器か」で断る必要は、ふつうのアプリにはありません。
### 一覧と削除
```java
List passkeys = Passkey.list(me.id()); // id・rp_id・label・created_at・last_used_at・backed_up
Passkey.delete(me.id(), id); // FULL_AUTH のルートから。ほかの人のものは消さない
Passkey.deleteAll("", me.id()); // 退会など
```
| | |
| --- | --- |
| 置き場 | `auth_passkey` テーブル。**DB とセッションが要ります**。使ったチャレンジは `auth_passkey_challenge` に残し、同じものは2度通しません(2.5.2 から。cookie のセッションで、取られた要求を送り直させないため) |
| 種別 | ルートの `Auth.REALM` で分けます(登録もログインも、そのルートの種別。一覧と削除は種別を渡す形もあります) |
| 端末を失くした | 本人に別の手段(パスワードなど)でログインしてもらい、`Passkey.delete` で消します。`Auth.revoke` はいまのセッションを止めるだけで、パスキーは残ります |
> [!TRAP]
> **登録は `Auth.FULL_AUTH` を付けたルートから。**セッションを盗んだ人が自分のパスキーを足すと、
> パスワードを変えられても、そのパスキーで入り続けられます。
>
> **パスキーのログインの口には、流量制限を掛けてください**(`RateLimit`)。総当たりは効きませんが、チャレンジを出すたびにセッションを作ります。
## Basic 認証
運用向けの口には Basic 認証が使えます([リクエストとレスポンス](./request-response))。
```java
path("/ops", () -> {
before(BasicAuth.of("ops", System.getenv("OPS_PASSWORD")));
attribute(Auth.PUBLIC, true); // セッションのログインとは別の仕組み
get("/whoami", Ops::whoami);
});
```
## やらないこと
| | |
| --- | --- |
| 注釈(`@PreAuthorize` のようなもの) | [原則](./principles)のとおり使いません。ルート属性で宣言します |
| 「あと何日で切れるか」を利用者に見せる口 | ありません。行を自分で引いてください |
| JWT | **出しません。**失効できず鍵の管理が増えます。API の認証には、DB に持つ不透明なトークン(上の「API のトークン」)を使ってください |
| SAML | まだありません |
| 権限(permission)の対応表 | 役割の文字列だけです |
動いているものは `examples/approval-auth` にあります。
---
# 実行モデル
**リクエスト1本につき仮想スレッドが1本**です。その上で、最初から最後まで**同期的に**走ります。
- スレッドプールの設定はありません(Helidon に任せています)
- `CompletableFuture` を返す API はありません
- **ブロッキングして構いません。**JDBC もそのまま呼びます
## 1リクエストの流れ
```
onRequest
↓
before(外側 → 内側)
↓
ルートの処理 / Executor を順に実行
↓
送信
↓
(例外なら)error(内側 → 外側)
↓
after(内側 → 外側)※必ず通る
↓
onComplete ※必ず通る
```
`onRequest` は**ルートに当たらなくても呼ばれます**。
`after` と `onComplete` は `finally` にあるので、例外が出ても必ず通ります。
### 「送ったら止まる」は1か所で見ている
`before` でログイン画面を返したら、**その先の `before`・ルート・Executor は走りません。**
この判定は1か所(`Stage`)に閉じています。
**`after` と `onComplete` は走ります。**この2つは `finally` にあるので、
送信済みでも例外が出ても必ず通ります(上の図のとおりです)。
「送ったら止まる」のは**送信までの段だけ**です。
> [!NOTE]
> 移送元(jooby_base)は同じ判定を7か所にコピーしていました。
> 1か所に集めたのは、**書き足したところだけ判定を忘れる**のを防ぐためです(要件 F-C-13)。
### Executor
ルートは処理を直接書くほかに、**Executor をキューに積む**書き方ができます。
```java
post("/save", () -> new SaveExecutor());
```
- キューは実行中にも積めます(`context.addExecutor(...)`)
- `cancel()` は**その場で `execute` を抜けます**(`ExecutorCanceled` を投げ、枠組みが受け止めます)。残りの Executor が捨てられ、`onCancel` が呼ばれます。
`catch (Exception e)` で囲んだ中で呼ぶと、そこで受け止められてあとの行が走るので、`try` の外で呼んでください(1.x は印を立てるだけで、あとの行も走っていました)
- `OPTIONS` はプリフライト専用なので、**キューを回しません**(要件 F-W-19)
## どこが何のスレッドで動くか
| 仕組み | スレッド |
| --- | --- |
| HTTP リクエスト | Helidon の**仮想スレッド**(リクエストごとに1本) |
| `AsyncData` / `AsyncList` の読み込み・先読み | **呼び出しスレッドそのもの。**名前に反して別スレッドは使いません |
| SSE | **そのリクエストの仮想スレッドを掴んだまま** |
| WebSocket | Helidon のリスナのスレッド(接続ごと) |
| MQ のワーカー | 仮想スレッド。型ごとに `mq.thread_count.<型>` 本 |
| スケジューラの時計 | プラットフォームスレッド1本(`jimble-scheduler`) |
| スケジューラが起動するバッチ | 仮想スレッド。`scheduler.execute_threads` 本(`0` で無制限) |
| バッチのハートビート | 仮想スレッド1本(`jimble-batch-heartbeat`) |
本数を絞りたいところでは `VirtualThreadManager` を使います。
> [!NOTE]
> `VirtualThreadManager(n)` の `n` は「**同時に走る**本数」です。
> セマフォを取るのは仮想スレッドを起こした後なので、待っているスレッドは作られています。
> 仮想スレッドは安いので実害はありませんが、「n 本しか作らない」ではありません。
## 詰まるのはスレッドではなく接続プール
仮想スレッドはいくらでも増えます。**先に尽きるのは DB の接続です。**
> [!TRAP]
> トランザクションの中や `selectListWithFetcher` の途中は、接続をプールに返していません。
> その状態で SSE のように長く繋ぐと、**人数分だけプールを食います。**
> `maximum_pool_size = 10` なら10人で尽き、11人目は「遅い」でも「エラー」でもなく
> **接続待ちで止まったまま**になります。
SSE で無限ループを書かないのはこのためです。
有限のぶんを送って閉じ、クライアントに繋ぎ直させてください([SSE](./sse))。
## 仮想スレッドで気をつけること
| | |
| --- | --- |
| **`synchronized` を使わない** | 仮想スレッドがキャリアに固定されます。`ReentrantLock` を使ってください(jimble 自身もそうしています) |
| **共有可変状態を守る** | `static` な `HashMap` は同時アクセスで壊れます。「たまたま動いていた」が起きにくいぶん、仮想スレッドでは**実際に壊れます** |
| **`ThreadLocal` に頼らない** | jimble はリクエストの受け渡しに `ScopedValue` を使っています(`Context.current()`) |
## 止めるとき
止め方は1か所(`Shutdown`)に預けます。
```java
Shutdown.add("わたしのワーカー", worker::stop);
```
| | |
| --- | --- |
| 順番 | **後に預けたものから先に止める**(立てた順の逆)。DB を先に閉じると、止まる途中のものが DB を使えないため |
| 失敗したら | **残りは止める。**止められなかったものの名前が返る |
| jimble が預けるもの | DB プール / サーバー / スケジューラ |
> [!NOTE]
> **SIGTERM を受けると、預けたものが逆順に止まります**([サーバー設定](./server))。
> コンテナは SIGTERM を送って待つので、受け取らずに死ぬと
> 処理中のリクエストが途中で切れます。
>
> 普通に動かしているぶんには要りません。**プロセスが終われば全部止まります。**
> 要るのはプロセスを終わらせずにアプリを入れ替えるとき、つまり `jimbleRun` です。
> 止め忘れたものは入れ替え後のものと二重に動き、「同じジョブが2回走る」という形で出ます
> ([ホットリロード](./hot-reload))。
リクエスト1本ぶんの後始末は `Shutdown` ではなく `context.onClose(...)` です。
登録の逆順に呼ばれ、1つ失敗しても残りを畳みます。
---
# DB を使う
## 設定する
`application.conf` にデータソースを書きます。
```conf
db {
blog_example {
main = true
driver = "org.mariadb.jdbc.Driver"
url = "jdbc:mariadb://127.0.0.1:3306/blog_example?useUnicode=true&characterEncoding=UTF-8"
url = ${?DB_URL}
username = "blog"
username = ${?DB_USER}
password = ""
password = ${?DB_PASSWORD}
maximum_pool_size = 10
connection_pool_type = "hikari"
}
}
codegen {
package = "db"
}
```
**秘密はファイルに書かないでください。** `${?ENV_NAME}` の形にしておくと、
環境変数があればそちらが勝ちます。無ければ前の行の値が残ります。
ドライバは `jimble-db` が MySQL / MariaDB と PostgreSQL の両方を持っているので、
ふつうは足す必要はありません。
## 接続の暗号化(TLS)
**2.6.0 から、MySQL / MariaDB のドライバ(MariaDB Connector/J 4.0)は既定で TLS を使い、サーバーの証明書を確かめます**(`sslMode=verify-full`)。
2.5 までのドライバ(3.5)は、何も書かなければ**暗号化せずに**繋いでいました。
証明書を確かめられないと、**起動時に繋がらずに落ちます。**Docker の `mysql:8.4` のような自己署名の証明書の MySQL では、こう出ます。
```
Cannot use authentication plugin caching_sha2_password with a Self signed certificates.
Either set sslMode=trust, use password with a MitM-Proof authentication plugin or provide server certificate to client
```
MariaDB 11.4 以降のサーバーは、自己署名の証明書でも書き足さずに確かめられます(手元の `mariadb:11.4` は何も書かずに繋がりました)。
`url` に `sslMode` を書いて決めます。
| 書き方 | 使うところ |
| --- | --- |
| 書かない(`verify-full`) | 証明書が JDK の信頼する CA で署名されているとき。MariaDB 11.4 以降のサーバー |
| `sslMode=verify-full&serverSslCert=/path/to/ca.pem` | **本番。**サーバーの CA 証明書を渡す(Amazon RDS / Aurora なら AWS が配っている CA 証明書) |
| `sslMode=trust` | 暗号化はするが、証明書を確かめない。盗み見は防ぐが、なりすましは防がない |
| `sslMode=disable` | 暗号化しない。**手元の DB だけ** |
手元だけ暗号化しないなら、ローカル用の設定に書きます。
```conf
# application.local.conf
include "application.conf"
db {
blog_example {
url = "jdbc:mariadb://127.0.0.1:3306/blog_example?sslMode=disable"
}
}
```
> [!NOTE]
> 古い書き方の `useSSL=false` も `sslMode=disable` と同じに扱われます(jimble のテストの設定はこちらです)。
## DB 製品を選ぶ
`product` に製品を書くと、**SQL ビルダーがその製品用の SQL を出します。**
```conf
db {
blog_example {
main = true
product = "postgresql"
driver = "org.postgresql.Driver"
url = "jdbc:postgresql://127.0.0.1:5432/blog_example"
username = "blog"
password = "blog"
password = ${?DB_PASSWORD}
}
}
```
> [!TRAP]
> **`${?ENV}` の行だけを書かないでください。**環境変数が無いときにキーごと消えます
> (上の書き方は「まず既定値、あれば環境変数で上書き」の2行組です)。
> パスワードが消えたまま PostgreSQL に繋ぐと、返ってくるのは
> `The server requested SCRAM-based authentication, but no password was provided.` で、
> 設定の話に見えません。
## データベースがまだ無いとき
`create_database_sql` を書いておくと、**繋がらなかったときに1度だけ流します。**
```conf
db {
blog_example {
...
create_database_sql = "CREATE DATABASE blog_example"
}
}
```
書かなければ何もしないので、**手で作ってあるなら要りません。**
本番では権限を持たない利用者で繋ぐほうが普通なので、開発と CI のためのものです。
| 書ける値 | 製品 |
|---|---|
| `mysql`(既定)/ `mariadb` | MySQL 8.x / MariaDB |
| `postgresql` / `postgres` / `pgsql` | PostgreSQL 16 以降 |
省略すると `mysql` です。知らない値を書くと**起動時に落ちます**。
**アプリのコードは1行も変わりません。**
```java
// この1行が、product に応じて
// MySQL SELECT `post`.`id` AS `post__id` ... LIMIT ? OFFSET ?
// PostgreSQL SELECT "post"."id" AS "post__id" ... LIMIT ? OFFSET ?
// になります
SQL.select().from(Post.instance()).where(Post.id.eq(1L));
```
データソースごとに違う製品を書けます(メインは MySQL、集計用は PostgreSQL、など)。
> [!NOTE]
> **対応している製品は MySQL(MariaDB)と PostgreSQL の2つだけです。**
> 知らない名前を書くと**起動時に落ちます**——黙って MySQL に倒すと、
> PostgreSQL のつもりで書いたアプリに**バッククォートの SQL が飛びます**。
>
> **自前の方言を足す口はありません**(`Dialect` は `sealed` です)。
> 方言には SQL 関数ごとのメソッドが 40 以上あり、
> **jimble が関数を1つ足すたびに、外の実装が壊れる**ためです。
> 対応してほしい製品があれば、枠組みに足す形で受け付けます。
### その製品で書けないもの
**書けないものは、SQL を組み立てたところで `DialectException` になります。**
実行してから製品の構文エラーで落ちる、ということはありません。
| ビルダー | PostgreSQL |
|---|---|
| `forceIndex` / `useIndex` / `ignoreIndex`(インデックスのヒント) | 書けない。PostgreSQL にヒントは無く、黙って外すと遅いことに気づけないので断ります |
| `Dsl.match(...)`(全文検索) | 書けない。`to_tsvector` は語彙の分割もスコアも別物なので、黙って置き換えません |
| `Dsl.dateFormat(col, "%Y-%m-%d")` | 書けない。`to_char` は書式の言語が違うので、置き換えると**例外にならずに違う文字列**が返ります。Java 側で整えてください |
| `Dsl.jsonExtract(col, "$.a[0]")` | 配列・ワイルドカード・引用符つきキーは書けません。`$.a.b` の形だけ |
逆に、**名前だけ違うものは黙って揃えます**(`RAND` / `RANDOM`、`IFNULL` / `COALESCE`、
`TRUNCATE` / `TRUNC` など)。`Dsl.concat(...)` は PostgreSQL では `||` になります
(PostgreSQL の `concat()` は NULL を空文字として飲み込むので、
名前だけ置き換えると MySQL と結果が変わります)。
どうしてもその製品の書き方を使いたいときは `Dsl.freeSql(...)` で逃げられます。
### 気をつけること
- **空間関数の座標の順が違います。**MySQL 8 の SRID 4326 は緯度・経度、
PostGIS は経度・緯度です。jimble は吸収しないので、両方で使うなら自分で合わせてください
- マイグレーションの SQL(`conf/migration/...`)は**自分で書いたものがそのまま流れます。**
両方の製品で動かすなら、`001_xxx.mysql.sql` / `001_xxx.postgresql.sql` と
**ファイル名の接尾辞**で分けます(接尾辞なしはどちらでも当たります)。
詳しくは [製品ごとに SQL を分ける](./codegen)
## 起動時に繋ぐ
**設定を書いただけでは繋がりません。**アプリの `main` で明示的に呼びます。
```java
public static void main (String[] args) {
Migration.install(); // 起動時マイグレーション(要るなら。DBUtil.load より前)
DBUtil.load(Conf.conf().config(), App.class); // 繋がらなければ例外。ここで止まります
JimbleServer.start(new App());
}
```
`DBUtil.load` の2つ目は**クラスパスの起点**です(マイグレーション SQL をここから探します)。
自分のアプリのクラスを渡してください。
**繋がらなければ `SqlExecuteException`(`DB_007`)を投げ、起動はそこで止まります。**
戻り値はありません(`void`)。受け止めずにそのまま `main` から投げてください。
> [!NOTE]
> 1.x の `DBUtil.load` は繋がらなくても `false` を返すだけで、捨てるとサーバーが起動してしまいました。
> 2.0 で例外になりました([2.0 への移行](./migrate-2))。
> [!TRAP]
> **入口が複数あるなら、この並びを1か所にまとめてください。**
> Web・バッチ・スケジューラで別々に書くと、どれか1つだけ古くなります。
> テストからも同じものを呼びます([落とし穴](./pitfalls) / [テスト](./testing))。
終わるときは `DBUtil.stop()` です(Web は[グレースフルシャットダウン](./server)が呼びます。
バッチやテストでは自分で呼びます)。
## データソースが複数あるとき
```conf
db {
main_db { main = true, ... }
log_db { ... }
}
```
`DBUtil.getDB("log_db")` で別のデータソースが取れます。
`main = true` のものは `DBUtil.getMainDB()` です(引数なしの `getDB()` はありません)。
トップレベルに並べたものは、**1本ごとに独立した扱い**を受けます。
- `conf/migration/<データソース名>/` のマイグレーションが当たります
- codegen が `db/<データソース名>/` を作ります
- `migration` / `db_lock` / `db_value` / `db_cache` もそちらに作られます
**設定を書き写さずにもう1本ぶら下げたい**だけなら、`subs` を使います。
```conf
db {
main_db {
main = true
...
subs {
archive_db { }
}
}
}
```
`db.newSubDB("archive_db")` で取ります。
> [!TRAP]
> **接続もトランザクションも共有しません。**`subs` は親とは別のプールです。
> 同じデータベースを指していても別の接続なので、親のトランザクションの中で
> `newSubDB` に書いても**一緒には戻りません**。片方だけ残ります。
> 親から設定を引き継ぐ、それ以上のことはしていないと思ってください。
> [!TRAP]
> **`subs` にはマイグレーションも codegen も当たりません。**
> データソースごとの処理は、トップレベルの `db { }` しか回らないためです。
> だから `subs` に置けるのは「アプリが自分で作る作業用テーブル」までです。
> 表と型が要るもの(別 DB の監査ログなど)は、**トップレベルにもう1本**書いてください。
> また `subs` が別のデータベースを指すなら、**そのデータベースが先に在ること**。
> `subs` は親のデータソースを作る途中で繋ぎにいくので、あとに書いた
> トップレベルの `create_database_sql` は間に合いません
> (`failed create subs datasource` で起動が止まります)。
2本立てと `subs` を両方書いたものが `examples/approval-data` にあります。
## テーブル定義を生成する
```bash
./gradlew codegen
```
DB のスキーマから、テーブルごとのクラスを生成します。
```java
Post.id // Column
Post.title
Post.instance() // Table
```
`io.jimble.db` プラグインを入れると、`migrate → codegen → compileJava` が繋がります。
DDL を書いて起動すれば、コードのほうが追いつきます。
```kotlin
plugins {
id("io.jimble.db")
}
```
生成されたクラスは**リポジトリに入れてください**。
生成物をコミットしないと、DB が無い環境でビルドできなくなります。
## 引く
```java
Data row = db.select(
SQL.select()
.from(Post.instance())
.where(Post.id.eq(1L))
).orElseThrow();
// SELECT の結果はテーブル名でネストする(要件 F-D-02)
String title = row.getData("post").getString("title");
// Column で引けば、途中の文字列が出てこない
String same = row.getString(Post.title);
```
**`select` は `Optional` を返します。**1件も無ければ空です
(上の `.orElseThrow()` は「必ずある」と決めて取り出す書き方です)。
読めなかったときは空ではなく例外です(下の「エラーの見方」)。
**SELECT の結果はテーブル名でネストします。**
`post` と `comment` を join したとき、両方に `id` があっても衝突しません。
`Column` で引けば、文字列のキーがコードに出てきません。
`row.getData(Post.instance())` で、1テーブルぶんを**平らにして**取り出せます。
> [!TRAP]
> **`extractTableData` は平らにしません。**`{post: {...}}` をネストしたまま返すので、
> そのまま JSON にすると**入れ子が1段残ります**。
> 平らにしたいときは `getData(テーブル)` か `flattenTable(テーブル)` です。
> [!TRAP]
> **文字列の SQL(`db.select("SELECT ...")`)の結果はネストしません。**ネストはビルダーが列に
> `post__title` の別名を付けるから起きるので、自分で書いた SQL は平らな Data になります。
> 結合すると**同じ名前の列(`id` など)はあとの値で黙って上書き**されるので、別名を付けてください。
### JSON の列
MySQL の `JSON`、PostgreSQL の `json` / `jsonb` の列は、**読んだ時点で `Data`(オブジェクト)か `List`(配列)になります。**
```java
row.getStringList("tags"); // ["a", "b"] 配列の列
row.getData("options"); // オブジェクトの列
row.getString("tags"); // "a" ← 配列の先頭の要素だけ。JSON の文字ではない
```
**配列の列を `getString` すると先頭の要素だけが返ります**(そうしたときは1度だけ WARN が出ます)。
JSON の文字のまま欲しいなら、SQL で文字にして読んでください——MySQL は `CAST(列 AS CHAR)`、PostgreSQL は `列::text` です。
## エラーの見方
```java
try (DB db = BlogExample.db()) {
/*
* 0件は空(空リスト・空の Optional・件数 0)、失敗は SqlExecuteException(2.0)。
* 書かなければ上まで飛んで 500。トランザクションの中なら巻き戻る。
*/
List rows = db.selectList(SQL.select().from(Post.instance()));
// 分岐したい失敗は一意制約くらい。それだけを受け止める
try {
db.insert(SQL.insert(Post.instance()).value(Post.title, "hello"));
} catch (DuplicateKeyException ex) {
Log.info("もうあります");
}
}
```
**DB の失敗は例外で返ります。**SQL が通らなかった・繋がらなかったときは `SqlExecuteException`(非検査)です。
受け止めなければ上まで飛んで 500 になり、トランザクションの中なら巻き戻ります。
コードは `getCode()`(`DB_999` など)、元の JDBC の例外は `getCause().getCause()` にあります。
**0件は失敗ではありません。**空の `Optional`・空のリスト・件数 0 で返ります。
| メソッド | 返すもの | 0件のとき |
| --- | --- | --- |
| `select` / `selectCached` | `Optional` | 空の `Optional` |
| `selectList` / `selectListCached` / `selectListPerformance` | `List`(`null` は返しません) | 空リスト |
| `insert` | なし(`void`) | —— |
| `insertKey` | 採番された値。採番されなければ `SqlExecuteException` | —— |
| `update` / `delete` | 当たった件数(`int`) | `0` |
| `execute` | 当たった件数(`int`)。DDL と結果セットを返す文は `0` | `0` |
| `executeBatch` / `insertBatch` | 文ごとの件数 / 採番値の一覧 | 空の入力は空リスト |
`executeBatch` / `insertBatch` は**SQL が全部同じでなければなりません。**違うものが混ざっていれば `DB_998` の例外です。
> [!NOTE]
> **PostgreSQL では、ドライバの設定で速さが変わります。**jimble は接続の URL にドライバの設定を足しません。
>
> - **まとめて入れる**:URL に `reWriteBatchedInserts=true` を書くと、`insertBatch` の INSERT を複数行の1本にまとめて送ります
> - **大きな結果を少しずつ読む**:`fetch_size` は**トランザクションの中でしか効きません**(自動コミットのままだと、結果を全部ドライバが持ちます)。`db.transaction(...)` の中で読んでください
### 「1件も無かった」と「読めなかった」
**別のものとして返ります。**空の `Optional` は「1件も無かった」だけで、読めなかったときは例外です。
```java
Optional user = db.select(sql, id); // 読めなければ、ここで SqlExecuteException
if (user.isEmpty()) { return 誰でもない; } // 空は「1件も無かった」だけ
Data post = db.select(sql, id)
.orElseThrow(() -> new HttpException(404, "記事がありません"));
```
DB が落ちた日に「そんな利用者はいません」と答えることはありません。
> [!NOTE]
> 1.x の `select` は、0件も失敗も `null` でした(`isError()` を見ないと見分けられませんでした)。
> 2.0 で分かれました。`selectOrThrow` / `selectListOrThrow` は `select` / `selectList` と同じ意味になったので、
> 非推奨です(2.x で消します)。
### 一意制約だけを受け止める
分岐したい失敗は、たいてい一意制約だけです。**それは型で分かれます。**
一意制約に当たると `DuplicateKeyException`(`SqlExecuteException` の子)が飛びます(`insert` / `insertKey` / `update` など、どれでも)。
```java
try {
long id = db.insertKey(SQL.insert(User.instance()).value(User.email, email));
} catch (DuplicateKeyException e) {
return 「そのメールアドレスは使われています」;
}
```
> [!TRAP]
> **トランザクションの中で受け止めて続けると、その Tx は確定できません。**
> `tx.commit()` が `TransactionException`(`DB_004`)で断り、全部巻き戻します([トランザクション](./transaction))。
> 「あれば更新」に使うなら、トランザクションの外で受け止めるか、
> SQL で書いてください(MySQL は `INSERT ... ON DUPLICATE KEY UPDATE`、PostgreSQL は `INSERT ... ON CONFLICT`)。
### どこで catch するか
**DB の例外は非検査なので、コンパイラは catch を求めません。**受けるところは、次の表で決めてください。表に無いものは受けずに、500 と巻き戻しに任せます。
| こう書くとき | 受ける例外 | 返すもの |
| --- | --- | --- |
| 利用者の入力を、一意制約(UNIQUE・主キー)のある列に入れる・変える(メールアドレス・ログイン ID など) | `DuplicateKeyException` | 409 などの「もう使われています」 |
| 利用者の操作で `RedisLock.lock(...)` を取る | `RedisLockException` | 409 などの「処理中です」(`tryLock(...)` の空で分けてもよい) |
| それ以外(`SqlExecuteException` / `TransactionException`) | 受けない | 枠組みが 500 を返し、ログに出す |
- **先に `select` で「まだ無い」と確かめても、catch は省けません。**同時に2つ来れば、両方が確かめを通って片方が一意制約に当たります
- **受けるのはトランザクションの外です**(上の TRAP)
- 同じ値を2回入れるテストを1本書いておくと、catch を忘れたところが 500 で見つかります
### 採番値と件数
**`insert` は何も返しません。**採番値が要るなら `insertKey`、件数が要る `INSERT ... SELECT` などは `execute` です。
```java
db.insert(SQL.insert(Tag.instance()).value(Tag.name, name)); // 入れるだけ
long id = db.insertKey(SQL.insert(Post.instance()).value(...)); // 採番値
int count = db.execute("INSERT INTO archive SELECT * FROM post WHERE ..."); // 件数
```
> [!NOTE]
> 1.x の `insert` は、採番値が取れればその値、取れなければ入った件数を返していました
> (採番列を1本足しただけで意味が変わりました)。2.0 で分けました。
> `insertNoReturnKey` は非推奨です(2.x で消します)。`insert` か `execute` を使ってください。
## マイグレーション
`conf/migration/<スキーマ名>/001_xxx.sql` に置きます。
```sql
# --- !Ups
CREATE TABLE `note` (
`id` BIGINT UNSIGNED AUTO_INCREMENT COMMENT 'ID' PRIMARY KEY,
`title` VARCHAR(200) NOT NULL COMMENT 'タイトル',
`created_at` DATETIME NOT NULL COMMENT '作成日時'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='メモ';
# --- !Downs
DROP TABLE `note`;
```
```bash
./gradlew migrate
```
適用済みのものは記録されるので、二度は走りません。
複数のサーバーが同時に起動しても、ロックを取るので1つしか流れません。
いつ何が走るか、失敗したときにどうなるか、生成される型の対応は
[マイグレーションとコード生成](./codegen) にまとめてあります。
---
# SQL DSL
文字列で SQL を書くこともできますが、DSL のほうが型が効きます。
## 引く
```java
List posts = db.selectList(
SQL.select()
.from(Post.instance())
.where(Post.published.eq(true))
.orderBy(Post.created_at.desc())
.limit(20)
);
```
`limit` と `offset` があります。`select()` に何も渡さなければ全列です。列を選ぶなら渡します。
```java
SQL.select(Post.id, Post.title).from(Post.instance())
```
## 条件
```java
.where(Post.id.eq(1L))
.where(Post.title.like("%jimble%"))
.where(Post.created_at.ge(from).and(Post.created_at.lt(to)))
.where(Post.id.in(List.of(1L, 2L, 3L)))
```
`where` を複数回呼ぶと AND で繋がります。
OR や括弧は `Dsl.anyOf(...)` / `Dsl.allOf(...)` でまとめます(1.5.0 から)。
```java
.where(Post.shop_id.eq(shopId), Dsl.anyOf(Post.status.eq("draft"), Post.status.eq("review")))
// WHERE (shop_id = ?) AND ( status = ? OR status = ?)
```
`a.or(b).and(c)` のようにつなぐと、**左から読んだとおり** `(a OR b) AND c` になります(2.2.3 から)。
2.2.2 までは括弧を付けずに `a OR b AND c`(= `a OR (b AND c)`)と書いていたので、
「公開、または自分のもの」に「自分のテナント」を足したつもりが、ほかのテナントの公開データまで返りました。
> [!TRAP]
> **リクエストを `apply(Data)` / `setRow(Data)` / `valueRow(Data)` にそのまま渡さないでください。**どの列名でも受け付けるので、
> `?where[users][password_hash|starts_with]=...` で画面に出していない列を当てられたり、
> `role` などの書き換えてはいけない列を足されたりします。**許す列を渡す形**を使ってください(2.2.3 から)。
>
> ```java
> SQL.select(User.id, User.name).from(User.instance()).apply(context.request().bodyAll(), User.name, User.created_at)
> SQL.update(User.instance()).setRow(form, User.nickname, User.bio).where(User.id.eq(me))
> ```
> [!TRAP]
> **1つの条件に比較は1つだけです。**`Post.id.ge(1).le(9)` は組み立て時に落ちます——
> 1.4 までは**後ろの `le(9)` だけが残り、例外も出ませんでした**。範囲は `between(a, b)`、
> 別の条件は `.and(Post.id.le(9))` で書きます。
> 列に直接 `and(...)` / `or(...)` を付けるのも落ちます(1.4 までは `null` が返っていました)。
> [!NOTE]
> **`contains` / `starts_with` / `ends_with` は、渡した文字を「文字として」探します。**
> 検索欄に打たれた `50%` は「50 で始まる何か」ではなく **`50%` という文字列**として探し、
> `a_c` は `abc` に当たりません(`%` `_` を逃がし、`ESCAPE '!'` を付けています)。
> **`%` を自分で書きたいときは `like(...)` を使ってください**——あちらは値をそのまま渡します。
> [!NOTE]
> **`in(...)` には `List` でも配列でも渡せます。**`long[]` や `int[]` のような素の配列も通ります。
> **空の一覧(と `null`)は組み立て時に落ちます**——`IN ()` は壊れた SQL で、
> `IN (NULL)` に潰すと**どの行にも当たらないまま 0 件が返る**ためです。
> [!NOTE]
> **`NULL` との比較は `is_null()` / `is_not_null()` で書きます。**
> `eq(null)` / `not(null)` は組み立て時に `SqlBuildException` になります。`= NULL` はどの行にも当たらないためです。
## 結合
```java
SQL.select()
.from(Post.instance())
.left(Comment.instance()).on(Comment.post_id.eq(Post.id))
.where(Post.id.eq(id))
```
内部結合は `inner(...)` です。`on()` は直前の結合に付きます。
2度呼ぶと AND で繋がります。**結合の無いところで `on()` を呼ぶと落ちます**
(1.4 までは、`from` がまだ素のテーブルのとき**ON が黙って消えていました**)。
結果はテーブル名でネストするので、`row.getData("comment")` で取れます。
### インデックスのヒント(MySQL)
テーブルに `forceIndex(...)` / `useIndex(...)` / `ignoreIndex(...)` を付けて、`from` や結合に渡します(2.5.0 から)。
```java
SQL.select()
.from(Orders.instance().forceIndex("orders__created_at")) // FROM `orders` FORCE INDEX (`orders__created_at`)
.left(Customer.instance().useIndex("customer__code")) // LEFT JOIN `customer` USE INDEX (`customer__code`)
.on(Customer.id.eq(Orders.customer_id))
.where(Orders.created_at.ge(from))
```
| | |
| --- | --- |
| `forceIndex` | その索引を使わせる(テーブル全体を読むより必ずこちら) |
| `useIndex` | その索引の中から選ばせる |
| `ignoreIndex` | その索引を使わせない。主キーは `"PRIMARY"` |
名前はいくつでも並べられます。名前に見えないもの(空白・引用符・括弧)は、組み立てる前に `SqlBuildException` です。
> [!TRAP]
> **最後の手段にしてください。**オプティマイザが索引を選び違えるのは、統計が古い(`ANALYZE TABLE`)・索引が足りない・条件が索引に合っていない、のどれかが多いです。
> ヒントを書くと、データが増えて別の索引のほうが速くなっても、ずっとこちらを使い続けます。使うなら、`EXPLAIN` で効いていることを確かめてください。
>
> **PostgreSQL にはヒントが無いので、組み立てたところで `DialectException` になります**(黙って外しません。外すと「MySQL では速いのに PostgreSQL では遅い」が気づかれないまま残ります)。
## 入れる・直す・消す
```java
long id = db.insertKey(
SQL.insert(Post.instance())
.value(Post.title, title)
.value(Post.created_at, new Date()) // アプリ側の時計
);
int updated = db.update(
SQL.update(Post.instance())
.set(Post.published, true)
.where(Post.id.eq(id))
);
int deleted = db.delete(
SQL.delete(Post.instance()).where(Post.id.eq(id))
);
```
> [!NOTE]
> **時刻はどちらの時計で入れるかを決めてください。**`new Date()` はアプリ側の時計、
> `Dsl.now()` はDB 側の時計です。
>
> ```java
> .value(Post.created_at, Dsl.now()) // DB 側の時計
> ```
>
> **アプリを複数台で動かすなら DB 側にそろえるほうが安全です**——
> 台ごとに時計がずれていると、あとから入れた行のほうが古いことが起こり、
> 作成日時で並べた一覧が入れ替わります。
`insertKey` は採番された ID を返します(採番されなければ例外)。ID が要らないときは
`insert` です(戻り値はありません)。件数が要る `INSERT ... SELECT` などは `execute(...)` で流します。
`update` / `delete` は当たった件数を返します。**失敗はどれも `SqlExecuteException`** で、
一意制約の違反だけ `DuplicateKeyException` です([DB を使う](./db))。
> [!NOTE]
> 現在時刻は `Dsl.now()` です。**文字列の `"now()"` はただの文字列として入ります**(1.x は現在時刻に置き換えていました)。
送られた項目だけ直す、という書き方はこうなります。
```java
patch("/posts/{id}", context -> {
long id = id(context);
BlogApp.findPost(id);
Data request = context.request().bodyAll();
UpdateBuilder builder = SQL.update(Post.instance());
boolean hasChange = false;
if (request.containsKey("title")) {
builder.set(Post.title, request.getString("title"));
hasChange = true;
}
if (request.containsKey("body")) {
builder.set(Post.body, request.getString("body"));
hasChange = true;
}
if (request.containsKey("published")) {
builder.set(Post.published, request.getBoolean("published"));
hasChange = true;
}
if (!hasChange) {
throw new HttpException(400, "変える項目がありません(title / body / published)");
}
int updated = BlogExample.db().update(builder.where(Post.id.eq(id)));
context.response().json("updated", updated);
});
```
## 関数を使う
`Dsl` に関数が並んでいます。**どれも MySQL と PostgreSQL の両方で動きます。**
```java
SQL.select(Dsl.count(), Dsl.max(Post.created_at)).from(Post.instance())
```
`CASE WHEN` のように**値としても列としても使えるもの**は、
select に直接は渡せません。`SelectQuery` で包んでください。
```java
SQL.select(new SelectQuery().dsl(Dsl.caseWhen()...)).from(...)
```
包まずに渡すと、列ではなく**バインドされる値**として扱われます。
### 一覧
| 系統 | ある関数 |
| --- | --- |
| 文字列 | `lower` / `upper` / `trim` / `ltrim` / `rtrim` / `length` / `byteLength` / `substring` / `replace` / `left` / `right` / `lpad` / `rpad` / `reverse` / `repeat` / `concat` / `concatWs` / `md5` / `locate` |
| 数値 | `abs` / `mod` / `power` / `sqrt` / `sign` / `exp` / `ln` / `log10` / `ceiling` / `floor` / `round` / `truncate` / `greatest` / `least` |
| 日付 | `now` / `curDate` / `curTime` / `date` / `year` / `month` / `day` / `hour` / `minute` / `second` / `quarter` / `dayOfWeek` / `dayOfYear` / `weekOfYear` / `dateAdd` / `dateSub` / `dateDiff` / `secondsBetween` / `unixTimestamp` / `fromUnixTime` / `secondsAgo` … `yearsAfter` |
| 条件・型変換 | `caseWhen` / `ifThenElse` / `ifnull` / `coalesce` / `nullif` / `cast` / `castDecimal` / `regexp` / `regexpIgnoreCase` |
| 集約 | `count` / `countDistinct` / `sum` / `sumDistinct` / `min` / `max` / `avg` / `stddev` / `variance` / `groupConcat` / `groupConcatDistinct` |
| ウィンドウ | `rowNumber` / `rank` / `denseRank` / `nTile` / `lag` / `lead` / `firstValue` / `lastValue` / `over` |
| JSON・地理空間 | `jsonExtract` / `jsonUnquote` / `stGeomFromText` / `stDistanceSphere` / `stWithin` / `match` |
```java
// 名前を整えて、空なら「(無名)」
Dsl.coalesce(Dsl.trim(Post.title), "(無名)").as("title")
// 月ごとの件数
SQL.select(Dsl.year(Post.created_at).as("y"), Dsl.month(Post.created_at).as("m"), Dsl.count())
.from(Post.instance())
.groupBy(Dsl.year(Post.created_at), Dsl.month(Post.created_at))
// 30 日後
Dsl.dateAdd(Post.created_at, 30, DateUnit.DAY)
// タグをまとめて1つの文字列に
Dsl.groupConcat(Tag.name, "/").as("tags")
```
単位(`DateUnit`)と型(`CastType`)は enum です。**文字列では渡せません。**
SQL にそのまま入るところなので、外から来た文字列を通せないようにしてあります。
## まとめた値で絞る(`having`)
**集計した値は、そのまま条件にできます。**列と同じ書き方です。
```java
SQL.select(
Department.id
, Dsl.sum(Request.amount).as("total"))
.from(Request.instance())
.groupBy(Department.id)
// 合計が 50 万を超える部署だけ
.having(Dsl.sum(Request.amount).ge(500_000L));
```
`CASE` の条件にも置けます。
```java
new SelectQuery().dsl(Dsl.caseWhen()
.when(Dsl.sum(Request.amount).ge(500_000L)).then("大")
.elseCase("小")).as("size")
```
書けるのは**比べるもの**だけです
(`eq` / `not` / `gt` / `lt` / `ge` / `le` / `between` / `is_null` / `is_not_null`)。
`like` や `contains` は足していません。集計に対しては、書けても意味がないためです。
> [!TRAP]
> **`where` には書けません。**SQL の決まりで、`WHERE` は集計より前に評価されます。
> まとめたあとの値で絞るのは `having` の仕事です。
## ウィンドウ関数
**行をまとめずに、まとめた結果を各行に付けます。**`GROUP BY` と違って行が減りません。
```java
SQL.select(
Sale.shop_id
, Sale.amount
// 店ごとの売上順位
, Dsl.rank().partitionBy(Sale.shop_id).orderBy(Sale.amount.desc()).as("rank")
// 累計
, Dsl.over(Dsl.sum(Sale.amount))
.orderBy(Sale.sold_at.asc())
.rowsBetween(WindowFrame.unboundedPreceding(), WindowFrame.currentRow())
.as("total"))
.from(Sale.instance());
```
`Dsl.over(...)` には集約(`sum` / `count` / `avg` など)を渡します。
別名や計算を中に入れると例外になります(`OVER` のあとに付けてください)。
> [!WARNING]
> **ウィンドウ関数は `where` にも `having` にも書けません。**
> SQL の決まりで、評価されるのがどちらより後だからです。
> `Dsl.rowNumber().over(...).eq(1)` と書くと**組み立てた時点で例外**になります。
> 順位で絞るなら、いったん副問い合わせで出してから外側で絞ってください。
## `in` に空の一覧は渡せません
```java
where(Site.id.in(List.of())) // ← 組み立てた時点で例外
```
`IN ()` は構文エラーです。**黙って DB に投げると、
エラーメッセージからどこで空を渡したのか辿れません**(要件 F-D-07)。
**「空なら条件を外す」ことはしません。**
`in(空)` は「どれにも当たらない」で、条件を外すと**全件**です。
どちらの意味かは呼び出し側にしか分からないので、呼び出し側で分けてください。
```java
if (ids.isEmpty()) {
return List.of(); // 「どれにも当たらない」
}
where(Site.id.in(ids));
```
## 製品の違いで気をつけること
**名前が同じでも意味が違う**ものは DSL が揃えます。
| | どう揃えているか |
| --- | --- |
| `length` | **文字数**です(MySQL の `LENGTH` はバイト数)。バイト数は `byteLength` |
| `dayOfWeek` | **日曜が 1**(PostgreSQL の `DOW` は 0 なので +1 しています) |
| `weekOfYear` | **ISO 週**(MySQL の `WEEK` は既定が ISO ではないので `WEEKOFYEAR`) |
| `second` | 小数秒は**切り捨て**(PostgreSQL は素だと四捨五入して 60 を返すことがあります) |
| `unixTimestamp` | **接続のタイムゾーン**で読みます(PostgreSQL の `timestamp` は素だと UTC 扱い) |
| `stddev` / `variance` | **標本**(`STDDEV_SAMP` / `VAR_SAMP`) |
| `concat` | 1つでも NULL なら NULL(PostgreSQL では `\|\|` になります) |
**揃えていない**ものもあります。
| | 違い |
| --- | --- |
| `greatest` / `least` | **MySQL は1つでも NULL なら NULL、PostgreSQL は NULL を無視します。**NULL が入りうるなら `ifnull` で埋めてから渡してください |
| `regexp` | 正規表現の方言が違います(MySQL 8 は ICU、PostgreSQL は POSIX)。`^` `$` `[]` `+` は同じですが、**`\d` は片方でしか効きません。**`[0-9]` と書いてください |
| 0 除算・負の長さ | MySQL は NULL を返し、**PostgreSQL は落ちます**(`mod(x, 0)`、`left(x, -1)` など) |
| 数値を文字列関数に渡す | MySQL は暗黙に変換し、**PostgreSQL は「関数が無い」で落ちます。**`cast` を挟んでください |
| `groupConcat` | MySQL は `group_concat_max_len`(既定 1024 バイト)を超えると**黙って切ります** |
| `cast` で数にできない文字列 | MySQL は **0** を返し、PostgreSQL は落ちます |
**その製品に無いものは、SQL を組み立てたところで例外**(`DialectException`)になります。
[DB を使う](./db) にまとめてあります。
## DB 製品が違うとき
**同じコードのまま、`db.xxx.product` に応じた SQL が出ます。**
識別子の囲み(`` ` `` と `"`)、`ON DUPLICATE KEY UPDATE` と `ON CONFLICT`、
`INSERT IGNORE` と `ON CONFLICT DO NOTHING`、`RAND()` と `RANDOM()` などは
ビルダーが吸収します。
**その製品で書けないものは、SQL を組み立てたところで例外**(`DialectException`)になります。
どれが書けないかは [DB を使う](./db) にまとめてあります。
```java
// PostgreSQL では DialectException(to_char は書式の言語が違う)
SQL.select(Dsl.dateFormat(Post.created_at, "%Y-%m-%d")).from(Post.instance());
```
## まとめて流す
```java
List counts = db.executeBatch(builderList); // 件数のリスト
List ids = db.insertBatch(builderList); // 採番値のリスト
```
**2つで戻り値が違います。**`executeBatch` は件数(`List`)、
`insertBatch` は採番値(`List`)です。
**失敗は例外です。**空の一覧を渡すと空のリストが返ります。
> [!TRAP]
> **積んだビルダーの SQL は、全部同じでなければなりません。**
> 1本の文にパラメータだけを積み替えて流すためです。
> `value()` を積む順が途中で変わると SQL も変わるので、
> **ループの中で順を揃えてください**。
> 揃っていなければ `DB_998` の `SqlExecuteException` になります
> (直すまでは、例外も警告も無しに**値が横にずれて入っていました**)。
## 大きい結果
全部をリストに載せたくないときは、カーソルで1行ずつ受け取ります。
```java
try (DB db = BlogExample.db();
ResultSetFetcher fetcher = new ResultSetFetcher()) {
db.selectListWithFetcher(fetcher, SQL.select().from(Post.instance()));
for (Data row : fetcher) {
// 1行ずつ来る
}
}
```
> [!TRAP]
> **`DB` も一緒に畳んでください。**
> ふつうの SQL は1文ごとにコネクションをプールへ返すので、`DB` を閉じ忘れても
> 何も残りません(`DBUtil.getMainDB()` を使い捨てにする書き方は、これで成り立っています)。
> **カーソルだけは違います。**読み終わるまで `ResultSet` を開けておく必要があるので、
> `close()` までコネクションを握ったままです。
> `fetcher` だけ畳んで `DB` を畳まないと、呼ばれるたびにプールから1本ずつ消えます——
> SQL は成功し、例外も警告も出ないので、**プールが枯れるまで誰も気づきません**。
> 畳み忘れは実行の終わりに拾いますが(要件 F-D-16)、そのときはエラーログが出ます。
---
# トランザクション
注釈はありません。**囲んだところがトランザクションです。**
```java
try (Tx tx = db.begin()) {
long id;
try {
id = db.insertKey(
SQL.insert(Post.instance())
.value(Post.title, request.getString("title"))
.value(Post.body, request.getString("body"))
.value(Post.image_name, request.getStringOptional("image_name"))
.value(Post.published, request.getBoolean("published"))
.value(Post.created_at, new Date())
);
} catch (SqlExecuteException ex) {
return -1; // tx.commit() まで来ないので、抜けたら巻き戻る
}
new NoticeExecutor().put(db, new Data()
.putData("post_id", id)
.putData("title", request.getString("title")));
tx.commit(); // 記事とキューを一緒に確定して終わる
/*
* コミットしてから流す。
* 先に流すと、ロールバックしたときに
* 「入っていない記事のお知らせ」だけが届く。
*/
PostFeedHandler.notifyNewPost(request.getStringOptional("title"));
return id;
}
```
`db.begin()` が返す `Tx` を try-with-resources で囲み、最後に `tx.commit()` を呼びます。
**`commit()` を呼ばずに抜けたら巻き戻ります**(`return` でも例外でも)。
上の例では、記事が入らなかったときに `return -1` で抜けるので、何も残りません。
## 書き方は3つ
```java
db.transaction(tx -> {
db.insert(...);
db.update(...);
}); // 例外なく終われば確定、例外が出れば巻き戻して投げ直す
long id = db.transactionResult(tx -> db.insertKey(...)); // 値を返す版
try (Tx tx = db.begin()) { // 自分で確定したいとき
db.update(...);
tx.commit(); // 確定して終わる。呼ばずに抜けたら巻き戻す
}
```
**ふつうは `db.transaction(...)` がいちばん短く書けます。**
途中で抜けたいとき(上の `return -1` のような)や、確定を自分で決めたいときは `db.begin()` です。
`transaction` と `transactionResult` で名前が分かれているのは、
`tx -> db.update(...)` のような式のラムダが「値を返す」とも「返さない」とも読めて、
同じ名前だと呼び分けられないためです。
## `Tx` のメソッド
| メソッド | 何をするか |
| --- | --- |
| `commit()` | 確定して**終わる** |
| `checkpoint()` | ここまでを確定して、**続ける** |
| `rollback()` | 巻き戻して終わる |
| `close()` | 終わっていなければ巻き戻す(try-with-resources が呼びます) |
失敗は `TransactionException`(非検査。`SqlExecuteException` の子)です。`getCode()` で `DB_004` などが取れます。
**`commit()` と `rollback()` は1度だけです。**終わったあとにもう一度呼ぶと `IllegalStateException` になります。
確定して続けたいなら `checkpoint()` を使います。`checkpoint()` のあとに書いた分は、`commit()` しなければ巻き戻ります。
`db.transaction(...)` の中で `tx.commit()` / `tx.rollback()` を自分で呼んでもかまいません。そのときは、抜けたあとに何もしません。
> [!NOTE]
> 1.x の `DBTransaction` と、DB の `beginTransaction()` / `commit()` / `commitEndTransaction()` などは 2.0 で消しました。
> `DBTransaction.commit()` は終わらせない確定だったので、そのあとに書いた分が `close()` で黙って巻き戻っていました。
> 2.0 では、終わらせる確定が `commit()`、続ける確定が `checkpoint()` です([2.0 への移行](./migrate-2))。
## 中で SQL が失敗したら
**SQL の失敗は例外です**([DB を使う](./db))。受け止めなければ Tx から抜けて、全部巻き戻ります。
途中まで入ることはありません。
**受け止めて続けても、その Tx は確定できません。**
```java
db.transaction(tx -> {
db.insert(...); // 通った
try {
db.update(...); // 一意制約に当たった
} catch (DuplicateKeyException e) {
// 受け止めて続ける
}
db.insert(...); // 通った
}); // ← ここで TransactionException(DB_004)。全部巻き戻る
```
トランザクションを始めてから1度でも SQL が失敗していたら、`commit()` は全部を巻き戻して
`TransactionException`(`DB_004`)を投げます。**失敗のあとに成功する文があっても素通りしません。**
`checkpoint()` も同じように断ります。
### 失敗を見て、別の道で書き直したいとき
**その Tx は巻き戻して、新しい Tx で書き直します。**
```java
try {
db.transaction(tx -> {
insert(...);
update(...); // 一意制約に当たるかもしれない
}); // 例外で抜けたので、ここで巻き戻っている
} catch (DuplicateKeyException e) {
db.transaction(tx -> insertFallback(...)); // 新しい Tx で、別の道
}
```
外にもトランザクションがあるときは、中の Tx は外に合流しています(下の「入れ子」)。
その場合は、外ごと書き直してください。
> [!TRAP]
> **失敗のあとの文が通るかどうかは、製品によって違います。**
>
> | | 失敗のあとの文 |
> | --- | --- |
> | PostgreSQL | **通りません。**`ROLLBACK` するまで全部断ります(`current transaction is aborted, ...`) |
> | MySQL | **通ります。**1文の失敗ではトランザクションを中断しません |
>
> **どちらにも寄りかからないでください。**jimble が約束するのは
> 「**コミットは拒まれ、1行も残らない**」ところまでです。
> 失敗を受け止めたら、続けて書かずに、その Tx を終わらせてください。
## 中身が検査例外を投げたら
`db.transaction(...)` / `transactionResult(...)` の中身は、検査例外を投げてもかまいません(`throws` は要りません)。
**非検査例外はそのまま、検査例外は `TransactionException`(`DB_006`)に包んで**、巻き戻してから投げ直します。
元の例外は `getCause().getCause()` にあります。
## 入れ子(合流)
すでにトランザクションが始まっている DB で `db.begin()`(や `db.transaction(...)`)を呼ぶと、
**新しくは始めず、外に合流します。**合流しているかは `tx.isJoined()` で分かります。
| 中でしたこと | どうなるか |
| --- | --- |
| `commit()` / `checkpoint()` | 何もしない。確定させるのは外 |
| `rollback()` | **外を巻き戻し専用にする** |
| commit せずに閉じた(例外で抜けた) | **外を巻き戻し専用にする** |
巻き戻し専用になった外の `commit()` は、全部を巻き戻して `TransactionException`(`DB_005`)を投げます。
**中の例外を外で受け止めて続けても、外は確定できません。**
外を途中まで確定させることもできません(中の `checkpoint()` は何もしません)。
## DB の行でロックする(`DBLock`)
Redis が無い場所では、`DBLock` で「同じキーの処理を1つずつ」にできます。
```java
DBLock.create(db, "daily"); // キーの行を作る(あれば何もしない)。先に1回
db.transaction(tx -> {
DBLock.lock(db, "daily"); // SELECT ... FOR UPDATE。トランザクションが終わるまで1つだけ
...
});
```
**`DBLock.lock` はトランザクションの中で呼びます。**外で呼ぶと `IllegalStateException` です
(`FOR UPDATE` の鍵は文の終わりで外れるので、外で取っても何も守りません)。
- キーが無い(`create` していない)ときも `IllegalStateException` です
- `create` / `lock` は戻り値がありません(`void`)。SQL の失敗は `SqlExecuteException` です
- 解放は**トランザクションの終わり**です。明示的に外す口はありません
> [!NOTE]
> 1.x の `DBLock.lock` は `boolean` を返し、トランザクションの外で呼んでも `true` を返していました。
> 何も守らないまま先へ進んでいたので、2.0 で例外にしました。
Redis を使うロックは [キャッシュとロック](./cache) にあります。
## 外へ出すものと、DB に積むもの
**どちらの中で呼ぶかが逆になります。**ここは間違えやすいところです。
| | どこで呼ぶか | なぜ |
| --- | --- | --- |
| **DB のキューへ積む**([MQ](./mq) の `put()`) | **トランザクションの中** | 同じ DB なので、ロールバックすれば積んだものも消えます |
| **外へ出す**(SSE・メール送信・外部 API) | **コミットしたあと** | 戻せないので、先に流すと取り返しがつきません |
```java
db.transaction(tx -> {
long id = db.insertKey(...);
new NoticeExecutor().put(db, data); // ← 中で積む(DB のキュー)
});
PostFeedHandler.notifyNewPost(title); // ← 外へ出すのはコミットしてから
```
外へ出すものを先に流すと、ロールバックしたときに
**「入っていない記事のお知らせ」だけが届きます。**
逆に、DB のキューをコミット後に積むと、**積む直前に落ちたぶんが黙って消えます**——
記事は入っているのに、お知らせは誰も知らない状態になります。
> [!TIP]
> **外へ出す処理は、キューの中でやるのがいちばん確実です。**
> トランザクションの中で `put()` して、実際の送信は
> [MQ](./mq) の `execute()` に書きます。こうすると、
> 「どちらの中で呼ぶか」を考えなくてよくなります。
## 畳み忘れ
try-with-resources を使わずに `Tx tx = db.begin()` として、`commit()` も `rollback()` もせずに途中で `return` すると、
ロールバックもされず接続もプールへ戻りません。
jimble は**実行(`Context`)の終わりに拾います**。
```
ERROR コミットもロールバックもされていないトランザクションが残っていました。ロールバックして閉じます: blog_example
```
黙って戻すと「入れたつもりが入っていない」が残るので、ERROR を出してからロールバックします。
このログが出たら、囲み忘れです。
(見ているのは `DB` の側です。コネクションを握っているのは `DB` なので。)
## 閉じ忘れた `DB`
**ふつうの SQL は、`DB` を閉じ忘れても漏れません。**
1文ごとにコネクションをプールへ返しているので、`DBUtil.getMainDB()` を
使い捨てにする書き方でかまいません。
**握ったまま抜ける道は2つだけ**で、どちらも実行の終わりに拾います。
| 握るもの | いつ返るか |
| --- | --- |
| トランザクション中 | `tx.commit()` / `tx.rollback()` / `tx.close()` |
| カーソル(`selectListWithFetcher`) | `db.close()` |
```
ERROR 閉じられていない DB が残っていました。閉じます: blog_example(selectListWithFetcher はカーソルなので、close() までコネクションを返しません)
```
> [!TRAP]
> **拾うのは最後の砦です。**拾われた時点で ERROR が出ているので、
> ログが出たら直すものだと思ってください。
> 実行が長いバッチでは、実行の終わりまで1本が握られたままになります。
---
# 遅延読み込みと先読み
一覧に「詳細も返せるようにしておきたいが、返さないことも多い」ものがあります。
`AsyncData` / `AsyncList` は、**参照された時点ではじめて SQL を投げます**。
```java
protected List load () {
return BlogExample.db().selectList(
SQL.select()
.from(Comment.instance())
.where(Comment.post_id.eq(postId))
.orderBy(Comment.created_at)
);
}
```
```java
Data post = ...;
post.put("comments", new CommentList(post.getLong(Post.id)));
// ここまでは SQL が飛ばない
post.getJsonString(); // ← ここで飛ぶ
```
## 実装するもの
| メソッド | 何をするか |
| --- | --- |
| `load()` | 1件ぶん引く |
| `setData(Data)` | 引いたものを自分に反映する |
| `setRelationData(...)` | 全件の `setData` が終わってから1回だけ。要らなければ書かない |
| `hashKey()` | 同じものかどうかを決めるキー |
`AsyncData` は `Data`、`AsyncList` は `List` として振る舞うので、
読み込んだあとは普通のデータです。
**触れば読みます——読む側も、書き換える側もです。**
`get` や `size` はもちろん、`put` / `remove` / `add` / `clear` /
`computeIfAbsent` / `putFirst` のような**書き換える口も**読み込みを起こします。
> [!NOTE]
> **0.6.x までは、書き換える口が読み込みを起こしていませんでした。**
> 未読み込みのまま `remove("id")` すると**空のマップから消す**ことになり、
> あとで誰かが読んだ瞬間に**消したはずの値が戻ってきました**。
> `add` した1件が、読み込みで**上書きされて消える**という形もありました。
> 落ちも警告も出ないので、気づけるのは**結果がおかしいと言われたとき**だけです。
## 読み込みを起こさずに見られるもの
```java
list.isLoaded() // 読み込み済みか
list.isLoadFailed() // 失敗したか
list.loadedValues() // すでに持っている値だけ
list.loadedSize() // すでに持っている件数
```
`toString()` も読み込みを起こしません。**ログに1行出しただけで枝という枝に
SQL が飛ぶ**、ということが起きないようにしてあります。
`hashCode()` / `equals()` も `hashKey()` しか見ません。**比較しただけで
SQL が飛びません。**
## 読み込みに失敗したとき
例外は投げません。ログを出して先へ進みます。
**失敗しても「読み込み済み」になります。** そうしないと、参照されるたびに
落ちるクエリを投げ続けます。失敗したことは `isLoadFailed()` で分かります。
## N+1 になるとき
一覧の全行にぶら下げて、全部を JSON にすると **1 + N 本**飛びます。
```
記事を20件引く 1本
記事1のコメント 1本
記事2のコメント 1本
... ×20
```
これを2本にするのが**先読み**です。
## 先読み
`load()` と同じことを、`IN` 句で書きます。
```java
protected Map