IDORは認証ではなく認可の失敗だ
一言でいうと
認証は「あなたが誰であるかを確認すること」であり、認可は「この人がこのリソースに対してこの操作をしてよいかを判断すること」です。実務の事故の多くは後者で起きます。
なぜ必要なのか
ログインはきちんと作ってあるのに、事故が起きるケースが多くあります。ユーザーは正常に認証され、トークンも有効です。ところが、/api/orders/1042の数字を1043に変えると、他人の注文が見えてしまいます。
これがIDOR(Insecure Direct Object Reference)です。認証は通っているのに、認可をしていません。「このトークンの持ち主が、この注文の所有者か」を、誰も確認していなかったのです。
どう動くのか
2つの問いを、コードの中で分けて考える習慣が必要です。
認証は、リクエストの入口で一度だけ行います。トークンの署名を検証し、有効期限を確認し、主体(sub)を取り出します。この段階はミドルウェアで処理でき、リソースとは無関係です。
認可は、リソースにアクセスする地点ごとに行います。そして、リソースの所有者や属性を知らなければ判断できないので、ミドルウェアだけでは足りません。「注文1043のowner_idが、現在のユーザーのsubと同じか」は、その注文を読んだ後でなければ判断できません。
ここから、実務のルールが1つ出てきます。取得クエリ自体に所有者の条件を入れることです。SELECT * FROM orders WHERE id=? AND owner_id=?と書けば、他人の注文はそもそも結果に出ません。読んでから比較する方式よりも、ミスをしにくくなります。
ロールと権限も区別しておく価値があります。ロールは人に付けるラベル(例: order-admin)で、権限は操作に付ける名前(例: order:refund)です。ロールだけで認可を組むとロールの数が爆発し、権限だけで組むと管理が難しくなります。実務では、たいていロールに権限をまとめ、ユーザーにロールを与えます。
認可をどこに置くのか
同じルールを複数の層に散らばらせると、いつかずれが生じます。3つの置き場所のうち1つを決め、 残りは補助としてだけ使います。
| 置き場所 | 得意なこと | 苦手なこと |
|---|---|---|
| ゲートウェイ | パス・メソッド単位のブロック、認証の検証 | リソースの所有者を知らない |
| サービスコード | 所有者・状態に応じた判断 | ルールがコードに散らばる |
| ポリシーエンジン(OPAなど) | ルールを1か所にまとめて監査する | 1往復増える |
実務の基本形は、ゲートウェイで認証、サービスで認可です。ゲートウェイは トークンが有効かどうかだけを見て、「この注文をこの人が見てよいか」は、その注文を知っている サービスが判断します。
権限チェックを忘れない構造
人の注意力に頼ると、いつかは抜け落ちます。構造で防ぎます。
デフォルトを拒否にします。新しいエンドポイントを作ったとき、デコレーターが何もなければ 403が返らなければなりません。許可がデフォルトだと、抜け落ちた場所がそのまま穴になります。
@router.get("/orders/{oid}")
@requires("order:read") # 없으면 라우터 등록 단계에서 거부
def get_order(oid: int, user=Depends(current_user)):
# 조회 자체에 소유자 조건을 넣는다 — 읽은 뒤 비교하는 것보다 실수하기 어렵다
row = db.one("select * from orders where id=%s and owner_id=%s", (oid, user.sub))
if not row:
raise HTTPException(404) # 403 이 아니라 404 — 존재 여부도 흘리지 않는다
return row
最後の行が重要です。他人のリソースに403を返すと、「その番号は存在する」と教えてしまいます。 連番のIDを使うシステムでは、これだけで全体の規模が漏れてしまいます。 存在しないかのように404を返すほうが適切です。
一覧取得にも同じ条件を入れる必要があります。詳細は防いだのに、一覧はすべて 返してしまう事故はよくあります。そのため、取得関数自体が所有者の引数を受け取るように作り、 受け取らない関数はそもそも置かないという方法を取ります。
マルチテナントではもう一層
複数の組織が1つのシステムを使う場合、owner_idだけでは足りません。同じ組織の中の別の
人が見られなければならないリソースがあるからです。tenant_idをすべての表に入れ、
すべてのクエリに強制的に付与する層を置きます。PostgreSQLなら、Row Level Securityで
DBに直接強制させることもできます。
alter table orders enable row level security;
create policy tenant_isolation on orders
using (tenant_id = current_setting('app.tenant_id')::uuid);
アプリケーションがミスをしても、DBが防ぎます。その代償は、接続ごとにset_configを呼ぶ
手間と、コネクションプールで設定が漏れないように注意することです。
現場での姿
トークンにロールを入れるか、リクエストごとにサーバーで取得するかにも、選択があります。トークンに入れると速いのですが、ロールを取り消しても、トークンの有効期限が切れるまでは有効なままです。そのため、アクセストークンを短く保つことは、認可の面でも重要です。
もう1つ、よく間違える点があります。フロントエンドでボタンを隠すことは、認可ではありません。UIは利便性のためのもので、判断は必ずサーバーでもう一度行わなければなりません。「管理者だけが見られる画面なのに、APIは開いている」という状況は、ペネトレーションテストで真っ先に見つかります。
次の確認で見ること
まずクイズで、認証の成功とリソースの認可の成功を区別できるかを確認します。次のモジュールでOAuth2のグラントタイプの全体像を整理してから、実際のKeycloakに進みます。