ログインなしの「本人モード」 — 秘密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:" + キー) だけ。だから全ブラウザのクッキーの値がまったく同じになる。
ここから性質がそのまま出てくる。キーを替えれば全クッキーが「古いキーのダイジェスト」になり、一度に全部切れる。その代わりスマホ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 : セッションN | usersテーブル | 構成次第 | テーブル・画面・メール |
| マジックリンク | リンク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]"}
キーポイント
/owner?owner_key=… に一度アクセスするとcredentialsのキーとsecure_compare、違えば404
合えば署名付きクッキーにSHA256(owner-mode:キー)をpermanent・httponlyで残す
書き込みアクションはbefore_action :require_ownerで守る(違えば403)
キーはパスではなくクエリで、名前は_keyで終わるように — ログで[FILTERED]になる
デプロイ前に入力フォーム(POST)へ替える。URLが閲覧履歴に残るから