はじめに
情報安全確保支援士の勉強を進める中で、「セッション管理の脆弱性」が気になったので、実際に手を動かして再現し、理解を深めてみました。
この記事では、初心者の方でも試せるように、セッション認証と簡易的なセッションハイジャックについてわかりやすく紹介します。
興味のある方は、ぜひ一緒にハンズオンをしてみましょう。
セッション認証とは?
セッション認証は、Webアプリケーションにおいて、ユーザーのログイン状態を保持するための仕組みのひとつです。
多くの場合、ユーザーがログインに成功すると、サーバー側でセッションIDを発行し、それをブラウザにクッキーとして保存します。
以後のリクエストでは、そのセッションIDをもとに「このリクエストは誰のものか?」をサーバー側で判別し、認証された処理を実行します。
参照
事前準備
このハンズオンでは、ログインフォームを実装して、簡易的なセッションハイジャックの再現を行います。
以下のツールを事前に準備しておいてください。
環境
- OS: macOS Sonoma 14.6.1
- Node.js: 20.16.0
- npm: 10.8.2
- Vite: 6.2.0
- ブラウザ: Google Chrome
クライアント側:ViteでReactプロジェクト作成
アプリは公式でも推奨されている Vite を使って作成します。
# プロジェクトを作成(テンプレートとして React を指定)
npm create vite@latest session-auth-client -- --template react
# プロジェクト配下に移動
cd session-auth-client
# パッケージをインストール
npm install
# ローカルサーバーを立ち上げる
npm run dev
Vite
+ React
の初期画面が表示されましたか?
おめでとうございます!クライアント側のセットアップが完了しました。
参考
サーバー側: Express のセットアップ
次に、バックエンドの簡単な HTTP
サーバーを Express
を使って構築していきます。
まずは「Hello World!」が表示させてみましょう。
# package.json を初期化
npm init -y
# 必要なパッケージをインストール
npm install express express-session cors
# サーバーのコードを書くファイルを作成
touch hello-world.js
続いて、hello-world.js
ファイルに以下のコードを記述してください。
const express = require('express')
const app = express()
const port = 3000
// ルート設定
app.get('/', (req, res) => {
res.send('Hello World!')
})
// サーバー起動
app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})
ターミナルで以下のコマンドを実行して、サーバーを起動しましょう。
node hello-world.js
ブラウザで http://localhost:3000
にアクセスしてください。
Hello World!
が表示されましたか?
おめでとうございます!サーバー側のセットアップが完了しました。
参考
ログインフォームを作成
ここからは、ログイン機能を実装していきます。
まずはクライアント側( React
)で、ログインフォームのUIを作ってみましょう。
ReactでログインUIの実装
React
プロジェクトの App.jsx
に以下のコードを記述してください。
import { useState, useEffect } from "react";
import axios from "axios";
axios.defaults.withCredentials = true;
function App() {
const [user, setUser] = useState(null);
const [form, setForm] = useState({ username: "", password: "" });
const fetchUser = async () => {
try {
const res = await axios.get("http://localhost:4000/me");
setUser(res.data.user);
} catch {
setUser(null);
}
};
useEffect(() => {
fetchUser();
}, []);
const login = async () => {
try {
await axios.post("http://localhost:4000/login", form);
fetchUser();
} catch {
alert("ログイン失敗");
}
};
const logout = async () => {
await axios.post("http://localhost:4000/logout");
setUser(null);
};
return (
<div style={{ margin: 20 }}>
<h2>セッション認証デモ</h2>
{user ? (
<>
<p>ようこそ、{user.username}さん!</p>
<button onClick={logout}>ログアウト</button>
</>
) : (
<>
<input style={{ marginRight: 20 }}
placeholder="username"
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
<input style={{ marginRight: 20 }}
placeholder="password"
type="password"
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<button onClick={login}>ログイン</button>
</>
)}
</div>
);
}
export default App;
ログインフォームができたら、開発サーバーを再起動して、動作を確認してみましょう。
npm run dev
username
と password
の入力欄が表示されましたか?
これでクライアント側のログイン画面が完成です。
※ まだサーバー側と通信していないので、ログイン処理そのものは動きません。
では、次はログイン情報をサーバーに送信する処理を実装していきましょう。
Expressでセッション認証のサーバー実装
次に、セッション認証のためのサーバーを実装していきます。
まず、プロジェクト直下に server.js
を作成してください。
touch server.js
続いて、以下のようなコードを server.js
に記述してください。
const express = require("express");
const session = require("express-session");
const cors = require("cors");
const app = express();
const PORT = 4000;
// CORSの設定(Reactアプリからのアクセスを許可)
app.use(cors({
origin: "http://localhost:5173", // React側のURL
credentials: true
}));
// JSONパースの設定
app.use(express.json());
// セッションの設定
app.use(session({
secret: "my-secret-key",
resave: false,
saveUninitialized: true,
cookie: {
secure: false,
httpOnly: false,
},
genid: function(req) {
return "user-1234";
}
}));
// ユーザー認証
const USER = {
username: "user1",
password: "pass1"
};
// ログイン処理
app.post("/login", (req, res) => {
const { username, password } = req.body;
if (username === USER.username && password === USER.password) {
req.session.user = { username };
return res.json({ message: "ログイン成功" });
}
res.status(401).json({ message: "認証失敗" });
});
// 認証チェック
app.get("/me", (req, res) => {
if (req.session.user) {
return res.json({ loggedIn: true, user: req.session.user });
}
res.status(401).json({ loggedIn: false });
});
// ログアウトの処理
app.post("/logout", (req, res) => {
req.session.destroy(() => {
res.json({ message: "ログアウトしました" });
});
});
// サーバー起動
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
では、サーバーを起動しておきましょう。
node server.js
これで実装は終了です。お疲れ様でした。
動作検証
では、React側のフォームからログインしてみましょう。
ユーザー名・パスワード( user1
/ pass1
)を入力してログインボタンを押してください。
「ようこそ、user1さん」が表示されたら成功です。おめでとうございます!
セッションハイジャックの再現実験
本記事では、実際にセッションIDを外部から知っている状態を仮定して、「もし漏洩していたらどうなるか」を再現することで、セッションハイジャックの影響と対策を体験できる構成にしています。
ブラウザの「検証ツール」>「Application」から connect.sid
の値をコピーしてください。
以下のようにターミナルからcurl
コマンドを使ってリクエストを送信してください。
curl -H "Cookie: connect.sid=s%3Auser-1234.vaX4V%2BMOA3Vhz4nbV0ZFGgkI6IQaiwMYY999yTN33VA" http://localhost:4000/me
# 以下のようにユーザー情報が返ってきたら成功
{"loggedIn":true,"user":{"username":"user1"}}
username
と password
を入力していないのに、ユーザー情報が返ってきましたね。
防御策について考える
4-(iv)-a ログイン成功後に、新しくセッションを開始する。
利用者が新しくログインしたセッションに対し、悪意のある人は事前に手に入れたセッションIDではアクセスできなくなります。
引用
それでは、ログイン成功時にセッションIDを再生成するように変更して、セッションハイジャックへの防御策を講じてみましょう。
サーバー側の修正
これまでのコードでは、あえて検証しやすくするためにセッションIDを固定値にしていました。
以下のように、genid
オプションを削除して、デフォルト動作に戻しましょう。
app.use(session({
secret: "my-secret-key",
resave: false,
saveUninitialized: true,
cookie: {
secure: false,
httpOnly: false,
},
// ↓ genidをコメントアウト
// genid: function(req) {
// return "user-1234";
// }
}));
genid
新しいセッションIDを生成するために呼び出す関数。セッションIDとして使われる文字列を返す関数を提供する。
引用
検証
ブラウザの検証ツールで、connect.sid
の値を確認してみましょう。
ログインのたびにセッションIDが変わっていることが確認できれば、正しくセッションを再生成できています。
検証1:ログイン直後
検証2:一度ログアウト → 再ログイン
値が変わっていることが確認できましたか?
これで、事前に盗まれたセッションIDを無効化できるようになり、セッションハイジャックに対する1つの対策となります。
さらに理解を深めるために
ここまで実装できた方は、次のステップにもチャレンジしてみてはいかがでしょうか?
- ローカル環境に
HTTPS
を導入して、Secure
属性の効果を体験する - トークンを使った認証方式に切り替えてみる
- セッション認証と
JWT
認証の違いを比較してみる
おわりに
この記事では、初心者の方でも試せるように、セッション認証と簡易的なセッションハイジャックについてわかりやすく紹介しました。
自分で手を動かして体験すると、やっぱり理解が深まりますね。
参考書や過去問だけを眺めているよりも、実際に実装しながら学ぶことで体系的に身につけられると感じました。
とはいえ、時間かかるのがちょっとした難点…。でもその分、確かな手応えがあると思います。
webエンジニアを募集中
私の所属する日本システム技研では、webエンジニアを募集しています。
興味のある方は、ぜひ求人情報をご覧ください。