Cloudflare Pagesに Basic認証をかける

Cloudflare Pagesに Basic認証をかける

Cloudflare Pagesは静的ファイルの公開を目的としたストレージサービスのためBasic認証などのアクセス制限はデフォルトでは用意されていません。

ただし、Cloudflare Workers や Cloudflare Pages Functionsといったサーバーレスサービスが用意されており、これらのサービスに処理を実装することでBasic認証でのアクセス制限をかけることができます。

Cloudflare Workers か Cloudflare Pages Functions か

Cloudflare Workers を利用するか Cloudflare Pages Functions を利用するかはサイトの設定によって異なります。

Cloudflare上でドメインを登録したりCloudflareのDNSを利用している場合など、WebサイトをCloudflare上で管理している場合はWorkers ルートが設定できるのでCloudflare Workersが利用できます。

Cloudflare Pagesを単独で利用している場合つまりpages.devドメインを利用していたり、カスタムドメインで cname を利用して直接サブドメインを割り振っている場合はCloudflare Pages Functionsを利用する必要があります。

Cloudflare WorkersでBasic認証を設定する

Workersサービスの作成

Cloudflare Workers は Workers CLIというCLIツールで作成してコードベースでも管理できますが、Basic認証のように一度設定してしまえば変更が不要なスクリプトの場合は管理画面から設定してしまうほうが楽でしょう。

Cloudflareの管理画面上の左メニューの「Workers」からCloudflare Workersのページに遷移して「Create Service」から新しいWorkersサービスを作成します。

サービス名は「basic-auth」などわかりやすい名前にしておくと良いでしょう。

「Quick edit」ボタンよりコードの更新画面に遷移します。

ベーシック認証のスクリプトはサンプルのコードが公開されているのでそれをカスタマイズするのが良いでしょう。
参考:HTTP Basic Authentication · Cloudflare Workers docs

以下が認証の対象を全ページに変更して認証が通った後にもとのファイルを表示するようにカスタマイズしたスクリプトです。

/**
 * Shows how to restrict access using the HTTP Basic schema.
 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication
 * @see https://tools.ietf.org/html/rfc7617
 *
 * A user-id containing a colon (":") character is invalid, as the
 * first colon in a user-pass string separates user and password.
 */
const BASIC_USER = 'admin';
const BASIC_PASS = 'admin';

/**
 * Receives a HTTP request and replies with a response.
 * @param {Request} request
 * @returns {Promise<Response>}
 */
async function handleRequest(request) {
    // The "Authorization" header is sent when authenticated.
    if (request.headers.has('Authorization')) {
        // Throws exception when authorization fails.
        const { user, pass } = basicAuthentication(request);
        verifyCredentials(user, pass);

        // Only returns this response when no exception is thrown.
        return fetch(request)
    }

    // Not authenticated.
    return new Response('You need to login.', {
        status: 401,
        headers: {
            // Prompts the user for credentials.
            'WWW-Authenticate': 'Basic realm="my scope", charset="UTF-8"',
        },
    });
}

/**
 * Throws exception on verification failure.
 * @param {string} user
 * @param {string} pass
 * @throws {UnauthorizedException}
 */
function verifyCredentials(user, pass) {
  if (BASIC_USER !== user) {
    throw new UnauthorizedException('Invalid credentials.');
  }

  if (BASIC_PASS !== pass) {
    throw new UnauthorizedException('Invalid credentials.');
  }
}

/**
 * Parse HTTP Basic Authorization value.
 * @param {Request} request
 * @throws {BadRequestException}
 * @returns {{ user: string, pass: string }}
 */
function basicAuthentication(request) {
  const Authorization = request.headers.get('Authorization');

  const [scheme, encoded] = Authorization.split(' ');

  // The Authorization header must start with Basic, followed by a space.
  if (!encoded || scheme !== 'Basic') {
    throw new BadRequestException('Malformed authorization header.');
  }

  // Decodes the base64 value and performs unicode normalization.
  // @see https://datatracker.ietf.org/doc/html/rfc7613#section-3.3.2 (and #section-4.2.2)
  // @see https://dev.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/normalize
  const buffer = Uint8Array.from(atob(encoded), character => character.charCodeAt(0));
  const decoded = new TextDecoder().decode(buffer).normalize();

  // The username & password are split by the first colon.
  //=> example: "username:password"
  const index = decoded.indexOf(':');

  // The user & password are split by the first colon and MUST NOT contain control characters.
  // @see https://tools.ietf.org/html/rfc5234#appendix-B.1 (=> "CTL = %x00-1F / %x7F")
  if (index === -1 || /[\0-\x1F\x7F]/.test(decoded)) {
    throw new BadRequestException('Invalid authorization value.');
  }

  return {
    user: decoded.substring(0, index),
    pass: decoded.substring(index + 1),
  };
}

function UnauthorizedException(reason) {
  this.status = 401;
  this.statusText = 'Unauthorized';
  this.reason = reason;
}

