認証されたユーザのみにS3に置かれたファイルを表示する方法

# はじめに

例えばRaspberry Piなりなんなりで作成した監視カメラでS3に撮影画像を送信するとします。
撮影された画像を見るWeb UIを作りたいわけですがもちろん「誰でも見れる」のは論外です。というわけで、オブジェクトURLを使うことはできません。
「認証を行ったうえでアクセスさせればいいんだよな、Cognitoを使えばできる？」とやってみたのが今回のネタになります。

# S3の設定

まずはS3バケットを作成します。パブリックアクセスブロックはもちろんオンです。

![01_s3-block-public-access.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141091/ee4d3450-4aa3-ee5a-8c16-303772dc3740.jpeg)

Web（JavaScript）からアクセスする際にはCORSの設定も必要です。

```xml
<?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>
```

# Cognitoの設定

Cognitoは使ったことがなかったので用語を理解するのに少し手間取りました。
なお各リソースの作り方の詳細なステップについては他の方が記事を挙げられているので省略します（手抜き）

1. ユーザープールを作成する
2. ユーザープールにアプリクライアントを作成する（「クライアントシークレットを生成」はオフにする[^1]）
3. ユーザを作成する（許可したものしかアクセスさせないので、当然自分でサインアップするのは禁止です）
4. ユーザープールとアプリクライアントを指定してIDプールを作成する
5. IDプールと同時に作成されたIAMロールに権限を付加する（後述）

[^1]: 後述のクライアントライブラリで指定する方法がないからなのかなんなのか理由は不明ですがともかくシークレットありだと駄目でした。

「認証されたロール」にS3バケットへのアクセス権を追加します。
というかこれでアクセスはできるものの書き方が正しいのかよくわからない。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::restricted-s3-junjis0203",
                "arn:aws:s3:::restricted-s3-junjis0203/*"
            ]
        }
    ]
}
```

# JavaScriptクライアント

以上でリソースの設定はできたので「パブリックアクセス禁止」のS3にアクセスするJavaScriptクライアントを作成します。Cognitoの認証を行うにはAWS SDK本体に加えて[Amazon Cognito Identity SDK](https://github.com/aws-amplify/amplify-js/tree/master/packages/amazon-cognito-identity-js)が必要です。今はAmplifyというフレームワークの一部になっているようですが、見たところAmplifyが提供する機能でやりたいことはできなそうなのでSDKを素で使いました。

コードはこちらに置きました。もちろんID系は抜いているので試す場合は必要な情報を埋めてください。
https://github.com/junjis0203/restricted-s3-access-example

## Cognitoの認証（パスワード変更付き）

サンプルの基本的な動きは以下のようになります。

`index.html`にアクセスされると`userPool`から「current user」を取得します。
current userはlocalStorageに保存されていますが初回はもちろんnullです。認証を行うために`signin.html`に飛ばします。

```js:index.html抜粋
            const cognitoUser = userPool.getCurrentUser();
            if (!cognitoUser) {
                document.location.href = 'signin.html'
            }
```

手動でCognitoのユーザを作る場合、初回に必ずパスワード変更が必要になります。JavaScript的には`authenticateUser`メソッドを呼び出すと`newPasswordRequired`コールバックが呼び出されるという流れになります。この場合の対処方法は[SDKドキュメント](https://github.com/aws-amplify/amplify-js/tree/master/packages/amazon-cognito-identity-js)のUse case 23（アンカーになってないので検索してください）に書かれています。

```js:signin.html抜粋
                cognitoUser = new AmazonCognitoIdentity.CognitoUser(userData);
                cognitoUser.authenticateUser(authenticationDetails, {
                    onSuccess: function(result) {
                        document.location.href = 'index.html'
                    },

                    onFailure: function(err) {
                        console.log(err)
                    },

                    newPasswordRequired: function(userAttributes, requiredAttributes) {
                        delete userAttributes.email_verified
                        sessionUserAttributes = userAttributes

                        changePasswordForm.style = 'display: block;'
                    }
                });
