メインコンテンツまでスキップ

Cookieと認証(発展)

多くのWebアプリケーションは、利用者が本人であることを確認するための機能を備えています。この節では、Webアプリケーションにおける「ログイン」機能を実装するための、最も基本的な技術要素について学びます。

危険

認証は、Webアプリケーションのセキュリティにおいて非常に重要な要素で、この節で学ぶ内容のみでは、実際のアプリケーション向けの実装としては不十分です。Firebase AuthenticationAuth0などの外部サービスを用いることで、強固なセキュリティを手軽に実現できます。また、どうしても外部サービスを利用できない場合は、Passport.jsや、Auth.jsなどのライブラリを利用することを強く推奨します。

IDとパスワードによる認証

IDとパスワードによる認証は、Webアプリケーションにおいて最も一般的な認証方法です。アプリケーションの利用者は、ID(通常はメールアドレスやユーザー名)とパスワードを入力し、サーバーは、その組み合わせをデータベースに保存されているものと比較し、一致すれば認証が成功したと判断します。

認証に成功すると、サーバーはクライアントに対し、一時的な「証明書」を発行します。クライアントは、その証明書を次回以降のリクエストに含めることで、認証が成功していることをサーバーに示します。サーバーは、その証明書の正当性を確認することで、リクエストが正規のユーザーからのものであることを判断します。

認証が必要なWebアプリケーションの基本的な構造

Cookieの利用

Webアプリケーションのクライアントが、サーバーから発行された証明書を保管しておく手段として、もっともよく用いられるのがCookieです。Cookieは、ブラウザ内に保存される小さなデータで、文字列のキーと値のペアとして保存されます。

Cookieを操作するための最も基本的な方法は、HTTPリクエストやレスポンスのヘッダを用いて送受信することです。HTTPレスポンスヘッダにSet-Cookieヘッダを含めることにより、次回以降のリクエストで、クライアントはそのデータをCookieリクエストヘッダに入れて毎回送信します。例えば、

  • Set-Cookie: name=tanaka
  • Set-Cookie: age=20

のようなHTTPレスポンスヘッダを受け取ったブラウザは、name=tanakaage=20という2つのキーと値のペアをCookieとして保存します。そして、次回以降のリクエストでは、

  • Cookie: name=tanaka; age=20

のようなヘッダがリクエストに含まれるようになります。

次の例は、アクセスした回数をCookieに保存し、アクセスのたびにその値を1増やして表示するプログラムです。アクセス回数はCookieに保存されており、サーバーはその値を通してアクセスされた回数を把握しています。

Cookieを利用したアクセスカウンタの処理の流れ

Expressを用いてSet-Cookieヘッダをレスポンスに設定するには、express.Response#cookieメソッドを使用するのが一般的です。また、cookie-parserパッケージを使用すると、リクエストヘッダに含まれるCookieを簡単に取得できます。このパッケージは、request.headers.cookieを解析し、request.cookiesプロパティにオブジェクト形式で格納します。

main.mjs
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);
package.json (抜粋)
{
"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の文脈における「セッション」は、ユーザーがログインしてからログアウトするまでの期間を指します。

schema.prisma (抜粋)
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としてクライアントに送信します。誤っていれば、直ちに認証失敗のステータスコードを返して終了します。

main.mjs (POST /login の抜粋)
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テーブルから取得し、その情報をレスポンスとして返します。

なお、この例では認証処理と実際の処理を同じ場所に記述していますが、実際のアプリケーションでは、認証はほとんどの場所で前段階として必要になるため、共通の実装として切り出しておくことが一般的です。

main.mjs (GET /profile の抜粋)
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リクエストを送信し、認証が成功すればユーザー名を表示します。認証に失敗した場合は、ログインページに移動します。

public/index.html (抜粋)
<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を代入します。

public/script.js
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がブラウザに保存され、/に移動します。認証に失敗した場合はアラートを表示します。

public/login/index.html
<p>ユーザー名: <input id="username-input" /></p>
<p>パスワード: <input id="password-input" type="password" /></p>
<button type="button" id="login-button">ログイン</button>
public/login/script.js
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("ログインに失敗しました");
}
};

全体の処理の流れは、次の図のようになります。

Cookieを利用して認証情報を保存するWebアプリケーションの動作