TT Lab
はじめる
学ぶ 学習パス コース

Keycloakと企業認証

IDORは認証ではなく認可の失敗だ

TT Labで続きを見る

一言でいうと

認証は「あなたが誰であるかを確認すること」であり、認可は「この人がこのリソースに対してこの操作をしてよいかを判断すること」です。実務の事故の多くは後者で起きます。

なぜ必要なのか

ログインはきちんと作ってあるのに、事故が起きるケースが多くあります。ユーザーは正常に認証され、トークンも有効です。ところが、/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に進みます。