🐠

Remixで認証機能を実装

2024/01/29に公開

フルスタックWebアプリケーションフレームワークのRemixに関する内容です。
今回は、メールアドレスとパスワードでサインアップ、ログインするというよくある仕組みを実装してみます。

認証はこのようなイメージです。

ログインの際、クライアントから送られた情報を元にユーザーの存在チェックを行います。
本来ならデータベースを参照しますが、今回は簡略化のためJSONファイルを参照します。
ユーザーが存在していればセッションを生成し、レスポンスのヘッダーにクッキーとして付与します。

認可はこのようなイメージです。

付与されたクッキーセッションがある状態でサーバーにリクエストを送ります。
サーバー側ではクッキーのセッション情報を検証してOKならレスポンスを返す、NGならログイン画面にリダイレクトさせるような仕様です。

最終的なコードをGitHubにて公開しています。
https://github.com/t-aono/remix-auth-sample

Remixで実装

プロジェクト作成とルーティング設定

Quick Startのコマンドを実行します。

npx create-remix@latest
log
npx create-remix@latest

 remix   v2.5.1 💿 Let's build a better website...

   dir   Where should we create your new project?
         remix-auth-sample

      ◼  Using basic template See https://remix.run/guides/templates for more
      ✔  Template copied

   git   Initialize a new git repository?
         Yes

  deps   Install dependencies with npm?
         Yes

      ✔  Dependencies installed

      ✔  Git initialized

  done   That's it!

         Enter your project directory using cd ./remix-auth-sample
         Check out README.md for development and deploy instructions.

         Join the community at https://rmx.as/discord

ホーム画面とサインアップ、ログイン画面のページを作ります。

app/routes/_index.tsx
export default function Index() {
  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
      <h1>Home</h1>
      <p>home page</p>
    </div>
  );
}
app/routes/auth.tsx
export default function AuthPage() {
  return <AuthForm />;
}
components/auth/AuthForm.tsx
export default function AuthForm() {
  const [searchParams] = useSearchParams();
  const validationErrors = useActionData<string[]>();

  const authMode = searchParams.get("mode") || "login";
  const submitBtnCaption = authMode === "login" ? "Login" : "Create User";
  const toggleBtnCaption =
    authMode === "login" ? "Create a new user" : "Log in with existing user";

  return (
    <Form method="post" id="auth-form">
      <p>
        <label htmlFor="email">Email Address</label>
        <input type="email" id="email" name="email" required />
      </p>
      <p>
        <label htmlFor="password">Password</label>
        <input type="password" id="password" name="password" minLength={7} />
      </p>
      {validationErrors && (
        <ul>
          {Object.values(validationErrors).map((error) => (
            <li key={error}>{error}</li>
          ))}
        </ul>
      )}
      <div>
        <button> {submitBtnCaption} </button>
      </div>
      <p>
        <Link to={authMode === "login" ? "?mode=signup" : "?mode=login"}>
          {toggleBtnCaption}
        </Link>
      </p>
    </Form>
  );
}

authページでは以下ような動作になります。
/auth?mode=signup にアクセス:サインアップ画面を表示。
/auth?mode=login にアクセス:ログイン画面を表示。

サインアップ

入力されたパスワードはハッシュ化したいので、それ用のライブラリを入れおきます。

npm i bcryptjs
npm i -D @types/bcryptjs

バックエンドの処理は、data/auth.server.tsを用意してsignup関数とcreateSession関数を書いておきます。
signup関数ではログイン情報をもとにユーザー作成します。今回はユーザー情報をJSONファイルで管理しています。

signup
export async function signup({
  email,
  password,
}: {
  email: string;
  password: string;
}) {
  const users: User[] = JSON.parse(fs.readFileSync("user.json").toString());
  const existingUser = users.find((user) => user.email === email);

  if (existingUser) {
    const error: any = new Error(
      "A user with the provided email address exists already."
    );
    error.status = 422;
    throw error;
  }

  const passwordHash = await hash(password, 12);

  fs.writeFileSync(
    "user.json",
    JSON.stringify([...users, { email: email, password: passwordHash }])
  );

  return await createUserSession(users.length + 1, "/");
}

createUserSession関数では、ユーザーIDを受け取りセッション生成をしてレスポンスヘッダーにクッキーセッションを付与しています。
補足ですが、SESSION_SECRETの値は環境変数で管理するのが一般的です。

createUserSession
const sessionStorage = createCookieSessionStorage({
  cookie: {
    secure: process.env.NODE_ENV === "production",
    secrets: [SESSION_SECRET],
    sameSite: "lax",
    maxAge: 30 * 24 * 60 * 60,
    httpOnly: true,
  },
});

