Open6

CookieとSession

KeiKei

はじめに

RemixでCookieとSessionをどのように扱うか知りたいと思ったので、本スクラップにまとめる。

KeiKei

Cookiesについて

  • Cookieはサーバー側で生成される。
  • サーバーがHTTPレスポンスで送信する小さな情報でありブラウザは以降のリクエストで送信。
  • クッキーを使うことで、認証、カート、ユーザ設定など多くの機能を実装できる。

https://remix.run/docs/en/main/utils/cookies

クッキーの使用

  • 前提として、Cokkieはセッションストレージを使用する方が一般的である。

  • loaderactionでCookieを操作することが多い

  • cookieの作成方法は↓

app/cookies.server.ts
import { createCookie } from "@remix-run/node"; 

export const userPrefs = createCookie("user-prefs", {
  maxAge: 604_800, // one week
});
ルートモジュールの例
app/routes/_index.tsx
import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "@remix-run/node"; // or cloudflare/deno
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import {
  useLoaderData,
  Link,
  Form,
} from "@remix-run/react";

import { userPrefs } from "~/cookies.server";

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const cookieHeader = request.headers.get("Cookie");
  const cookie =
    (await userPrefs.parse(cookieHeader)) || {};
  return json({ showBanner: cookie.showBanner });
}

export async function action({
  request,
}: ActionFunctionArgs) {
  const cookieHeader = request.headers.get("Cookie");
  const cookie =
    (await userPrefs.parse(cookieHeader)) || {};
  const bodyParams = await request.formData();

  if (bodyParams.get("bannerVisibility") === "hidden") {
    cookie.showBanner = false;
  }

  return redirect("/", {
    headers: {
      "Set-Cookie": await userPrefs.serialize(cookie),
    },
  });
}

export default function Home() {
  const { showBanner } = useLoaderData<typeof loader>();

  return (
    <div>
      {showBanner ? (
        <div>
          <Link to="/sale">Don't miss our sale!</Link>
          <Form method="post">
            <input
              type="hidden"
              name="bannerVisibility"
              value="hidden"
            />
            <button type="submit">Hide</button>
          </Form>
        </div>
      ) : null}
      <h1>Welcome!</h1>
    </div>
  );
}

Cookieの属性

const cookie = createCookie("cookie-name", {
  expires: new Date(Date.now() + 60_000),
  httpOnly: true,
  maxAge: 60,
  path: "/",
  sameSite: "lax",
  secrets: ["s3cret1"],
  secure: true,
});

// デフォルト使用
cookie.serialize(userPrefs);

// 必要に応じて設定の上書きができる
cookie.serialize(userPrefs, { sameSite: "strict" });

Signing cookies

  • クッキー受信時に内容を自動検証するには、クッキー署名が必要
  • HTTPヘッダの詐称は容易なため、誰かに詐称されたくない場合に効果的。
  • Cookieに署名するには、作成時にsecretsを1つ以上指定する
const cookie = createCookie("user-prefs", {
    secrets: ["s3cret1"]
});
  • secretsは、配列の先頭に新規シークレットを追加することで、ローテーション可能
  • 古いsecretsで署名されたクッキーは、cookie.parse()で正常にデコードされ、cookie.serialize()で作成された送信クッキーへの署名には、常に最新のsecrets(配列の最初のもの)が使われる。
app/cookies.server.ts
export const cookie = createCookie("user-prefs", {
  secrets: ["n3wsecr3t", "olds3cret"],
});

app/routes/route.tsx
import { cookie } from "~/cookies.server";

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const oldCookie = request.headers.get("Cookie");
  // oldCookie may have been signed with "olds3cret", but still parses ok
  const value = await cookie.parse(oldCookie);

  new Response("...", {
    headers: {
      // Set-Cookie is signed with "n3wsecr3t"
      "Set-Cookie": await cookie.serialize(value),
    },
  });
}

KeiKei

Cookie API

  • createCookieで返されるcookieコンテナはいくつかのプロパティとメソッドがある。
const cookie = createCookie(name);
cookie.name;
cookie.parse();
// ...etc

ここから紹介する↓

cookie.name

  • CookieSet-Cookie HTTPヘッダで使われるクッキーの名前。

cookie.parse()

  • Cookieヘッダ内のクッキー値を取り出して返すメソッド
