プロトコル共有ウェブアプリ — セッション認証、OAuth、IDOR対策を統合
このトピックを終えると
教科書で学んだ セッション認証、OAuth 2.0、IDOR を組み合わせて、実験プロトコルを安全に共有するウェブアプリを自分で作成できるようになります。protocols.ioのような実用的なツールが、なぜ高度な権限システムを備えているのか、そしてそれをPython/Nodeでどのように作成できるのかを理解します。
この記事は、教育目的の一般的な例 です。実運用でのデプロイには、Auth0、Clerk、Supabase Authなどのマネージドサービスを使用します。
「ちょっと待って、URLを変えるだけで他のラボのプロトコルが全部見えてしまう? — IDOR の落とし穴」
あなたが実験プロトコル共有サイトを初めて作ったとしましょう。
- ユーザーログイン:OK
- 自分のプロトコルを作成:OK
- 特定のプロトコルを表示:
/api/protocols/42
友人がURLを少し変えてアクセスします:/api/protocols/43。他のラボの秘密のプロトコルが開いてしまいます。 URLの数字を変えるだけで、他人のデータにアクセスできてしまう — IDOR (Insecure Direct Object Reference) の脆弱性です。
この問題の原因が面白い点です。あなたのコードは、ユーザーが**ログインしているか(認証)は確認していますが、ユーザーがこのリソースにアクセスする権限があるか(認可)**は確認していません。認証と認可は別個です。
OWASP Top 10 で A01: Broken Access Control が毎年上位にランクインしているのは、このためです。開発者が繰り返しこの問題を作り出してしまいます。protocols.io、GitHub、Notion など、すべての実用的なサービスは、高度な権限システムでこの脆弱性から保護しています。
CS の言葉でこの問題を表現すると、インターフェースに認可ゲートがない状態です。API エンドポイントがリクエストを受け取ると、誰がこのリクエストをしているかを確認するのが認証であり、この特定の資源にアクセスする権限があるかを確認するのが認可です。認証だけでは、システムはログインしたすべての人がすべての資源にアクセスできる状態になってしまいます。
ブラックボックスからコンポーネントへ
コンポーネント 1: セッションベース認証
最も基本的な認証パターン。ログインに成功すると、サーバーがセッションを作成し、セッション ID を Cookie としてブラウザに送信します。
import express from "express";
import session from "express-session";
import bcrypt from "bcrypt";
import pg from "pg";
const app = express();
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 24 * 60 * 60 * 1000
}
}));
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
app.post("/api/login", async (req, res) => {
const { email, password } = req.body;
const result = await pool.query("SELECT id, password_hash FROM users WHERE email = $1", [email]);
if (result.rows.length === 0) {
return res.status(401).json({ error: "Invalid credentials" });
}
const user = result.rows[0];
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.status(401).json({ error: "Invalid credentials" });
}
req.session.userId = user.id;
res.json({ ok: true });
});
app.post("/api/logout", (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});セキュリティの必須要素:
httpOnly: true— JavaScript から Cookie にアクセスできない (XSS によるセッションの乗っ取りを防ぐ)secure: true— HTTPS でのみ送信 (本番環境)sameSite: "lax"— CSRF を部分的に防御bcryptでパスワードをハッシュ化 (平文での保存禁止)- セッションシークレットを環境変数に設定
コンポーネント 2: 認証ミドルウェア
認証されたユーザーのみがアクセスできるエンドポイントで使用するミドルウェア。
function requireAuth(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: "Login required" });
}
next();
}
app.get("/api/me", requireAuth, async (req, res) => {
const result = await pool.query("SELECT id, email, lab_id FROM users WHERE id = $1", [req.session.userId]);
res.json(result.rows[0]);
});コンポーネント 3: リソースオーナー検証 (IDOR 対策)
重要: リソースを検索する際に、WHERE 句に必ず所有者条件を含める。
app.get("/api/protocols/:id", requireAuth, async (req, res) => {
const { id } = req.params;
const result = await pool.query(
`SELECT p.*
FROM protocols p
WHERE p.id = $1
AND (
p.owner_user_id = $2
OR p.visibility = 'public'
OR EXISTS (
SELECT 1 FROM protocol_shares ps
WHERE ps.protocol_id = p.id AND ps.user_id = $2
)
)`,
[id, req.session.userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: "Not found" });
}
res.json(result.rows[0]);
});セキュリティ原則:
WHERE id = $1だけでなく、所有者条件も必ず含める。- 所有者ではなく、共有もされていないプロトコルにアクセスしようとした場合に、404 (403 ではない)。「存在しない」ことを露呈しないようにする。
- 削除・更新エンドポイントも同じ原則を適用する。
コンポーネント 4: OAuth 2.0 ソーシャルログイン
メールログインの代わりに、Google/GitHub などの外部認証プロバイダーを使用する。
import { OAuth2Client } from "google-auth-library";
const oauth = new OAuth2Client({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: process.env.GOOGLE_REDIRECT_URI
});
app.get("/api/auth/google", (req, res) => {
const url = oauth.generateAuthUrl({
access_type: "offline",
scope: ["email", "profile"],
state: generateStateToken(req)
});
res.redirect(url);
});
app.get("/api/auth/google/callback", async (req, res) => {
const { code, state } = req.query;
if (!verifyStateToken(req, state)) {
return res.status(400).json({ error: "Invalid state" });
}
const { tokens } = await oauth.getToken(code);
const ticket = await oauth.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID
});
const payload = ticket.getPayload();
const email = payload.email;
let user = await pool.query("SELECT id FROM users WHERE email = $1", [email]);
if (user.rows.length === 0) {
user = await pool.query(
"INSERT INTO users (email, oauth_provider, oauth_id) VALUES ($1, 'google', $2) RETURNING id",
[email, payload.sub]
);
}
req.session.userId = user.rows[0].id;
res.redirect("/");
});OAuth のセキュリティの必須要素:
- state パラメーター — CSRF 対策。リクエスト開始時にランダムなトークンをセッションに保存し、コールバックで検証する。
- id_token の検証 — プロバイダーの公開鍵で署名を検証する。
- audience の検証 — トークンがこのアプリケーション用であることを確認する。
- PKCE — ネイティブアプリの場合は必須。
パイプラインの組み立て
全体のウェブアプリのフロー:
1. ユーザーが /login にアクセス
2. メール/パスワードでログイン、または「Google でログイン」をクリック
3. 成功した場合、セッションクッキーを発行し、/dashboard にリダイレクト
4. /dashboard で自身のプロトコルのリストを表示
- GET /api/protocols?mine=true (WHERE owner = session.userId)
5. 特定のプロトコルをクリック
- GET /api/protocols/42 (所有者の検証を含む)
6. 共有対象のユーザーを追加
- POST /api/protocols/42/share (自身の所有権を検証した後)各エンドポイントで、必ず認証(誰か)+ 認可(何を見る権限があるか) を行う。
フェーディング — 埋めるべき3つの空白
空白 1:監査ログ
機密性の高いアクションはすべてログに記録する。誰が、いつ、何をしたかを追跡する。
app.use(async (req, res, next) => {
const originalJson = res.json.bind(res);
res.json = function(body) {
// TODO 1: リクエスト情報を収集する (userId, method, path, statusCode)
// TODO 2: audit_logsテーブルに挿入する (非同期のfire-and-forget)
// TODO 3: originalJsonを呼び出す
return originalJson(body);
};
next();
});ヒント: pool.query("INSERT INTO audit_logs (user_id, method, path, status) VALUES ($1, $2, $3, $4)", [req.session.userId, req.method, req.path, res.statusCode]).catch(console.error);.
空白 2:ロールベースのアクセス制御(RBAC)
各ユーザーにはロール(admin、editor、viewer)があり、各ロールは異なる権限を持つ。
function requireRole(role) {
return async (req, res, next) => {
// TODO 1: session.userIdでusersテーブルをクエリする
// TODO 2: ユーザーのロールが、必要なロール以上であることを検証する
// TODO 3: そうでなければ403を返す
next();
};
}
app.delete("/api/protocols/:id", requireAuth, requireRole("admin"), async (req, res) => {
// 管理者のみ削除可能
});空白 3:セッションハイジャック対策
セッションクッキーが盗まれた場合でも、IP / User-Agentが変更された場合に無効化する。
function detectSessionAnomaly(req, res, next) {
if (!req.session.userId) return next();
const currentIp = req.ip;
const currentUA = req.headers["user-agent"];
// TODO 1: セッションに保存されたoriginalIp、originalUAと比較する
// TODO 2: 大きく異なる場合は、セッションを破棄し、再ログインを要求する
// TODO 3: 正常であれば、次のミドルウェアに進む
next();
}ヒント: 最初のログイン時に req.session.originalIp = req.ip; req.session.originalUA = req.headers["user-agent"];。以降のリクエストで if (req.session.originalIp !== req.ip) { req.session.destroy(); return res.status(401).json({error: "Session anomaly"}); }.
考察 — 実運用における認証システムとの違い
マネージドサービス: 実際の運用では、Auth0、Clerk、Supabase Auth、Firebase Authなどのマネージドサービスが広く利用されています。あなたが構築したものは、これらのサービスが裏でどのような処理を行っているかの理解を深めるためのものです。
PASETO / JWT: セッションの代わりに、自己検証可能なトークンを使用します。セッションストアが不要になるため、スケーラビリティに優れています。ただし、無効化(ログアウト)の管理が難しくなります。
MFA(多要素認証): TOTP(Google Authenticator)、WebAuthn(ハードウェアキー)など。アカウントの乗っ取りが発生した場合の最後の防衛策です。
Zero Trustアーキテクチャ: すべてのリクエストに対して、認証と認可を再検証します。ネットワークの位置(社内ネットワーク)を信頼の根拠として使用しません。
パスワードの代替手段: Passkey(WebAuthn)が最近の標準です。パスワードなしのログインが可能です。Apple、Google、MSがすべてサポートしています。
OWASP ASVS: Application Security Verification Standard。アプリケーションが満たすべきセキュリティ要件を階層別にまとめたチェックリストです。
拡張プロジェクト
1. Passkeyサポート: WebAuthnライブラリを使用してハードウェア認証を追加。
2. チームワークスペース: 複数のユーザーが同じワークスペースに所属し、プロトコルを共有。招待リンク。
3. APIトークンシステム: セッションの代わりにAPIキーを使用して、プログラムによるアクセスをサポート。スコープごとの権限。
4. レート制限: ブルートフォース攻撃対策。IPアドレスごとのログイン試行回数を1分あたり5回に制限。
このパートの構成要素
- [F] セッション認証: クッキー、セッションストア、httpOnly、secure、sameSite。
- [F] OAuth 2.0: 認可コードフロー、stateパラメータ、id_tokenの検証。
- [F] IDOR対策: リソース参照時に所有者条件を必須とする。404と403のどちらかを選択。
- [W] Express・pg: ルーティングとデータベースアクセス(完成したスクリプトを提供)。
[F] = ユーザーが自分で実装 / [W] = 完成したコードとして提供。