Cloudflare Workersで手っ取り早くBasic認証を設定する

オープン前のGoogle除けなど、一時的に関係者限定のサイトにするためにBasic認証を設定したいんだよな、というシーンがあるかと思います。

コンテンツ側で htaccess とかで設定してもいいのですが、配信がCloudflare経由になっているなら、Cloudflare Workers でも実現できますよ、ということでメモしておこうと思います。

-----

まずはWorkersを作成します。

初めての場合は、ベースとなるサブドメインの設定やプランの選択を行うウィザードが表示されると思いますので、画面の手順に従ってセットアップを行います。

![Workersを作成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219010/66753c87-77b2-52d7-068c-2b7639545e71.jpeg)

「サービスを作成」ボタンを選択します。

![サービスを作成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219010/19932256-c68c-ecc9-8cf1-fc2bf9174910.jpeg)

適当にサービス名を設定して「サービスの作成」ボタンを選択します。

![サービス名を設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219010/c63d1038-4419-1925-a64a-5cb56eedb6bc.jpeg)

「クイック編集」ボタンを選択してコードエディタを開きます。

![コードエディタを開く](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219010/2345a966-8a77-a437-89b2-b5715c84317b.jpeg)

プログラムは[Cloudflare Workersのサンプル](https://developers.cloudflare.com/workers/examples/basic-auth)にもズバリのコードがあるので、これを参考にしましょう。

サンプルはオリジンレスで動かす前提になっているので、これをサイトエッジで動かす前提として、認証後は通常通りオリジンにリクエストが流れるよう調整します。

``` index.js
/**
 * 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) {
  const { protocol, pathname } = new URL(request.url)

  // In the case of a "Basic" authentication, the exchange 
  // MUST happen over an HTTPS (TLS) connection to be secure.
  if ('https:' !== protocol || 'https' !== request.headers.get('x-forwarded-proto')) {
    throw new BadRequestException('Please use a HTTPS connection.')
  }

  // 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="Please input id and password.", 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 username.')
  }

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

/**
 * 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 decoded = atob(encoded).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,
        }
      })
    })
  )
})
```

プレビューでBasic認証のダイアログが表示されることまで確認したら「保存してデプロイ」を選択します。

![保存してデプロイ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219010/d57e50ec-3310-2a61-6c81-9ca0d682d9e5.jpeg)

保存したら次はサイトの設定です。アカウントホームに戻って、Basic認証を設定したいサイトを選択します。

![サイトを選択](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219010/0924258d-9193-e40a-1618-803ef764c845.jpeg)

メニューから「Workers」を選択して、「ルートを追加」を選択します。

![ルートを追加](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219010/d7c878bc-3c7d-7edb-d6f9-d796c600706d.jpeg)

ルートには「[ドメイン]/*」を入力、サービスは先程作成したWorkersのサービスを選択、環境は「production」を選択して、「保存」ボタンを選択します。

![10.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219010/e2c9b1cf-3657-7a13-bf8c-1a997a004080.jpeg)

これですべて設定は完了です。

実際にアクセスしてBasic認証が設定されていることを確認しましょう。

![実際に確認](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/219010/adecba1d-6fbf-8d9d-733c-1a50cf287fed.jpeg)

-----

設定箇所に慣れてしまえば、 htaccess でいちいちサーバーにファイル設置して・・・、など作業するよりも早くてかんたんなのではないでしょうか!?