async function createUserSession(userId: number, redirectPath: string) {
  const session = await sessionStorage.getSession();
  session.set("userId", userId);
  return redirect(redirectPath, {
    headers: {
      "Set-Cookie": await sessionStorage.commitSession(session),
    },
  });
}

ユーザー情報をsubmitした際のハンドラーとしてapp/routes/auth.tsxにaction関数を追加します。
action関数ではフォームの値を取得してバックエンドのsignupに渡してます。

action
export async function action({ request }: ActionFunctionArgs) {
  const searchParams = new URL(request.url).searchParams;
  const authMode = searchParams.get("mode") || "login";

  const formData = await request.formData();
  const credentials = Object.fromEntries(formData) as {
    email: string;
    password: string;
  };

  try {
    if (authMode === "login") {
      // return await login(credentials);
    } else {
      return await signup(credentials);
    }
  } catch (error: any) {
    if (error.status === 422) {
      return { credentials: error.message };
    }
  }
}

ログイン認証

同様にdata/auth.server.tsにlogin関数を定義します。
login関数ではユーザーの存在チェック、パスワードが正しいか判定をして、OKならcreateSession関数を使いレスポンスヘッダーにクッキーセッションを付与しています。

login
export async function login({
  email,
  password,
}: {
  email: string;
  password: string;
}) {
    const users: User[] = JSON.parse(fs.readFileSync("user.json").toString());
  const userIndex = users.findIndex((user) => user.email === email);
  const existingUser = users[userIndex];

  if (!existingUser) {
    const error: any = new Error(
      "Could not log you in, please check the provided email."
    );
    error.status = 401;
    throw error;
  }

  const passwordCorrect = await compare(password, existingUser.password);

  if (!passwordCorrect) {
    const error: any = new Error(
      "Could not log you in, please check the provided password."
    );
    error.status = 401;
    throw error;
  }

  return createUserSession(userIndex + 1, "/");
}

login関数をapp/routes/auth.tsxのaction関数の中で呼び出します。
ユーザーが存在しない、またはパスワードが一致しない場合はステータスコード401が返ってくるので、app/routes/auth.tsxのaction関数にも条件分岐を追加しておきます。

action
try {
  if (authMode === "login") {
    return await login(credentials);
  } else {
    return await signup(credentials);
  }
} catch (error: any) {
  if (error.status === 401) {
    return { credentials: error.message };
  }
  if (error.status === 422) {
    return { credentials: error.message };
  }
}

認証の動作確認

サインアップ画面からユーザー作成がうまくいけば、ホームにリダイレクトされます。
devloper toolのApplicationを見るとCookiesに生成されたセッションの値が保存されていればOKです。

ログイン画面でも認証がうまくいけば、ホームにリダイレクトされCookiesにセッションの値が保存されます。認証に失敗するとメッセージが表示されこのページに留まります。

マイページと認可

Cookieにユーザーのセッション情報がセットされているかをサーバー側で判定する必要があるので、data/auth.server.tsにrequireUserSession関数とgetUserFromSession関数を作成します。
セッション情報が正しくセットされていればユーザーIDを返し、ダメな場合はログインページにリダイレクトします。

requireUserSession
export async function requireUserSession(request: Request) {
  const userId = await getUserFromSession(request);

  if (!userId) {
    throw redirect("/auth?mode=login");
  }

  return userId;
}
getUserFromSession
export async function getUserFromSession(request: Request) {
  const session = await sessionStorage.getSession(
    request.headers.get("Cookie")
  );

  const userId: string = session.get("userId");

  if (!userId) {
    return null;
  }

  return userId;
}

ログインユーザーのみが見れるマイページという画面を作成します。loader関数にてユーザーセッションの判定を行います。

app/routes/mypage.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  await requireUserSession(request);
  return null;
}

export default function MyPage() {
  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
      <p>my page</p>
    </div>
  );
}

未ログイン状態で /mypage を開くと弾かれるようになりました。

ログイン後に /mypage を開くと表示できます。

まとめ

今回はRemixを使った認証機能の実装についてまとめてみました。

RemixはNext.jsに比べると知名度が高くないですが、個人的には覚える事が少なくシンプルで使いやすいフレームワークだと思います。バックエンドでの処理を手軽に書ける点も良いです。

今後もRemix使ってWebアプリの開発をやってみようと思います。

参考

Remix > Quick Start
公式ドキュメントです。
https://remix.run/docs/en/main/start/quickstart

Remix.js - The Practical Guide
Udemyの動画教材です。Remixの基礎が学べます。
https://www.udemy.com/course/remix-course/

Discussion