Cookieと認証(発展)
多くのWebアプリケーションは、利用者が本人であることを確認するための機能を備えています。この節では、Webアプリケーションにおける「ログイン」機能を実装するための、最も基本的な技術要素について学びます。
認証は、Webアプリケーションのセキュリティにおいて非常に重要な要素で、この節で学ぶ内容のみでは、実際のアプリケーション向けの実装としては不十分です。Firebase AuthenticationやAuth0などの外部サービスを用いることで、強固なセキュリティを手軽に実現できます。また、どうしても外部サービスを利用できない場合は、Passport.jsや、Auth.jsなどのライブラリを利用することを強く推奨します。
IDとパスワードによる認証
IDとパスワードによる認証は、Webアプリケーションにおいて最も一般的な認証方法です。アプリケーションの利用者は、ID(通常はメールアドレスやユーザー名)とパスワードを入力し、サーバーは、その組み合わせをデータベースに保存されているものと比較し、一致すれば認証が成功したと判断します。
認証に成功すると、サーバーはクライアントに対し、一時的な「証明書」を発行します。クライアントは、その証明書を次回以降のリクエストに含めることで、認証が成功していることをサーバーに示します。サーバーは、その証明書の正当性を確認することで、リクエストが正規のユーザーからのものであることを判断します。
Cookieの利用
Webアプリケーションのクライアントが、サーバーから発行された証明書を保管しておく手段として、もっともよく用いられるのがCookieです。Cookieは、ブラウザ内に保存される小さなデータで、文字列のキーと値のペアとして保存されます。
Cookieを操作するための最も基本的な方法は、HTTPリクエストやレスポンスのヘッダを用いて送受信することです。HTTPレスポンスヘッダにSet-Cookie
ヘッダを含めることにより、次回以降のリクエストで、クライアントはそのデータをCookie
リクエストヘッダに入れて毎回送信します。例えば、
Set-Cookie: name=tanaka
Set-Cookie: age=20
のようなHTTPレスポンスヘッダを受け取ったブラウザは、name=tanaka
とage=20
という2つのキーと値のペアをCookieとして保存します。そして、次回以降のリクエストでは、
Cookie: name=tanaka; age=20
のようなヘッダがリクエストに含まれるようになります。
次の例は、アクセスした回数をCookieに保存し、アクセスのたびにその値を1増やして表示するプログラムです。アクセス回数はCookieに保存されており、サーバーはその値を通してアクセスされた回数を把握しています。
Expressを用いてSet-Cookie
ヘッダをレスポンスに設定するには、express.Response#cookie
メソッドを使用するのが一般的です。また、cookie-parserパッケージを使用すると、リクエストヘッダに含まれるCookieを簡単に取得できます。このパッケージは、request.headers.cookie
を解析し、request.cookies
プロパティにオブジェクト形式で格納します。
import express from "express";
import cookieParser from "cookie-parser";
const app = express();
app.use(cookieParser());
app.get("/", (request, response) => {
// Cookieの値は文字列なので数値に変換が必要
const count = Number(request.cookies.count) || 0;
const newCount = count + 1;
// 変更後の値をレスポンスヘッダに乗せる
response.cookie("count", newCount.toString());
response.send(`${newCount}回目のアクセスですね。`);
});
app.listen(3000);
{
"dependencies": {
"cookie-parser": "^1.4.7",
"express": "^5.1.0"
}
}
確認問題
サンプルプログラムを実行し、Google Chromeの開発者ツールを用いてリクエストやレスポンスに含まれるSet-Cookie
ヘッダやCookie
ヘッダの値を確認してみましょう。
解答例
開発者ツールの「Network」タブを開き、ページを更新します。初回のリクエストではSet-Cookie: count=1
ヘッダがレスポンスに含まれています。2回目以降のリクエストでは、Cookie: count=1
(2回目のアクセス時)、Cookie: count=2
(3回目のアクセス時)といったヘッダがリクエストに含まれていることを確認してください。ブラウザの閲覧データを消去するか、シークレットモードでアクセスすると、Cookieをリセットして再度試すことができます。
認証情報をCookieに保存する
Cookieを使用して、実際に認証が必要なWebアプリケーションを実装してみましょう。次のプログラムは、IDとパスワードによって初回の認証を行い、その結果サーバーで発行された証明書をCookieに保存して次回以降の認証を行うWebアプリケーションの例です。ログインが必要なページで、認証が成功すると、ユーザー名が表示されます。認証に失敗した場合は、自動的にログインページに移動します。
サーバー
このアプリケーションでは、ユーザー情報を保存するテーブルUser
と、セッション情報を保存するテーブルSession
の2つを持つデータベースを使用します。Session
テーブルには、一意でランダムなIDであるsessionId
と、ユーザーのIDであるuserId
が保存されます。サーバーは、クライアントがsessionId
を知っていれば、そのuserId
に対応するユーザーとしてクライアントを認証します。つまり、このsessionId
が、前述の「証明書」に相当します。
なお、一般的に「セッション」とは、ある処理の開始から終了までの一連の流れを指しますが、ここHTTPの文脈における「セッション」は、ユーザーがログインしてからログアウトするまでの期間を指します。
model User {
id Int @id @default(autoincrement())
username String @unique
password String
}
model Session {
id Int @id @default(autoincrement())
sessionId String @unique // 一意でランダムなID
userId Int // ユーザーのID
}
/login
は、IDとパスワードを含むJSON形式のPOSTリクエストを受け取り、データベースのUser
テーブルのデータと比較することで、認証情報が正しいかどうかを検証します。正しければ、crypto.randomUUID
関数を用いて一意でランダムな文字列を生成し、新しいレコードをSession
テーブルに作成した上で、Cookieとしてクライアントに送信します。誤っていれば、直ちに認証失敗のステータスコードを返して終了します。
app.post("/login", async (request, response) => {
if (!request.body.username || !request.body.password) {
response.sendStatus(400); // Bad Request (リクエストの形式が不正)
return;
}
const user = await prismaClient.user.findUnique({
where: { username: request.body.username },
});
if (!user || user.password !== request.body.password) {
response.sendStatus(401); // Unauthorized (認証に失敗)
return;
}
const session = await prismaClient.session.create({
data: { userId: user.id, sessionId: crypto.randomUUID() },
});
response.cookie("sessionId", session.sessionId);
response.sendStatus(200); // OK (成功)
});
/profile
は、GETリクエストを受け取り、リクエストを送信したユーザーの情報をJSON形式で返します。前半部分ではCookieからsessionId
を取得し、その値を用いてSession
テーブルからレコードを検索して認証します。セッション情報が見つかれば、そのuserId
に対応するユーザー情報をUser
テーブルから取得し、その情報をレスポンスとして返します。
なお、この例では認証処理と実際の処理を同じ場所に記述していますが、実際のアプリケーションでは、認証はほとんどの場所で前段階として必要になるため、共通の実装として切り出しておくことが一般的です。
app.get("/profile", async (request, response) => {
// 認証
if (!request.cookies.sessionId) {
response.sendStatus(401); // Unauthorized (認証に失敗)
return;
}
const session = await prismaClient.session.findUnique({
where: { sessionId: request.cookies.sessionId },
});
if (!session) {
response.sendStatus(401); // Unauthorized (認証に失敗)
return;
}
// 実際の処理
const user = await prismaClient.user.findUnique({
where: { id: session.userId },
});
response.json({ username: user.username });
});
クライアント
クライアント側のアプリケーションは、認証が必要なページ/
とログインページ/login
の2つから構成されています。
/
では、ページが読み込まれたときに/profile
にGETリクエストを送信し、認証が成功すればユーザー名を表示します。認証に失敗した場合は、ログインページに移動します。
<h1>ホーム</h1>
<p>ようこそ!<span id="username-display"></span>さん!</p>
4行目では、fetch
関数の第2引数のcredentials
プロパティに"include"
を指定しています。これは、リクエストにCookieを含めるためのオプションです。指定しないと、認証に必要なCookieがサーバーに送信されず、認証に失敗してしまいます。
5行目では、Response#ok
プロパティを用いて、レスポンスのステータスコードが200番台であるかどうかを確認しています。200番台以外のステータスコードを受け取った場合、認証に失敗したと判断し、ログインページに移動します。
6行目のログインページへの移動には、location.replace
関数を使用し、現在のページを履歴に残さずに移動させています。これにより、ブラウザの「戻る」ボタンが正しく機能するようになります。逆に、履歴を残したい場合は、location.href
プロパティにURLを代入します。
const usernameDisplay = document.getElementById("username-display");
async function initialize() {
const response = await fetch("/profile", { credentials: "include" });
if (!response.ok) {
location.replace("/login");
return;
}
const profile = await response.json();
usernameDisplay.textContent = profile.username;
}
initialize();
/login
では、ユーザー名とパスワードを入力するフォームと、ログインボタンを用意します。ログインボタンがクリックされると、/login
にPOSTリクエストを送信します。認証に成功すれば、サーバーから送信されたCookieがブラウザに保存され、/
に移動します。認証に失敗した場合はアラートを表示します。
<p>ユーザー名: <input id="username-input" /></p>
<p>パスワード: <input id="password-input" type="password" /></p>
<button type="button" id="login-button">ログイン</button>
const usernameInput = document.getElementById("username-input");
const passwordInput = document.getElementById("password-input");
document.getElementById("login-button").onclick = async () => {
const response = await fetch("/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: usernameInput.value,
password: passwordInput.value,
}),
credentials: "include",
});
if (response.ok) {
alert("ログインに成功しました");
location.replace("/");
} else {
alert("ログインに失敗しました");
}
};
全体の処理の流れは、次の図のようになります。