Next.jsで特定IPでのみコンテンツの閲覧を許可する
追記(2022/01/21)
こちらの記事は Next.js v10の頃の記事であり、現在のv12では同じ方法でIPによるアクセスブロックは行なえません。
しかし、v12ではmiddlewareによるアクセスブロックが可能です。
公式のmiddlewareのサンプルリストにIP制限の例がありますので、そちらに従って実装してみてください。
また、筆者はmiddlewareによるアクセスコントロールを統括するためのライブラリを開発しています。
公式のサンプルよりはかんたんに導入できるかと思いますので、ぜひ検討してみてください。
コントリビュートもお待ちしています。
下記サンプルは、/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 ページへ、クッキーを保持しているアクセスは、通常通りのダイナミックルーティングを行い、コンテンツ閲覧を可能にさせる。
前述の特定クッキーを付与するために、APIルーティングを使用し、指定したIPからのアクセスに対してのみ、クッキーを付与する。
注意
仕組みを見てわかるように、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',
}
]
}
}
}
補足
トップページも制限対象にするには、 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
認証をチャレンジするためのページ。
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
デプロイ
下記環境変数を設定してからデプロイする
ALLOW_FROM=許可したいIP
AUTH_KEY=適当な文字列
デモ
制限かけていない方(production)
制限かけている方(preview)
Discussion
この記事のレポジトリのnextのversionだとうまくいきますが、12.0.8で試しましたが最新のversionだと無限ループになってしまいますね。
router.reload()
しなければ無限ループにならないけどリロードしないと画面更新できないし。。。😢検討中らしい
hasNot
オプションが追加されれば特定のhost以外を制限とかが簡単にできそうです。(最新versionで解決した人がいたら是非おしえてくださいませ)
わざわざ試していただいてありがとうございます 🙇♂️
更新が全く追いついていなくて申し訳ないのですが、この記事を書いた後、安定性を高めるためにgetServerSidePropsをリバースプロキシ化して対応したりなど、別の方法で対応しておりました。
そして、v12以降でしたらmiddlewareを利用したほうがよろしいかと思います。
実は私、next-fortress というNext.jsでmiddlewareを用いてアクセスコントロールを行うためのライブラリを開発しておりまして、IPアドレスのルールにも対応しておりますので、そちらで是非試していただけますと幸いです。(ip以外にもfirebase, cognito, auth0の認証にも対応しています)
時間があるときに、本記事も更新しておきます!
先ほどちょうどmiddlewareのドキュメントをみていて使えそうだと思いました🙏
おぉ!ライブラリ開発しているのですね。興味あるので時間ある時に使わせていただきます。
ご返信ありがとうございます!