const value = await cookie.parse(
  request.headers.get("Cookie")
);

cookie.serialize()

  • 値をシリアライズし、オプションを組み合わせてResponseで使用するのに適したSet-Cookieヘッダを作成する
new Response("...", {
  headers: {
    "Set-Cookie": await cookie.serialize({
      showBanner: true,
    }),
  },
});

cookie.isSigned

  • クッキーがsecretを使用しているかに応じて、true or falseを返す
let cookie = createCookie("user-prefs");
console.log(cookie.isSigned); // false

cookie = createCookie("user-prefs", {
  secrets: ["soopersekrit"],
});
console.log(cookie.isSigned); // true

cookie.expires

  • クッキーの有効期限となる日付
  • もしmaxAgeexpiresの両方を持つ場合、maxAgeが優先されるため、この値は現在の日付 + maxAgeとなることに注意
const cookie = createCookie("user-prefs", {
  expires: new Date("2021-01-01"),
});

console.log(cookie.expires); // "2020-01-01T00:00:00.000Z"

KeiKei

Sessionについて

  • セッションは、サーバー側で同一人物からのリクエストを判別できるようにする仕組み。
    • サーバー側のフォーム検証で使われたり、JSがページ上になくても機能する。
  • ユーザーを「ログイン」させる多くのサイトにおける基本的な構成要素。

https://remix.run/docs/en/main/utils/sessions

Remixにおけるセッション管理

  • セッションはsessionStorageオブジェクトを使って、loaderactionメソッドで各ルートで管理されるもの。
  • sessionStorageは、クッキーの解析と生成、DBやファイルシステムへのセッションデータ保存が可能。

Sessionの使い方

下記はcookieSessionStorageを使った例

app/sessions.ts
import { createCookieSessionStorage } from "@remix-run/node";

type SessionData = {
  userId: string;
};

type SessionFlashData = {
  error: string;
};

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage<SessionData, SessionFlashData>(
    {
      // a Cookie from `createCookie` or the CookieOptions to create one
      cookie: {
        name: "__session",

        // これらは任意
        domain: "remix.run",
        httpOnly: true,
        maxAge: 60,
        path: "/",
        sameSite: "lax",
        secrets: ["s3cret1"],
        secure: true,
      },
    }
  );

export { getSession, commitSession, destroySession };

  • ログインフォームはこんなイメージ↓
