Amazon S3 による Web ページの作成

2020年7月31日

はじめに

Amazon S3 で認証も含めた Web ページを作成してみる。

Web ページの作成

以下の S3 のサンプルを参考に Web ページを用意する。

S3 バケットにある画像を表示する。Web ページ自体も S3 バケットに置くことにする。

S3 バケットの作成

まずは S3 バケットを作成する。

  • AWS マネジメントコンソールで S3 を開く。
  • [バケットを作成] を選択。
  • バケット名を入れて [作成]。
  • リストからバケット名を選択し、[アクセス権] タブをクリック。[ブロックパブリックアクセス] の [編集] で "パブリックアクセスをすべてブロック" のチェックを外す。[保存]。

ID プールの作成

Amazon Cognito の ID プールを作成する。

  • AWS マネジメントコンソールで Amazon Cognito を開いて、[ID プールの管理] を選択。
  • ID プール名を入れる。"認証されていない ID" の "認証されていない ID に対してアクセスを有効にする" にチェックを入れる。[プールの作成]。
  • ID プールで使うロールについての画面になるので、"詳細を表示" で "認証されていないときのロール" (2 つあるうちの下のほう) のロール名を確認する。[許可]。
  • "Amazon Cognito での作業開始" と出るので、"プラットフォーム" に "JavaScript" を選択し、"AWS 認証情報の取得" のコードをメモする。
// Amazon Cognito 認証情報プロバイダーを初期化します
AWS.config.region = 'ap-northeast-1'; // リージョン
AWS.config.credentials = new AWS.CognitoIdentityCredentials({
    IdentityPoolId: '(プール ID)',
});

ロールの設定

IAM を開き、ID プールの作成時に作成された "認証されていないときのロール" に、以下の JSON をインラインポリシーで設定する。

{
   "Version": "2012-10-17",
   "Statement": [
      {
         "Effect": "Allow",
         "Action": [
            "s3:ListBucket"
         ],
         "Resource": [
            "arn:aws:s3:::(バケット名)"
         ]
      }
   ]
}

S3 の CORS の設定

S3 のバケットの [アクセス権限]-[CORS の設定] で以下を設定する。

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
    <CORSRule>
        <AllowedOrigin>*</AllowedOrigin>
        <AllowedMethod>GET</AllowedMethod>
        <AllowedMethod>HEAD</AllowedMethod>
        <AllowedHeader>*</AllowedHeader>
    </CORSRule>
</CORSConfiguration>

※CORS (Cross-Origin Resource Sharing): Web ページのサーバーとは別のところとデータをやりとりするための仕組み。

バケットの準備

  • S3 でバケットにフォルダ "album1"、"album2"、"album3" を作成する。
  • "album1"、"album2" に [アップロード] で画像をアップロードする。その際、"パブリックアクセス許可を管理する" で "このオブジェクトに対するパブリック読み取りアクセス許可を付与" を選択する。アップロードした後にファイルを選択して [公開する] を選択してもよい。

Web ページの作成

ウェブページを作る。

index.html

<!DOCTYPE html>
<html>
  <head>
    <script src="https://sdk.amazonaws.com/js/aws-sdk-2.528.0.min.js"></script>
    <script src="./PhotoViewer.js"></script>
    <script>listAlbums();</script>
  </head>
  <body>
    <h1>Photo Album Viewer</h1>
    <div id="viewer" />
  </body>
</html>

AWS SDK for JavaScript の最新バージョンは こちら で確認する。

PhotoViewer.js

const albumBucketName = '(バケット名)';

// Cognito でメモした "AWS 認証情報の取得" のコード
// Initialize the Amazon Cognito credentials provider
AWS.config.region = '(リージョン)';
AWS.config.credentials = new AWS.CognitoIdentityCredentials({
    IdentityPoolId: '(プール ID)',
});

// Create a new service object
const s3 = new AWS.S3({
  apiVersion: '2006-03-01',
  params: {Bucket: albumBucketName}
});

// A utility function to create HTML.
function getHtml(template) {
  return template.join('\n');
}


