👮‍♂️

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

5 min read

https://github.com/aiji42/authrize-preview-sample

モチベーション

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/

Discussion

ログインするとコメントできます