app/routes/login.tsx
app/routes/login.tsx
import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import { getSession, commitSession } from "../sessions";

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const session = await getSession(
    request.headers.get("Cookie")
  );

  if (session.has("userId")) {
    // すでにログイン済みならばホームへリダイレクト
    return redirect("/");
  }

  const data = { error: session.get("error") };

  return json(data, {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

export async function action({
  request,
}: ActionFunctionArgs) {
  const session = await getSession(
    request.headers.get("Cookie")
  );
  const form = await request.formData();
  const username = form.get("username");
  const password = form.get("password");

  const userId = await validateCredentials(
    username,
    password
  );

  if (userId == null) {
    session.flash("error", "Invalid username/password");

    // Redirect back to the login page with errors.
    return redirect("/login", {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    });
  }

  session.set("userId", userId);

  // ログイン成功後、ホームへリダイレクトさせる
  return redirect("/", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

export default function Login() {
  const { error } = useLoaderData<typeof loader>();

  return (
    <div>
      {error ? <div className="error">{error}</div> : null}
      <form method="POST">
        <div>
          <p>Please sign in</p>
        </div>
        <label>
          Username: <input type="text" name="username" />
        </label>
        <label>
          Password:{" "}
          <input type="password" name="password" />
        </label>
      </form>
    </div>
  );
}

  • ログアウトフォームはこんなイメージ↓
logout.tsx
import { getSession, destroySession } from "../sessions";

export const action = async ({
  request,
}: ActionFunctionArgs) => {
  const session = await getSession(
    request.headers.get("Cookie")
  );
  return redirect("/login", {
    headers: {
      "Set-Cookie": await destroySession(session),
    },
  });
};

export default function LogoutRoute() {
  return (
    <>
      <p>Are you sure you want to log out?</p>
      <Form method="post">
        <button>Logout</button>
      </Form>
      <Link to="/">Never mind</Link>
    </>
  );
}
KeiKei

CreateSessionStorage

  • 独自データベースにセッションを保存する方法の一つ?
  • Cookieとセッションデータ管理用のCRUDメソッドのセットを必要とする。
    • createData
      • クッキーにセッションIDが存在しない場合、初期セッション生成時にcommitSessionから呼ばれる
    • readData
      • クッキーにセッションIDが存在する場合、getSessionから呼ばれる
    • updateData
      • クッキーにセッションIDが存在する場合、commitSessionから呼ばれる
    • deleteData
      • destorySessionから呼ばれる
  • CookieはセッションIDの永続化に使用している。

使い方

import { createSessionStorage } from "@remix-run/node"; // or cloudflare/deno

// DBにセッションストレージを作成する関数
function createDatabaseSessionStorage({
  cookie,
  host,
  port,
}) {
  // 好みのDBクライアント設定
  const db = createDatabaseClient(host, port);
  
  return createSessionStorage({
    cookie,
    async createData(data, expires) {
      const id = await db.insert(data);
      return id;
    },
    async readData(id) {
      return (await db.select(id)) || null;
    },
    async updateData(id, data, expires) {
      await db.update(id, data);
    },
    async deleteData(id) {
      await db.delete(id);
    },
  });
}

  • 呼び出すときはこう↓
const { getSession, commitSession, destroySession } =
  createDatabaseSessionStorage({
    host: "localhost",
    port: 1234,
    cookie: {
      name: "__session",
      sameSite: "lax",
    },
  });

CreateCookieSessionStorage

  • cookieベースのセッション(cookieをセッションストレージとして扱う手法)
  • メリットは追加のBEサービスやDBを必要としない点。
  • デメリットは、ブラウザの最大許容cookieのサイズ(4kb)を超過できない点。
  • 欠点は、ほとんどすべてのローダとアクションで commitSession を行わなければならないことです。ローダーやアクションがセッションを少しでも変更したら、それをコミットしなければなりません。つまり、あるアクションでsession.flashを実行し、別のアクションでsession.getを実行した場合、flashされたメッセージを消すためにはコミットしなければなりません。他のセッション保存戦略では、セッションが作成されたときだけコミットする必要があります(ブラウザのクッキーはセッションデータを保存しないので、変更する必要はありません。)
  • 他のセッションの実装はクッキーに一意なセッション ID を保存し、その ID を使って真実の情報源(インメモリ、ファイルシステム、 DB など)でセッションを検索することに注意してください。クッキー・セッションでは、クッキーが真実の情報源なので、一意な ID は最初からありません。クッキー・セッションで一意な ID を追跡する必要がある場合、session.set() によって ID 値を自分で追加する必要があります。

〜参考〜
https://techgeek-school.com/blogs/shopifyアプリ開発/remix入門-セッション管理を徹底解説-会員登録-ログイン

使い方

import { createCookieSessionStorage } from "@remix-run/node"; // or cloudflare/deno

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      name: "__session",
      secrets: ["r3m1xr0ck5"],
      sameSite: "lax",
    },
  });
KeiKei

Session API

  • getSessionで取得したセッションオブジェクトは、いくつかのメソッドとプロパティを持っている。

session.has(key)

  • セッションに指定した変数がある場合、trueを返却する
session.has("userId");

session.set(key, value)

  • 以降のリクエストで使用するセッション値を設定する
session.set("userId", "1234");

session.flash(key, value)

  • 初回読み取り時に、unset()されるセッション値を設定して、あとで消える
  • フラッシュメッセージやサーバーサイドのフォーム検証メッセージに便利
import { commitSession, getSession } from "../sessions";

export async function action({
  params,
  request,
}: ActionFunctionArgs) {
  const session = await getSession(
    request.headers.get("Cookie")
  );
  const deletedProject = await archiveProject(
    params.projectId
  );
  
  // ✅ここでメッセージ出す
  session.flash(
    "globalMessage",
    `Project ${deletedProject.name} successfully archived`
  );

  return redirect("/dashboard", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

session.get()

  • 前回リクエストのセッション値の取得
session.get("name");

session.unset()

  • セッションから値を取り除く
session.unset("name");
  • またcookieSessionStorageを使用する場合、unset時にはsessionをコミットしなければならないことに注意!
export async function loader({
  request,
}: LoaderFunctionArgs) {
  // ...

  return json(data, {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}