function BadRequestException(reason) {
  this.status = 400;
  this.statusText = 'Bad Request';
  this.reason = reason;
}

addEventListener('fetch', event => {
  event.respondWith(
    handleRequest(event.request).catch(err => {
      const message = err.reason || err.stack || 'Unknown Error';

      return new Response(message, {
        status: err.status || 500,
        statusText: err.statusText || null,
        headers: {
          'Content-Type': 'text/plain;charset=UTF-8',
          // Disables caching by default.
          'Cache-Control': 'no-store',
          // Returns the "Content-Length" header for HTTP HEAD requests.
          'Content-Length': message.length,
        },
      });
    })
  );
});

「Save and deploy」ボタンを押下すればBasic認証用のWorkersサービスの作成は完了です。

Workersサービスの適用

Basic認証用のWorkersサービスの作成が完了したら、Webサイトに作成したWorkersサービスを適用します。

TOPページの左メニューから「Websites」を選択して、適用したサイトを選択します。

この場所に適用したいサイトがない場合は後から解説するCloudflare Pages FunctionsでBasic認証を設定する必要があります。

サイトの管理画面に遷移したら左メニューの「Workers Routes」を選択、HTTP Routesにある「Add route」ボタンを押下します。

Routeに指定したいURLをドメイン込みの正規表現で指定を行い、Serviceには先ほど作成したBasic認証用のWorkersサービスを指定し、Environmentにはproductionを指定します。

これで「Save」を押せばBasic認証が適応されています。

Cloudflare Pages FunctionsでBasic認証を設定する

Cloudflare Workersが利用できない場合はCloudflare Pages Functionsを利用します。

Cloudflare Pages FunctionsはGitのルートディレクトリに functionsという名前でディレクトリを配置してその中にファイルとして格納します。

Basic認証は /functions/_middleware.jsという命名で配置します。_middleware.jsと命名したfunctionsはページのリクエストの前に実行されるため事前に認証をチェックすることができます。

ベーシック認証のスクリプトはサンプルのコードをCloudflare Pages Functionsに持っていく場合にはカスタマイズが必要です。

こちらはカスタマイズしたスクリプトです。

/**
 * Shows how to restrict access using the HTTP Basic schema.
 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication
 * @see https://tools.ietf.org/html/rfc7617
 *
 * A user-id containing a colon (":") character is invalid, as the
 * first colon in a user-pass string separates user and password.
 */
const BASIC_USER = 'admin'
const BASIC_PASS = 'admin'

async function errorHandling(context) {
  try {
    return await context.next()
  } catch (err) {
    return new Response(`${err.message}\n${err.stack}`, { status: 500 })
  }
}

async function handleRequest({ next, request }) {
  // The "Authorization" header is sent when authenticated.
  if (request.headers.has("Authorization")) {
    const Authorization = request.headers.get('Authorization')
    // Throws exception when authorization fails.

    const [scheme, encoded] = Authorization.split(' ')

    // The Authorization header must start with Basic, followed by a space.
    if (!encoded || scheme !== 'Basic') {
      return new Response(`The Authorization header must start with Basic`, {
        status: 400,
      })
    }

    // Decodes the base64 value and performs unicode normalization.
    // @see https://datatracker.ietf.org/doc/html/rfc7613#section-3.3.2 (and #section-4.2.2)
    // @see https://dev.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/normalize
    const buffer = Uint8Array.from(atob(encoded), (character) =>
      character.charCodeAt(0)
    )
    const decoded = new TextDecoder().decode(buffer).normalize()

    // The username & password are split by the first colon.
    //=> example: "username:password"
    const index = decoded.indexOf(':')

    // The user & password are split by the first colon and MUST NOT contain control characters.
    // @see https://tools.ietf.org/html/rfc5234#appendix-B.1 (=> "CTL = %x00-1F / %x7F")
    if (index === -1 || /[\0-\x1F\x7F]/.test(decoded)) {
      return new Response('Invalid authorization value.', { status: 400 })
    }

    const user = decoded.substring(0, index);
    const pass = decoded.substring(index + 1);

    if (BASIC_USER !== user) {
      return new Response('Invalid credentials.', { status: 401 })
    }

    if (BASIC_PASS !== pass) {
      return new Response('Invalid credentials.', { status: 401 })
    }

    // Only returns this response when no exception is thrown.
    return await next()
  }

  // Not authenticated.
  return new Response('You need to login.', {
    status: 401,
    headers: {
      // Prompts the user for credentials.
      'WWW-Authenticate': 'Basic realm="my scope", charset="UTF-8"',
    },
  })
}

export const onRequest = [errorHandling, handleRequest]

こちらをGitにpushすればページにBasic認証が適用されます。

少し面倒ですがCloudflare PagesでBasic認証が試してみるとよいでしょう。

追記:2023/08/02
Cloudflare Pages FunctionsのBasic認証から本番環境を除外する という環境毎にBasic認証の制御を切り替える方法の記事を書きましたのでそちらも合わせてご確認ください。