🛹

【Next.js】アクセスコントロールパターン

2021/09/19に公開

はじめに

Next.js で MPA を構築していると、ページ単位でアクアセスコントロールを行うニーズやケースがよく発生します。

ここでのアクセスコントロールは、ページごとにアクセス可能な条件を定義したり、ルールにそぐわないアクセスを別のページに転送させるなどの処理を意味します。

例えば、一般ユーザ向けのページと、登録済みユーザ向けのマイページを持つケースを考えてみると…

  • 一般ユーザ向けページは誰でもアクセス可能
  • マイページはログイン済みのユーザのみアクセス可能
  • ログインのためのサインイン・アップフォームのページも存在するが、ログイン済みであればマイページにリダイレクトされる
    • ただし、パスワードリセットのフォームは誰でもアクセス可能

今回は上記のルールを実装する上での、いくつかのアクセスコントロールパターンを考えます。


個人的には中規模以上のプロジェクトではパターン3をおすすめします。そのため時間がなければパターン3まで読み飛ばしていただいて構いません。

パターン1: 各ページコンポネント内で制御する

まずは各ページでアクセスコントロールを行うケースです。
便宜上 isLoggedIn() の実装は省略します。

// pages/mypage.tsx
const Mypage: VFC = (props) => {
  const router = useRouter()
  if (!isLoggedIn()) router.replace('/signin') // ログインしていなければサインインページへ転送

  return <div>...</div>
}

export default Mypage
// pages/signin.tsx
const Signin: VFC = (props) => {
  const router = useRouter()
  if (isLoggedIn()) router.replace('/mypage') // ログイン済みであればマイページへ転送

  return <div>...</div>
}

export default Signin

このパターンは非常にシンプルです。おそらく誰もが一番最初に思いつくパターンでしょう。
ページ数が少ないうちはこれで問題ないのですが、ページ数が増えてくると破綻しやすくなります。
例えば、/mypage/a /mypage/b /mypage/c のように複数ページある場合や、新しくページを追加する場合に、アクセスコントロールの記述を漏らしてしまい、誰でもアクセス可能な状況に晒してしまう可能性があります。

パターン2: _app.tsx で集中管理する

続いて、ページの共通コンポネントである _app.tsx で完結させるさせるケースを考えます。

// pages/_app.tsx

const useAccessControll = () => {
  const router = useRouter()
  useEffect(() => {
    if (/^\/mypage/.test(router.asPath) && !isLoggedIn())
      router.replace('/signin')
    if (/^\/(signin|signup)/.test(router.asPath) && isLoggedIn())
      router.replace('/mypge')
  }, [router]) 
}

const App: VFC<AppProps> = ({ Component, pageProps }) => {
  useAccessControll()
  return <Component {...pageProps} />
}

export default App

このように一箇所でアクセスコントロールを定義すれば、ルールの見通しがよく管理もしやすいです。
しかし、ページ数の増加やコントロールパターンの増加により、処理が肥大化しやすく、ひいては _app のバンドルサイズが大きくなってしまい、ユーザビリティを損ねる可能性もあります。

パターン3: getAccessControl

パターン1と2それぞれの欠点を補うパターンを提案します。

// index.d.ts
type AccessControlType = 'replace' | 'push';
type AccessControlFallback = { type: AccessControlType; destination: string }
type GetAccessControl = () =>
  | null
  | AccessControlFallback
  | Promise<null | AccessControlFallback>
type WithGetAccessControl<P> = P & {
  getAccessControl?: GetAccessControl
}
// pages/mypage.tsx
const Mypage: WithGetAccessControl<VFC> = (props) => {
  return <div>...</div>
}

Mypage.getAccessControl = () => {
  return !isLoggedIn() ? { type: 'replace', destination: '/signin' } : null
}

export default Mypage
// pages/signin.tsx
const Signin: WithGetAccessControl<VFC> = (props) => {
  return <div>...</div>
}

Signin.getAccessControl = () => {
  return isLoggedIn() ? { type: 'replace', destination: '/mypage' } : null
}

export default Signin
// pages/_app.tsx
const useAccessControll = (getAccessControll: GetAccessControl) => {
  const router = useRouter()
  useEffect(() => {
    const controll = async () => {
      const accessControl = await getAccessControll()
      if (!accessControl) return
      router[accessControl.type](accessControl.destination)
    }
    controll()
  }, [router]) 
}

const accessControl = () => {
  throw new Error('getAccessControl が定義されていません。');
};

type Props = AppProps & {
  Component: {
    getAccessControl?: GetAccessControl
  }
}

const App: VFC<Props> = ({ Component, pageProps }) => {
  const { getAccessControl = accessControl } = Component
  useAccessControll(getAccessControl)
  return <Component {...pageProps} />
}

export default App

各ページでページコンポネントに対して、getAccessControl という関数を定義して注入します。
_appgetAccessControl を取り出して実行することで、アクセスコントロールの制御ルールを得て、それに従って処理をするという流れです。
そうすることで、アクセスコントロールの制御構文そのものは各ページに閉じることになるため、関心分離が行えており、処理の複雑化に伴う _app のバンドルサイズ増加も防止できます。
各ページでの定義忘れを防止するために、エラーをスローするだけのデフォルト用の関数を定義すれば、パターン1の欠点を補うことができます。

この、ページのコンポネントに対して、外部から関数を生やすという記述に関して、一見ルールから外れているようにも思えますが、実は Next.js のレイアウトに関する公式のドキュメントでも同じような方法を推奨しています。
https://nextjs.org/docs/basic-features/layouts#per-page-layouts

番外編: サーバサイドで制御する

ここまで、クライアントサイドで制御する方法を記述しましたが、本来マイページのようなセンシティブなページは、サーバサイドでアクセスをブロックするほうが安全です。
getServerSidenext.config.jsrewrites, redirects ルールなどで制御することも視野に入れることをおすすめします。

もし、特定パスに対しての非認証ユーザのアクセスをブロック・リダイレクトするだけでよいのであれば、こちらのプラグインもおすすめです。
firebase auth, cognito, auth0 などの認証プロバイダだけでなくIPレンジでの制御も可能で、ルールの記述は next.config.js で行うため、アプリケーション側の記述を減らすことができます。
https://github.com/aiji42/next-fortress

制御方法に firebase を使う場合はこんな感じです。
この設定だけで /mypage 配下へのアクセスは全てサーバサイドでコントロールされ、firebase でのログインがなければログインフォームへリダイレクトされます。

// next.config.js
const withFortress = require('next-fortress')({
  forts: [
    {
      inspectBy: 'firebase',
      mode: 'redirect',
      source: '/mypage/:path*',
      destination: '/signin',
    }
  ],
  firebase: {
    clientEmail: 'your client emai',
    projectId: 'your project id',
    privateKey: 'your private key'
  }
})
// pages/_app.tsx
import { useFortressWithFirebase } from 'next-fortress/build/client'
import firebase from 'firebase/app'

function MyApp({ Component, pageProps }: AppProps) {
  useFortressWithFirebase(firebase)
  return <Component {...pageProps} />
}

まとめ

3つのクライアントサイドでのアクセスコントロールパターンと、捕捉的にサーバサイドでのアクセスコントロールパターンについて記述しました。
小規模のプロジェクトであれば、設定忘れはレビュー等の運用でカバーできますし、バンドルサイズの問題もほとんど無視できると思われますので、パターン1・2でも問題ないはずです。
しかし、中規模以上のプロジェクトになってくると一気に管理が難しくなってきますので、パターン3の採用を個人的にはお勧めします。

GitHubで編集を提案

Discussion