👮‍♂️

Next.jsで特定IPでのみコンテンツの閲覧を許可する

2021/05/11に公開
3

追記(2022/01/21)

こちらの記事は Next.js v10の頃の記事であり、現在のv12では同じ方法でIPによるアクセスブロックは行なえません。
しかし、v12ではmiddlewareによるアクセスブロックが可能です。
公式のmiddlewareのサンプルリストにIP制限の例がありますので、そちらに従って実装してみてください。

https://github.com/vercel/examples/tree/main/edge-functions/ip-blocking-datadome

また、筆者はmiddlewareによるアクセスコントロールを統括するためのライブラリを開発しています。

https://github.com/aiji42/next-fortress

公式のサンプルよりはかんたんに導入できるかと思いますので、ぜひ検討してみてください。
コントリビュートもお待ちしています。

下記サンプルは、/admin配下へのアクセスに対してIPアドレスのチェックを行い、許可リスト外のIPからのアクセスであればトップページにリダイレクトする場合のものです。

// /pages/admin/_middleware.ts
import { makeIPInspector } from 'next-fortress'

// 第一引数: 許可するIPアドレス。CIDR形式、および配列で複数のIP指定も可
// 第二引数: 許可IPレンジ外からのアクセスに対しての制御ルール。詳しくはREADMEをどうぞ。https://github.com/aiji42/next-fortress#usage
export const middleware = makeIPInspector('123.123.123.123/32', {
  type: 'redirect',
  destination: '/'
})

IPアドレス以外にも firebase、auth0、aws cognito の認証に対応しています。
2022/01/21時点では、どのプロバイダもgithubのissueは上がっているものの、依然としてmiddlewareでの動作は未サポートの状態です。
next-fortressでは、各プロバイダのアクセストークンをJWTパースライブラリを用いて検証することで(payloadだけでなくシグネチャも含めて)、独自に認証状態の解決をしています。
詳しくは README を御覧ください。


モチベーション

Next.jsで作成したサイトの閲覧にIPアドレスで制限をかけたい。

Vercel と Next.js の組み合わせは非常によく、開発体験が非常に良い。
しかし、プレビューに対してアクセス制限を行うためには、追加料金の支払いが必要。
自前でハックして費用をかけずに、アクセスの制限を行いたい。

今回は業務でよくあるIP制限での制限方法を記載。

仕組み

rewrites ルールを使用し、特定のクッキーを保持していないアクセスは Deny ページへ、クッキーを保持しているアクセスは、通常通りのダイナミックルーティングを行い、コンテンツ閲覧を可能にさせる。
https://nextjs.org/docs/api-reference/next.config.js/rewrites#header-cookie-and-query-matching

前述の特定クッキーを付与するために、APIルーティングを使用し、指定したIPからのアクセスに対してのみ、クッキーを付与する。
https://nextjs.org/docs/api-routes/api-middlewares#extending-the-reqres-objects-with-typescript

注意

仕組みを見てわかるように、IPで制限とはいえ正確にはクッキーでの制限になる。
つまり、一度クッキーを取得してしまったあとは、ブラウザさえ変えなければIPを変えてもアクセスが可能である。
実際には、クッキーの有効期限を有限期間かつ短く設定することで、なるべく安全な状態を確保する必要がある。

また、今回紹介するコード及びリポジトリのコードは、あくまでルートのhtmlへのアクセスを防ぐだけで、jsや画像などのファイルへのアクセスは制限していない。
厳密に制限をかけたいのであれば、/_next/配下も同じように設定する必要がある。

設定

api ルートのパス以外で認証クッキーを持っているかどうか判定する

// next.config.js
module.exports = {
  trailingSlash: true,
  rewrites: async () => {
    if (process.env.VERCEL_ENV !== 'preview') return {}
    return {
      beforeFiles: [
        {
          source: '/api/:path*/',
          destination: '/api/:path*',
        },
        {
          source: '/:path*/',
          has: [
            {
              type: 'cookie',
              key: 'x-custom-authorized',
              value: process.env.AUTH_KEY,  // AUTH_KEYはリリースごとに変わるようにするとより良い
            },
          ],
          destination: '/:path*',
        },
        {
          source: '/:path*/',
          destination: '/challenge',
        }
      ]
    }
  }
}

https://github.com/aiji42/authrize-preview-sample/blob/main/next.config.js