// アルバムの一覧表示
// List the photo albums that exist in the bucket.
function listAlbums() {
  s3.listObjects({Delimiter: '/'}, function(err, data) {
    if (err) {
      return alert('There was an error listing your albums: ' + err.message);
    } else {
      const albums = data.CommonPrefixes.map(function(commonPrefix) {
        const prefix = commonPrefix.Prefix;
        const albumName = decodeURIComponent(prefix.replace('/', ''));
        return getHtml([
          '<li>',
            '<button style="margin:5px;" onclick="viewAlbum(\'' + albumName + '\')">',
              albumName,
            '</button>',
          '</li>'
        ]);
      });

      const message = albums.length ?
        getHtml([
          '<p>Click on an album name to view it.</p>',
        ]) :
        '<p>You do not have any albums. Please Create album.</p>';

      const htmlTemplate = [
        '<h2>Albums</h2>',
        message,
        '<ul>',
          getHtml(albums),
        '</ul>',
      ]

      document.getElementById('viewer').innerHTML = getHtml(htmlTemplate);
    }
  });
}


// アルバムの表示
// Show the photos that exist in an album.
function viewAlbum(albumName) {
  const albumPhotosKey = encodeURIComponent(albumName) + '/';
  s3.listObjects({Prefix: albumPhotosKey}, async function(err, data) {
    if (err) {
      return alert('There was an error viewing your album: ' + err.message);
    }
    // 'this' references the AWS.Response instance that represents the response
    const href = this.request.httpRequest.endpoint.href;
    const bucketUrl = href + albumBucketName + '/';

    const photos = data.Contents.map(function(photo) {
      const photoKey = photo.Key;
      if (photoKey === albumPhotosKey) {
        return getHtml([]);
      }

      const photoUrl = bucketUrl + encodeURIComponent(photoKey);
      return getHtml([
        '<span>',
          '<div>',
            '<br/>',
            '<img style="width:128px;height:128px;" src="' + photoUrl + '"/>',
          '</div>',
          '<div>',
            '<span>',
              photoKey.replace(albumPhotosKey, ''),
            '</span>',
          '</div>',
        '</span>',
      ]);
    });

    const message = photos.length ?
      '<p>The following photos are present.</p>' :
      '<p>There are no photos in this album.</p>';

    const htmlTemplate = [
      '<div>',
        '<button onclick="listAlbums()">',
          'Back To Albums',
        '</button>',
      '</div>',
      '<h2>',
        'Album: ' + albumName,
      '</h2>',
      message,
      '<div>',
        getHtml(photos),
      '</div>',
      '<h2>',
        'End of Album: ' + albumName,
      '</h2>',
      '<div>',
        '<button onclick="listAlbums()">',
          'Back To Albums',
        '</button>',
      '</div>',
    ];

    document.getElementById('viewer').innerHTML = getHtml(htmlTemplate);
  });
}

ID プール作成時にコピーした "AWS 認証情報の取得" のコードで認証情報が取得される。といっても、今回はユーザー情報を入れていないので、非認証状態であり、その場合は "認証されていないときのロール" が用いられる。s3.listObjects() で S3 バケットの中身のリストを得ているが、これは "認証されていないときのロール" のポリシーで許可されているので、成功する。"album1" などの中身を得るために、s3.listObjects() の引数で "{Prefix: albumPhotosKey}" としているが、これは "album1/" 自身も合致するので、フォルダ名それ自体は除外するような処理を追加している。

ここで、S3 バケットの中身のリストの取得は "認証されていないときのロール" で特別に許可されているが、画像の URL を取得して表示しているだけなので、画像自体は URL さえわかれば誰でもアクセスできることに注意する。

index.html をブラウザで表示すると、フォルダに対応したボタンが 3 つ出てくる。

album1 ボタンを押すとフォルダの中の画像が表示される。

album2 も同様。

album3 には何もないので次のようになる。

画像データを直接取得する

画像自体にアクセス制限を設定するために、画像のリンクではなく、画像データそれ自体を S3 から取得するようにする。

