About

ログインなしの「本人モード」 — 秘密URL認証は1:1ではなく1:Nだ

クッキーにキーではなくダイジェストを入れると「全部切る」がタダになる。ログにキーが出た話も

自分しか使わないRailsアプリで、会員登録なしに「持ち主かどうか」だけを見分けるやり方だ。読み終わると、秘密URL + 署名付きクッキーの付け方と、この方式がなぜ1:Nになるのか、どこまでならこれで足りてどこからログインにすべきかを判断できる。

ログインを付けたくなかった

移住カウントダウン(app151)は自分だけが使うチェックリストだ。進捗はgitリポジトリのJSONLに積んでいる。誰でも書き込めては困るので持ち主かどうかは見分けたいが、会員登録・ログイン・パスワード再設定まで付けるのは大げさだった。

そこでこうした。/owner?owner_key=<秘密キー> に一度アクセスすると、サーバーがcredentialsのキーと secure_compare する。違えば404。パスが存在することも教えない。合っていれば署名付きクッキーを残し、以降の書き込みリクエストはそのクッキーを見て通す。だめなら403。

1:1ではない

聞いた瞬間は、端末ごとにトークンを発行して1:1で結びつける方式に見える。違う。キーはアプリ全体で1つ。スマホでもノートPCでも同じキーで入る。サーバーは誰にクッキーを渡したかを記録しない。セッションテーブルがない。

クッキーの中身は SHA256("owner-mode:" + キー) だけ。だから全ブラウザのクッキーの値がまったく同じになる。

One key : N browsers. The server keeps no list.credentialsowner_key = Kone key for the whole appsigned cookie valueSHA256(owner-mode:K)same value everywherePhone - ownerLaptop - ownerTablet - ownerRotate the key: every browser is out at onceowner_key = K2cookie still holdsSHA256(owner-mode:K)All three: no matchCut just one: not possiblePutting the key itself in the cookie would leak the key. Putting true would survive rotation.

ここから性質がそのまま出てくる。キーを替えれば全クッキーが「古いキーのダイジェスト」になり、一度に全部切れる。その代わりスマホ1台だけ切ることはできない。端末を見分ける情報がサーバーにもクッキーにもないからだ。誰が変更したのかもわからない。みんな同じ「持ち主」だ。

ダイジェスト1行が設計のすべて

クッキーにキーそのものを入れると、クッキー=キーなのでクッキーが漏れればキーが漏れる。固定値の true を入れると、キーを替えても古いクッキーが生き残って「全部切る」ができない。ダイジェストはその両方を防ぐ。署名付きクッキーなので、ブラウザで値をでっち上げても通らない。

ログにキーが出ていた

最初は /owner/:key のようにパスで受け取っていた。log/development.log を開いたら、Started GET "/owner/_rPX... とキーが丸ごと出ていた。本番ならログ収集サービスまで流れていたところだ。

直し方が面白い。パスではなくクエリで受け取り、名前を owner_key にした。すると Started GET "/owner?owner_key=[FILTERED]" になった。

Railsの Started 行は request.filtered_path を出す。これがクエリ文字列はフィルタするのにパスはしない。actionpack 8.0.4のソースでも、path はそのままでクエリだけを filtered_query_string に置き換えている。さらに filter_parameters は部分一致なので、デフォルトにある :_key が owner_key に引っかかる。設定は1つも足していない。逆に :key を足していたら monkey や keyword まで隠れていたはずだ。

それでもブラウザの閲覧履歴とアドレスバーの補完には残る。だからこの方式はローカルで一人で使う間だけにして、デプロイ前に入力フォーム(POST)に替えることにした。

ほかの方法と比べると

HTTP Basic認証もサーバー状態がなくコストはゼロだが、セッションごとにポップアップが出てログアウトもなく、スマホでは使いづらい。Deviseなら端末ごとのセッションを扱えるが、usersテーブル・画面・メールがついてくる。端末1台だけ切りたいなら、結局「端末の一覧」がどこかに要る。マジックリンクでも、端末別トークンでも、Passkeyでも同じだ。サーバー状態をゼロにする代償が1:Nだ。

つまり条件はこうだ。一人で使い、ローカルのままか、デプロイ前にフォームへ替える。二人目が使い始めたらログインに移る。方式別の比較表とコードは下の詳細にある。

社内ツールや個人ツールに「どこまで認証を付けるか」で迷っているなら、negabaro に状況を書いて送ってもらってかまわない。

「持ち主の見分け方」の比較

方式対応サーバー状態1台だけ切る必要なもの
秘密URL + 署名付きクッキーキー1 : ブラウザNなしできない(全部だけ)ほぼなし
HTTP Basicパスワード1 : Nなしできないなし。ポップアップ・ログアウトなし
Deviseログインアカウント1 : セッションNusersテーブル構成次第テーブル・画面・メール
マジックリンクリンク1 : ブラウザ1トークン保存できるメール送信・期限管理
端末別トークントークン1 : 端末1端末一覧できる一覧の置き場所
Passkeyキー1 : 端末1公開鍵一覧できる実装が一番重い
Tailscale・Cloudflare Accessアプリの外でアプリにはなしできるアプリのコード0、インフラ頼み

一人で使うローカルツールなら1行目で足りる。二人以上が使う、または公開サーバーに載せるなら下の行へ移る。

concernとコントローラー

module OwnerMode
  COOKIE = :owner_mode

  def self.digest(key) = Digest::SHA256.hexdigest("owner-mode:" + key)

  private

  def owner?
    key = OwnerMode.key
    key.present? &&
      ActiveSupport::SecurityUtils.secure_compare(cookies.signed[COOKIE].to_s, OwnerMode.digest(key))
  end

  def require_owner
    head :forbidden unless owner?
  end
end

class OwnerSessionsController < ActionController::Base
  include OwnerMode

  def show   # GET /owner?owner_key=...
    return head(:not_found) unless OwnerMode.valid_key?(params[:owner_key])
    cookies.signed.permanent[COOKIE] = { value: OwnerMode.digest(OwnerMode.key), httponly: true }
    redirect_to "/"
  end
end

ログはこう変わった

# パスで受け取ったとき
Started GET "/owner/_rPX...(キー全体)"

# クエリ + owner_keyで受け取ったとき
Started GET "/owner?owner_key=[FILTERED]"
  Parameters: {"owner_key"=>"[FILTERED]"}

キーポイント

1

/owner?owner_key=… に一度アクセスするとcredentialsのキーとsecure_compare、違えば404

2

合えば署名付きクッキーにSHA256(owner-mode:キー)をpermanent・httponlyで残す

3

書き込みアクションはbefore_action :require_ownerで守る(違えば403)

4

キーはパスではなくクエリで、名前は_keyで終わるように — ログで[FILTERED]になる

5

デプロイ前に入力フォーム(POST)へ替える。URLが閲覧履歴に残るから

ユースケース

自分だけが使うトラッカー・メモアプリの書き込み保護 ログインを付ける前段階の社内ツール
← APIキーはなぜ一度しか見せないのか — 「もう一度見る」ボタンを作らなかった理由 DBなしでGitHubリポジトリを保存先に — 成立条件は「1人」ではなく「append-onlyのイベントログ」 →