補足
トップページも制限対象にするには、 trailingSlash: true を設定しなければならない。
プロジェクトのルール的に、トレイリングスラッシュをオフにしたい場合には、process.env.VERCEL_ENV が preview のときのみ true になるように設定してやれば良い。


/api/passport へ指定IPからアクセスしたときに認証クッキーを付与する

// util/setCookie.ts
import { serialize, CookieSerializeOptions } from 'cookie'
import { NextApiResponse } from 'next'

export const setCookie = (
  res: NextApiResponse,
  name: string,
  value: unknown,
  options: CookieSerializeOptions = {}
) => {
  const stringValue =
    typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)

  if ('maxAge' in options) {
    options.expires = new Date(Date.now() + options.maxAge)
    options.maxAge /= 1000
  }

  res.setHeader('Set-Cookie', serialize(name, String(stringValue), options))
}

// pages/api/passport.ts
import { NextApiHandler } from 'next'
import { setCookie } from '../../util/setCookie'

const handler: NextApiHandler = (req, res) => {
  if (process.env.AUTH_KEY && process.env.ALLOW_FROM && req.headers['x-forwarded-for']?.includes(process.env.ALLOW_FROM)) {
    setCookie(res, 'x-custom-authorized', process.env.AUTH_KEY, { path: '/' })
    res.end(res.getHeader('Set-Cookie'))
    return
  }
  res.status(401).json({ message: 'Unauthorized' })
}

export default handler

https://github.com/aiji42/authrize-preview-sample/blob/main/util/setCookie.ts
https://github.com/aiji42/authrize-preview-sample/blob/main/pages/api/passport.ts


認証をチャレンジするためのページ。
cookie を保持していなければ、rewrite ルールによってこちらのページがレンダリングされる。

// pages/challenge.tsx
import Head from 'next/head'
import Image from 'next/image'
import styles from '../styles/Home.module.css'
import { FC, useEffect } from 'react'
import { useRouter } from 'next/router'

const Challenge: FC = () => {
  const router = useRouter()
  // マウント時に認証をリクエストする
  useEffect(() => {
    fetch('/api/passport')
      .then(({ ok }) => {
        ok && router.reload()
      })
  }, [])

  return (
    <div className={styles.container}>
      <Head>
        <title>Deny your access</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <h1 className={styles.title}>
          Deny your access
        </h1>
      </main>

      <footer className={styles.footer}>
        <a
          href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          Powered by{' '}
          <span className={styles.logo}>
            <Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
          </span>
        </a>
      </footer>
    </div>
  )
}

export default Challenge

https://github.com/aiji42/authrize-preview-sample/blob/main/pages/challenge.tsx

デプロイ

下記環境変数を設定してからデプロイする

ALLOW_FROM=許可したいIP
AUTH_KEY=適当な文字列

デモ

制限かけていない方(production)
https://authrize-preview-sample.vercel.app/

制限かけている方(preview)
https://authrize-preview-sample-5oesakgya-aiji42.vercel.app/

GitHubで編集を提案

Discussion

kazuki tanidakazuki tanida

この記事のレポジトリのnextのversionだとうまくいきますが、12.0.8で試しましたが最新のversionだと無限ループになってしまいますね。
router.reload() しなければ無限ループにならないけどリロードしないと画面更新できないし。。。😢
検討中らしいhasNot オプションが追加されれば特定のhost以外を制限とかが簡単にできそうです。
(最新versionで解決した人がいたら是非おしえてくださいませ)

aiji42aiji42

わざわざ試していただいてありがとうございます 🙇‍♂️
更新が全く追いついていなくて申し訳ないのですが、この記事を書いた後、安定性を高めるためにgetServerSidePropsをリバースプロキシ化して対応したりなど、別の方法で対応しておりました。

そして、v12以降でしたらmiddlewareを利用したほうがよろしいかと思います。
実は私、next-fortress というNext.jsでmiddlewareを用いてアクセスコントロールを行うためのライブラリを開発しておりまして、IPアドレスのルールにも対応しておりますので、そちらで是非試していただけますと幸いです。(ip以外にもfirebase, cognito, auth0の認証にも対応しています)
https://github.com/aiji42/next-fortress#control-by-ip-address

時間があるときに、本記事も更新しておきます!

kazuki tanidakazuki tanida

先ほどちょうどmiddlewareのドキュメントをみていて使えそうだと思いました🙏
おぉ!ライブラリ開発しているのですね。興味あるので時間ある時に使わせていただきます。
ご返信ありがとうございます!