// Show the photos that exist in an album.
function viewAlbum(albumName) {
  const albumPhotosKey = encodeURIComponent(albumName) + '/';
  s3.listObjects({Prefix: albumPhotosKey}, async function(err, data) {
    if (err) {
      return alert('There was an error viewing your album: ' + err.message);
    }
    // 'this' references the AWS.Response instance that represents the response
    const href = this.request.httpRequest.endpoint.href;
    const bucketUrl = href + albumBucketName + '/';

    let images = {};
    for(photo of data.Contents){
      const photoKey = photo.Key;
      if (photoKey === albumPhotosKey) {
        images[photoKey] = "";
        continue;
      }

      let image = "";
      s3.getObject({Key: photoKey}, function(err, file) {
        if (err) {
          console.log('Error: ' + err);
        } else {
          image = "data:image/png;base64," + encode(file.Body);
        }
      });
      await sleep(100);

      images[photoKey] = image;
    }

    const photos = data.Contents.map(function(photo) {
      const photoKey = photo.Key;
      if (photoKey === albumPhotosKey) {
        return getHtml([]);
      }

      const image = images[photoKey];
      return getHtml([
        '<span>',
          '<div>',
            '<br/>',
            '<img style="width:128px;height:128px;" src="' + image + '"/>',
          '</div>',
          '<div>',
            '<span>',
              photoKey.replace(albumPhotosKey, ''),
            '</span>',
          '</div>',
        '</span>',
      ]);
    });

    const message = photos.length ?
      '<p>The following photos are present.</p>' :
      '<p>There are no photos in this album.</p>';
    const htmlTemplate = [
      '<div>',
        '<button onclick="listAlbums()">',
          'Back To Albums',
        '</button>',
      '</div>',
      '<h2>',
        'Album: ' + albumName,
      '</h2>',
      message,
      '<div>',
        getHtml(photos),
      '</div>',
      '<h2>',
        'End of Album: ' + albumName,
      '</h2>',
      '<div>',
        '<button onclick="listAlbums()">',
          'Back To Albums',
        '</button>',
      '</div>',
    ];
    document.getElementById('viewer').innerHTML = getHtml(htmlTemplate);
  });
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function encode(data)
{
  const str = data.reduce(function(a,b){ return a+String.fromCharCode(b) },'');
  return btoa(str).replace(/.{76}(?=.)/g,'$&\n');
}

s3.getObject() 画像データを取得し、Base64 にエンコードして表示させている。sleep() を挟んで少し待たせている。

認証されていないときのロールのポリシー設定を以下のようにする。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::(バケット名)"
        },
        {
            "Effect": "Allow",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::(バケット名)/*"
        }
    ]
}

ちなみに、画像ではなくテキストデータを取得したい場合は、file.Body.toString() で文字列を得られる。

参考

Web ページの公開

Web ページを S3 バケットに置いて外部に公開する。S3 バケットで index.html、PhotoView.js を "このオブジェクトに対するパブリック読み取りアクセス許可を付与" を選択してアップロード (あるいはアップロードして [公開する] を選択)。index.html ファイルを選択したときに出てくる "オブジェクト URL" にアクセスすればよい。

Web サイト公開の方法については、これとは別に静的 Web サイトホスティングを用いる方法がある。バケットの [プロパティ] の "Static website hosting" で "このバケットを使用してウェブサイトをホストする" を選ぶ。"インデックスドキュメント" に index.html を指定する。そこに出ている "エンドポイント" が URL になる。独自ドメインを使いたいときは Route 53 を、SSL (HTTPS) 化したいときは CloudFront を用いるようである。

認証

Web ページに認証の仕組みを組み込む。

ユーザープールの作成

Amazon Cognito のユーザープールを作成する。これはユーザーを管理するものである。ここではユーザーは管理側で作成するものとし、ユーザーには e-mail アドレスなどを要求しないものとする。

  • AWS マネジメントコンソールで Amazon Cognito を開いて、[ユーザープールの管理] を選択。
  • [ユーザープールを作成する] を選択。
  • プール名を設定。[デフォルトを確認する] を選択。
  • 左の [属性] で "どの標準属性が必要ですか?" で "email" のチェックを外す。「次のステップ] を選択。
  • [ポリシー] の "ユーザーに自己サインアップを許可しますか?" で "管理者のみにユーザーの作成を許可する" を選択。[次のステップ
  • [確認] で [プールの作成] を選択。プール ID をメモしておく。

※ちなみに、属性で email を有効にして自己サインアップを許可すると、e-mail アドレスを用いてユーザー自身でユーザー登録を行うことができる。

  • 左の [全体設定]-[アプリクライアント] で [アプリクライアントの追加] を選択。アプリクライアント名を設定。"クライアントシークレットを作成" のチェックを外す。[アプリクライアントの作成] を選択。アプリクライアント ID が表示されるので、メモする。
  • [アプリの統合]-[アプリクライアントの設定] で "有効な ID プロバイダ" の "Cognito User Pool" にチェックを入れる。"コールバック URL"、"サインアウト URL" を HTTPS で設定する。ここでは S3 バケットにある index.html の オブジェクト URL を入れる。"OAuth 2.0" の "許可されている OAuth フロー" の "Implicit grant" および "許可されている OAuth スコープ" の "opneid" にチェックを入れる。[変更の保存]。
  • [アプリの統合]-[ドメイン名] で "Amazon Cognito ドメイン" の "ドメインのプレフィックス" を設定する。ここではバケット名にする。[使用可能かチェック] で問題なければ [変更の保存]。ドメインをメモしておく。

ログイン画面へのアクセス

ログイン画面にアクセスできるか確認する。ログイン画面の URL は次のようになる。

https://(プレフィックス).auth.ap-northeast-1.amazoncognito.com/login?response_type=token&client_id=(アプリクライアント ID)&redirect_uri=(コールバック URL)

コールバック URL は [アプリクライアントの設定] で設定したものと同じものである。とりあえずログイン画面が出てくれば OK。

  • [全体設定] - [ユーザーとグループ] でユーザーを作成する。[ユーザーの作成] を選び、ユーザー名を設定。"この新規ユーザーに招待を送信しますか?"、"電話番号を検証済みにしますか?”、"E メールを検証済みにしますか?" のチェックを外す。仮パスワードを設定して [ユーザーの作成]。
  • ログイン画面にアクセスし、今作ったユーザーでログインしてみる。パスワードを変えろと出てくるので、新しいパスワードを設定する (同じパスワードでも OK)。
  • コールバック URL に移動するが、URL を見ると情報がくっついているのが確認できる。

ログアウトは次の URL にアクセスする。

https://(プレフィックス).auth.ap-northeast-1.amazoncognito.com/logout?client_id=(アプリクライアント ID)&logout_uri=(コールバック URL)

パブリックアクセスを無効にする

画像のアクセス制限を行うために、パブリックアクセスを無効にする。ファイル単位でパブリックアクセスを無効にする場合は、ファイルを選択して [アクセス権限] で "パブリックアクセス" の "Everyone" を選び、"オブジェクトへのアクセス" の "オブジェクトの読み取り" のチェックを外す。

S3 のデータは、認証されていないユーザーではパブリックアクセスにしないと見えない。認証されたユーザーにアクセス権を与えて見れるようにする。

アクセス可能なものだけをバケットの [アクセス権]-[バケットポリシー] で設定するには、たとえば次のようにする。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AddPerm",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": [
                "arn:aws:s3:::(バケット名)/index.html"
            ]
        }
    ]
}

[ブロックパブリックアクセス] で、「ACL を介して〜」の 2 つをオンにして、「パブリックバケットポリシーを介して〜」の 2 つをオフにする。

認証

  • Cognio の ID プールにユーザープールを登録する。 [ID プールの設定] のあと ID プール名をクリック、右上の [ID プールの編集] で編集できる。そこの [認証プロバイダー] の “Cognito” タブでユーザープール ID とアプリクライアント ID を設定できるので、そこに作成したユーザープールの情報を設定する。
  • Web ページのスクリプトを修正する。

PhotoViewr.js

const idToken = (() => {
  const params = new URLSearchParams(location.hash.slice(1));
  return params.get('id_token');
})();

// Initialize the Amazon Cognito credentials provider
AWS.config.region = '(リージョン)';
AWS.config.credentials = new AWS.CognitoIdentityCredentials({
  IdentityPoolId: '(ID プール ID)',
  Logins : {
    'cognito-idp.ap-northeast-1.amazonaws.com/(ユーザープール ID)': idToken
  }
});

ログイン画面で認証して index.html に移動すると、URL の後ろに "#..." の形でパラメータ (ID トークン) がくっついている。それを URLSearchParams で取り出して、AWS SDK に渡して認証している。これにより "認証されたときのロール" が適用される。

  • IAM で、認証されていないときのロールに設定したポリシーを削除する。

この時点で index.html にアクセスすると、アルバムは表示されない ("There was an error listing your albums: Access Denied" と出る)。

  • IAM で、認証されたときのロールに以下のポリシーを追加する。
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::(バケット名)"
        },
        {
            "Effect": "Allow",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::(バケット名)/*"
        }
    ]
}

ログインしてみて、index.html で画像を見ることができれば OK。

ログイン画面の URL は長くて使いにくいが、直接 index.html にアクセスしたときに認証トークンがなかったらログイン画面にリダイレクトするなどすればよいだろう。

if (idToken === '') {
  location.href = (ログイン画面の URL);
}

認証の成否

認証の成否は、アクセスキー ID などが取得できているかどうかで確認できる。

AWS.config.credentials.get(function(){
  const accessKeyId = AWS.config.credentials.accessKeyId;
  const secretAccessKey = AWS.config.credentials.secretAccessKey;
  const sessionToken = AWS.config.credentials.sessionToken;
  ...
});

認証はどうもたまに失敗するようで (タイミングの問題?) なんどか再認証したほうがよいかもしれない。

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

(async () => {
for (i = 0; i < 5; i++) {
    let ok = false;
    AWS.config.credentials.get(() => {
    const secretAccessKey = AWS.config.credentials.secretAccessKey;
    if (secretAccessKey !== undefined) {
        ok = true;
    }
  });
  await sleep(500);
  if (ok) return;
}
location.href = (ログイン画面の URL);
})();

ユーザー名の取得

ID トークンは JWT (JSON Web Token) であり、Base64 の文字列が '.' で区切られている。これをデコードすればユーザー名を取り出せる。

const username = (() => {
  const tokens = idToken.split('.');
  const obj = JSON.parse(atob(tokens[1]));
  return obj['cognito:username'];
})();

IP アクセス制限

ログインユーザーの IP アドレス制限を実現するには、以下の方法が考えられる。

  • Cognito のアドバンスドセキュリティを使う。
  • 認証された時のポリシーに IP アドレス制限をかける。
  • S3 のバケットポリシーで IP アドレス制限をかける。

バケットポリシーに IP アドレス制限をかけるには、次のようにする。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AddPerm",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": [
                "arn:aws:s3:::(バケット名)/index.html"
            ],
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": "xxx.xxx.xxx.xxx/24"
                }
            }
        }
    ]
}

こうしておけば、必要な人にのみファイルを公開することができる。

PDF を取得する

上記の例では画像データを扱ったが、PDF も扱える。たとえば、別窓で PDF を開きたい場合は次のようにする。

function showPDF(path) {
  s3.getObject({Key: path}, function(err, file) {
    if (err) {
      console.log('Error: ' + err);
    } else {
      const pdf = "data:application/pdf;base64," + encode(file.Body);
      w = window.open("", "PDFWindow");
      w.document.open();
      w.document.write("<html>");
      w.document.write("<head/>");
      w.document.write('<title>showPDF</title>');
      w.document.write("</head>");
      w.document.write("<body>");
      w.document.write('<embed style="position:absolute; left: 0; top: 0;"');
      w.document.write(' width="100%" height="100%" type="application/pdf"');
      w.document.write(` src="${pdf}">`);
      w.document.write('</embed>');
      w.document.write("</body>");
      w.document.write("</html>");
      w.document.close();
    }
  });
}

あるいは、ダウンロードボタンを作るなら次のようにする。まず、ボタンを作る。

<div class="button"><a id="pdflink" href="" download="download.pdf">ダウンロード</a></div>

別途 PDF を取得してボタンに設定。

s3.getObject({Key: path}, function(err, file) {
  if (err) {
    console.log('Error: ' + err);
  } else {
    const pdfLink = document.getElementById("pdflink");
    const pdf = "data:application/pdf;base64," + encode(file.Body);
    pdfLink.href = pdf;
  }
});