```

ともかく以下のような適当フォームで新しいパスワードを入力させてパスワード変更を行います。ドキュメントでは書かれていませんが`completeNewPasswordChallenge`メソッドでも`onSuccess`と`onFailure`のコールバックを受け付けます（書かないとエラーになったような気もする）

![02_change_password.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141091/e29c845d-fcf5-ee73-810c-7ee97343dda3.jpeg)

```js:signin.html
                cognitoUser.completeNewPasswordChallenge(newPassword, sessionUserAttributes, {
                    onSuccess: function(result) {
                        document.location.href = 'index.html'
                    },

                    onFailure: function(err) {
                        console.log(err)
                    }
                })
```

## S3へのアクセス

さてこれでともかく認証が行えました。
がこの後どのようにしてS3にアクセスすればいいのかがよくわかりませんでした。

初めに考えたのはLambdaで「（API Gatewayに）アクセスしたユーザの権限でS3からファイルを取得し流せばよい？」というものだったのですが、API Gatewayでアクセスユーザの設定をする方法（オーソライザー？）がよくわからずに試行錯誤しているうちに、
「あれ？よく思うとAmazon Cognito Identity SDKって証明書の更新してるし普通にアクセスできるんじゃね？」と思いやってみました。

```js:index.html抜粋
                s3.listObjects({Bucket: S3_BUCKET}, function(err, data) {
                    if (err) {
                        console.error(err)
                        return
                    }

                    const ul = document.createElement('ul')
                    data.Contents.forEach(function(content) {
                        if (content.Key.endsWith('.jpg')) {
                            const li = document.createElement('li')
                            const a = document.createElement('a')
                            const br = document.createElement('br')
                            const img = document.createElement('img')
                            a.href = '#'
                            a.innerHTML = content.Key
                            a.addEventListener('click', function(e) {
                                // 後述
                            })
                            li.appendChild(a)
                            li.appendChild(br)
                            li.appendChild(img)
                            ul.appendChild(li)
                        }
                    })
                    const app = document.getElementById('app')
                    app.appendChild(ul)
                })
```

普通にアクセスできました。
なお本当は日付→各日のファイル一覧のように階層化したかったのですが検証コードなのでそこら辺は手抜きです。

![03_list_objects.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141091/e92b3e22-bca3-9457-bf35-c18806b511ad.jpeg)

リンクがクリックされたらファイルを取得して`img`に突っ込みます。

```js:index.html抜粋
                            a.addEventListener('click', function(e) {
                                e.preventDefault()

                                s3.getObject({Bucket: S3_BUCKET, Key: content.Key}, function(err, data) {
                                    if (err) {
                                        console.error(err)
                                        return
                                    }

                                    console.log(data)
                                    const blob = new Blob([data.Body], {type: data.ContentType})
                                    img.src = URL.createObjectURL(blob)
                                })
                            })
```

画像を確認したところﾈｺﾁｬｰﾝが集まっていました。これは緊急事態ですね。
※前に散歩してたときに撮った画像です。

![04_get-object.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141091/b81453f2-7eac-213f-eda7-417a0b7d0d67.jpeg)

証明書等の期限切れが気になるところですがそのうち考えます。

# おわりに

以上今回はパブリックアクセスはブロックしつつ認証されたユーザにはS3内のファイルアクセスを許可するという方法について説明しました。
普通に需要がありそうなのでもう少し簡単な方法があるのかもしれません。少なくとも軽く探してみた限りでは見当たらなかったですが。

# 参考

ユーザープールの作り方などJavaScriptでCognitoを使う方法全般
https://qiita.com/tmiki/items/f36edc6c7473f5baa398

CORS設定などを参照しました。
https://docs.aws.amazon.com/ja_jp/sdk-for-javascript/v2/developer-guide/s3-example-photos-view.html

Ajaxで画像ファイルを取得して`img`に設定する方法。`getObject`で返されるのは`UInt8Array`のため`Blob`にする必要があります。
https://qiita.com/Yarimizu14/items/f56123c738f12ad1844a
http://var.blog.jp/archives/62330155.html