行動すれば次の現実

ほどよくモダンなシステム開発を目指しています。メインテーマは生産性、Ruby、Javascriptです。

Next.jsとdevise_token_authを使って認証周りを実装する

前回、Rails7 APIモードの認証機能をdevise_token_authで実装するという記事で、devise_token_authの導入方法や使い方を一通り説明いたしました。

今回はNext.jsとdevise_token_authを使ってログイン処理と認証制御周りを具体的な実装例を踏まえて説明します。

認証処理はクライアントサイドで行うか、サーバーサイドで行うか

まず実装に入る前に、認証処理をクライアントサイドで行うのか、サーバーサイドで行うのかを決める必要があります。

クライアントサイドで認証

クライアントサイドで認証を行う場合は、認証用のAPIを別途設けて、その結果により遷移先を分けるという実装をします。 認証結果が真の場合は、該当ページへの遷移を許可し、偽の場合はログインページへリダイレクトさせます。

クライアントサイドでの認証の場合、ページリクエストの前に毎回認証用APIが実行されるので、リクエストが2回発生することになります。

サーバーサイドで認証

サーバーサイドで認証する場合は、認証処理も含めてサーバーサイドで行われます。認証結果が真の場合は、該当アクションのデータを取得し、偽の場合はログインページへリダイレクトさせます。

リクエスト回数が1回で済みますので、私はサーバーサイドでの認証を採用することにしました。

ログイントークンはCookieで保持するか、LocalStorageで保持するか

devise-token-authではaccess-token、client、uidという3つのトークン情報を用いて認証を行います。 これら3つの情報をリクエストヘッダーにセットしておくことで認証処理が行われますので、トークン情報をクライアント側でも保持しておく必要があります。

トークンを保持するにはCookieかlocalStorageを使用するのが一般的かと思います。

私は初めにlocalStorageを検討しました。サーバーサイド認証を実装するにはServerSidePropsを使用するのですが、ServerSidePropsではlocalStorageが読み取れない仕様のようです。

そのため、今回はCookieを使用してトークン情報を保持することにしました。

具体的な実装例

ログインページ

ログインページの実装例を記します。ポイントは/api/v1/auth/sign_inへのログイン処理の結果をCookieにセットしている部分です。 js-cookieを使用してaccess-token、client、uidをそれぞれCookieにセットしています。

// pages/login.tsx

import React, { ReactElement, useState } from "react";
import { useRouter } from "next/router";
import {
  Alert,
  Box,
  Button,
  Container,
  TextField,
  Typography,
} from "@mui/material/";
import axios from "axios";
import Cookies from "js-cookie";

const Login = () => {
  const router = useRouter();
  const [isError, setIsError] = useState<boolean>(false);
  const [errorMessage, setErrorMessage] = useState<string>("");

  const handleSubmit = (event) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const axiosInstance = axios.create({
      baseURL: `http://localhost:3000/api/v1/`,
      headers: {
        "content-type": "application/json",
      },
    });
    (async () => {
      setIsError(false);
      setErrorMessage("");
      return await axiosInstance
        .post("auth/sign_in", {
          email: data.get("email"),
          password: data.get("password"),
        })
        .then(function (response) {
          // Cookieにトークンをセットしています
          Cookies.set("uid", response.headers["uid"]);
          Cookies.set("client", response.headers["client"]);
          Cookies.set("access-token", response.headers["access-token"]);
          router.push("/home");
        })
        .catch(function (error) {
          // Cookieからトークンを削除しています
          Cookies.remove("uid");
          Cookies.remove("client");
          Cookies.remove("access-token");
          setIsError(true);
          setErrorMessage(error.response.data.errors[0]);
        });
    })();
  };

  return (
    <Container component="main" maxWidth="xs">
      <Box>
        <Typography component="h1" variant="h5">
          ログイン
        </Typography>
        <Box component="form" onSubmit={handleSubmit}>
          <TextField
            id="email"
            label="メールアドレス"
            name="email"
            autoComplete="email"
            autoFocus
          />
          <TextField
            name="password"
            label="パスワード"
            type="password"
            id="password"
            autoComplete="current-password"
          />
          <Button
            type="submit"
            variant="contained"
            sx={{ mt: 3, mb: 2 }}
          >
            ログイン
          </Button>
          {isError ? (
            <Alert
              onClose={() => {
                setIsError(false);
                setErrorMessage("");
              }}
              severity="error"
            >
              {errorMessage}
            </Alert>
          ) : null}
        </Box>
      </Box>
    </Container>
  );
};

ServerSidePropsのラッパー関数

getServerSideProps関数に認証機能の制御をもたせたラッパー関数を実装します。 APIリクエスト時にCookieからトークンを取得してリクエストヘッダーにセットします。

レスポンスが認証失敗(401)だった場合はログイン画面へリダイレクトさせます。成功した場合はレスポンスデータをpropsとして返却します。

// lib/auth.tsx

import { GetServerSideProps } from "next";

export const withAuthServerSideProps = (url: string): GetServerSideProps => {
  return async (context) => {
    const { req, res } = context;

    const response = await fetch(`${process.env.API_ORIGIN}/${url}`, {
      headers: {
        "Content-Type": "application/json",
        uid: req.cookies["uid"],
        client: req.cookies["client"],
        "access-token": req.cookies["access-token"],
      },
    });
    if (!response.ok && response.status === 401) {
      return {
        redirect: {
          destination: "/login",
          permanent: false,
        },
      };
    }
    // TODO: 他にも500エラーを考慮した分岐も必要
    return { props: response.data };
  };
};

認証が必要なページ

認証が必要なページのgetServerSideProps関数を、上記で実装したwithAuthServerSideProps関数でラッパーした状態で実行します。 それによりサーバーサイドレンダリング時に認証処理を制御することが可能になります。

// pages/home.tsx
import * as React from "react";
import styles from "../styles/Home.module.css";
import Head from "next/head";
import { GetServerSideProps } from "next";
import { withAuthServerSideProps } from "../lib/auth";

export const getServerSideProps: GetServerSideProps =
  withAuthServerSideProps("/api/v1/home");

const Home = () => {
  return (
    <>
      <div className={styles.container}>
        <main className={styles.main}>
          <h1 className={styles.title}>HOME</h1>
          <p className={styles.description}>ホーム画面です</p>
        </main>
      </div>
    </>
  );
};

export default Home;

Rails側の処理

Railsのアクションではbefore_actionでdevise_token_authによる認証メソッド(authenticate_api_v1_user!)を実行します。 これにより認証処理が実行されて、認証が失敗した場合は401が返却されます。

class Api::V1::HomeController < ApplicationController
  before_action :authenticate_api_v1_user!

  def index
    render json: { message: 'hello' }
  end
end

終わりに

Next.jsとdevise_token_authを使った認証制御周りの実装は以上になります。

上記の実装以外にも「ログアウト処理」や「認証用Layoutと通常Layoutの2つを用意してナビゲーションバーの共通化する」などの考慮が必要になると思いますが本記事では省略しております。また機会がありましたら記事にしようと思います。