jimble

ログインと認可#

注釈はありません。before を1行と、ルートに付ける属性だけです。

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 いまパスワードを入れた人だけが通れるか(下記)

ブロックに書けば、その中のルート全部に付きます(ルーティング)。 1本だけ違うなら、そのルートで上書きします。

落とし穴

PUBLICNO_SESSION は別の判断です。 ログインの入口はログインが要らないが、セッションは要ります—— CSRF トークンも、ログイン後のセッションも、そこで持つからです。

一緒にするとログイン画面自身がセッションを持てず、誰もログインできなくなります (302 は返るのに、次のリクエストで 401 になります。実際に踏みました)。

NO_SESSION を付けてよいのは、本当に公開のページだけです。

ログインさせる#

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 を振り直してから入れて、保存までします (セッションとセキュリティ)。

落とし穴

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 分ロック」にはしていません。 アカウント単位で止める仕組みはそのまま嫌がらせの道具になるからです—— わざと間違えるだけで、その人を締め出せます。 待ち時間を倍にしていく形なら、攻撃者から見た試行速度は実質ゼロになり、 正規の利用者は数秒待つだけで済みます。

落とし穴

数える単位は「入力されたログイン ID」です。 見つかった利用者の DB 上の ID を渡すと、居ない ID のときだけ数えられません。 総当たりは居ない ID から始まりますし、 「待たされるかどうか」でどの ID が在るかが分かってしまいます。

置き場 auth_attempt テーブル。DB が無ければ何もしません(ログに1度だけ出ます)
単位 ログイン ID(大小と前後の空白はそろえて SHA-256 で保存します。平文では残りません)
設定 auth.lockout.*設定
掃除 自動です(失敗を数えたついでに、1時間に1度)。手で呼ぶなら Lockout.cleanup()
解除 Lockout.clear(loginId)

流量制限の代わりにはなりません。 流量制限は IP ごとなので、1つのアカウントに 1000 個の IP から1回ずつ来ると発火しません。 こちらはアカウントごとなので、誰から来ても数えます。両方掛けてください。

いま誰か#

Principal me = Auth.principal(context);

me.id();                  // 0 なら未ログイン
me.name();
me.hasRole("approver");

**null は返りません。**ログインしていなければ Principal.ANONYMOUS です。

Principal が持つのは id / 表示名 / 役割の3つだけです。 利用者のオブジェクトを丸ごとセッションに入れると、

  • DB で名前を直してもログインし直すまで古いまま
  • 権限を剥奪してもセッションが切れるまで効かない
  • Cookie セッションなら、その全部がブラウザへ出ていく

残りは要るときに DB から引いてください。引くのが重いならキャッシュの仕事です。

ログアウト#

Auth.logout(context);

セッションを丸ごと捨てます。ログインの鍵だけ消すと、 買い物かごや下書きが次の利用者に見えます(共用の端末で効きます)。

401 と 403 の返し方#

Auth.guardHttpException を投げるだけです。 画面へ飛ばすか JSON を返すかは、アプリの error() が決めます。

error((context, cause, statusCode) -> {

	if (statusCode == 401 && !context.request().acceptJson()) {
		context.response().redirect("/login");
		return;
	}

	context.response().code(statusCode).json("error", cause.getMessage());

});

役割が足りないときは 403 で、401 ではありません。 ログインし直しても結果が変わらないことを、状態コードで言います。 401 を返すと、利用者は入り直せば見られると思って何度も試します。

ログインしたままにする(remember-me)#

{
	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"));
}

ログインのときに、印が付いていたときだけ覚えます。

Auth.login(context, principal);

if ("1".equals(request.getString("remember"))) {
	Remember.issue(context, principal);
}

落とし穴

before(Remember.restore(...))before(Auth::guard) より先に置いてください。 あとに置くと、guard が「ログインしていない」と決めたあとで思い出すことになります。

**いつも issue を呼ばないでください。**共用の端末で、次の人が入れます。

これが remember-me を出せる理由です。 思い出して戻ってきた人は「いまパスワードを入れた人」ではないので、 attribute(Auth.FULL_AUTH, true) を付けたルートでは 401 になります。

パスワードの変更・退会・決済・連絡先の変更に付けてください。 コードの中で見るなら Auth.fullyAuthenticated(context) ですが、 ルート属性のほうが書き忘れが起きません。

盗まれたら気づく#

Cookie には selector:validator の2つが入っていて、 validator は使うたびに作り直します。 盗まれた Cookie と本物の Cookie は同時に生きられないので、 回転前の値が使われたら、それが盗まれた合図です。

合図を見つけたら、その利用者の記憶を全部消します(ログにも残ります)。 先に使ったのが本人か盗んだ側かは区別できないので、 片方だけ消すと本人だけが締め出されて盗んだ側が残ることがあります。

置き場 auth_remember テーブル。DB が無ければ何もしません
Cookie selector:validatorvalidator は SHA-256 にして保存します(selector は引くための鍵なのでそのまま)
期限 最後に使ってから 30 日、かつ発行から 90 日(使い続けても、いつかは必ず切れます)
猶予 回した直後の 60 秒は古いほうも通します(並列のリクエストで勝手にログアウトさせないため)
ログアウト Auth.logout が消します
パスワード変更 Remember.forgetAll(userId) を呼んでください(下記)
設定 auth.remember.*設定

落とし穴

パスワードを変えたら Remember.forgetAll(userId) を呼んでください。 呼ばないと、盗まれた Cookie はそのまま使えます——変えた意味がありません。 「全端末からログアウト」も同じものです。

Basic 認証#

運用向けの口には Basic 認証が使えます(リクエストとレスポンス)。

path("/ops", () -> {
	before(BasicAuth.of("ops", System.getenv("OPS_PASSWORD")));
	attribute(Auth.PUBLIC, true);      // セッションのログインとは別の仕組み
	get("/whoami", Ops::whoami);
});

やらないこと#

注釈(@PreAuthorize のようなもの) 原則のとおり使いません。ルート属性で宣言します
「あと何日で切れるか」を利用者に見せる口 ありません。行を自分で引いてください
JWT **出しません。**失効できず鍵の管理が増えます。API の認証が要るなら、DB に持つ不透明なトークンにしてください
OAuth / OIDC / SAML まだありません
権限(permission)の対応表 役割の文字列だけです

動いているものは examples/approval-auth にあります。